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:
+11
-5
@@ -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>
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
const { children }: Props = $props();
|
||||
|
||||
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'eventsErrors';
|
||||
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'containers' | 'eventsErrors';
|
||||
|
||||
const navItems: ReadonlyArray<{
|
||||
href: string;
|
||||
@@ -37,7 +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: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: '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 },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import type { ContainerView, WorkloadKind } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
@@ -9,52 +11,82 @@
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let containers = $state<ContainerView[]>([]);
|
||||
// allContainers holds the unfiltered list — kind/state filters are applied
|
||||
// client-side so the tab counters reflect the whole population, not the
|
||||
// current narrowed view (otherwise picking "Project" would show All=0).
|
||||
let allContainers = $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('');
|
||||
// Filters seed from query string so the tab is shareable / refresh-safe.
|
||||
const initialKind = (() => {
|
||||
const k = $page.url.searchParams.get('kind') ?? '';
|
||||
if (k === 'project' || k === 'stack' || k === 'site') return k;
|
||||
return '';
|
||||
})();
|
||||
const initialState = $page.url.searchParams.get('state') ?? '';
|
||||
const initialQ = $page.url.searchParams.get('q') ?? '';
|
||||
|
||||
let kindFilter = $state<'' | WorkloadKind>(initialKind);
|
||||
let stateFilter = $state(initialState);
|
||||
let searchTerm = $state(initialQ);
|
||||
|
||||
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
|
||||
});
|
||||
// Race-safety: keep the latest fetch's result and discard stragglers.
|
||||
const seq = ++loadSeq;
|
||||
const [containers, workloads] = await Promise.all([
|
||||
api.listContainers({}),
|
||||
api.listWorkloads()
|
||||
]);
|
||||
if (seq !== loadSeq) return;
|
||||
allContainers = containers;
|
||||
const map: Record<string, string> = {};
|
||||
for (const wl of workloads) map[wl.id] = wl.ref_id;
|
||||
refIDByWorkload = map;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load containers';
|
||||
error = e instanceof Error ? e.message : $t('containers.errLoad');
|
||||
} finally {
|
||||
loading = false;
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
let loadSeq = 0;
|
||||
|
||||
$effect(() => {
|
||||
void load(true);
|
||||
});
|
||||
|
||||
// Keep URL params in sync so reload + share-link Just Work.
|
||||
$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 params = new URLSearchParams();
|
||||
if (kindFilter !== '') params.set('kind', kindFilter);
|
||||
if (stateFilter !== '') params.set('state', stateFilter);
|
||||
if (searchTerm !== '') params.set('q', searchTerm);
|
||||
const target = params.toString() ? `?${params.toString()}` : window.location.pathname;
|
||||
const current = window.location.search.replace(/^\?/, '');
|
||||
if (current !== params.toString()) {
|
||||
void goto(target, { replaceState: true, keepFocus: true, noScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
const filteredByKindState = $derived.by(() => {
|
||||
return allContainers.filter((c) => {
|
||||
if (kindFilter !== '' && c.workload_kind !== kindFilter) return false;
|
||||
if (stateFilter !== '' && c.state !== stateFilter) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const visible = $derived.by(() => {
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
if (term === '') return containers;
|
||||
return containers.filter((c) => {
|
||||
if (term === '') return filteredByKindState;
|
||||
return filteredByKindState.filter((c) => {
|
||||
return (
|
||||
c.workload_name.toLowerCase().includes(term) ||
|
||||
c.role.toLowerCase().includes(term) ||
|
||||
@@ -67,7 +99,7 @@
|
||||
|
||||
const stateCounts = $derived.by(() => {
|
||||
const out: Record<string, number> = {};
|
||||
for (const c of containers) {
|
||||
for (const c of allContainers) {
|
||||
out[c.state] = (out[c.state] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
@@ -75,21 +107,28 @@
|
||||
|
||||
const kindCounts = $derived.by(() => {
|
||||
const out: Record<string, number> = { project: 0, stack: 0, site: 0 };
|
||||
for (const c of containers) {
|
||||
for (const c of allContainers) {
|
||||
out[c.workload_kind] = (out[c.workload_kind] ?? 0) + 1;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
const kindLabel: Record<WorkloadKind, string> = {
|
||||
project: 'Project',
|
||||
stack: 'Stack',
|
||||
site: 'Site'
|
||||
};
|
||||
function kindLabel(k: WorkloadKind): string {
|
||||
switch (k) {
|
||||
case 'project':
|
||||
return $t('common.project');
|
||||
case 'stack':
|
||||
return $t('common.stack');
|
||||
case 'site':
|
||||
return $t('common.site');
|
||||
default:
|
||||
return k;
|
||||
}
|
||||
}
|
||||
|
||||
function detailHref(c: ContainerView): string {
|
||||
function detailHref(c: ContainerView): string | undefined {
|
||||
const refID = refIDByWorkload[c.workload_id];
|
||||
if (!refID) return '#';
|
||||
if (!refID) return undefined;
|
||||
switch (c.workload_kind) {
|
||||
case 'project':
|
||||
return `/projects/${refID}`;
|
||||
@@ -97,8 +136,9 @@
|
||||
return `/stacks/${refID}`;
|
||||
case 'site':
|
||||
return `/sites/${refID}`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
return '#';
|
||||
}
|
||||
|
||||
function tabClass(active: boolean): string {
|
||||
@@ -121,7 +161,7 @@
|
||||
class="forge-btn-ghost"
|
||||
>
|
||||
<IconRefresh size={14} />
|
||||
<span>Refresh</span>
|
||||
<span>{$t('common.refresh')}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
@@ -134,7 +174,7 @@
|
||||
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each Array(3) as _}
|
||||
{#each { length: 3 } as _, i (i)}
|
||||
<SkeletonCard />
|
||||
{/each}
|
||||
</div>
|
||||
@@ -148,24 +188,23 @@
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchTerm}
|
||||
placeholder="Search workload, role, image, subdomain…"
|
||||
placeholder={$t('containers.searchPlaceholder')}
|
||||
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">
|
||||
<div class="inline-flex rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5" role="tablist" aria-label={$t('containers.kindFilterLabel')}>
|
||||
{#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}
|
||||
{ value: '' as const, label: $t('common.all'), count: allContainers.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 (opt.value || 'all')}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={kindFilter === opt.value}
|
||||
onclick={() => {
|
||||
kindFilter = opt.value;
|
||||
void load(false);
|
||||
}}
|
||||
class={tabClass(kindFilter === opt.value)}
|
||||
>
|
||||
@@ -174,20 +213,19 @@
|
||||
{/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">
|
||||
<div class="inline-flex rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-0.5" role="tablist" aria-label={$t('containers.stateFilterLabel')}>
|
||||
{#each [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'running', label: 'Running' },
|
||||
{ value: 'stopped', label: 'Stopped' },
|
||||
{ value: 'missing', label: 'Missing' }
|
||||
] as opt}
|
||||
{ value: '', label: $t('common.all') },
|
||||
{ value: 'running', label: $t('common.running') },
|
||||
{ value: 'stopped', label: $t('common.stopped') },
|
||||
{ value: 'missing', label: $t('common.missing') }
|
||||
] as opt (opt.value || 'all')}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={stateFilter === opt.value}
|
||||
onclick={() => {
|
||||
stateFilter = opt.value;
|
||||
void load(false);
|
||||
}}
|
||||
class={tabClass(stateFilter === opt.value)}
|
||||
>
|
||||
@@ -197,34 +235,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if containers.length === 0}
|
||||
{#if allContainers.length === 0}
|
||||
<EmptyState
|
||||
title="No containers"
|
||||
description="Deploy a project, stack, or site to see containers here."
|
||||
icon="instances"
|
||||
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-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>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.workload')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.kind')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.role')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.image')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.state')}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('containers.col.subdomain')}</th>
|
||||
<th class="px-4 py-3 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 visible as c (c.id)}
|
||||
{@const href = detailHref(c)}
|
||||
<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 href}
|
||||
<a
|
||||
{href}
|
||||
class="text-sm font-medium text-[var(--text-primary)] hover:text-[var(--text-link)] transition-colors"
|
||||
>{c.workload_name || c.workload_id}</a>
|
||||
{:else}
|
||||
<span class="text-sm font-medium text-[var(--text-tertiary)]">{c.workload_name || c.workload_id}</span>
|
||||
{/if}
|
||||
{#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}
|
||||
@@ -233,7 +275,7 @@
|
||||
</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]}
|
||||
{kindLabel(c.workload_kind)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-[var(--text-secondary)]">
|
||||
@@ -258,11 +300,11 @@
|
||||
</div>
|
||||
|
||||
{#if visible.length === 0}
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">No containers match the current filters.</p>
|
||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('containers.noMatch')}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-xs text-[var(--text-tertiary)]">
|
||||
Showing {visible.length} of {containers.length} container{containers.length === 1 ? '' : 's'}
|
||||
{$t('containers.showingN', { visible: String(visible.length), total: String(allContainers.length) })}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user