fix: resolve ERR_INSUFFICIENT_RESOURCES connection exhaustion
- Add concurrency limiter (max 4 GET requests) to API layer, leaving slots for SSE and health checks. Write ops bypass the limiter. - Add AbortController to ContainerStats, project detail page, and dashboard to cancel in-flight requests on navigation/unmount. - Move global SSE connection from layout to events page (only consumer). - Add 30s heartbeat to SSE endpoint to detect zombie connections. - Serialize dashboard project fetches to avoid parallel burst. - Rebuild frontend in dev-server.sh so go:embed stays in sync.
This commit is contained in:
+114
-19
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Project, Instance, StaleContainer } from '$lib/types';
|
||||
import type { Project, Instance, StaleContainer, StaticSite } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||
import { IconDeploy, IconBox, IconServer, IconAlert, IconClock } from '$lib/components/icons';
|
||||
import { IconDeploy, IconBox, IconServer, IconAlert, IconClock, IconGlobe } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
@@ -14,46 +14,58 @@
|
||||
let unusedImagesMB = $state(0);
|
||||
let unusedImagesCount = $state(0);
|
||||
let unusedImagesExceeded = $state(false);
|
||||
let sites = $state<StaticSite[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let loadController: AbortController | null = null;
|
||||
|
||||
async function loadDashboard() {
|
||||
loadController?.abort();
|
||||
const controller = new AbortController();
|
||||
loadController = controller;
|
||||
const signal = controller.signal;
|
||||
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.listProjects();
|
||||
projects = await api.listProjects(signal);
|
||||
|
||||
const detailPromises = projects.map(async (p) => {
|
||||
// Fetch project details sequentially to avoid exhausting
|
||||
// browser connection pool (HTTP/1.1 allows only 6 per host).
|
||||
const results: { projectId: string; instances: Instance[] }[] = [];
|
||||
for (const p of projects) {
|
||||
try {
|
||||
const detail = await api.getProject(p.id);
|
||||
const detail = await api.getProject(p.id, signal);
|
||||
const stages = detail.stages ?? [];
|
||||
const stageInstances = await Promise.all(
|
||||
stages.map((s) => api.listInstances(p.id, s.id))
|
||||
);
|
||||
return { projectId: p.id, instances: stageInstances.flat() };
|
||||
} catch {
|
||||
return { projectId: p.id, instances: [] };
|
||||
const stageInstances: Instance[][] = [];
|
||||
for (const s of stages) {
|
||||
stageInstances.push(await api.listInstances(p.id, s.id, signal));
|
||||
}
|
||||
results.push({ projectId: p.id, instances: stageInstances.flat() });
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
||||
results.push({ projectId: p.id, instances: [] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const [results, staleResult] = await Promise.all([
|
||||
Promise.all(detailPromises),
|
||||
api.fetchStaleContainers().catch(() => [] as StaleContainer[])
|
||||
]);
|
||||
const mapped: Record<string, Instance[]> = {};
|
||||
for (const r of results) {
|
||||
mapped[r.projectId] = r.instances;
|
||||
}
|
||||
instancesByProject = mapped;
|
||||
staleContainers = staleResult;
|
||||
|
||||
staleContainers = await api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[]);
|
||||
|
||||
sites = await api.listStaticSites(signal).catch(() => [] as StaticSite[]);
|
||||
|
||||
try {
|
||||
const imgStats = await api.getUnusedImageStats();
|
||||
const imgStats = await api.getUnusedImageStats(signal);
|
||||
unusedImagesMB = imgStats.total_size_mb;
|
||||
unusedImagesCount = imgStats.count;
|
||||
unusedImagesExceeded = imgStats.exceeded;
|
||||
} catch { /* non-critical */ }
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
error = e instanceof Error ? e.message : $t('dashboard.loadFailed');
|
||||
} finally {
|
||||
loading = false;
|
||||
@@ -62,6 +74,7 @@
|
||||
|
||||
$effect(() => {
|
||||
loadDashboard();
|
||||
return () => { loadController?.abort(); };
|
||||
});
|
||||
|
||||
const totalProjects = $derived(projects.length);
|
||||
@@ -76,6 +89,24 @@
|
||||
.filter((i) => i.status === 'failed').length
|
||||
);
|
||||
const totalStale = $derived(staleContainers.length);
|
||||
const totalSites = $derived(sites.length);
|
||||
const deployedSites = $derived(sites.filter((s) => s.status === 'deployed').length);
|
||||
const failedSitesCount = $derived(sites.filter((s) => s.status === 'failed').length);
|
||||
|
||||
function siteStatusBadge(status: string): { text: string; cls: string } {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
return { text: 'Deployed', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
||||
case 'syncing':
|
||||
return { text: 'Syncing', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
||||
case 'stopped':
|
||||
return { text: 'Stopped', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
default:
|
||||
return { text: 'Idle', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -96,7 +127,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Stats cards -->
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<div class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--color-brand-50)] text-[var(--color-brand-600)]">
|
||||
<IconBox size={24} />
|
||||
@@ -133,6 +164,23 @@
|
||||
<p class="mt-0.5 text-2xl font-bold {totalStale > 0 ? 'text-amber-600' : 'text-[var(--text-primary)]'}">{totalStale}</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/sites" class="flex items-center gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex h-12 w-12 items-center justify-center rounded-xl {totalSites > 0 ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-600)]' : 'bg-gray-50 text-gray-400'}">
|
||||
<IconGlobe size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-[var(--text-secondary)]">{$t('dashboard.totalSites')}</p>
|
||||
<p class="mt-0.5 text-2xl font-bold text-[var(--text-primary)]">
|
||||
{totalSites}
|
||||
{#if deployedSites > 0}
|
||||
<span class="text-sm font-medium text-emerald-600">{deployedSites} {$t('dashboard.deployedSites')}</span>
|
||||
{/if}
|
||||
{#if failedSitesCount > 0}
|
||||
<span class="text-sm font-medium text-red-600">{failedSitesCount} {$t('dashboard.failedSites')}</span>
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Unused images warning -->
|
||||
@@ -152,6 +200,53 @@
|
||||
<!-- System health summary -->
|
||||
<SystemHealthCard />
|
||||
|
||||
<!-- Static sites summary -->
|
||||
{#if !loading}
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('dashboard.staticSites')}</h2>
|
||||
{#if sites.length > 0}
|
||||
<a href="/sites" class="text-sm font-medium text-[var(--color-brand-600)] hover:text-[var(--color-brand-700)]">
|
||||
{$t('dashboard.viewAllSites')} →
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if sites.length === 0}
|
||||
<div class="mt-4">
|
||||
<EmptyState
|
||||
title={$t('dashboard.noSites')}
|
||||
description={$t('dashboard.addFirstSite')}
|
||||
actionLabel={$t('sites.title')}
|
||||
actionHref="/sites"
|
||||
icon="projects"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each sites as site (site.id)}
|
||||
{@const badge = siteStatusBadge(site.status)}
|
||||
<a href="/sites/{site.id}" class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium text-[var(--text-primary)]">{site.name}</span>
|
||||
<span class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.cls}">{badge.text}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<span class="truncate">{site.repo_owner}/{site.repo_name}</span>
|
||||
{#if site.domain}
|
||||
<span class="truncate text-[var(--color-brand-600)]">{site.domain}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if site.last_sync_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.lastSync')}: {new Date(site.last_sync_at).toLocaleString()}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Project cards -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('dashboard.projects')}</h2>
|
||||
|
||||
Reference in New Issue
Block a user