Files
tiny-forge/web/src/routes/+page.svelte
T
alexei.dolgolyov 410a131cec feat(apps): stepped creation wizard, branch previews, and app-creation fixes
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.
2026-05-29 02:09:54 +03:00

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')} &middot; {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')} &rarr;</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>