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;
}