410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
253 lines
9.8 KiB
Svelte
253 lines
9.8 KiB
Svelte
<script lang="ts">
|
|
// Workload-first dashboard. Replaces the legacy project / site
|
|
// summaries with a single workload count grouped by source_kind and
|
|
// a running-container tally pulled from /api/containers.
|
|
//
|
|
// We no longer fan out N+1 fetches per project to gather instance
|
|
// status — the global containers index already carries the workload
|
|
// reference and state.
|
|
import type { ContainerView, StaleContainer, Workload } from '$lib/types';
|
|
import * as api from '$lib/api';
|
|
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
|
import SystemDaemonsCard from '$lib/components/SystemDaemonsCard.svelte';
|
|
import SystemResourcesCard from '$lib/components/SystemResourcesCard.svelte';
|
|
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
|
import { IconBox, IconAlert } from '$lib/components/icons';
|
|
import { t } from '$lib/i18n';
|
|
import { fmt } from '$lib/format/datetime';
|
|
|
|
let workloads = $state<Workload[]>([]);
|
|
let containers = $state<ContainerView[]>([]);
|
|
let staleContainers = $state<StaleContainer[]>([]);
|
|
let unusedImagesMB = $state(0);
|
|
let unusedImagesCount = $state(0);
|
|
let unusedImagesExceeded = $state(false);
|
|
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 {
|
|
// Parallelize the cheap top-level reads. Each falls back to an
|
|
// empty list so a single slow daemon (e.g. Docker stats) does
|
|
// not blank the entire dashboard.
|
|
const [wls, ctrs, stale] = await Promise.all([
|
|
api.listWorkloads(undefined, signal),
|
|
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
|
|
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
|
|
]);
|
|
workloads = wls;
|
|
containers = ctrs;
|
|
staleContainers = stale;
|
|
|
|
try {
|
|
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;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
loadDashboard();
|
|
return () => { loadController?.abort(); };
|
|
});
|
|
|
|
// Plugin-native workloads only. Legacy pre-cutover rows (kind project/
|
|
// stack/site) carry an empty source_kind and have no UI home post-cutover,
|
|
// so they must not inflate the headline count or the recent strip — this
|
|
// matches the /apps list, which shows the same set.
|
|
const pluginWorkloads = $derived(workloads.filter((w) => w.source_kind !== ''));
|
|
const totalWorkloads = $derived(pluginWorkloads.length);
|
|
const totalRunning = $derived(containers.filter((c) => c.state === 'running').length);
|
|
const totalFailed = $derived(containers.filter((c) => c.state === 'failed').length);
|
|
const totalStale = $derived(staleContainers.length);
|
|
|
|
// Latest 6 workloads by updated_at desc — enough for an at-a-glance
|
|
// recent-activity strip without paging the entire list.
|
|
const recentWorkloads = $derived(
|
|
[...pluginWorkloads]
|
|
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
|
|
.slice(0, 6)
|
|
);
|
|
|
|
function containerStateFor(workloadID: string): string {
|
|
// Pick the most informative state across this workload's
|
|
// containers: failed > running > stopped > anything else.
|
|
const states = containers.filter((c) => c.workload_id === workloadID).map((c) => c.state);
|
|
if (states.length === 0) return 'idle';
|
|
if (states.includes('failed')) return 'failed';
|
|
if (states.includes('running')) return 'running';
|
|
if (states.includes('stopped')) return 'stopped';
|
|
return states[0];
|
|
}
|
|
|
|
function containerCountFor(workloadID: string): number {
|
|
return containers.filter((c) => c.workload_id === workloadID).length;
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{$t('dashboard.title')} - {$t('app.name')}</title>
|
|
</svelte:head>
|
|
|
|
<div class="space-y-6 dashboard">
|
|
<!-- Hero -->
|
|
{#snippet heroToolbar()}
|
|
<a href="/apps/new" class="forge-btn">
|
|
<IconBox size={14} />
|
|
<span>{$t('dashboard.newApp')}</span>
|
|
</a>
|
|
{/snippet}
|
|
<ForgeHero
|
|
eyebrow="THE FORGE"
|
|
eyebrowSuffix={$t('nav.dashboard').toUpperCase()}
|
|
title={$t('dashboard.title')}
|
|
accent="."
|
|
size="lg"
|
|
toolbar={heroToolbar}
|
|
/>
|
|
|
|
<!-- Stats grid -->
|
|
<div class="forge-stat-grid">
|
|
<a href="/apps" class="forge-stat stat-link">
|
|
<span class="forge-stat-label">{$t('dashboard.totalWorkloads')}</span>
|
|
<span class="forge-stat-value">{String(totalWorkloads).padStart(2, '0')}</span>
|
|
<span class="forge-stat-sub">{$t('dashboard.statSubWorkloads')}</span>
|
|
</a>
|
|
<a href="/containers" class="forge-stat stat-link">
|
|
<span class="forge-stat-label">{$t('dashboard.runningContainers')}</span>
|
|
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
|
<span class="forge-stat-sub">{$t('dashboard.statSubRunning')}</span>
|
|
</a>
|
|
<a href="/containers?state=failed" class="forge-stat stat-link">
|
|
<span class="forge-stat-label">{$t('dashboard.failedContainers')}</span>
|
|
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
|
<span class="forge-stat-sub">{$t('dashboard.statSubNeedAttention')}</span>
|
|
</a>
|
|
<a href="/containers/stale" class="forge-stat stat-link">
|
|
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
|
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
|
<span class="forge-stat-sub">{$t('dashboard.statSubStale')}</span>
|
|
</a>
|
|
</div>
|
|
|
|
<!-- Unused images warning -->
|
|
{#if unusedImagesExceeded}
|
|
<a href="/settings" class="flex items-center gap-3 rounded-xl border border-amber-300 bg-amber-50 dark:border-amber-700 dark:bg-amber-950/30 p-4 transition-colors hover:bg-amber-100 dark:hover:bg-amber-950/50">
|
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-amber-100 dark:bg-amber-900/50 text-amber-600">
|
|
<IconAlert size={20} />
|
|
</div>
|
|
<div class="flex-1">
|
|
<p class="text-sm font-medium text-amber-800 dark:text-amber-300">{$t('dashboard.unusedImagesWarning')}</p>
|
|
<p class="text-xs text-amber-700 dark:text-amber-400">{unusedImagesCount} {$t('dashboard.unusedImages')} · {unusedImagesMB >= 1024 ? (unusedImagesMB / 1024).toFixed(1) + ' GB' : unusedImagesMB + ' MB'}</p>
|
|
</div>
|
|
<span class="text-xs font-medium text-amber-600 dark:text-amber-400">{$t('settings.pruneImages')} →</span>
|
|
</a>
|
|
{/if}
|
|
|
|
<!-- System health summary -->
|
|
<CollapsibleSection id="system-health" title={$t('dashboard.systemHealth')}>
|
|
<SystemHealthCard />
|
|
</CollapsibleSection>
|
|
|
|
<!-- Detailed daemon panel: Docker engine + NPM/Traefik proxy -->
|
|
<CollapsibleSection id="system-daemons" title={$t('dashboard.daemons')} defaultOpen={false}>
|
|
<SystemDaemonsCard />
|
|
</CollapsibleSection>
|
|
|
|
<!-- Host CPU/memory/disk + top consumers -->
|
|
<CollapsibleSection
|
|
id="system-resources"
|
|
title={$t('dashboard.systemResources')}
|
|
subtitle={$t('dashboard.systemResourcesSubtitle')}
|
|
>
|
|
<SystemResourcesCard />
|
|
</CollapsibleSection>
|
|
|
|
<!-- Recent workloads strip -->
|
|
<CollapsibleSection
|
|
id="dashboard-workloads"
|
|
title={$t('dashboard.recentWorkloads')}
|
|
badge={!loading && workloads.length > 0 ? String(workloads.length) : ''}
|
|
>
|
|
{#if loading}
|
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
{#each Array(3) as _}
|
|
<SkeletonCard />
|
|
{/each}
|
|
</div>
|
|
{:else if error}
|
|
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
<button
|
|
type="button"
|
|
class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline"
|
|
onclick={loadDashboard}
|
|
>
|
|
{$t('dashboard.retry')}
|
|
</button>
|
|
</div>
|
|
{:else if workloads.length === 0}
|
|
<EmptyState
|
|
title={$t('dashboard.noWorkloads')}
|
|
description={$t('dashboard.noWorkloadsDesc')}
|
|
actionLabel={$t('dashboard.newApp')}
|
|
actionHref="/apps/new"
|
|
icon="projects"
|
|
/>
|
|
{:else}
|
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{#each recentWorkloads as wl (wl.id)}
|
|
{@const state = containerStateFor(wl.id)}
|
|
{@const count = containerCountFor(wl.id)}
|
|
<a
|
|
href={wl.app_id ? `/apps/${wl.app_id}` : '/apps'}
|
|
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)]">{wl.name}</span>
|
|
<StatusBadge status={state} size="sm" />
|
|
</div>
|
|
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
|
<span class="truncate">{wl.source_kind || wl.kind}</span>
|
|
<span class="opacity-60">·</span>
|
|
<span>{count} {count === 1 ? $t('common.instance') : $t('common.instances')}</span>
|
|
</div>
|
|
{#if wl.updated_at}
|
|
<p class="text-xs text-[var(--text-tertiary)]">{$fmt.dateTime(wl.updated_at)}</p>
|
|
{/if}
|
|
</a>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</CollapsibleSection>
|
|
</div>
|
|
|
|
<style>
|
|
.dashboard { position: relative; }
|
|
|
|
.stat-link {
|
|
text-decoration: none;
|
|
transition: background 150ms ease;
|
|
}
|
|
.stat-link:hover { background: var(--surface-card-hover); }
|
|
</style>
|