From 3e28588f103063304817df9f464af0830d67f92d Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 9 May 2026 14:02:20 +0300 Subject: [PATCH] feat(workload): global Containers tab + frontend client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- web/src/lib/api.ts | 70 ++++++- web/src/lib/i18n/en.json | 3 +- web/src/lib/i18n/ru.json | 3 +- web/src/lib/types.ts | 71 +++++++ web/src/routes/+layout.svelte | 5 +- web/src/routes/containers/+page.svelte | 269 +++++++++++++++++++++++++ 6 files changed, 417 insertions(+), 4 deletions(-) create mode 100644 web/src/routes/containers/+page.svelte diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 850c81a..0d8e50a 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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 { + const path = kind ? `/api/workloads?kind=${encodeURIComponent(kind)}` : '/api/workloads'; + return get(path, signal); +} + +export function getWorkload(id: string, signal?: AbortSignal): Promise { + return get(`/api/workloads/${id}`, signal); +} + +export function listWorkloadContainers(id: string, signal?: AbortSignal): Promise { + return get(`/api/workloads/${id}/containers`, signal); +} + +export function setWorkloadAppID(id: string, appID: string): Promise { + return request(`/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 { + 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(path, signal); +} + +// ── Apps ──────────────────────────────────────────────────────────── + +export function listApps(signal?: AbortSignal): Promise { + return get('/api/apps', signal); +} + +export function getApp(id: string, signal?: AbortSignal): Promise { + return get(`/api/apps/${id}`, signal); +} + +export function createApp(data: { name: string; description?: string }): Promise { + return post('/api/apps', data); +} + +export function updateApp(id: string, data: { name: string; description?: string }): Promise { + return put(`/api/apps/${id}`, data); +} + +export function deleteApp(id: string): Promise { + return del(`/api/apps/${id}`); +} + export { ApiError }; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index d40dfa7..d24dafd 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -22,7 +22,8 @@ "logout": "Log out", "dns": "DNS Records", "sites": "Sites", - "stacks": "Stacks" + "stacks": "Stacks", + "containers": "Containers" }, "dashboard": { "title": "Dashboard", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 4e16a53..b3a3297 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -22,7 +22,8 @@ "logout": "Выйти", "dns": "DNS-записи", "sites": "Сайты", - "stacks": "Стеки" + "stacks": "Стеки", + "containers": "Контейнеры" }, "dashboard": { "title": "Панель управления", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 79af4c5..d4ceb50 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -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; +} + diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index fce2226..5834cac 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -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 @@ {:else if item.icon === 'stacks'} + {:else if item.icon === 'containers'} + {:else if item.icon === 'deploy'} {:else if item.icon === 'proxies'} diff --git a/web/src/routes/containers/+page.svelte b/web/src/routes/containers/+page.svelte new file mode 100644 index 0000000..70905b5 --- /dev/null +++ b/web/src/routes/containers/+page.svelte @@ -0,0 +1,269 @@ + + + + {$t('nav.containers')} - {$t('app.name')} + + +
+ {#snippet heroToolbar()} + + {/snippet} + + + {#if loading} +
+ {#each Array(3) as _} + + {/each} +
+ {:else if error} +
+

{error}

+
+ {:else} + +
+ + +
+ {#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} + + {/each} +
+ +
+ {#each [ + { value: '', label: 'All' }, + { value: 'running', label: 'Running' }, + { value: 'stopped', label: 'Stopped' }, + { value: 'missing', label: 'Missing' } + ] as opt} + + {/each} +
+
+ + {#if containers.length === 0} + + {:else} +
+ + + + + + + + + + + + + + {#each visible as c (c.id)} + + + + + + + + + + {/each} + +
WorkloadKindRoleImageStateSubdomainLast seen
+ {c.workload_name || c.workload_id} + {#if c.app_name} + + {c.app_name} + + {/if} + + + {kindLabel[c.workload_kind]} + + + {c.role || '—'} + + {c.image_ref || '—'} + + + + {c.subdomain || '—'} + + {c.last_seen_at ? $fmt.relative(c.last_seen_at) : '—'} +
+
+ + {#if visible.length === 0} +

No containers match the current filters.

+ {/if} + +

+ Showing {visible.length} of {containers.length} container{containers.length === 1 ? '' : 's'} +

+ {/if} + {/if} +