739b67856a
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.
189 lines
5.2 KiB
Svelte
189 lines
5.2 KiB
Svelte
<!--
|
|
Container log viewer with tail line limit and auto-scroll.
|
|
|
|
Workload-scoped only after the hard cutover. The legacy `instance` and
|
|
`site` source variants targeted /api/projects/.../logs and
|
|
/api/sites/.../logs respectively — both gone with their routes.
|
|
-->
|
|
<script lang="ts">
|
|
import { onDestroy } from 'svelte';
|
|
import { fetchWorkloadContainerLogs } from '$lib/api';
|
|
import { getAuthToken } from '$lib/auth';
|
|
import { t } from '$lib/i18n';
|
|
import { IconLoader, IconX } from '$lib/components/icons';
|
|
|
|
export type LogSource = {
|
|
kind: 'workload';
|
|
workloadId: string;
|
|
containerRowId: string;
|
|
};
|
|
|
|
interface Props {
|
|
source: LogSource;
|
|
onclose: () => void;
|
|
}
|
|
|
|
const { source, onclose }: Props = $props();
|
|
|
|
let lines = $state<string[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state('');
|
|
let tailCount = $state(200);
|
|
let following = $state(false);
|
|
let logContainer: HTMLDivElement | undefined = $state();
|
|
let eventSource: EventSource | null = null;
|
|
|
|
// Batch incoming SSE log lines to avoid per-line re-renders.
|
|
let pendingLines: string[] = [];
|
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function flushPendingLines() {
|
|
flushTimer = null;
|
|
if (pendingLines.length === 0) return;
|
|
let updated = [...lines, ...pendingLines];
|
|
pendingLines = [];
|
|
if (updated.length > tailCount * 2) {
|
|
updated = updated.slice(-tailCount);
|
|
}
|
|
lines = updated;
|
|
scrollToBottom();
|
|
}
|
|
|
|
function enqueueLine(line: string) {
|
|
pendingLines.push(line);
|
|
if (!flushTimer) {
|
|
flushTimer = setTimeout(flushPendingLines, 150);
|
|
}
|
|
}
|
|
|
|
function buildFollowUrl(token: string | null): string {
|
|
const tokenParam = token ? `&token=${token}` : '';
|
|
return `/api/workloads/${source.workloadId}/containers/${source.containerRowId}/logs?follow=true&tail=0${tokenParam}`;
|
|
}
|
|
|
|
async function fetchLogs(tail: number): Promise<string[]> {
|
|
return fetchWorkloadContainerLogs(source.workloadId, source.containerRowId, tail);
|
|
}
|
|
|
|
async function loadLogs() {
|
|
loading = true;
|
|
error = '';
|
|
try {
|
|
lines = await fetchLogs(tailCount);
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : 'Failed to load logs';
|
|
} finally {
|
|
loading = false;
|
|
scrollToBottom();
|
|
}
|
|
}
|
|
|
|
function startFollowing() {
|
|
if (eventSource) return;
|
|
following = true;
|
|
const token = getAuthToken();
|
|
eventSource = new EventSource(buildFollowUrl(token));
|
|
|
|
eventSource.onmessage = (e) => {
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
if (data.line) {
|
|
enqueueLine(data.line);
|
|
}
|
|
} catch { /* ignore parse errors */ }
|
|
};
|
|
|
|
eventSource.onerror = () => {
|
|
stopFollowing();
|
|
};
|
|
}
|
|
|
|
function stopFollowing() {
|
|
if (eventSource) {
|
|
eventSource.close();
|
|
eventSource = null;
|
|
}
|
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
flushPendingLines();
|
|
following = false;
|
|
}
|
|
|
|
function scrollToBottom() {
|
|
requestAnimationFrame(() => {
|
|
if (logContainer) {
|
|
logContainer.scrollTop = logContainer.scrollHeight;
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleTailChange(e: Event) {
|
|
const value = (e.target as HTMLSelectElement).value;
|
|
tailCount = parseInt(value, 10);
|
|
stopFollowing();
|
|
loadLogs();
|
|
}
|
|
|
|
$effect(() => { loadLogs(); });
|
|
|
|
onDestroy(() => {
|
|
stopFollowing();
|
|
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
|
|
});
|
|
</script>
|
|
|
|
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-page)] shadow-lg">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between border-b border-[var(--border-primary)] px-4 py-2.5">
|
|
<div class="flex items-center gap-3">
|
|
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('logs.title')}</h3>
|
|
<select
|
|
value={String(tailCount)}
|
|
onchange={handleTailChange}
|
|
class="rounded border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-0.5 text-xs text-[var(--text-secondary)] focus:outline-none"
|
|
>
|
|
<option value="50">50 {$t('logs.lines')}</option>
|
|
<option value="200">200 {$t('logs.lines')}</option>
|
|
<option value="500">500 {$t('logs.lines')}</option>
|
|
<option value="1000">1000 {$t('logs.lines')}</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
class="rounded px-2 py-0.5 text-xs font-medium transition-colors {following
|
|
? 'bg-emerald-600 text-white'
|
|
: 'border border-[var(--border-input)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
|
onclick={() => following ? stopFollowing() : startFollowing()}
|
|
>
|
|
{following ? $t('logs.following') : $t('logs.follow')}
|
|
</button>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onclick={onclose}
|
|
class="rounded p-1 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] transition-colors"
|
|
>
|
|
<IconX size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Log content -->
|
|
<div
|
|
bind:this={logContainer}
|
|
class="h-80 overflow-auto bg-gray-950 p-3 font-mono text-xs leading-relaxed text-gray-300"
|
|
>
|
|
{#if loading}
|
|
<div class="flex items-center gap-2 text-gray-500">
|
|
<IconLoader size={14} />
|
|
{$t('logs.loading')}
|
|
</div>
|
|
{:else if error}
|
|
<p class="text-red-400">{error}</p>
|
|
{:else if lines.length === 0}
|
|
<p class="text-gray-500">{$t('logs.noLogs')}</p>
|
|
{:else}
|
|
{#each lines as line}
|
|
<div class="hover:bg-gray-900/50 px-1 -mx-1 rounded whitespace-pre-wrap break-all">{line}</div>
|
|
{/each}
|
|
{/if}
|
|
</div>
|
|
</div>
|