refactor(workload): finalize containers index + post-review hardening

Wraps up the workload refactor with the fixes that came out of the multi-agent
code review (see docs/plans/workload-refactor.md "What actually shipped").

Backend:
- store.ReconcileContainer: separate write path so the 30s reconciler tick no
  longer overwrites deployer-owned fields (subdomain, proxy_route_id,
  npm_proxy_id, image_tag).
- Container.stage_id column + index; ListProxyRoutes / ListContainersByStageID
  join via stage_id (survives stage rename), with legacy fallback to
  (project_id, role=stage_name).
- Reconciler: workload-existence check (rejects forged tinyforge.workload.id
  labels), skips inventing project-kind rows, child-context cancel before
  wg.Wait() on shutdown.
- Transactional CRUD across projects / stacks / static_sites: parent UPDATE
  and workload sync land in one transaction so secret rotations are durable.
- Webhook routing reads exclusively through workloads.webhook_secret; legacy
  GetProjectByWebhookSecret / GetStaticSiteByWebhookSecret fallback removed.
- store.GetStackByComposeProjectName + indexed lookup (no more full-table
  stack scan per compose container per tick).
- store.ListMissingSweepRows: filtered query for the missing-sweep.
- /api/instances/* handlers verify (workload_id, role) match URL
  (project_id, stage_name) before mutating — closes the cross-project
  hijack the security review flagged.
- extra_json no longer referenced from Go (column kept on disk for now).

Frontend:
- WorkloadContainers.svelte: generic detail-page panel reusable by stack and
  site detail pages.
- Containers page polish: client-side kind/state filters over an unfiltered
  fetch, URL-synced filters, race-safe loads via sequence number, EN+RU i18n,
  sidebar counter via navCounts.containers.

Misc:
- scripts/dev-server.sh: tolerate empty netstat grep result.
- .gitignore: ignore docker-watcher binaries, .claude/worktrees/, .facts-sync.json.
This commit is contained in:
2026-05-09 15:44:41 +03:00
parent d8ab22876f
commit cba2149aa9
30 changed files with 1227 additions and 509 deletions
+2 -2
View File
@@ -23,7 +23,7 @@
const { children }: Props = $props();
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'eventsErrors';
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'containers' | 'eventsErrors';
const navItems: ReadonlyArray<{
href: string;
@@ -37,7 +37,7 @@
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers' },
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: 'containers' },
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
+106 -64
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import type { ContainerView, WorkloadKind } from '$lib/types';
import * as api from '$lib/api';
import EmptyState from '$lib/components/EmptyState.svelte';
@@ -9,52 +11,82 @@
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
let containers = $state<ContainerView[]>([]);
// allContainers holds the unfiltered list — kind/state filters are applied
// client-side so the tab counters reflect the whole population, not the
// current narrowed view (otherwise picking "Project" would show All=0).
let allContainers = $state<ContainerView[]>([]);
let refIDByWorkload = $state<Record<string, string>>({});
let loading = $state(true);
let refreshing = $state(false);
let error = $state('');
let kindFilter = $state<'' | WorkloadKind>('');
let stateFilter = $state('');
let searchTerm = $state('');
// Filters seed from query string so the tab is shareable / refresh-safe.
const initialKind = (() => {
const k = $page.url.searchParams.get('kind') ?? '';
if (k === 'project' || k === 'stack' || k === 'site') return k;
return '';
})();
const initialState = $page.url.searchParams.get('state') ?? '';
const initialQ = $page.url.searchParams.get('q') ?? '';
let kindFilter = $state<'' | WorkloadKind>(initialKind);
let stateFilter = $state(initialState);
let searchTerm = $state(initialQ);
async function load(initial: boolean): Promise<void> {
if (initial) loading = true;
else refreshing = true;
error = '';
try {
containers = await api.listContainers({
kind: kindFilter === '' ? undefined : kindFilter,
state: stateFilter === '' ? undefined : stateFilter
});
// Race-safety: keep the latest fetch's result and discard stragglers.
const seq = ++loadSeq;
const [containers, workloads] = await Promise.all([
api.listContainers({}),
api.listWorkloads()
]);
if (seq !== loadSeq) return;
allContainers = containers;
const map: Record<string, string> = {};
for (const wl of workloads) map[wl.id] = wl.ref_id;
refIDByWorkload = map;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load containers';
error = e instanceof Error ? e.message : $t('containers.errLoad');
} finally {
loading = false;
refreshing = false;
}
}
let loadSeq = 0;
$effect(() => {
void load(true);
});
// Keep URL params in sync so reload + share-link Just Work.
$effect(() => {
api
.listWorkloads()
.then((wls) => {
const map: Record<string, string> = {};
for (const wl of wls) map[wl.id] = wl.ref_id;
refIDByWorkload = map;
})
.catch(() => {});
const params = new URLSearchParams();
if (kindFilter !== '') params.set('kind', kindFilter);
if (stateFilter !== '') params.set('state', stateFilter);
if (searchTerm !== '') params.set('q', searchTerm);
const target = params.toString() ? `?${params.toString()}` : window.location.pathname;
const current = window.location.search.replace(/^\?/, '');
if (current !== params.toString()) {
void goto(target, { replaceState: true, keepFocus: true, noScroll: true });
}
});
const filteredByKindState = $derived.by(() => {
return allContainers.filter((c) => {
if (kindFilter !== '' && c.workload_kind !== kindFilter) return false;
if (stateFilter !== '' && c.state !== stateFilter) return false;
return true;
});
});
const visible = $derived.by(() => {
const term = searchTerm.trim().toLowerCase();
if (term === '') return containers;
return containers.filter((c) => {
if (term === '') return filteredByKindState;
return filteredByKindState.filter((c) => {
return (
c.workload_name.toLowerCase().includes(term) ||
c.role.toLowerCase().includes(term) ||
@@ -67,7 +99,7 @@
const stateCounts = $derived.by(() => {
const out: Record<string, number> = {};
for (const c of containers) {
for (const c of allContainers) {
out[c.state] = (out[c.state] ?? 0) + 1;
}
return out;
@@ -75,21 +107,28 @@
const kindCounts = $derived.by(() => {
const out: Record<string, number> = { project: 0, stack: 0, site: 0 };
for (const c of containers) {
for (const c of allContainers) {
out[c.workload_kind] = (out[c.workload_kind] ?? 0) + 1;
}
return out;
});
const kindLabel: Record<WorkloadKind, string> = {
project: 'Project',
stack: 'Stack',
site: 'Site'
};
function kindLabel(k: WorkloadKind): string {
switch (k) {
case 'project':
return $t('common.project');
case 'stack':
return $t('common.stack');
case 'site':
return $t('common.site');
default:
return k;
}
}
function detailHref(c: ContainerView): string {
function detailHref(c: ContainerView): string | undefined {
const refID = refIDByWorkload[c.workload_id];
if (!refID) return '#';
if (!refID) return undefined;
switch (c.workload_kind) {
case 'project':
return `/projects/${refID}`;
@@ -97,8 +136,9 @@
return `/stacks/${refID}`;
case 'site':
return `/sites/${refID}`;
default:
return undefined;
}
return '#';
}
function tabClass(active: boolean): string {
@@ -121,7 +161,7 @@
class="forge-btn-ghost"
>
<IconRefresh size={14} />
<span>Refresh</span>
<span>{$t('common.refresh')}</span>
</button>
{/snippet}
<ForgeHero
@@ -134,7 +174,7 @@
{#if loading}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each Array(3) as _}
{#each { length: 3 } as _, i (i)}
<SkeletonCard />
{/each}
</div>
@@ -148,24 +188,23 @@
<input
type="text"
bind:value={searchTerm}
placeholder="Search workload, role, image, subdomain…"
placeholder={$t('containers.searchPlaceholder')}
class="w-full max-w-md rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
/>
<div class="inline-flex rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5" role="tablist" aria-label="Workload kind">
<div class="inline-flex rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5" role="tablist" aria-label={$t('containers.kindFilterLabel')}>
{#each [
{ value: '' as const, label: 'All', count: containers.length },
{ value: 'project' as const, label: kindLabel.project, count: kindCounts.project ?? 0 },
{ value: 'stack' as const, label: kindLabel.stack, count: kindCounts.stack ?? 0 },
{ value: 'site' as const, label: kindLabel.site, count: kindCounts.site ?? 0 }
] as opt}
{ value: '' as const, label: $t('common.all'), count: allContainers.length },
{ value: 'project' as const, label: kindLabel('project'), count: kindCounts.project ?? 0 },
{ value: 'stack' as const, label: kindLabel('stack'), count: kindCounts.stack ?? 0 },
{ value: 'site' as const, label: kindLabel('site'), count: kindCounts.site ?? 0 }
] as opt (opt.value || 'all')}
<button
type="button"
role="tab"
aria-selected={kindFilter === opt.value}
onclick={() => {
kindFilter = opt.value;
void load(false);
}}
class={tabClass(kindFilter === opt.value)}
>
@@ -174,20 +213,19 @@
{/each}
</div>
<div class="inline-flex rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5" role="tablist" aria-label="State">
<div class="inline-flex rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5" role="tablist" aria-label={$t('containers.stateFilterLabel')}>
{#each [
{ value: '', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'stopped', label: 'Stopped' },
{ value: 'missing', label: 'Missing' }
] as opt}
{ value: '', label: $t('common.all') },
{ value: 'running', label: $t('common.running') },
{ value: 'stopped', label: $t('common.stopped') },
{ value: 'missing', label: $t('common.missing') }
] as opt (opt.value || 'all')}
<button
type="button"
role="tab"
aria-selected={stateFilter === opt.value}
onclick={() => {
stateFilter = opt.value;
void load(false);
}}
class={tabClass(stateFilter === opt.value)}
>
@@ -197,34 +235,38 @@
</div>
</div>
{#if containers.length === 0}
{#if allContainers.length === 0}
<EmptyState
title="No containers"
description="Deploy a project, stack, or site to see containers here."
icon="instances"
title={$t('containers.emptyTitle')}
description={$t('containers.emptyDesc')}
/>
{:else}
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">Workload</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">Kind</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">Role</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">Image</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">State</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">Subdomain</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">Last seen</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.workload')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.kind')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.role')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.image')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.state')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.subdomain')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.lastSeen')}</th>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each visible as c (c.id)}
{@const href = detailHref(c)}
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-4 py-3">
<a
href={detailHref(c)}
class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors"
>{c.workload_name || c.workload_id}</a>
{#if href}
<a
{href}
class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors"
>{c.workload_name || c.workload_id}</a>
{:else}
<span class="text-sm font-medium text-[var(--text-tertiary)]">{c.workload_name || c.workload_id}</span>
{/if}
{#if c.app_name}
<span class="ml-2 rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-xs text-[var(--text-tertiary)]">
{c.app_name}
@@ -233,7 +275,7 @@
</td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">
<span class="inline-flex items-center rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium">
{kindLabel[c.workload_kind]}
{kindLabel(c.workload_kind)}
</span>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">
@@ -258,11 +300,11 @@
</div>
{#if visible.length === 0}
<p class="text-center text-sm text-[var(--text-tertiary)]">No containers match the current filters.</p>
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('containers.noMatch')}</p>
{/if}
<p class="text-xs text-[var(--text-tertiary)]">
Showing {visible.length} of {containers.length} container{containers.length === 1 ? '' : 's'}
{$t('containers.showingN', { visible: String(visible.length), total: String(allContainers.length) })}
</p>
{/if}
{/if}