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
+11 -5
View File
@@ -167,6 +167,13 @@ function del<T>(path: string): Promise<T> {
return request<T>(path, { method: 'DELETE' });
}
function patch<T>(path: string, body: unknown): Promise<T> {
return request<T>(path, {
method: 'PATCH',
body: JSON.stringify(body)
});
}
// ── Projects ────────────────────────────────────────────────────────
export function listProjects(signal?: AbortSignal): Promise<Project[]> {
@@ -1068,10 +1075,7 @@ export function listWorkloadContainers(id: string, signal?: AbortSignal): Promis
}
export function setWorkloadAppID(id: string, appID: string): Promise<Workload> {
return request<Workload>(`/api/workloads/${id}/app`, {
method: 'PATCH',
body: JSON.stringify({ app_id: appID })
});
return patch<Workload>(`/api/workloads/${id}/app`, { app_id: appID });
}
// ── Containers (global index) ───────────────────────────────────────
@@ -1086,7 +1090,9 @@ export interface ListContainersFilter {
export function listContainers(filter: ListContainersFilter = {}, signal?: AbortSignal): Promise<ContainerView[]> {
const params = new URLSearchParams();
for (const [k, v] of Object.entries(filter)) {
if (v) params.set(k, String(v));
// Skip unset / empty filters; explicitly check undefined and '' instead
// of truthy so future filter shapes (numbers, booleans) aren't dropped.
if (v !== undefined && v !== '') params.set(k, String(v));
}
const qs = params.toString();
const path = qs ? `/api/containers?${qs}` : '/api/containers';
@@ -0,0 +1,113 @@
<script lang="ts">
/**
* Shared container panel for workload detail pages (stack, site, and any
* future kind that doesn't shard containers by stage). Reads from the
* normalized containers index via /api/workloads/{id}/containers — the
* single source of truth maintained by the reconciler. Project detail
* pages keep their stage-grouped layout in [id]/+page.svelte and don't
* use this component.
*/
import type { Container } from '$lib/types';
import * as api from '$lib/api';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
interface Props {
workloadId: string;
/** Optional title override; defaults to "Containers". */
title?: string;
/** Polling interval in ms; <=0 disables polling. Default 30s. */
pollIntervalMs?: number;
}
const { workloadId, title, pollIntervalMs = 30_000 }: Props = $props();
let containers = $state<Container[]>([]);
let loading = $state(true);
let error = $state('');
let abort: AbortController | null = null;
async function load(): Promise<void> {
abort?.abort();
abort = new AbortController();
try {
containers = await api.listWorkloadContainers(workloadId, abort.signal);
error = '';
} catch (e) {
if ((e as Error)?.name === 'AbortError') return;
error = e instanceof Error ? e.message : $t('containers.errLoad');
} finally {
loading = false;
}
}
$effect(() => {
// Reactive on workloadId — re-fetch when the parent navigates between
// detail pages without unmounting.
void workloadId;
loading = true;
void load();
if (pollIntervalMs > 0) {
const tick = setInterval(() => void load(), pollIntervalMs);
return () => {
clearInterval(tick);
abort?.abort();
};
}
return () => abort?.abort();
});
</script>
<section class="space-y-3">
<header class="flex items-center justify-between">
<h3 class="text-sm font-semibold uppercase tracking-wide text-[var(--text-tertiary)]">
{title ?? $t('containers.col.workload')}
</h3>
{#if !loading && containers.length > 0}
<span class="text-xs text-[var(--text-tertiary)]">
{containers.length}
</span>
{/if}
</header>
{#if loading}
<p class="text-sm text-[var(--text-tertiary)]">{$t('common.loading')}</p>
{:else if error}
<div class="rounded-lg border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-3">
<p class="text-sm text-[var(--color-danger)]">{error}</p>
</div>
{:else if containers.length === 0}
<EmptyState
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-2 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.role')}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.image')}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.state')}</th>
<th class="px-4 py-2 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.subdomain')}</th>
<th class="px-4 py-2 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 containers as c (c.id)}
<tr>
<td class="px-4 py-2 text-sm text-[var(--text-secondary)]">{c.role || '—'}</td>
<td class="px-4 py-2 font-mono text-xs text-[var(--text-secondary)] truncate" style="max-width: 280px;">{c.image_ref || '—'}</td>
<td class="px-4 py-2"><StatusBadge status={c.state} /></td>
<td class="px-4 py-2 font-mono text-xs text-[var(--text-secondary)]">{c.subdomain || '—'}</td>
<td class="px-4 py-2 text-xs text-[var(--text-tertiary)]">{c.last_seen_at ? $fmt.relative(c.last_seen_at) : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
+27 -1
View File
@@ -732,6 +732,8 @@
"loading": "Loading...",
"noData": "No data",
"project": "Project",
"stack": "Stack",
"site": "Site",
"back": "Back",
"actions": "Actions",
"stop": "Stop",
@@ -743,7 +745,31 @@
"next": "Next",
"yes": "Yes",
"no": "No",
"saving": "Saving..."
"saving": "Saving...",
"refresh": "Refresh",
"all": "All",
"running": "Running",
"stopped": "Stopped",
"missing": "Missing"
},
"containers": {
"errLoad": "Failed to load containers",
"searchPlaceholder": "Search workload, role, image, subdomain…",
"kindFilterLabel": "Workload kind",
"stateFilterLabel": "Container state",
"emptyTitle": "No containers",
"emptyDesc": "Deploy a project, stack, or site to see containers here.",
"noMatch": "No containers match the current filters.",
"showingN": "Showing {visible} of {total} containers",
"col": {
"workload": "Workload",
"kind": "Kind",
"role": "Role",
"image": "Image",
"state": "State",
"subdomain": "Subdomain",
"lastSeen": "Last seen"
}
},
"instance": {
"stopConfirm": "This will stop the running container. The instance can be started again later.",
+27 -1
View File
@@ -732,6 +732,8 @@
"loading": "Загрузка...",
"noData": "Нет данных",
"project": "Проект",
"stack": "Стек",
"site": "Сайт",
"back": "Назад",
"actions": "Действия",
"stop": "Остановить",
@@ -743,7 +745,31 @@
"next": "Далее",
"yes": "Да",
"no": "Нет",
"saving": "Сохранение..."
"saving": "Сохранение...",
"refresh": "Обновить",
"all": "Все",
"running": "Работает",
"stopped": "Остановлен",
"missing": "Отсутствует"
},
"containers": {
"errLoad": "Не удалось загрузить контейнеры",
"searchPlaceholder": "Поиск по нагрузке, роли, образу, поддомену…",
"kindFilterLabel": "Тип нагрузки",
"stateFilterLabel": "Состояние контейнера",
"emptyTitle": "Нет контейнеров",
"emptyDesc": "Разверните проект, стек или сайт — контейнеры появятся здесь.",
"noMatch": "Нет контейнеров, подходящих под фильтры.",
"showingN": "Показано {visible} из {total} контейнеров",
"col": {
"workload": "Нагрузка",
"kind": "Тип",
"role": "Роль",
"image": "Образ",
"state": "Состояние",
"subdomain": "Поддомен",
"lastSeen": "Замечен"
}
},
"instance": {
"stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
+5 -1
View File
@@ -17,6 +17,7 @@ export interface NavCounts {
sites: number | null;
stacks: number | null;
proxies: number | null;
containers: number | null;
/** Error-severity events only; dashboard surfaces total separately. */
eventsErrors: number | null;
}
@@ -26,6 +27,7 @@ const EMPTY: NavCounts = {
sites: null,
stacks: null,
proxies: null,
containers: null,
eventsErrors: null
};
@@ -40,11 +42,12 @@ async function refreshOnce(): Promise<void> {
if (inFlight || !isAuthenticated()) return;
inFlight = true;
try {
const [projects, sites, stacks, proxies, eventStats] = await Promise.allSettled([
const [projects, sites, stacks, proxies, containers, eventStats] = await Promise.allSettled([
api.listProjects(),
api.listStaticSites(),
api.listStacks(),
api.listProxyRoutes(),
api.listContainers({}),
api.fetchEventLogStats()
]);
@@ -53,6 +56,7 @@ async function refreshOnce(): Promise<void> {
sites: sites.status === 'fulfilled' ? sites.value.length : prev.sites,
stacks: stacks.status === 'fulfilled' ? stacks.value.length : prev.stacks,
proxies: proxies.status === 'fulfilled' ? proxies.value.length : prev.proxies,
containers: containers.status === 'fulfilled' ? containers.value.length : prev.containers,
eventsErrors: eventStats.status === 'fulfilled' ? eventStats.value.error : prev.eventsErrors
}));
} finally {
+1
View File
@@ -261,6 +261,7 @@ a[aria-disabled="true"] {
.toggle-switch {
position: relative;
flex-shrink: 0;
width: 2.75rem;
height: 1.5rem;
background-color: var(--border-primary);
+20 -5
View File
@@ -36,12 +36,17 @@ export interface Stage {
/**
* Instance is a back-compat alias: project deploys used to live in a
* dedicated `instances` table, but after the workload refactor the canonical
* row is a Container. The Instance name is kept on the frontend so existing
* components don't all rename in one change — new code should use Container
* directly, and `instance.state` (not `.status`) is the current field.
* row is a Container. New code should use Container directly. The fields the
* deployer always populates for project containers (workload_id, role,
* stage_id, container_id, etc.) are required on Container; the alias is a
* straight rename, not a relaxation of the type contract.
*/
export type Instance = Container;
/**
* @deprecated Use {@link ContainerState} for new code. Kept around for older
* components that still narrow on the legacy four-state union.
*/
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'removing';
export interface Deploy {
@@ -549,6 +554,14 @@ export interface Workload {
updated_at: string;
}
/**
* Canonical container states. The trailing `(string & {})` is the
* "literal-friendly string" trick — it lets the union accept arbitrary
* strings (handy when the backend's normalize-state path adds a value that
* the frontend hasn't caught up with) WITHOUT collapsing to plain `string`,
* so editor autocomplete and exhaustiveness checks still work on the named
* literals.
*/
export type ContainerState =
| 'running'
| 'stopped'
@@ -559,7 +572,8 @@ export type ContainerState =
| 'created'
| 'restarting'
| 'paused'
| string;
// eslint-disable-next-line @typescript-eslint/ban-types
| (string & {});
/** A row from the normalized containers index. */
export interface Container {
@@ -567,6 +581,8 @@ export interface Container {
workload_id: string;
workload_kind: WorkloadKind;
role: string;
/** Project containers only; '' for stack and site rows. */
stage_id: string;
container_id: string;
image_ref: string;
image_tag: string;
@@ -577,7 +593,6 @@ export interface Container {
proxy_route_id: string;
npm_proxy_id: number;
last_seen_at: string;
extra_json: string;
created_at: string;
updated_at: string;
}