feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s
Build / build (push) Successful in 10m39s
The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.
Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
internal/stack/manager.go gone (the rest of those packages stay as
helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
table (projects, stages, stage_env, volumes, deploys, deploy_logs,
poll_states, stacks, stack_revisions, stack_deploys, static_sites,
static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
so api + store paths share one secret-generation impl (no
panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
+ static-site label paths; only canonical tinyforge.workload.id
dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
private (no external callers)
Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
helper + types (Project, Stage, Stack, StaticSite, Deploy,
Instance, Volume, etc.); kept Workload, Container, App, Settings,
Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
/deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
listWorkloads + listContainers only; 4-card stat grid
(workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
proxies/+page.svelte, containers/+page.svelte all rewired to the
workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
instance.*, confirm.* namespaces; en/ru parity preserved (1042
keys each)
Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):
- Sec H1: dead-end workload webhook URL handlers (would mint URLs
that 404 the new trigger-only ingress) deleted across backend +
frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
field names, workloadIDRow rationale, webhook_deliveries.target_type
enum, WebhookDeliveryLog component header
Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
items are now shipped. Next focus is Priority 3 polish (apps.* i18n
+ codemap entries) and Priority 4 tests.
Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
/api/webhook/sites/{secret} return 404; CI configs must repoint to
/api/webhook/triggers/{secret} (the trigger-split boot backfill
lifted any embedded workload secret onto a Trigger row, so the
secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
links replaced with /apps and /triggers.
This commit is contained in:
+92
-161
@@ -1,7 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { Project, Instance, StaleContainer, StaticSite } from '$lib/types';
|
||||
// Workload-first dashboard. Replaces the legacy project / site
|
||||
// summaries with a single workload count grouped by source_kind and
|
||||
// a running-container tally pulled from /api/containers.
|
||||
//
|
||||
// We no longer fan out N+1 fetches per project to gather instance
|
||||
// status — the global containers index already carries the workload
|
||||
// reference and state.
|
||||
import type { ContainerView, StaleContainer, Workload } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||
@@ -9,17 +15,17 @@
|
||||
import SystemResourcesCard from '$lib/components/SystemResourcesCard.svelte';
|
||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import { IconDeploy, IconAlert } from '$lib/components/icons';
|
||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||
import { IconBox, IconAlert } from '$lib/components/icons';
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
|
||||
let projects = $state<Project[]>([]);
|
||||
let instancesByProject = $state<Record<string, Instance[]>>({});
|
||||
let workloads = $state<Workload[]>([]);
|
||||
let containers = $state<ContainerView[]>([]);
|
||||
let staleContainers = $state<StaleContainer[]>([]);
|
||||
let unusedImagesMB = $state(0);
|
||||
let unusedImagesCount = $state(0);
|
||||
let unusedImagesExceeded = $state(false);
|
||||
let sites = $state<StaticSite[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let loadController: AbortController | null = null;
|
||||
@@ -33,35 +39,17 @@
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
projects = await api.listProjects(signal);
|
||||
|
||||
// Fetch project details sequentially to avoid exhausting
|
||||
// browser connection pool (HTTP/1.1 allows only 6 per host).
|
||||
const results: { projectId: string; instances: Instance[] }[] = [];
|
||||
for (const p of projects) {
|
||||
try {
|
||||
const detail = await api.getProject(p.id, signal);
|
||||
const stages = detail.stages ?? [];
|
||||
const stageInstances: Instance[][] = [];
|
||||
for (const s of stages) {
|
||||
stageInstances.push(await api.listInstances(p.id, s.id, signal));
|
||||
}
|
||||
results.push({ projectId: p.id, instances: stageInstances.flat() });
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
||||
results.push({ projectId: p.id, instances: [] });
|
||||
}
|
||||
}
|
||||
|
||||
const mapped: Record<string, Instance[]> = {};
|
||||
for (const r of results) {
|
||||
mapped[r.projectId] = r.instances;
|
||||
}
|
||||
instancesByProject = mapped;
|
||||
|
||||
staleContainers = await api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[]);
|
||||
|
||||
sites = await api.listStaticSites(signal).catch(() => [] as StaticSite[]);
|
||||
// Parallelize the cheap top-level reads. Each falls back to an
|
||||
// empty list so a single slow daemon (e.g. Docker stats) does
|
||||
// not blank the entire dashboard.
|
||||
const [wls, ctrs, stale] = await Promise.all([
|
||||
api.listWorkloads(undefined, signal),
|
||||
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
|
||||
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
|
||||
]);
|
||||
workloads = wls;
|
||||
containers = ctrs;
|
||||
staleContainers = stale;
|
||||
|
||||
try {
|
||||
const imgStats = await api.getUnusedImageStats(signal);
|
||||
@@ -82,35 +70,32 @@
|
||||
return () => { loadController?.abort(); };
|
||||
});
|
||||
|
||||
const totalProjects = $derived(projects.length);
|
||||
const totalRunning = $derived(
|
||||
Object.values(instancesByProject)
|
||||
.flat()
|
||||
.filter((i) => i.state === 'running').length
|
||||
);
|
||||
const totalFailed = $derived(
|
||||
Object.values(instancesByProject)
|
||||
.flat()
|
||||
.filter((i) => i.state === 'failed').length
|
||||
);
|
||||
const totalWorkloads = $derived(workloads.length);
|
||||
const totalRunning = $derived(containers.filter((c) => c.state === 'running').length);
|
||||
const totalFailed = $derived(containers.filter((c) => c.state === 'failed').length);
|
||||
const totalStale = $derived(staleContainers.length);
|
||||
const totalSites = $derived(sites.length);
|
||||
const deployedSites = $derived(sites.filter((s) => s.status === 'deployed').length);
|
||||
const failedSitesCount = $derived(sites.filter((s) => s.status === 'failed').length);
|
||||
|
||||
function siteStatusBadge(status: string): { text: string; cls: string } {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
return { text: 'Deployed', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
||||
case 'syncing':
|
||||
return { text: 'Syncing', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
case 'failed':
|
||||
return { text: 'Failed', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
||||
case 'stopped':
|
||||
return { text: 'Stopped', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
default:
|
||||
return { text: 'Idle', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
||||
}
|
||||
// Latest 6 workloads by updated_at desc — enough for an at-a-glance
|
||||
// recent-activity strip without paging the entire list.
|
||||
const recentWorkloads = $derived(
|
||||
[...workloads]
|
||||
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
|
||||
.slice(0, 6)
|
||||
);
|
||||
|
||||
function containerStateFor(workloadID: string): string {
|
||||
// Pick the most informative state across this workload's
|
||||
// containers: failed > running > stopped > anything else.
|
||||
const states = containers.filter((c) => c.workload_id === workloadID).map((c) => c.state);
|
||||
if (states.length === 0) return 'idle';
|
||||
if (states.includes('failed')) return 'failed';
|
||||
if (states.includes('running')) return 'running';
|
||||
if (states.includes('stopped')) return 'stopped';
|
||||
return states[0];
|
||||
}
|
||||
|
||||
function containerCountFor(workloadID: string): number {
|
||||
return containers.filter((c) => c.workload_id === workloadID).length;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -121,9 +106,9 @@
|
||||
<div class="space-y-6 dashboard">
|
||||
<!-- Hero -->
|
||||
{#snippet heroToolbar()}
|
||||
<a href="/deploy" class="forge-btn">
|
||||
<IconDeploy size={14} />
|
||||
<span>{$t('dashboard.quickDeploy')}</span>
|
||||
<a href="/apps/new" class="forge-btn">
|
||||
<IconBox size={14} />
|
||||
<span>{$t('dashboard.newApp')}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
<ForgeHero
|
||||
@@ -137,35 +122,26 @@
|
||||
|
||||
<!-- Stats grid -->
|
||||
<div class="forge-stat-grid">
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalProjects')}</span>
|
||||
<span class="forge-stat-value">{String(totalProjects).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">active</span>
|
||||
</div>
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningInstances')}</span>
|
||||
<a href="/apps" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalWorkloads')}</span>
|
||||
<span class="forge-stat-value">{String(totalWorkloads).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">workloads →</span>
|
||||
</a>
|
||||
<a href="/containers" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.runningContainers')}</span>
|
||||
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">instances</span>
|
||||
</div>
|
||||
<div class="forge-stat">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedInstances')}</span>
|
||||
<span class="forge-stat-sub">running</span>
|
||||
</a>
|
||||
<a href="/containers?state=failed" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.failedContainers')}</span>
|
||||
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">need attention</span>
|
||||
</div>
|
||||
</a>
|
||||
<a href="/containers/stale" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
||||
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">stale →</span>
|
||||
</a>
|
||||
<a href="/sites" class="forge-stat stat-link">
|
||||
<span class="forge-stat-label">{$t('dashboard.totalSites')}</span>
|
||||
<span class="forge-stat-value">{String(totalSites).padStart(2, '0')}</span>
|
||||
<span class="forge-stat-sub">
|
||||
{#if deployedSites > 0}<span class="tag ok">{deployedSites} up</span>{/if}
|
||||
{#if failedSitesCount > 0}<span class="tag bad">{failedSitesCount} fail</span>{/if}
|
||||
{#if deployedSites === 0 && failedSitesCount === 0}static sites →{/if}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Unused images warning -->
|
||||
@@ -201,59 +177,11 @@
|
||||
<SystemResourcesCard />
|
||||
</CollapsibleSection>
|
||||
|
||||
<!-- Static sites summary -->
|
||||
{#if !loading}
|
||||
{#snippet sitesActions()}
|
||||
{#if sites.length > 0}
|
||||
<a href="/sites" class="text-xs font-medium text-[var(--color-brand-600)] hover:underline">
|
||||
{$t('dashboard.viewAllSites')} →
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
<CollapsibleSection
|
||||
id="dashboard-sites"
|
||||
title={$t('dashboard.staticSites')}
|
||||
badge={sites.length > 0 ? String(sites.length) : ''}
|
||||
actions={sitesActions}
|
||||
>
|
||||
{#if sites.length === 0}
|
||||
<EmptyState
|
||||
title={$t('dashboard.noSites')}
|
||||
description={$t('dashboard.addFirstSite')}
|
||||
actionLabel={$t('sites.title')}
|
||||
actionHref="/sites"
|
||||
icon="projects"
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each sites as site (site.id)}
|
||||
{@const badge = siteStatusBadge(site.status)}
|
||||
<a href="/sites/{site.id}" class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium text-[var(--text-primary)]">{site.name}</span>
|
||||
<span class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.cls}">{badge.text}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<span class="truncate">{site.repo_owner}/{site.repo_name}</span>
|
||||
{#if site.domain}
|
||||
<span class="truncate text-[var(--color-brand-600)]">{site.domain}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if site.last_sync_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.lastSync')}: {$fmt.dateTime(site.last_sync_at)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</CollapsibleSection>
|
||||
{/if}
|
||||
|
||||
<!-- Project cards -->
|
||||
<!-- Recent workloads strip -->
|
||||
<CollapsibleSection
|
||||
id="dashboard-projects"
|
||||
title={$t('dashboard.projects')}
|
||||
badge={!loading && projects.length > 0 ? String(projects.length) : ''}
|
||||
id="dashboard-workloads"
|
||||
title={$t('dashboard.recentWorkloads')}
|
||||
badge={!loading && workloads.length > 0 ? String(workloads.length) : ''}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -272,18 +200,36 @@
|
||||
{$t('dashboard.retry')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if projects.length === 0}
|
||||
{:else if workloads.length === 0}
|
||||
<EmptyState
|
||||
title={$t('empty.noProjects')}
|
||||
description={$t('empty.noProjectsDesc')}
|
||||
actionLabel={$t('empty.createProject')}
|
||||
actionHref="/projects"
|
||||
title={$t('dashboard.noWorkloads')}
|
||||
description={$t('dashboard.noWorkloadsDesc')}
|
||||
actionLabel={$t('dashboard.newApp')}
|
||||
actionHref="/apps/new"
|
||||
icon="projects"
|
||||
/>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each projects as project (project.id)}
|
||||
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each recentWorkloads as wl (wl.id)}
|
||||
{@const state = containerStateFor(wl.id)}
|
||||
{@const count = containerCountFor(wl.id)}
|
||||
<a
|
||||
href={wl.app_id ? `/apps/${wl.app_id}` : '/apps'}
|
||||
class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="truncate font-medium text-[var(--text-primary)]">{wl.name}</span>
|
||||
<StatusBadge status={state} size="sm" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||
<span class="truncate">{wl.source_kind || wl.kind}</span>
|
||||
<span class="opacity-60">·</span>
|
||||
<span>{count} {count === 1 ? $t('common.instance') : $t('common.instances')}</span>
|
||||
</div>
|
||||
{#if wl.updated_at}
|
||||
<p class="text-xs text-[var(--text-tertiary)]">{$fmt.dateTime(wl.updated_at)}</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -298,19 +244,4 @@
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
.stat-link:hover { background: var(--surface-card-hover); }
|
||||
.stat-link .forge-stat-sub .tag {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--forge-mono);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat-link .tag.ok { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.stat-link .tag.bad { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
:global([data-theme='dark']) .stat-link .tag.ok { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .stat-link .tag.bad { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
.section { margin-top: 0.5rem; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user