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:
+69
-1
@@ -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 };
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"logout": "Log out",
|
||||
"dns": "DNS Records",
|
||||
"sites": "Sites",
|
||||
"stacks": "Stacks"
|
||||
"stacks": "Stacks",
|
||||
"containers": "Containers"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"logout": "Выйти",
|
||||
"dns": "DNS-записи",
|
||||
"sites": "Сайты",
|
||||
"stacks": "Стеки"
|
||||
"stacks": "Стеки",
|
||||
"containers": "Контейнеры"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Панель управления",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user