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:
@@ -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 {
|
||||
|
||||
@@ -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 !== ''}
|
||||
|
||||
Reference in New Issue
Block a user