Files
tiny-forge/web/src/lib/components/ContainerLogs.svelte
T
alexei.dolgolyov 739b67856a
Build / build (push) Successful in 10m39s
feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
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.
2026-05-16 06:00:21 +03:00

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>