feat(workload): global Containers tab + frontend client

Adds the user-visible piece of the Workload refactor:

- web/src/lib/types.ts          — Workload, Container, ContainerView,
                                  App, WorkloadKind, ContainerState
- web/src/lib/api.ts            — listWorkloads, getWorkload,
                                  listWorkloadContainers, setWorkloadAppID,
                                  listContainers (with filter),
                                  CRUD for apps
- web/src/lib/i18n/{en,ru}.json — nav.containers
- web/src/routes/+layout.svelte — "Containers" nav item between Stacks
                                  and Deploy, IconContainer
- web/src/routes/containers/+page.svelte — global Containers table:
    * filter chips for kind (project/stack/site) and state
    * client-side search across workload name / role / image /
      subdomain / container ID prefix
    * Workload column links to the kind-specific detail page,
      resolved through a one-time /api/workloads call to map
      workload_id → ref_id
    * existing /containers/stale route untouched

The page renders against the live database now — boot backfill
populated workload rows from existing projects/stacks/sites,
the deployer dual-writes containers on every deploy, and the
30s reconciler keeps the index in sync with `docker ps`.
This commit is contained in:
2026-05-09 14:02:20 +03:00
parent 0acbcda084
commit 3e28588f10
6 changed files with 417 additions and 4 deletions
+69 -1
View File
@@ -1,7 +1,10 @@
import type {
ApiEnvelope,
App,
Container,
ContainerStats,
ContainerStatsSample,
ContainerView,
SystemStats,
SystemStatsSample,
TopContainerSample,
@@ -30,7 +33,9 @@ import type {
BrowseResult,
DnsZone,
DnsRecordView,
BackupInfo
BackupInfo,
Workload,
WorkloadKind
} from './types';
// ── Helpers ─────────────────────────────────────────────────────────
@@ -1047,4 +1052,67 @@ export async function getStackLogs(
return res.text();
}
// ── Workloads ───────────────────────────────────────────────────────
export function listWorkloads(kind?: WorkloadKind, signal?: AbortSignal): Promise<Workload[]> {
const path = kind ? `/api/workloads?kind=${encodeURIComponent(kind)}` : '/api/workloads';
return get<Workload[]>(path, signal);
}
export function getWorkload(id: string, signal?: AbortSignal): Promise<Workload> {
return get<Workload>(`/api/workloads/${id}`, signal);
}
export function listWorkloadContainers(id: string, signal?: AbortSignal): Promise<Container[]> {
return get<Container[]>(`/api/workloads/${id}/containers`, signal);
}
export function setWorkloadAppID(id: string, appID: string): Promise<Workload> {
return request<Workload>(`/api/workloads/${id}/app`, {
method: 'PATCH',
body: JSON.stringify({ app_id: appID })
});
}
// ── Containers (global index) ───────────────────────────────────────
export interface ListContainersFilter {
workload_id?: string;
kind?: WorkloadKind;
state?: string;
app_id?: string;
}
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));
}
const qs = params.toString();
const path = qs ? `/api/containers?${qs}` : '/api/containers';
return get<ContainerView[]>(path, signal);
}
// ── Apps ────────────────────────────────────────────────────────────
export function listApps(signal?: AbortSignal): Promise<App[]> {
return get<App[]>('/api/apps', signal);
}
export function getApp(id: string, signal?: AbortSignal): Promise<App> {
return get<App>(`/api/apps/${id}`, signal);
}
export function createApp(data: { name: string; description?: string }): Promise<App> {
return post<App>('/api/apps', data);
}
export function updateApp(id: string, data: { name: string; description?: string }): Promise<App> {
return put<App>(`/api/apps/${id}`, data);
}
export function deleteApp(id: string): Promise<void> {
return del<void>(`/api/apps/${id}`);
}
export { ApiError };
+2 -1
View File
@@ -22,7 +22,8 @@
"logout": "Log out",
"dns": "DNS Records",
"sites": "Sites",
"stacks": "Stacks"
"stacks": "Stacks",
"containers": "Containers"
},
"dashboard": {
"title": "Dashboard",
+2 -1
View File
@@ -22,7 +22,8 @@
"logout": "Выйти",
"dns": "DNS-записи",
"sites": "Сайты",
"stacks": "Стеки"
"stacks": "Стеки",
"containers": "Контейнеры"
},
"dashboard": {
"title": "Панель управления",
+71
View File
@@ -539,3 +539,74 @@ export interface SystemStatsSample {
disk_total_bytes: number;
}
// ── Workload / Container / App ────────────────────────────────────
export type WorkloadKind = 'project' | 'stack' | 'site';
/**
* Workload is the unifying primitive over Project / Stack / StaticSite.
* Read-only at this layer — mutations go through the kind-specific endpoints.
*/
export interface Workload {
id: string;
kind: WorkloadKind;
ref_id: string;
name: string;
app_id: string;
notification_url: string;
webhook_require_signature: boolean;
created_at: string;
updated_at: string;
}
export type ContainerState =
| 'running'
| 'stopped'
| 'failed'
| 'removing'
| 'missing'
| 'starting'
| 'created'
| 'restarting'
| 'paused'
| string;
/** A row from the normalized containers index. */
export interface Container {
id: string;
workload_id: string;
workload_kind: WorkloadKind;
role: string;
container_id: string;
image_ref: string;
image_tag: string;
host: string;
state: ContainerState;
port: number;
subdomain: string;
proxy_route_id: string;
npm_proxy_id: number;
last_seen_at: string;
extra_json: string;
created_at: string;
updated_at: string;
}
/**
* Container row decorated with workload + app names by the backend so the
* global Containers table can render without N+1 fetches.
*/
export interface ContainerView extends Container {
workload_name: string;
app_id?: string;
app_name?: string;
}
export interface App {
id: string;
name: string;
description: string;
created_at: string;
updated_at: string;
}
+4 -1
View File
@@ -6,7 +6,7 @@
import Toast from '$lib/components/Toast.svelte';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox } from '$lib/components/icons';
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox, IconContainer } from '$lib/components/icons';
import { goto } from '$app/navigation';
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
@@ -37,6 +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: '/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 },
@@ -281,6 +282,8 @@
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'stacks'}
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'containers'}
<IconContainer size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'deploy'}
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
{:else if item.icon === 'proxies'}
+269
View File
@@ -0,0 +1,269 @@
<script lang="ts">
import type { ContainerView, WorkloadKind } from '$lib/types';
import * as api from '$lib/api';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import StatusBadge from '$lib/components/StatusBadge.svelte';
import { IconRefresh } from '$lib/components/icons';
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
let containers = $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('');
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
});
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load containers';
} finally {
loading = false;
refreshing = false;
}
}
$effect(() => {
void load(true);
});
$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 visible = $derived.by(() => {
const term = searchTerm.trim().toLowerCase();
if (term === '') return containers;
return containers.filter((c) => {
return (
c.workload_name.toLowerCase().includes(term) ||
c.role.toLowerCase().includes(term) ||
c.image_ref.toLowerCase().includes(term) ||
c.subdomain.toLowerCase().includes(term) ||
c.container_id.startsWith(term)
);
});
});
const stateCounts = $derived.by(() => {
const out: Record<string, number> = {};
for (const c of containers) {
out[c.state] = (out[c.state] ?? 0) + 1;
}
return out;
});
const kindCounts = $derived.by(() => {
const out: Record<string, number> = { project: 0, stack: 0, site: 0 };
for (const c of containers) {
out[c.workload_kind] = (out[c.workload_kind] ?? 0) + 1;
}
return out;
});
const kindLabel: Record<WorkloadKind, string> = {
project: 'Project',
stack: 'Stack',
site: 'Site'
};
function detailHref(c: ContainerView): string {
const refID = refIDByWorkload[c.workload_id];
if (!refID) return '#';
switch (c.workload_kind) {
case 'project':
return `/projects/${refID}`;
case 'stack':
return `/stacks/${refID}`;
case 'site':
return `/sites/${refID}`;
}
return '#';
}
function tabClass(active: boolean): string {
return active
? 'rounded-md px-3 py-1.5 text-sm bg-[var(--color-brand-500)] text-white shadow-[var(--shadow-xs)]'
: 'rounded-md px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)]';
}
</script>
<svelte:head>
<title>{$t('nav.containers')} - {$t('app.name')}</title>
</svelte:head>
<div class="space-y-6">
{#snippet heroToolbar()}
<button
type="button"
disabled={refreshing}
onclick={() => void load(false)}
class="forge-btn-ghost"
>
<IconRefresh size={14} />
<span>Refresh</span>
</button>
{/snippet}
<ForgeHero
backHref="/"
eyebrowSuffix="GLOBAL"
title={$t('nav.containers')}
size="lg"
toolbar={heroToolbar}
/>
{#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>
</div>
{:else}
<!-- Filter bar -->
<div class="flex flex-wrap items-center gap-3">
<input
type="text"
bind:value={searchTerm}
placeholder="Search workload, role, image, subdomain…"
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">
{#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}
<button
type="button"
role="tab"
aria-selected={kindFilter === opt.value}
onclick={() => {
kindFilter = opt.value;
void load(false);
}}
class={tabClass(kindFilter === opt.value)}
>
{opt.label}<span class="ml-1 text-xs opacity-75">({opt.count})</span>
</button>
{/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">
{#each [
{ value: '', label: 'All' },
{ value: 'running', label: 'Running' },
{ value: 'stopped', label: 'Stopped' },
{ value: 'missing', label: 'Missing' }
] as opt}
<button
type="button"
role="tab"
aria-selected={stateFilter === opt.value}
onclick={() => {
stateFilter = opt.value;
void load(false);
}}
class={tabClass(stateFilter === opt.value)}
>
{opt.label}{#if opt.value !== '' && stateCounts[opt.value]}<span class="ml-1 text-xs opacity-75">({stateCounts[opt.value]})</span>{/if}
</button>
{/each}
</div>
</div>
{#if containers.length === 0}
<EmptyState
title="No containers"
description="Deploy a project, stack, or site to see containers here."
icon="instances"
/>
{: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>
</tr>
</thead>
<tbody class="divide-y divide-[var(--border-secondary)]">
{#each visible as c (c.id)}
<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 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}
</span>
{/if}
</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]}
</span>
</td>
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">
{c.role || '—'}
</td>
<td class="px-4 py-3 font-mono text-xs text-[var(--text-secondary)] truncate" style="max-width: 320px;">
{c.image_ref || '—'}
</td>
<td class="px-4 py-3">
<StatusBadge status={c.state} />
</td>
<td class="px-4 py-3 font-mono text-xs text-[var(--text-secondary)]">
{c.subdomain || '—'}
</td>
<td class="px-4 py-3 text-xs text-[var(--text-tertiary)]">
{c.last_seen_at ? $fmt.relative(c.last_seen_at) : '—'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if visible.length === 0}
<p class="text-center text-sm text-[var(--text-tertiary)]">No containers match the current filters.</p>
{/if}
<p class="text-xs text-[var(--text-tertiary)]">
Showing {visible.length} of {containers.length} container{containers.length === 1 ? '' : 's'}
</p>
{/if}
{/if}
</div>