feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
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:
2026-05-16 06:00:21 +03:00
parent 234c3c711e
commit 739b67856a
101 changed files with 1116 additions and 20768 deletions
+6 -20
View File
@@ -15,7 +15,6 @@
// 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('');
@@ -40,15 +39,9 @@
try {
// Race-safety: keep the latest fetch's result and discard stragglers.
const seq = ++loadSeq;
const [containers, workloads] = await Promise.all([
api.listContainers({}),
api.listWorkloads()
]);
const containers = await api.listContainers({});
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 : $t('containers.errLoad');
} finally {
@@ -127,18 +120,11 @@
}
function detailHref(c: ContainerView): string | undefined {
const refID = refIDByWorkload[c.workload_id];
if (!refID) return undefined;
switch (c.workload_kind) {
case 'project':
return `/projects/${refID}`;
case 'stack':
return `/stacks/${refID}`;
case 'site':
return `/sites/${refID}`;
default:
return undefined;
}
// Legacy project / stack / site detail pages were retired with the
// hard cutover. The workload-first equivalent lives under /apps —
// every workload now belongs to an app, so the row deep-links to
// the app detail page when one is attached, otherwise stays flat.
return c.app_id ? `/apps/${c.app_id}` : undefined;
}
function tabClass(active: boolean): string {
+128 -9
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import type { StaleContainer } from '$lib/types';
import * as api from '$lib/api';
import StaleContainerCard from '$lib/components/StaleContainerCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
@@ -9,6 +8,7 @@
import { IconTrash, IconLoader } from '$lib/components/icons';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime';
let containers = $state<StaleContainer[]>([]);
let loading = $state(true);
@@ -19,15 +19,26 @@
let cleaningIds = $state<Set<string>>(new Set());
let bulkCleaning = $state(false);
let loadController: AbortController | null = null;
async function loadStale() {
loadController?.abort();
const ac = new AbortController();
loadController = ac;
loading = true;
error = '';
try {
containers = await api.fetchStaleContainers();
const rows = await api.fetchStaleContainers(ac.signal);
if (ac.signal.aborted) return;
containers = rows;
} catch (e) {
if (ac.signal.aborted) return;
error = e instanceof Error ? e.message : $t('stale.loadFailed');
} finally {
loading = false;
if (loadController === ac) {
loading = false;
loadController = null;
}
}
}
@@ -68,6 +79,7 @@
$effect(() => {
loadStale();
return () => loadController?.abort();
});
</script>
@@ -124,17 +136,124 @@
/>
{:else}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each containers as container (container.container.id)}
<StaleContainerCard
{container}
cleaning={cleaningIds.has(container.container.id)}
oncleanup={requestCleanup}
/>
{#each containers as entry (entry.container.id)}
{@const c = entry.container}
{@const cleaning = cleaningIds.has(c.id)}
<article class="stale-card">
<header class="stale-card-head">
<div class="stale-card-title">
<span class="stale-workload">{entry.workload_name || c.workload_id || '—'}</span>
{#if entry.role}
<span class="stale-role">/ {entry.role}</span>
{/if}
</div>
<span class="stale-pill" title={$t('stale.daysStale')}>{entry.days_stale}d</span>
</header>
<dl class="stale-meta">
<div><dt>{$t('common.running')}</dt><dd>{c.state}</dd></div>
<div><dt>image</dt><dd class="truncate">{c.image_ref}{c.image_tag ? ':' + c.image_tag : ''}</dd></div>
{#if c.last_seen_at}
<div><dt>{$t('stale.lastAlive')}</dt><dd>{$fmt.dateTime(c.last_seen_at)}</dd></div>
{/if}
</dl>
<footer class="stale-card-foot">
<button
type="button"
class="forge-btn-ghost forge-btn-danger"
disabled={cleaning}
onclick={() => requestCleanup(c.id)}
>
{#if cleaning}<IconLoader size={14} />{/if}
<IconTrash size={14} />
<span>{$t('stale.cleanup')}</span>
</button>
</footer>
</article>
{/each}
</div>
{/if}
</div>
<style>
.stale-card {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md, 0.75rem);
background: var(--surface-card);
box-shadow: var(--shadow-sm);
}
.stale-card-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.stale-card-title {
min-width: 0;
display: flex;
gap: 0.35rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--text-primary);
}
.stale-workload {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.stale-role {
color: var(--text-tertiary);
font-weight: 400;
}
.stale-pill {
flex-shrink: 0;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--color-danger-dark);
background: var(--color-danger-light);
}
.stale-meta {
display: grid;
gap: 0.3rem;
font-size: 0.75rem;
color: var(--text-secondary);
margin: 0;
}
.stale-meta > div {
display: grid;
grid-template-columns: 5.5rem 1fr;
gap: 0.5rem;
}
.stale-meta dt {
color: var(--text-tertiary);
text-transform: uppercase;
font-size: 0.62rem;
letter-spacing: 0.06em;
}
.stale-meta dd {
margin: 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
}
.stale-card-foot {
display: flex;
justify-content: flex-end;
padding-top: 0.25rem;
border-top: 1px dashed var(--border-primary);
}
</style>
<!-- Single cleanup confirm -->
<ConfirmDialog
open={confirmSingleId !== ''}