From 44295ea931f9ce0f0e07da0a73866f23522d19a1 Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Tue, 8 Sep 2026 07:47:30 +0530 Subject: [PATCH] live compose output during deploys, plus a per-stack deploy history tab --- src/lib/components/LogViewer.svelte | 63 ++- .../components/icons/GitGenericIcon.svelte | 4 +- .../ui/error-dialog/error-dialog-core.ts | 87 +++ .../ui/error-dialog/error-dialog.svelte | 70 +-- src/lib/config/grid-columns.ts | 16 +- src/lib/icons/git-logo-path.ts | 10 + src/lib/openapi.generated.json | 20 +- src/lib/server/db.ts | 49 ++ src/lib/server/deploy-run-record-core.ts | 2 +- src/lib/server/deploy-run-record.ts | 18 +- src/lib/server/git.ts | 216 +++++-- src/lib/server/process-output-core.ts | 33 +- .../server/schedule-execution-access-core.ts | 61 ++ src/lib/server/sse.ts | 30 +- src/lib/types.ts | 2 +- src/lib/utils/deploy-run-view.ts | 22 + .../{server => utils}/deploy-summary-core.ts | 7 + src/lib/utils/log-lines.ts | 33 +- .../api/git/stacks/[id]/webhook/+server.ts | 30 +- .../api/schedules/executions/+server.ts | 64 ++- .../api/schedules/executions/[id]/+server.ts | 58 +- src/routes/settings/general/GeneralTab.svelte | 18 +- src/routes/stacks/+page.svelte | 143 +++-- src/routes/stacks/ComposeOutputModal.svelte | 83 ++- src/routes/stacks/DeployOutputHeader.svelte | 59 ++ src/routes/stacks/DeploySummaryBar.svelte | 49 ++ src/routes/stacks/DeploysPanel.svelte | 512 +++++++++++------ .../stacks/GitDeployProgressPopover.svelte | 263 ++++----- src/routes/stacks/GitStackModal.svelte | 161 +++++- src/routes/stacks/RedeployPopover.svelte | 12 +- src/routes/stacks/StackModal.svelte | 160 +++++- static/openapi.json | 20 +- tests/deploy-run-record-core.test.ts | 1 + tests/deploy-run-view.test.ts | 35 ++ tests/deploy-summary-core.test.ts | 19 +- tests/error-dialog-parse.test.ts | 65 +++ tests/git-stack-deploy-run-record.test.ts | 529 ------------------ tests/log-lines.test.ts | 46 ++ tests/schedule-execution-access-core.test.ts | 85 +++ tests/sse-run-recorder.test.ts | 57 ++ tests/stacks-collect-process.test.ts | 40 ++ 41 files changed, 2093 insertions(+), 1159 deletions(-) create mode 100644 src/lib/components/ui/error-dialog/error-dialog-core.ts create mode 100644 src/lib/icons/git-logo-path.ts create mode 100644 src/lib/server/schedule-execution-access-core.ts rename src/lib/{server => utils}/deploy-summary-core.ts (87%) create mode 100644 src/routes/stacks/DeployOutputHeader.svelte create mode 100644 src/routes/stacks/DeploySummaryBar.svelte create mode 100644 tests/error-dialog-parse.test.ts delete mode 100644 tests/git-stack-deploy-run-record.test.ts create mode 100644 tests/log-lines.test.ts create mode 100644 tests/schedule-execution-access-core.test.ts diff --git a/src/lib/components/LogViewer.svelte b/src/lib/components/LogViewer.svelte index d96801f..e52882f 100644 --- a/src/lib/components/LogViewer.svelte +++ b/src/lib/components/LogViewer.svelte @@ -1,6 +1,6 @@ diff --git a/src/lib/components/ui/error-dialog/error-dialog-core.ts b/src/lib/components/ui/error-dialog/error-dialog-core.ts new file mode 100644 index 0000000..7b823c4 --- /dev/null +++ b/src/lib/components/ui/error-dialog/error-dialog-core.ts @@ -0,0 +1,87 @@ +/** + * Pure parser for docker compose output shown in the error dialog. Extracted from + * error-dialog.svelte so it can be unit-tested (a .svelte script block can't be + * imported directly). The component imports parseDockerOutput from here. + */ + +export interface ParsedStep { + action: string; + status: 'creating' | 'created' | 'starting' | 'started' | 'stopping' | 'stopped' | 'removing' | 'removed' | 'error'; +} + +export interface ParsedOutput { + warnings: string[]; + steps: ParsedStep[]; + error: string | null; + raw: string; + parsed: boolean; +} + +export function parseDockerOutput(text: string): ParsedOutput { + const result: ParsedOutput = { + warnings: [], + steps: [], + error: null, + raw: text, + parsed: false + }; + + try { + const lines = text.split('\n').map((l) => l.trim()).filter(Boolean); + + for (const line of lines) { + // Parse time="..." level=warning msg="..." + // msg often contains escaped quotes, e.g. msg="The \"FOO\" variable is not set.", + // so the capture must allow \" (and \\) inside - a plain [^"]+ stops at the + // first \" and shows a truncated "The \". Un-escape before display. + const warningMatch = line.match(/time="[^"]*"\s+level=warning\s+msg="((?:\\.|[^"\\])*)"/); + if (warningMatch) { + result.warnings.push(warningMatch[1].replace(/\\(["\\])/g, '$1')); + result.parsed = true; + continue; + } + + // Parse container/network steps: "Network foo Creating" or "Container foo-1 Created" + const stepMatch = line.match( + /^\s*(Network|Container|Volume)\s+(\S+)\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i + ); + if (stepMatch) { + const [, type, name, status] = stepMatch; + result.steps.push({ + action: `${type} ${name}`, + status: status.toLowerCase() as ParsedStep['status'] + }); + result.parsed = true; + continue; + } + + // Parse error lines + if (line.startsWith('Error') || line.includes('error') || line.includes('failed')) { + result.error = result.error ? `${result.error}\n${line}` : line; + result.parsed = true; + continue; + } + } + + // If we parsed something but have no clear error, check for remaining unparsed content + if (result.parsed && !result.error) { + const unparsed = lines.filter((line) => { + if (line.match(/time="[^"]*"\s+level=warning/)) return false; + if ( + line.match( + /^\s*(Network|Container|Volume)\s+\S+\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i + ) + ) + return false; + return true; + }); + if (unparsed.length > 0) { + result.error = unparsed.join('\n'); + } + } + } catch { + // Parsing failed, will show raw message + } + + return result; +} diff --git a/src/lib/components/ui/error-dialog/error-dialog.svelte b/src/lib/components/ui/error-dialog/error-dialog.svelte index 827341a..82e1047 100644 --- a/src/lib/components/ui/error-dialog/error-dialog.svelte +++ b/src/lib/components/ui/error-dialog/error-dialog.svelte @@ -4,6 +4,7 @@ import { AlertCircle, Copy, Check, XCircle, AlertTriangle, CheckCircle2 } from 'lucide-svelte'; import * as Tooltip from '$lib/components/ui/tooltip'; import { copyToClipboard } from '$lib/utils/clipboard'; + import { parseDockerOutput } from './error-dialog-core'; interface Props { open: boolean; @@ -16,75 +17,6 @@ let { open = $bindable(), title, message, details, onClose }: Props = $props(); let copied = $state<'ok' | 'error' | null>(null); - interface ParsedOutput { - warnings: string[]; - steps: { action: string; status: 'creating' | 'created' | 'starting' | 'started' | 'error' }[]; - error: string | null; - raw: string; - parsed: boolean; - } - - // Parse docker compose output into structured format - function parseDockerOutput(text: string): ParsedOutput { - const result: ParsedOutput = { - warnings: [], - steps: [], - error: null, - raw: text, - parsed: false - }; - - try { - const lines = text.split('\n').map(l => l.trim()).filter(Boolean); - - for (const line of lines) { - // Parse time="..." level=warning msg="..." - const warningMatch = line.match(/time="[^"]*"\s+level=warning\s+msg="([^"]+)"/); - if (warningMatch) { - result.warnings.push(warningMatch[1]); - result.parsed = true; - continue; - } - - // Parse container/network steps: "Network foo Creating" or "Container foo-1 Created" - const stepMatch = line.match(/^\s*(Network|Container|Volume)\s+(\S+)\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i); - if (stepMatch) { - const [, type, name, status] = stepMatch; - const normalizedStatus = status.toLowerCase() as any; - result.steps.push({ - action: `${type} ${name}`, - status: normalizedStatus - }); - result.parsed = true; - continue; - } - - // Parse error lines - if (line.startsWith('Error') || line.includes('error') || line.includes('failed')) { - result.error = result.error ? `${result.error}\n${line}` : line; - result.parsed = true; - continue; - } - } - - // If we parsed something but have no clear error, check for remaining unparsed content - if (result.parsed && !result.error) { - const unparsed = lines.filter(line => { - if (line.match(/time="[^"]*"\s+level=warning/)) return false; - if (line.match(/^\s*(Network|Container|Volume)\s+\S+\s+(Creating|Created|Starting|Started|Stopping|Stopped|Removing|Removed)\s*$/i)) return false; - return true; - }); - if (unparsed.length > 0) { - result.error = unparsed.join('\n'); - } - } - } catch { - // Parsing failed, will show raw message - } - - return result; - } - const parsed = $derived(parseDockerOutput(message)); async function copyError() { diff --git a/src/lib/config/grid-columns.ts b/src/lib/config/grid-columns.ts index 0f1a43b..056754b 100644 --- a/src/lib/config/grid-columns.ts +++ b/src/lib/config/grid-columns.ts @@ -207,6 +207,19 @@ export const vulnerabilityColumns: ColumnConfig[] = [ { id: 'actions', label: '', fixed: 'end', width: 44, resizable: false } ]; +// Deploy-history grid columns (the "Deploys" panel on a stack). A dense, tabular +// view of past deploy runs; expanding a row reveals that run's log + details. +// Merged summary column carries option pills + error text, so it grows. +export const deployColumns: ColumnConfig[] = [ + { id: 'expand', label: '', fixed: 'start', width: 24, resizable: false }, + { id: 'status', label: 'Status', width: 120, minWidth: 96, resizable: false, sortable: true, sortField: 'status' }, + { id: 'when', label: 'When', width: 230, minWidth: 140, sortable: true, sortField: 'startedAt' }, + { id: 'duration', label: 'Duration', width: 90, minWidth: 70, align: 'right', sortable: true, sortField: 'duration' }, + { id: 'trigger', label: 'Trigger', width: 120, minWidth: 90, sortable: true, sortField: 'triggeredBy' }, + { id: 'summary', label: 'Summary', width: 260, minWidth: 140, grow: true, noTruncate: true }, + { id: 'actions', label: '', fixed: 'end', width: 44, resizable: false } +]; + // Map of grid ID to column definitions export const gridColumnConfigs: Record = { containers: containerColumns, @@ -222,7 +235,8 @@ export const gridColumnConfigs: Record = { backupDestinations: backupDestinationColumns, backups: backupColumns, repoSnapshots: repoSnapshotColumns, - vulnerabilities: vulnerabilityColumns + vulnerabilities: vulnerabilityColumns, + deploys: deployColumns }; // Get configurable columns (not fixed) diff --git a/src/lib/icons/git-logo-path.ts b/src/lib/icons/git-logo-path.ts new file mode 100644 index 0000000..6d3aa60 --- /dev/null +++ b/src/lib/icons/git-logo-path.ts @@ -0,0 +1,10 @@ +// The mdi-git logo path (viewBox "0 0 24 24"), the official Git mark -- distinct from +// lucide's GitBranch, which reads as a branch. Single source of truth shared by the +// GitGenericIcon.svelte component (Settings repo list) and the inline SVG string used +// in log output (wrapHtmlLines, which renders raw HTML and can't mount a component). +export const GIT_LOGO_PATH = + 'M2.6,10.59L8.38,4.8L10.07,6.5C9.83,7.35 10.22,8.28 11,8.73V14.27C10.4,14.61 10,15.26 10,16A2,2 0 0,0 ' + + '12,18A2,2 0 0,0 14,16C14,15.26 13.6,14.61 13,14.27V9.41L15.07,11.5C15,11.65 15,11.82 15,12A2,2 0 0,0 17,14A2,2 0 0,0 ' + + '19,12A2,2 0 0,0 17,10C16.82,10 16.65,10 16.5,10.07L13.93,7.5C14.19,6.57 13.71,5.55 12.78,5.16C12.35,5 11.9,4.96 ' + + '11.5,5.07L9.8,3.38L10.59,2.6C11.37,1.81 12.63,1.81 13.41,2.6L21.4,10.59C22.19,11.37 22.19,12.63 21.4,13.41L13.41,21.4C12.63,22.19 ' + + '11.37,22.19 10.59,21.4L2.6,13.41C1.81,12.63 1.81,11.37 2.6,10.59Z'; diff --git a/src/lib/openapi.generated.json b/src/lib/openapi.generated.json index f872a4b..3432d27 100644 --- a/src/lib/openapi.generated.json +++ b/src/lib/openapi.generated.json @@ -22409,6 +22409,12 @@ } } }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (the :view permission for the requested schedule type's resource - stacks/backups/schedules)" + }, "500": { "description": "Unexpected error while loading execution history" } @@ -22485,6 +22491,12 @@ "400": { "description": "Invalid execution id" }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Permission denied (the :view permission for the row's resource), or no access to its environment (enterprise)" + }, "404": { "description": "Execution not found" }, @@ -22543,8 +22555,14 @@ "400": { "description": "Invalid execution id" }, + "401": { + "description": "Authentication required" + }, "403": { - "description": "Permission denied (RBAC 'schedules:edit' missing)" + "description": "Permission denied (the :edit permission for the row's resource), or no access to its environment (enterprise)" + }, + "404": { + "description": "Execution not found" }, "500": { "description": "Unexpected error while deleting the execution" diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 311f673..a382a11 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -4277,6 +4277,32 @@ export async function saveDashboardPreferences(data: { // ============================================================================= export type ScheduleType = 'container_update' | 'git_stack_sync' | 'system_cleanup' | 'env_update_check' | 'image_prune' | 'backup' | 'restore' | 'stack_deploy' | 'deploy_log_reconcile'; + +// Runtime list of every ScheduleType. Used to bound a no-type-filter executions +// query to the caller's viewable types (viewableScheduleTypes). The two type +// assertions below make it a real drift-guard: `satisfies` rejects a bogus value, +// and the bidirectional _exhaustive check fails to compile if a union member is +// missing from (or extra in) this array. +export const ALL_SCHEDULE_TYPES = [ + 'container_update', + 'git_stack_sync', + 'system_cleanup', + 'env_update_check', + 'image_prune', + 'backup', + 'restore', + 'stack_deploy', + 'deploy_log_reconcile' +] as const satisfies readonly ScheduleType[]; +// Compile error if ALL_SCHEDULE_TYPES and ScheduleType ever diverge. +type _ScheduleTypeExhaustive = + Exclude extends never + ? Exclude<(typeof ALL_SCHEDULE_TYPES)[number], ScheduleType> extends never + ? true + : never + : never; +const _scheduleTypeExhaustive: _ScheduleTypeExhaustive = true; +void _scheduleTypeExhaustive; export type ScheduleTrigger = 'cron' | 'webhook' | 'manual' | 'startup'; export type ScheduleStatus = | 'queued' @@ -4334,8 +4360,19 @@ export interface ScheduleExecutionUpdateData { export interface ScheduleExecutionFilters { scheduleType?: ScheduleType; + // Enterprise per-resource RBAC: restrict to this allow-list of types when the + // caller lacks view on every resource (see viewableScheduleTypes). An empty + // array matches nothing (drizzle inArray([]) -> false), which is the correct + // "caller may see no types" result. + scheduleTypes?: ScheduleType[]; scheduleId?: number; environmentId?: number | null; + // Enterprise env-scoped RBAC: restrict to these environment ids. A row with a + // NULL environmentId (system schedules, local-env deploys) is always included + // -- those are not attributed to any environment, matching how the per-run + // deploy access checks treat null (deploy-run-access.ts). Ignored when the + // caller has all-environment access (pass undefined). + environmentIds?: number[]; // Powers "runs for this stack" lookups (schedule_executions_entity_env_idx) -- // paired with environmentId, an exact match on the (entity_name, environment_id) // index this filter was added for. @@ -4459,6 +4496,9 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters = if (filters.scheduleType) { conditions.push(eq(scheduleExecutions.scheduleType, filters.scheduleType)); } + if (filters.scheduleTypes !== undefined) { + conditions.push(inArray(scheduleExecutions.scheduleType, filters.scheduleTypes)); + } if (filters.scheduleId !== undefined) { conditions.push(eq(scheduleExecutions.scheduleId, filters.scheduleId)); } @@ -4469,6 +4509,15 @@ export async function getScheduleExecutions(filters: ScheduleExecutionFilters = conditions.push(eq(scheduleExecutions.environmentId, filters.environmentId)); } } + // Enterprise env-scoping: accessible envs OR a null (unattributed) env. + if (filters.environmentIds !== undefined) { + conditions.push( + or( + isNull(scheduleExecutions.environmentId), + inArray(scheduleExecutions.environmentId, filters.environmentIds) + ) + ); + } if (filters.entityName !== undefined) { conditions.push(eq(scheduleExecutions.entityName, filters.entityName)); } diff --git a/src/lib/server/deploy-run-record-core.ts b/src/lib/server/deploy-run-record-core.ts index 8602a18..ba06550 100644 --- a/src/lib/server/deploy-run-record-core.ts +++ b/src/lib/server/deploy-run-record-core.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import type { DeploySummary } from './deploy-summary-core'; +import type { DeploySummary } from '$lib/utils/deploy-summary-core'; /** * Assembles the `details` JSON stored on a stack_deploy schedule_executions row. diff --git a/src/lib/server/deploy-run-record.ts b/src/lib/server/deploy-run-record.ts index 9b6abae..deff476 100644 --- a/src/lib/server/deploy-run-record.ts +++ b/src/lib/server/deploy-run-record.ts @@ -4,7 +4,7 @@ import { type ScheduleTrigger } from './db'; import { appendRunLog, runLogFileName, SizeBudgetTracker } from './deploy-log-store'; -import { summarize } from './deploy-summary-core'; +import { summarize } from '$lib/utils/deploy-summary-core'; import { buildRunDetails, type DeployRunOptions } from './deploy-run-record-core'; import { redactLine } from './secret-redaction'; import type { RunRecorder } from './sse'; @@ -43,8 +43,11 @@ export class DeployRunRecorder implements RunRecorder { private readonly runId: string; private readonly startedAtMs: number; private readonly options: DeployRunOptions; - private readonly composeHash: string; - private readonly envHash: string; + /** Not readonly: setContentHashes() fills these after construction for callers that + * create the recorder before the compose content is known (the UI git-deploy path, + * which records the clone/read stages before it has the compose to hash). */ + private composeHash: string; + private envHash: string; private readonly userId?: number; /** F5 fix: which environment this run belongs to (or null) -- determines the ON-DISK * directory its log file lives in and which environment's size budget it counts @@ -145,6 +148,15 @@ export class DeployRunRecorder implements RunRecorder { } } + /** Set the compose/env content hashes once they are known -- for callers that create + * the recorder before the compose content is available (the UI git-deploy path). The + * hashes are only read at end() (into the stored details), so this is safe any time + * before the run closes. */ + setContentHashes(composeHash: string, envHash: string): void { + this.composeHash = composeHash; + this.envHash = envHash; + } + /** * Called on every path out of the deploy (success, failure, or throw) -- including * when no line() ever arrived, e.g. a deploy that fails before compose produces any diff --git a/src/lib/server/git.ts b/src/lib/server/git.ts index 6d161d6..ccd98d5 100644 --- a/src/lib/server/git.ts +++ b/src/lib/server/git.ts @@ -1378,6 +1378,29 @@ export async function deployGitStack( if (!syncResult.success) { console.log(`${logPrefix} Sync failed:`, syncResult.error); const failResult = { success: false, error: syncResult.error }; + // Record the sync failure as a failed stack_deploy run so it shows in the Deploys + // tab (webhook/scheduled/manual all reach here). Best-effort and fully isolated: + // a recorder error must never change the notify/return workflow below. Skip the + // benign "Sync already in progress" overlap -- it's a concurrency skip, not a + // deploy failure, and recording it would clutter history with false failures. + if (!(syncResult.error ?? '').includes('Sync already in progress')) { + try { + const failRecorder = await createRunRecorder({ + stackName: gitStack.stackName, + envId: gitStack.environmentId ?? null, + userId: options?.userId, + triggeredBy, + options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: !!(options?.force ?? true) }, + composeHash: '', + envHash: '', + secrets: [] + }); + failRecorder.line(`fatal: ${syncResult.error ?? 'git sync failed'}`); + await failRecorder.end(false, undefined, syncResult.error ?? 'git sync failed'); + } catch (e) { + console.error(`${logPrefix} Failed to record git sync failure:`, e); + } + } await notifyGitSync(gitStack.stackName, gitStack.environmentId, failResult); return failResult; } @@ -1433,19 +1456,32 @@ export async function deployGitStack( // from syncResult.composeContent, the EXACT string handed to deployStack right // below, not a redundant re-read off disk. // - // envHash/secrets mirror the same (deliberately approximate) resolution the - // non-git routes already use: getNonSecretEnvVarsAsRecord/getSecretEnvVarsAsRecord - // keyed by stackName+envId -- the same DB-sourced values deployStack itself - // resolves again internally at deploy time (stacks.ts). This does NOT include - // secret-provider-injected vars or the git repo's own .env file content; that's - // fine for the redaction list because deployStack's onLine output is - // independently redacted against ITS OWN, more complete secrets set - // (makeLineForwarder in stacks.ts) -- a leaked value in the log is still caught - // even if it isn't one of the vars hashed here. - const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); - const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); - const effectiveEnvVars = { ...nonSecretVars, ...secretVars }; + // envHash/secrets mirror the same resolution the non-git routes use + // (getNonSecretEnvVarsAsRecord/getSecretEnvVarsAsRecord keyed by stackName+envId, + // the DB-sourced values deployStack resolves again at deploy time in stacks.ts), + // PLUS the repo's own .env values below so the redaction set is complete for what + // end() stores. Secret-provider-injected vars are still only in deployStack's own + // makeLineForwarder set (stacks.ts), which redacts the streamed lines; end()'s + // stored errorMessage is the one that also needs the .env values folded in here. + // The repo's own .env values are passed to compose via --env-file and never + // flow through the DB-sourced var records above, so without this they'd be in + // NEITHER redaction set -- a repo-.env-only secret echoed to stderr would land + // unredacted in the stored errorMessage (which the executions API returns). Fold + // them into the recorder's secrets so recorder.end()'s redactLine catches them. + // Best-effort: these reads exist only to seed the recorder, so a decrypt/DB failure + // must not abort the deploy -- fall back to whatever we could read. + let effectiveEnvVars: Record = { ...(syncResult.envFileVars ?? {}) }; + try { + const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); + const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); + effectiveEnvVars = { ...nonSecretVars, ...secretVars, ...(syncResult.envFileVars ?? {}) }; + } catch (e) { + console.error(`${logPrefix} Failed to read env vars for run-record redaction (deploy continues):`, e); + } + // The recorder is a BEST-EFFORT side channel: its DB/FS I/O must never abort the + // deploy nor flip its outcome. Swallow a createRunRecorder failure so a record-store + // hiccup degrades to "no run record", never a failed or aborted deploy. const recorder = await createRunRecorder({ stackName: gitStack.stackName, envId: gitStack.environmentId ?? null, @@ -1455,13 +1491,16 @@ export async function deployGitStack( composeHash: hashComposeContent(syncResult.composeContent!), envHash: hashEnvFingerprint(effectiveEnvVars), secrets: Object.values(effectiveEnvVars) + }).catch((e) => { + console.error(`${logPrefix} Failed to create deploy run recorder (continuing without one):`, e); + return null; }); // Mirrors every redacted output line into the run record's log file, in addition // to (not instead of) forwarding it to the caller's own onLine (SSE progress) -- // see the recorder doc comment above for why this can't be "record OR stream". const onLine = (line: string) => { - recorder.line(line); + recorder?.line(line); options?.onLine?.(line); }; @@ -1485,8 +1524,13 @@ export async function deployGitStack( } catch (error) { // deployStack has never been observed to throw here (every existing caller of // deployGitStack calls it without a try/catch around this point), but the - // record must not be left "running" forever on the day that changes. - await recorder.end(false, undefined, error instanceof Error ? error.message : String(error)); + // record must not be left "running" forever on the day that changes. Best-effort: + // a recorder-close failure must not mask the original deploy error we re-throw. + try { + await recorder?.end(false, undefined, error instanceof Error ? error.message : String(error)); + } catch (e) { + console.error(`${logPrefix} Failed to close run recorder on deploy error (original error preserved):`, e); + } throw error; } @@ -1497,12 +1541,18 @@ export async function deployGitStack( // leaking into result.error (a real possibility: cron/webhook-triggered git // deploys are exactly the unattended runs most likely to use a bound provider) // would bypass end()'s redaction entirely. See deploy-run-record.ts. - recorder.addSecrets(result.resolvedSecrets ?? []); + recorder?.addSecrets(result.resolvedSecrets ?? []); // Closed right after the result is known, before the post-success bookkeeping // below (deletion sync, upsertStackSource) -- those never fail the deploy itself, // so the recorded duration reflects the deploy, not the bookkeeping around it. - await recorder.end(result.success, undefined, result.success ? undefined : result.error); + // Best-effort: a close failure (log FS / DB write) must NOT invert the already-known + // deploy result -- the deploy succeeded regardless of whether we could record it. + try { + await recorder?.end(result.success, undefined, result.success ? undefined : result.error); + } catch (e) { + console.error(`${logPrefix} Failed to close run recorder (deploy outcome unaffected):`, e); + } console.log(`${logPrefix} ----------------------------------------`); console.log(`${logPrefix} DEPLOY GIT STACK RESULT`); @@ -1624,6 +1674,10 @@ type ProgressCallback = (data: { step?: number; totalSteps?: number; error?: string; + // A raw (already secret-redacted) compose output line, forwarded alongside the + // discrete stages so the UI can show the full log under the step list. Carried on + // its own field, not `message`, so a log line is never rendered as a stage. + logLine?: string; }) => void; /** @@ -1668,14 +1722,53 @@ export async function deployGitStackWithProgress( const totalSteps = 5; - // Created just before the deployStack() call below, same as deployGitStack -- - // undefined here means the run never got that far (e.g. clone failed), exactly - // mirroring deployGitStack's own "only record a run that is actually happening". - let recorder: Awaited> | undefined; + // Create the run recorder up front (not at the deploy step) so a failure at ANY + // stage -- clone/fetch/read or the deploy -- produces a Deploys-tab record with the + // git-stage log lines, instead of leaving no trace. Compose/env hashes start empty + // and are irrelevant for a failed pre-compose run; a successful run's real summary is + // still derived from the compose output streamed into the recorder below. + // + // The recorder is a BEST-EFFORT side channel: its DB/FS I/O must never abort the + // deploy nor flip its outcome. createRunRecorder does DB writes that can throw -- + // swallow that here so a record-store hiccup degrades to "no run record", never a + // failed or aborted deploy. + const recorder = await createRunRecorder({ + stackName: gitStack.stackName, + envId: gitStack.environmentId ?? null, + triggeredBy: 'manual', + options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: false }, + composeHash: '', + envHash: '', + secrets: [] + }).catch((e) => { + console.error('Failed to create git deploy run recorder (continuing without one):', e); + return null; + }); + + // Mirror every git-stage message into the run log so the record reads like the live + // dialog (Connecting / Cloning / Fetching / ...), and forward progress to the caller. + const onProgressAndRecord: ProgressCallback = (data) => { + if (typeof data.message === 'string') recorder?.line(data.message); + onProgress(data); + }; + + // end() writes the DB row and is not idempotent; guard so the deploy-throw path and + // the outer catch don't both close the same record. Best-effort: a close failure is + // logged, never propagated -- it must not invert a successful deploy's result. + let recorderEnded = false; + const endRecorder = async (ok: boolean, error?: string) => { + if (recorderEnded || !recorder) return; + recorderEnded = true; + try { + await recorder.end(ok, undefined, error); + } catch (e) { + console.error('Failed to close git deploy run recorder (deploy outcome unaffected):', e); + } + }; try { // Step 1: Connecting - onProgress({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps }); + onProgressAndRecord({ status: 'connecting', message: 'Connecting to repository...', step: 1, totalSteps }); await updateGitStack(stackId, { syncStatus: 'syncing', syncError: null }); let updated = false; @@ -1687,7 +1780,7 @@ export async function deployGitStackWithProgress( const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null; // Step 2: Cloning - onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps }); + onProgressAndRecord({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps }); if (existsSync(repoPath)) { rmSync(repoPath, { recursive: true, force: true }); @@ -1697,7 +1790,7 @@ export async function deployGitStackWithProgress( const repoUrl = buildRepoUrl(repo.url, credential); // Step 3: Fetching (blobless clone - fetches all commits but blobs on-demand) - onProgress({ status: 'fetching', message: `Fetching branch ${effectiveBranch}...`, step: 3, totalSteps }); + onProgressAndRecord({ status: 'fetching', message: `Fetching branch ${effectiveBranch}...`, step: 3, totalSteps }); const cloneResult = await execGit( ['clone', '--filter=blob:none', '--branch', effectiveBranch, repoUrl, repoPath], process.cwd(), @@ -1738,7 +1831,7 @@ export async function deployGitStackWithProgress( currentCommit = commitResult.stdout.substring(0, 7); // Step 4: Reading compose file - onProgress({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps }); + onProgressAndRecord({ status: 'reading', message: `Reading ${gitStack.composePath}...`, step: 4, totalSteps }); const composePath = repoFilePath(repoPath, gitStack.composePath, "Compose path"); if (!existsSync(composePath)) { throw new Error(`Compose file not found: ${gitStack.composePath}`); @@ -1813,14 +1906,14 @@ export async function deployGitStackWithProgress( deletionData.plan.skipped ) ); - onProgress({ status: 'deploying', message: `File changes: ${changeTable[0]}`, step: 5, totalSteps }); + onProgressAndRecord({ status: 'deploying', message: `File changes: ${changeTable[0]}`, step: 5, totalSteps }); for (const line of changeTable.slice(1)) { - onProgress({ status: 'deploying', message: line, step: 5, totalSteps }); + onProgressAndRecord({ status: 'deploying', message: line, step: 5, totalSteps }); } // Step 5: Deploying stack // Uses `docker compose up -d --remove-orphans` which only recreates changed services - onProgress({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps }); + onProgressAndRecord({ status: 'deploying', message: `Deploying ${gitStack.stackName}...`, step: 5, totalSteps }); if (deletionData.plan.toDelete.length > 0) { onProgress({ status: 'deploying', @@ -1839,32 +1932,20 @@ export async function deployGitStackWithProgress( } } - // Same stack_deploy run record shape deployGitStack builds (see its doc - // comment above deployGitStack) -- this manual "Deploy" button path runs its - // own clone/read/deploy pipeline instead of calling deployGitStack (it needs - // this function's own discrete onProgress stages, not deployGitStack's onLine - // streaming), so it was the one caller of deployStack that never went through - // createRunRecorder at all -- unlike every other trigger (webhook, - // config-update-then-redeploy, and cron via runGitStackSync, all of which - // funnel through deployGitStack). Wired in here, right before the SAME - // deployStack() call deployGitStack wires it around, so it records without - // touching onProgress's own streaming. - const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); - const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); - const effectiveEnvVars = { ...nonSecretVars, ...secretVars }; - - recorder = await createRunRecorder({ - stackName: gitStack.stackName, - envId: gitStack.environmentId ?? null, - triggeredBy: 'manual', - // forceRecreate is never passed to deployStack below (unlike deployGitStack, - // which computes it from forceRedeploy/gitUpdated) -- this path always omits - // it, so the record should say so too rather than guessing. - options: { pull: !!gitStack.repullImages, build: !!gitStack.buildOnDeploy, forceRecreate: false }, - composeHash: hashComposeContent(composeContent), - envHash: hashEnvFingerprint(effectiveEnvVars), - secrets: Object.values(effectiveEnvVars) - }); + // The recorder was created up front with empty hashes (for failure coverage before + // the compose is known); now that we have the compose + env, fill in the real + // content hashes and feed the env values in as redaction secrets. All best-effort: + // these reads/updates exist only for the recorder, so a decrypt/DB failure must not + // abort the deploy -- swallow it and proceed with whatever we could read. + try { + const nonSecretVars = await getNonSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); + const secretVars = await getSecretEnvVarsAsRecord(gitStack.stackName, gitStack.environmentId); + const effectiveEnvVars = { ...nonSecretVars, ...secretVars }; + recorder?.setContentHashes(hashComposeContent(composeContent), hashEnvFingerprint(effectiveEnvVars)); + recorder?.addSecrets(Object.values(effectiveEnvVars)); + } catch (e) { + console.error('Failed to read env vars for run-record redaction (deploy continues):', e); + } let result: Awaited>; try { @@ -1879,16 +1960,20 @@ export async function deployGitStackWithProgress( noBuildCache: gitStack.noBuildCache, pullPolicy: gitStack.repullImages ? 'always' : undefined, filesToDelete: deletionData.plan.toDelete, - // Mirrors deploy output into the run record's log file. Not forwarded to - // onProgress -- that callback's contract is the 5 discrete stage messages - // this function already sends above, not raw compose output lines. - onLine: (line) => recorder?.line(line) + // Each `line` is already secret-redacted by deployStack (makeLineForwarder + // in stacks.ts). Mirror it into the run record's log file AND forward it to + // onProgress as a logLine so the UI shows the full compose log under the + // step list -- the same single redacted stream, two sinks. + onLine: (line) => { + recorder?.line(line); + onProgress({ status: 'deploying', logLine: line, step: 5, totalSteps }); + } }); } catch (error) { // deployStack has never been observed to throw here, but the record must // not be left "running" forever on the day that changes (same posture // deployGitStack takes around its own deployStack call). - await recorder.end(false, undefined, error instanceof Error ? error.message : String(error)); + await endRecorder(false, error instanceof Error ? error.message : String(error)); throw error; } @@ -1897,8 +1982,8 @@ export async function deployGitStackWithProgress( // `recorder` above was already built from the DB-only vars read before calling // it. Feed the provider-resolved values in now, before recorder.end(), so a // provider secret leaking into result.error is still redacted. - recorder.addSecrets(result.resolvedSecrets ?? []); - await recorder.end(result.success, undefined, result.success ? undefined : result.error); + recorder?.addSecrets(result.resolvedSecrets ?? []); + await endRecorder(result.success, result.success ? undefined : result.error); if (result.success) { // Deletion sync: persist manifest + log per-file change summary. @@ -1949,6 +2034,15 @@ export async function deployGitStackWithProgress( syncStatus: 'error', syncError: error.message }); + // Close the run record as failed. The recorder exists from the start, so a failure + // at ANY stage -- clone/fetch/read or the deploy -- leaves a record with the + // git-stage log lines captured above. Only log+close if the record isn't already + // closed (the deploy-throw inner catch closes it first); appending after close + // would write an orphan line the summarized row never reflects. + if (!recorderEnded) { + recorder?.line(`fatal: ${error.message}`); + await endRecorder(false, error.message); + } onProgress({ status: 'error', error: error.message }); return { success: false, error: error.message }; } diff --git a/src/lib/server/process-output-core.ts b/src/lib/server/process-output-core.ts index 680bd9b..7fe1e56 100644 --- a/src/lib/server/process-output-core.ts +++ b/src/lib/server/process-output-core.ts @@ -22,29 +22,40 @@ export function collectProcess( return new Promise((resolve, reject) => { const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; - let lineBuffer = ''; + // One buffer PER stream. A single shared buffer would splice a partial + // stdout line (no trailing '\n') onto the next stderr chunk (and vice + // versa) when the two streams interleave, producing corrupted lines. + let stdoutBuffer = ''; + let stderrBuffer = ''; - const emitLines = (chunk: string) => { - if (!onLine) return; - lineBuffer += chunk; - const parts = lineBuffer.split('\n'); - lineBuffer = parts.pop() ?? ''; // last part may be incomplete + // Emit each complete line in `buf + chunk`, return the trailing partial. + const emitLines = (buf: string, chunk: string): string => { + if (!onLine) return buf; + const parts = (buf + chunk).split('\n'); + const rest = parts.pop() ?? ''; // last part may be incomplete for (const line of parts) onLine(line); + return rest; }; proc.stdout?.on('data', (chunk: Buffer) => { stdoutChunks.push(chunk); - emitLines(chunk.toString()); + stdoutBuffer = emitLines(stdoutBuffer, chunk.toString()); }); proc.stderr?.on('data', (chunk: Buffer) => { stderrChunks.push(chunk); - emitLines(chunk.toString()); + stderrBuffer = emitLines(stderrBuffer, chunk.toString()); }); proc.on('error', reject); proc.on('close', (code) => { - if (onLine && lineBuffer.length > 0) { - onLine(lineBuffer); - lineBuffer = ''; + // Flush each stream's trailing partial line (stdout before stderr for a + // deterministic order). + if (onLine && stdoutBuffer.length > 0) { + onLine(stdoutBuffer); + stdoutBuffer = ''; + } + if (onLine && stderrBuffer.length > 0) { + onLine(stderrBuffer); + stderrBuffer = ''; } resolve({ exitCode: code ?? 1, diff --git a/src/lib/server/schedule-execution-access-core.ts b/src/lib/server/schedule-execution-access-core.ts new file mode 100644 index 0000000..3006437 --- /dev/null +++ b/src/lib/server/schedule-execution-access-core.ts @@ -0,0 +1,61 @@ +/** + * Pure permission mapping for reading schedule_executions rows. No imports, so + * it stays unit-testable without the DB/auth module chain. + * + * A schedule execution's row (its errorMessage, details, and full logs) is only + * as sensitive as the thing it ran, so reading it is gated by the SAME resource + * permission that gates the underlying feature -- NOT a single blanket + * 'schedules:view'. That would either over-restrict (a backups-only viewer + * loses backup run stats) or under-restrict (a schedules-only viewer reads + * compose stderr from a stack deploy they can't otherwise see). Deploy stderr + * can carry redacted-but-not-guaranteed-clean output, hence the care. + */ + +export type ScheduleExecResource = 'stacks' | 'backups' | 'schedules'; + +/** Every schedule type that maps to a NON-generic resource. Types not listed here + * fall through to 'schedules' in resourceForScheduleType. Kept here (not inlined) + * so the list gate can enumerate which types a resource-scoped caller may see. */ +const NON_GENERIC_TYPES: Record = { + stack_deploy: 'stacks', + git_stack_sync: 'stacks', + backup: 'backups', + restore: 'backups' +}; + +/** + * The resource whose `:view` permission a caller must hold to read executions of + * this schedule type. Unknown/unrecognized types fall back to 'schedules' -- the + * generic bucket, never a weaker one. + */ +export function resourceForScheduleType(scheduleType: string): ScheduleExecResource { + return NON_GENERIC_TYPES[scheduleType] ?? 'schedules'; +} + +/** + * The concrete schedule types whose executions a caller may read given which + * resource :view permissions they hold. Used to bound a NO-scheduleType-filter + * list query so a caller never receives errorMessage/details of a type they lack + * permission for. + * + * Returns null when 'schedules' AND 'stacks' AND 'backups' are all viewable -- + * i.e. every type is allowed, so no type filter is needed (null = "do not + * filter", matching getScheduleExecutions' scheduleTypes semantics). Otherwise + * returns the explicit allow-list: the always-generic types when 'schedules' is + * held, plus each NON_GENERIC type whose resource is held. + * + * `allKnownTypes` is passed in (the caller has the ScheduleType union) so this + * stays dependency-free and testable. + */ +export function viewableScheduleTypes( + viewable: { schedules: boolean; stacks: boolean; backups: boolean }, + allKnownTypes: readonly string[] +): string[] | null { + if (viewable.schedules && viewable.stacks && viewable.backups) return null; + const allowed: string[] = []; + for (const t of allKnownTypes) { + const resource = resourceForScheduleType(t); + if (viewable[resource]) allowed.push(t); + } + return allowed; +} diff --git a/src/lib/server/sse.ts b/src/lib/server/sse.ts index 84ad0d1..d204409 100644 --- a/src/lib/server/sse.ts +++ b/src/lib/server/sse.ts @@ -27,6 +27,10 @@ export interface RunRecorder { * the only method that reads the accumulated secret list -- see deploy-run-record.ts. */ addSecrets(values: string[]): void; + /** Sets the compose/env content hashes after construction, for callers that create the + * recorder before the compose content is known (the UI git-deploy path records the + * clone/read stages first). Read only at end(), so safe any time before the run closes. */ + setContentHashes(composeHash: string, envHash: string): void; /** Called exactly once, on every path out of the operation (success, failure, or throw). */ end(ok: boolean, exitCode?: number, error?: string): Promise; } @@ -98,9 +102,16 @@ export function createJobResponse( // Awaited here, before enqueue/close: this async start() is the // only place on the JSON path where "the run is over" exists as a // moment in time, so the recorder's close is placed right on it. + // Best-effort: the recorder is an optional side effect (writes the run + // log), so a failure to close it must never block the API response + // the client is waiting for. if (recorder) { const { ok, error } = endFromResult(resultData); - await recorder.end(ok, undefined, error); + try { + await recorder.end(ok, undefined, error); + } catch (e) { + console.error('Failed to close deploy run recorder (JSON path):', e); + } } } controller.enqueue(encoder.encode(JSON.stringify(resultData))); @@ -144,16 +155,29 @@ export function createJobResponse( // the path every browser call takes (neither +page.svelte nor StackModal.svelte // send an Accept header) -- reuse the JSON path's endFromResult() instead of // hardcoding end(true), or every such run was recorded as a success. + // Best-effort: a failure to close the recorder must not throw into .catch() + // below, which would fail an already-succeeded job just because the log + // write failed. if (recorder) { recorderEnded = true; const { ok, error } = endFromResult(resultData); - await recorder.end(ok, undefined, error); + try { + await recorder.end(ok, undefined, error); + } catch (e) { + console.error('Failed to close deploy run recorder (stream path):', e); + } } }) .catch(async (err: unknown) => { const message = err instanceof Error ? err.message : String(err); failJob(job, message); - if (recorder && !recorderEnded) await recorder.end(false, undefined, message); + if (recorder && !recorderEnded) { + try { + await recorder.end(false, undefined, message); + } catch (e) { + console.error('Failed to close deploy run recorder (stream error path):', e); + } + } }); return json({ jobId: job.id }); diff --git a/src/lib/types.ts b/src/lib/types.ts index 5640aa7..59daea8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -186,7 +186,7 @@ export interface GitRepository { } // Grid column configuration types -export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments' | 'backupDestinations' | 'backups' | 'repoSnapshots' | 'vulnerabilities'; +export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments' | 'backupDestinations' | 'backups' | 'repoSnapshots' | 'vulnerabilities' | 'deploys'; export interface ColumnConfig { id: string; diff --git a/src/lib/utils/deploy-run-view.ts b/src/lib/utils/deploy-run-view.ts index 0c4bb73..d1e164f 100644 --- a/src/lib/utils/deploy-run-view.ts +++ b/src/lib/utils/deploy-run-view.ts @@ -25,6 +25,8 @@ export interface DeployRunSummary { containersCreated: number; containersRecreated: number; containersStarted: number; + /** Distinct container names seen in the log (optional: older runs predate it). */ + containerNames?: string[]; imagesBuilt: string[]; imagesPulled: string[]; buildSteps: number; @@ -67,6 +69,8 @@ export interface DeployRunView { optionChips: string[]; imagesBuilt: string[]; imagesPulled: string[]; + /** Distinct container names touched by the deploy (for name-matched icons). */ + containerNames: string[]; /** Shortened last line of errorMessage -- only ever set for a failed run. */ errorSummary: string | null; truncated: boolean; @@ -225,7 +229,25 @@ export function buildDeployRunView(run: DeployRun): DeployRunView { optionChips: formatOptionChips(run.details?.options), imagesBuilt: summary?.imagesBuilt ?? [], imagesPulled: summary?.imagesPulled ?? [], + containerNames: summary?.containerNames ?? [], errorSummary: run.status === 'failed' ? lastErrorLine(run.errorMessage) : null, truncated: run.details?.truncated === true }; } + +/** Tab-badge tally: total runs plus a success/failure split. Pure so both the + * panel and its parent modals compute the same shape from a list of runs. */ +export interface DeployTally { + total: number; + ok: number; + failed: number; +} + +export function deployTallyFromRuns(runs: Pick[]): DeployTally { + let ok = 0, failed = 0; + for (const r of runs) { + if (r.status === 'success') ok++; + else if (r.status === 'failed') failed++; + } + return { total: runs.length, ok, failed }; +} diff --git a/src/lib/server/deploy-summary-core.ts b/src/lib/utils/deploy-summary-core.ts similarity index 87% rename from src/lib/server/deploy-summary-core.ts rename to src/lib/utils/deploy-summary-core.ts index 7956799..f96c967 100644 --- a/src/lib/server/deploy-summary-core.ts +++ b/src/lib/utils/deploy-summary-core.ts @@ -13,6 +13,8 @@ export interface DeploySummary { containersCreated: number; containersRecreated: number; containersStarted: number; + /** Distinct container names seen in the log, in first-appearance order. */ + containerNames: string[]; imagesBuilt: string[]; imagesPulled: string[]; buildSteps: number; @@ -30,6 +32,9 @@ export function summarize(lines: string[]): DeploySummary { const created = new Set(); const recreated = new Set(); const started = new Set(); + // Distinct names across all verbs, insertion-ordered (a name recurs across + // Recreated + Started, so this is not any single verb Set). + const names = new Set(); const built = new Set(); const pulled = new Set(); let buildSteps = 0; @@ -40,6 +45,7 @@ export function summarize(lines: string[]): DeploySummary { const c = CONTAINER.exec(line); if (c) { const [, name, verb] = c; + names.add(name); if (verb === 'Created') created.add(name); else if (verb === 'Recreated') recreated.add(name); else started.add(name); @@ -61,6 +67,7 @@ export function summarize(lines: string[]): DeploySummary { containersCreated: created.size, containersRecreated: recreated.size, containersStarted: started.size, + containerNames: [...names], imagesBuilt: [...built], imagesPulled: [...pulled], buildSteps, diff --git a/src/lib/utils/log-lines.ts b/src/lib/utils/log-lines.ts index ddbdfb0..3ad392b 100644 --- a/src/lib/utils/log-lines.ts +++ b/src/lib/utils/log-lines.ts @@ -1,6 +1,37 @@ +import { GIT_LOGO_PATH } from '$lib/icons/git-logo-path'; + +// A line beginning with this ASCII marker renders with a small leading git icon +// (the producer -- git stack deploy -- prefixes its own stage lines with it so they +// read as git operations amid the compose output). Compose output never starts a +// line with it, so a match is unambiguous; a stray match only adds a harmless icon. +export const GIT_LINE_MARKER = '@@git@@ '; + +// Inline generic Git logo, sized to sit on a log line. Inlined as a string (not a +// component) because the log body is rendered as raw HTML via {@html}, which can't mount +// a Svelte component -- so it shares GitGenericIcon's path via GIT_LOGO_PATH instead of +// duplicating it. currentColor keeps it monochrome; size/spacing/opacity from CSS. +const GIT_ICON_SVG = + '`; + +// Removes the leading git marker from every line -- for copy/download, where the log +// is read as plain text outside the viewer (the icon only exists in the rendered DOM). +export function stripLogMarkers(text: string): string { + return text + .split('\n') + .map((line) => (line.startsWith(GIT_LINE_MARKER) ? line.slice(GIT_LINE_MARKER.length) : line)) + .join('\n'); +} + export function wrapHtmlLines(html: string): string { return html .split('\n') - .map((line) => `
${line || ' '}
`) + .map((line) => { + if (line.startsWith(GIT_LINE_MARKER)) { + const rest = line.slice(GIT_LINE_MARKER.length); + return `
${GIT_ICON_SVG}${rest || ' '}
`; + } + return `
${line || ' '}
`; + }) .join(''); } diff --git a/src/routes/api/git/stacks/[id]/webhook/+server.ts b/src/routes/api/git/stacks/[id]/webhook/+server.ts index cac4a6a..1ff6b45 100644 --- a/src/routes/api/git/stacks/[id]/webhook/+server.ts +++ b/src/routes/api/git/stacks/[id]/webhook/+server.ts @@ -4,6 +4,7 @@ import { getGitStack } from '$lib/server/db'; import { deployGitStack } from '$lib/server/git'; import { auditGitStack } from '$lib/server/audit'; import { verifyWebhookSignature } from '$lib/server/webhook-signature'; +import { decideWebhookSecretPolicy, allowSecretlessWebhook } from '$lib/server/webhook-secret-policy'; function detectSource(request: Request): string { if (request.headers.get('x-hub-signature-256')) return 'github'; @@ -43,21 +44,32 @@ export const POST: RequestHandler = async (event) => { const source = detectSource(request); - // A secret is mandatory: reject if none is configured. - if (!gitStack.webhookSecret) { + const policy = decideWebhookSecretPolicy(!!gitStack.webhookSecret, allowSecretlessWebhook()); + if (policy.action === 'reject-no-secret') { await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, { method: 'POST', source, error: 'no_secret_configured' }); return json({ error: 'Webhook secret is not configured for this stack' }, { status: 401 }); } + if (policy.action === 'deploy-unverified') { + // ALLOW_WEBHOOKS_WITHOUT_SECRET opt-in (isolated network): deploy without + // verification, but record the unverified trigger in the audit trail. + const result = await deployGitStack(id, { force: false, triggeredBy: 'webhook' }); + await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, { + method: 'POST', source, verification: 'skipped_no_secret', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed' + }); + return json(result); + } + // policy.action === 'verify' here, so a secret is present. + const webhookSecret = gitStack.webhookSecret as string; const payload = await request.text(); const githubSignature = request.headers.get('x-hub-signature-256'); const gitlabToken = request.headers.get('x-gitlab-token'); const signature = githubSignature || gitlabToken; - if (!verifyWebhookSignature(payload, signature, gitStack.webhookSecret)) { + if (!verifyWebhookSignature(payload, signature, webhookSecret)) { await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, { method: 'POST', source, error: 'invalid_signature' }); @@ -107,13 +119,21 @@ export const GET: RequestHandler = async (event) => { return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 }); } - // A secret is mandatory (see POST handler). Reject if none is configured. - if (!gitStack.webhookSecret) { + const policy = decideWebhookSecretPolicy(!!gitStack.webhookSecret, allowSecretlessWebhook()); + if (policy.action === 'reject-no-secret') { await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, { method: 'GET', source: 'get', error: 'no_secret_configured' }); return json({ error: 'Webhook secret is not configured for this stack' }, { status: 401 }); } + if (policy.action === 'deploy-unverified') { + // ALLOW_WEBHOOKS_WITHOUT_SECRET opt-in: deploy without verification, audited. + const result = await deployGitStack(id, { force: false, triggeredBy: 'webhook' }); + await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, { + method: 'GET', source: 'get', verification: 'skipped_no_secret', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed' + }); + return json(result); + } // Verify secret via query parameter for GET requests const secret = url.searchParams.get('secret'); diff --git a/src/routes/api/schedules/executions/+server.ts b/src/routes/api/schedules/executions/+server.ts index 08e4e2d..0f62006 100644 --- a/src/routes/api/schedules/executions/+server.ts +++ b/src/routes/api/schedules/executions/+server.ts @@ -17,8 +17,14 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +import { authorize } from '$lib/server/authorize'; +import { + resourceForScheduleType, + viewableScheduleTypes +} from '$lib/server/schedule-execution-access-core'; import { getScheduleExecutions, + ALL_SCHEDULE_TYPES, type ScheduleType, type ScheduleTrigger, type ScheduleStatus @@ -38,11 +44,47 @@ import { * query: limit:integer Page size (default 50) * query: offset:integer Page offset (default 0) * resp-200: {executions:array<{id:integer!, scheduleType:string!, status:string!, startedAt:string!}>!, total:integer!, limit:integer!, offset:integer!} + * resp-401: Authentication required + * resp-403: Permission denied (the :view permission for the requested schedule type's resource - stacks/backups/schedules) * resp-500: Unexpected error while loading execution history */ -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + + const scheduleType = url.searchParams.get('scheduleType') as ScheduleType | null; + + // A row's errorMessage/details are as sensitive as the feature that produced + // them, so gate on THAT resource's :view (a stack_deploy needs stacks:view, a + // backup needs backups:view), not a blanket schedules:view. + // + // With an explicit scheduleType, gate on that one type's resource. WITHOUT it + // the response would mix types, so we must NOT settle for schedules:view alone: + // compute the types the caller may view from their held resource perms and + // constrain the query to exactly those (viewableTypes below). 403 only when the + // caller can view no type at all. + let viewableTypes: ScheduleType[] | null = null; // null = all types (no filter) + if (auth.authEnabled) { + if (scheduleType) { + if (!(await auth.can(resourceForScheduleType(scheduleType), 'view'))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } else { + const viewable = { + schedules: await auth.can('schedules', 'view'), + stacks: await auth.can('stacks', 'view'), + backups: await auth.can('backups', 'view') + }; + viewableTypes = viewableScheduleTypes(viewable, ALL_SCHEDULE_TYPES) as ScheduleType[] | null; + if (viewableTypes !== null && viewableTypes.length === 0) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + } + } + try { - const scheduleType = url.searchParams.get('scheduleType') as ScheduleType | null; const scheduleIdParam = url.searchParams.get('scheduleId'); const environmentIdParam = url.searchParams.get('environmentId'); const status = url.searchParams.get('status') as ScheduleStatus | null; @@ -68,6 +110,24 @@ export const GET: RequestHandler = async ({ url }) => { if (limitParam) filters.limit = parseInt(limitParam, 10); if (offsetParam) filters.offset = parseInt(offsetParam, 10); + // No explicit scheduleType: bound the mixed list to the types the caller may + // view, so a schedules:view-only user never receives stack_deploy/backup + // errorMessage/details. null = all types allowed (no filter needed). + if (viewableTypes !== null) { + filters.scheduleTypes = viewableTypes; + } + + // Enterprise env-scoped RBAC: a user without all-environment access sees + // only their accessible environments' runs (plus unattributed/null-env + // rows -- see getScheduleExecutions' environmentIds filter). This matters + // because a run's errorMessage can carry (redacted) compose stderr. + if (auth.authEnabled && auth.isEnterprise) { + const accessible = await auth.getAccessibleEnvironmentIds(); + if (accessible !== null) { + filters.environmentIds = accessible; + } + } + const result = await getScheduleExecutions(filters); return json(result); diff --git a/src/routes/api/schedules/executions/[id]/+server.ts b/src/routes/api/schedules/executions/[id]/+server.ts index b2d7f56..89bf43b 100644 --- a/src/routes/api/schedules/executions/[id]/+server.ts +++ b/src/routes/api/schedules/executions/[id]/+server.ts @@ -9,6 +9,7 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db'; import { authorize } from '$lib/server/authorize'; +import { resourceForScheduleType } from '$lib/server/schedule-execution-access-core'; /** * @openapi @@ -16,10 +17,17 @@ import { authorize } from '$lib/server/authorize'; * path: id:integer! Execution id (from GET /api/schedules/executions) * resp-200: {id:integer!, scheduleType:string!, status:string!, startedAt:string!, log:string} * resp-400: Invalid execution id + * resp-401: Authentication required + * resp-403: Permission denied (the :view permission for the row's resource), or no access to its environment (enterprise) * resp-404: Execution not found * resp-500: Unexpected error while loading the execution */ -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, cookies }) => { + const auth = await authorize(cookies); + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); + } + try { const id = parseInt(params.id, 10); if (isNaN(id)) { @@ -31,6 +39,24 @@ export const GET: RequestHandler = async ({ params }) => { return json({ error: 'Execution not found' }, { status: 404 }); } + // The row's logs/errorMessage are as sensitive as the feature that ran, so + // gate on that resource's :view (and, in enterprise, on access to the row's + // own environment -- a null env is unattributed and not env-scoped, matching + // deploy-run-access.ts). + if (auth.authEnabled) { + const resource = resourceForScheduleType(execution.scheduleType); + if (!(await auth.can(resource, 'view'))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + if ( + execution.environmentId != null && + auth.isEnterprise && + !(await auth.canAccessEnvironment(execution.environmentId)) + ) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + } + return json(execution); } catch (error: any) { console.error('Failed to get schedule execution:', error); @@ -44,13 +70,15 @@ export const GET: RequestHandler = async ({ params }) => { * path: id:integer! Execution id (from GET /api/schedules/executions) * resp-200: {success:boolean!} * resp-400: Invalid execution id - * resp-403: Permission denied (RBAC 'schedules:edit' missing) + * resp-401: Authentication required + * resp-403: Permission denied (the :edit permission for the row's resource), or no access to its environment (enterprise) + * resp-404: Execution not found * resp-500: Unexpected error while deleting the execution */ export const DELETE: RequestHandler = async ({ params, cookies }) => { const auth = await authorize(cookies); - if (auth.authEnabled && !await auth.can('schedules', 'edit')) { - return json({ error: 'Permission denied' }, { status: 403 }); + if (auth.authEnabled && !auth.isAuthenticated) { + return json({ error: 'Authentication required' }, { status: 401 }); } try { @@ -59,6 +87,28 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => { return json({ error: 'Invalid execution ID' }, { status: 400 }); } + const execution = await getScheduleExecution(id); + if (!execution) { + return json({ error: 'Execution not found' }, { status: 404 }); + } + + // Deleting a run record is gated by the :edit of the feature that ran it + // (a stack_deploy needs stacks:edit, a backup needs backups:edit), plus + // env access in enterprise -- symmetric with the GET above. + if (auth.authEnabled) { + const resource = resourceForScheduleType(execution.scheduleType); + if (!(await auth.can(resource, 'edit'))) { + return json({ error: 'Permission denied' }, { status: 403 }); + } + if ( + execution.environmentId != null && + auth.isEnterprise && + !(await auth.canAccessEnvironment(execution.environmentId)) + ) { + return json({ error: 'Access denied to this environment' }, { status: 403 }); + } + } + await deleteScheduleExecution(id); return json({ success: true }); diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index b71f77b..e789275 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -1137,13 +1137,29 @@ services:
+ + + + + + + + Every deploy from Dockhand keeps a log file on disk, linked to its run in the + Deploys tab. This job runs on a schedule to keep the two in sync: it deletes + orphaned log files whose deploy run was already removed, and marks a run whose + log file has gone missing (so the Deploys tab shows "log unavailable" instead + of a blank). It never deletes a deploy run itself. + + + +
-

Delete deploy-log files that no longer have a matching deploy record; deploy records that lost their log file are only flagged, never deleted

+

Keeps deploy-log files in sync with their deploy records: removes logs whose run is gone, and flags runs whose log went missing (never deletes a run).

{#if deployLogReconcileEnabled}
(undefined); let composeOutputExitCode = $state(undefined); - function startComposeOutput(title: string) { + let composeOutputStackName = $state(undefined); + + function startComposeOutput(title: string, stackName?: string) { composeOutputTitle = title; + composeOutputStackName = stackName; composeOutputLines = []; composeOutputRunning = true; composeOutputOpen = true; @@ -557,7 +560,7 @@ // network error, from the catch blocks below) — the payload does not carry an // exit code today (StackOperationResult has no such field, see stacks.ts), so // `exitCode` stays undefined in practice until the server starts sending one. - function finishComposeOutput(output: string | undefined, ok: boolean, exitCode?: number) { + function finishComposeOutput(output: string | undefined, ok: boolean, exitCode?: number, error?: string) { composeOutputRunning = false; composeOutputOk = ok; composeOutputMs = Date.now() - composeOutputStartedAt; @@ -565,6 +568,15 @@ if (composeOutputLines.length === 0 && output) { composeOutputLines = output.split('\n'); } + // On failure the reason lives in `error` (compose's stderr), which the streamed + // log lines don't carry -- append it so the modal shows WHY it failed instead of + // ending on a bare "Started" with the cause hidden in a separate dialog. + if (!ok && error) { + const errLines = error.split('\n').map((l) => l.trimEnd()).filter(Boolean); + const already = new Set(composeOutputLines.map((l) => l.trim())); + const fresh = errLines.filter((l) => !already.has(l.trim())); + if (fresh.length > 0) composeOutputLines = [...composeOutputLines, ...fresh]; + } } // Container-level confirmation popover state @@ -1012,26 +1024,27 @@ async function startStack(name: string) { operationError = null; stackActionLoading = name; - startComposeOutput(`Starting ${name}`); + startComposeOutput(`Starting ${name}`, name); try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' }); const data = await readJobResponse(response, appendComposeOutputLine); finishComposeOutput( typeof data.output === 'string' ? data.output : undefined, Boolean(data.success), - typeof data.exitCode === 'number' ? data.exitCode : undefined + typeof data.exitCode === 'number' ? data.exitCode : undefined, + typeof data.error === 'string' ? data.error : undefined ); if (!data.success) { - showErrorDialog(`Failed to start ${name}`, data.error || 'Failed to start stack'); + toast.error(`Failed to start ${name}`); return; } toast.success(`Started ${name}`); await fetchStacks(); } catch (error) { - finishComposeOutput(undefined, false); console.error('Failed to start stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to start stack'; - showErrorDialog(`Failed to start ${name}`, errorMsg); + finishComposeOutput(undefined, false, undefined, errorMsg); + toast.error(`Failed to start ${name}`); } finally { stackActionLoading = null; } @@ -1040,26 +1053,27 @@ async function stopStack(name: string) { operationError = null; stackActionLoading = name; - startComposeOutput(`Stopping ${name}`); + startComposeOutput(`Stopping ${name}`, name); try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' }); const data = await readJobResponse(response, appendComposeOutputLine); finishComposeOutput( typeof data.output === 'string' ? data.output : undefined, Boolean(data.success), - typeof data.exitCode === 'number' ? data.exitCode : undefined + typeof data.exitCode === 'number' ? data.exitCode : undefined, + typeof data.error === 'string' ? data.error : undefined ); if (!data.success) { - showErrorDialog(`Failed to stop ${name}`, data.error || 'Failed to stop stack'); + toast.error(`Failed to stop ${name}`); return; } toast.success(`Stopped ${name}`); await fetchStacks(); } catch (error) { - finishComposeOutput(undefined, false); console.error('Failed to stop stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack'; - showErrorDialog(`Failed to stop ${name}`, errorMsg); + finishComposeOutput(undefined, false, undefined, errorMsg); + toast.error(`Failed to stop ${name}`); } finally { stackActionLoading = null; } @@ -1068,7 +1082,7 @@ async function restartStack(name: string, mode: 'restart' | 'ordered' | 'recreate' = 'restart') { operationError = null; stackActionLoading = name; - startComposeOutput(mode === 'recreate' ? `Recreating ${name}` : `Restarting ${name}`); + startComposeOutput(mode === 'recreate' ? `Recreating ${name}` : `Restarting ${name}`, name); try { let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId); if (mode === 'recreate' || mode === 'ordered') { @@ -1079,19 +1093,22 @@ finishComposeOutput( typeof data.output === 'string' ? data.output : undefined, Boolean(data.success), - typeof data.exitCode === 'number' ? data.exitCode : undefined + typeof data.exitCode === 'number' ? data.exitCode : undefined, + typeof data.error === 'string' ? data.error : undefined ); if (!data.success) { - showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack'); + // The reason is now in the output modal's log; a toast points the user to it + // without a second, redundant error dialog stacked over the same modal. + toast.error(`Failed to restart ${name}`); return; } toast.success(mode === 'recreate' ? `Recreated ${name}` : `Restarted ${name}`); await fetchStacks(); } catch (error) { - finishComposeOutput(undefined, false); console.error('Failed to restart stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack'; - showErrorDialog(`Failed to restart ${name}`, errorMsg); + finishComposeOutput(undefined, false, undefined, errorMsg); + toast.error(`Failed to restart ${name}`); } finally { stackActionLoading = null; } @@ -1100,7 +1117,15 @@ async function redeployStack(name: string, options: { pull: boolean; build: boolean; forceRecreate: boolean }) { operationError = null; stackActionLoading = name; - startComposeOutput(`Redeploying ${name}`); + startComposeOutput(`Redeploying ${name}`, name); + // Record which redeploy options were chosen so the log shows them (they are not + // otherwise visible once the popover closes). + const chosen = [ + options.pull && 'pull images', + options.build && 'build images', + options.forceRecreate && 'force recreate' + ].filter(Boolean); + appendComposeOutputLine(`Options: ${chosen.length ? chosen.join(', ') : 'none'}`); try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/deploy`, envId), { method: 'POST', @@ -1111,19 +1136,24 @@ finishComposeOutput( typeof data.output === 'string' ? data.output : undefined, Boolean(data.success), - typeof data.exitCode === 'number' ? data.exitCode : undefined + typeof data.exitCode === 'number' ? data.exitCode : undefined, + typeof data.error === 'string' ? data.error : undefined ); if (!data.success) { - showErrorDialog(`Failed to redeploy ${name}`, data.error || 'Failed to redeploy stack'); + // The compose output window carries the full log (incl. the failure reason) + // plus the failed status, so a separate error dialog would duplicate it. + toast.error(`Failed to redeploy ${name}`); return; } toast.success(`Redeployed ${name}`); await fetchStacks(); } catch (error) { - finishComposeOutput(undefined, false); console.error('Failed to redeploy stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to redeploy stack'; - showErrorDialog(`Failed to redeploy ${name}`, errorMsg); + // A throw before any output streams would otherwise leave the window with + // only the Options line and a red status, so put the error text in it. + finishComposeOutput(undefined, false, undefined, errorMsg); + toast.error(`Failed to redeploy ${name}`); } finally { stackActionLoading = null; } @@ -1133,26 +1163,27 @@ operationError = null; stackActionLoading = name; stackDownLoading = name; - startComposeOutput(`Bringing down ${name}`); + startComposeOutput(`Bringing down ${name}`, name); try { const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' }); const data = await readJobResponse(response, appendComposeOutputLine); finishComposeOutput( typeof data.output === 'string' ? data.output : undefined, Boolean(data.success), - typeof data.exitCode === 'number' ? data.exitCode : undefined + typeof data.exitCode === 'number' ? data.exitCode : undefined, + typeof data.error === 'string' ? data.error : undefined ); if (!data.success) { - showErrorDialog(`Failed to bring down ${name}`, data.error || 'Failed to bring down stack'); + toast.error(`Failed to bring down ${name}`); return; } toast.success(`Brought down ${name}`); await fetchStacks(); } catch (error) { - finishComposeOutput(undefined, false); console.error('Failed to bring down stack:', error); const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack'; - showErrorDialog(`Failed to bring down ${name}`, errorMsg); + finishComposeOutput(undefined, false, undefined, errorMsg); + toast.error(`Failed to bring down ${name}`); } finally { stackActionLoading = null; stackDownLoading = null; @@ -1825,6 +1856,8 @@ {#snippet children()} @@ -2102,48 +2135,43 @@
{/if} - {#if (stack.status === 'not deployed' || stack.status === 'created') && source.gitStack} - + {#if source.sourceType === 'git' && source.gitStack} + + {#if stack.status === 'not deployed' || stack.status === 'created'} + + {/if} {#snippet children()} {/snippet} - {:else} - {#if source.sourceType === 'git' && source.gitStack} - - {#snippet children()} - - {/snippet} - - {/if} + {/if} + {#if stack.status !== 'not deployed' && stack.status !== 'created'} {#if $canAccess('stacks', 'edit')} {#if source.sourceType === 'git' && source.gitStack} - {#if isOpen} -
-
-
-
Containers
-
{view.containerSummary}
-
-
-
Build
-
{view.buildStatus}
-
-
-
Triggered by
-
{view.trigger}
-
-
- {#if view.optionChips.length > 0} -
- {#each view.optionChips as chip} - {chip} +
+
+
+
Containers
+
{view.containerSummary}
+ {#if view.containerNames.length > 0} +
+ {#each view.containerNames as cname} + + + {cname} + {/each}
{/if} - {#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0} -
- {#if view.imagesBuilt.length > 0} -
Built: {view.imagesBuilt.join(', ')}
- {/if} - {#if view.imagesPulled.length > 0} -
Pulled: {view.imagesPulled.join(', ')}
- {/if} +
+
+
Build
+
{view.buildStatus}
+
+
+
Triggered by
+
{view.trigger}
+
+
+ {#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0} +
+ {#if view.imagesBuilt.length > 0} +
+ Built: + {#each view.imagesBuilt as img} + + + {img} + + {/each}
{/if} - {#if view.errorSummary} - -
- {view.errorSummary} + {#if view.imagesPulled.length > 0} +
+ Pulled: + {#each view.imagesPulled as img} + + + {img} + + {/each}
{/if} -
-
- Log - {#if panelState.deletable} - deleteRun(run)} - onOpenChange={(open) => (confirmDeleteId = open ? run.id : null)} - > - {#snippet children({ open })} - {#if deletingIds.has(run.id)} - - {:else} - - {/if} - {/snippet} - - {/if} -
- {#if panelState.truncated} - -
- Log truncated -
- {/if} - {#if panelState.logMissing || logEntry?.status === 'missing'} - -
- Protocol no longer available. -
- {:else if logEntry?.status === 'error'} -
- {logEntry.error} -
- {:else} - - {/if} -
{/if} +
+ {#if panelState.truncated} +
+ + Log truncated + +
+ {/if} + {#if panelState.logMissing || logEntry?.status === 'missing'} + +
+ Protocol no longer available. +
+ {:else if logEntry?.status === 'error'} +
+ {logEntry.error} +
+ {:else} +
+ +
+ + + {/if} +
- {/each} -
+ {/snippet} + {/if} diff --git a/src/routes/stacks/GitDeployProgressPopover.svelte b/src/routes/stacks/GitDeployProgressPopover.svelte index 712603b..7b869ea 100644 --- a/src/routes/stacks/GitDeployProgressPopover.svelte +++ b/src/routes/stacks/GitDeployProgressPopover.svelte @@ -2,80 +2,92 @@ import { formatErrorLines } from '$lib/utils/format'; import * as Dialog from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; - import { Badge } from '$lib/components/ui/badge'; - import { Progress } from '$lib/components/ui/progress'; import { Rocket, - CheckCircle2, - XCircle, Loader2, AlertCircle, - GitBranch, - FileCode, - Server, - Link, - AlertTriangle, - Copy, - Check, - Database, - KeyRound + AlertTriangle } from 'lucide-svelte'; import type { Snippet } from 'svelte'; import { appSettings } from '$lib/stores/settings'; import { watchJob } from '$lib/utils/sse-fetch'; - import { copyToClipboard } from '$lib/utils/clipboard'; + import LogViewer from '$lib/components/LogViewer.svelte'; + import DeployOutputHeader from './DeployOutputHeader.svelte'; + import { GIT_LINE_MARKER } from '$lib/utils/log-lines'; interface Props { stackId: number; stackName: string; + // Stack icon value (lucide/selfhst/custom) + env, so the header shows the stack's + // own icon and the target environment -- matching the compose output modal. + stackIcon?: string | null; + envId?: number | null; + // Called when the dialog closes after a deploy ran, not when the deploy completes: + // this component is embedded in a table row, so refreshing the list mid-deploy + // would remount it and drop its open dialog. onComplete?: () => void; children: Snippet; } - let { stackId, stackName, onComplete, children }: Props = $props(); + let { stackId, stackName, stackIcon = null, envId = null, onComplete, children }: Props = $props(); + let deployFinished = $state(false); + + // The deploy-stream job emits these fields; the UI reads status/message/error/logLine + // (step/totalSteps are still sent by the server but no longer rendered as steps). interface StepProgress { status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'env' | 'secrets' | 'deploying' | 'complete' | 'error'; message?: string; - step?: number; - totalSteps?: number; error?: string; + // A raw (already redacted) compose output line. + logLine?: string; } let open = $state(false); let overallStatus = $state<'idle' | 'confirming' | 'deploying' | 'complete' | 'error'>('idle'); - let currentStep = $state(null); - let steps = $state([]); + // Everything the deploy emits -- git stage messages AND compose output -- lands in + // one log stream (matching the compose output modal), so there is no separate step + // list. Git stage lines carry the GIT_LINE_MARKER so LogViewer draws a git glyph. + let logLines = $state([]); let errorMessage = $state(''); - let copied = $state(false); const confirmDestructive = $derived($appSettings.confirmDestructive); - function getStepIcon(status: string) { - switch (status) { - case 'connecting': return Link; - case 'cloning': return GitBranch; - case 'fetching': return GitBranch; - case 'reading': return FileCode; - case 'env': return Database; - case 'secrets': return KeyRound; - case 'deploying': return Server; - case 'complete': return CheckCircle2; - case 'error': return XCircle; - default: return Loader2; - } - } + // Close the log with a colored status line once the deploy settles, matching the + // compose output modal. LogViewer colors via ansi_to_html; the ESC byte is built + // from its code point to keep the source ASCII. + const ANSI = { + green: String.fromCharCode(27) + '[32m', + red: String.fromCharCode(27) + '[31m', + reset: String.fromCharCode(27) + '[0m' + }; + let logText = $derived.by(() => { + const base = logLines.join('\n'); + if (overallStatus !== 'complete' && overallStatus !== 'error') return base; + // Match the closing line's indent to the log's own lines (compose prefixes a + // space, other producers don't) so it lines up with the text, not the edge. + const lastReal = [...logLines].reverse().find((l) => l.trim().length > 0); + const indent = lastReal ? (lastReal.match(/^\s*/)?.[0] ?? '') : ''; + const [color, label] = overallStatus === 'complete' ? [ANSI.green, 'Succeeded'] : [ANSI.red, 'Failed']; + return base + (base ? '\n' : '') + indent + color + label + ANSI.reset; + }); - function getStepColor(status: string, isCurrentStep: boolean): string { - if (status === 'complete') return 'text-green-600 dark:text-green-400'; - if (status === 'error') return 'text-red-600 dark:text-red-400'; - if (isCurrentStep) return 'text-violet-600 dark:text-violet-400'; - return 'text-muted-foreground'; + // The log viewer follows the app-wide light/dark switch (source of truth: the `.dark` + // class on ). Read it each time the dialog opens so it tracks the current theme, + // not whatever it was when this page-root component first mounted. + let logTheme = $state<'light' | 'dark'>('dark'); + function currentAppTheme(): 'light' | 'dark' { + if (typeof document !== 'undefined') { + return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; + } + return 'dark'; } + $effect(() => { + if (open) logTheme = currentAppTheme(); + }); async function startDeploy() { - steps = []; - currentStep = null; + logLines = []; overallStatus = 'deploying'; errorMessage = ''; @@ -95,19 +107,33 @@ await watchJob(jobId, (line) => { try { const data = line.data as StepProgress; + // Compose output line: append verbatim. + if (typeof data.logLine === 'string') { + logLines = [...logLines, data.logLine]; + return; + } if (data.status === 'complete') { + // Keep the final "Successfully deployed ..." line -- no git icon, it's + // a compose-side boundary, not a git operation. + if (typeof data.message === 'string') { + logLines = [...logLines, data.message]; + } overallStatus = 'complete'; - currentStep = data; - steps = [...steps, data]; - onComplete?.(); + deployFinished = true; } else if (data.status === 'error') { overallStatus = 'error'; errorMessage = data.error || 'Unknown error occurred'; - currentStep = data; - steps = [...steps, data]; - } else { - currentStep = data; - steps = [...steps, data]; + // Put the reason in the log too (as plain, uniconed lines) so a + // failure that happens mid-stream shows WHY, not just "Failed". + for (const l of errorMessage.split('\n')) { + if (l.trim()) logLines = [...logLines, l]; + } + deployFinished = true; + } else if (typeof data.message === 'string') { + // Only the actual git stages (clone/fetch/read) get the git glyph; the + // "Deploying ..." boundary is compose-side, so it stays plain. + const isGitStage = ['connecting', 'cloning', 'fetching', 'reading'].includes(data.status); + logLines = [...logLines, (isGitStage ? GIT_LINE_MARKER : '') + data.message]; } } catch (e) { console.error('Failed to process job line:', e); @@ -116,12 +142,13 @@ if (overallStatus === 'deploying') { overallStatus = 'complete'; - onComplete?.(); + deployFinished = true; } } catch (error: any) { console.error('Failed to deploy git stack:', error); overallStatus = 'error'; errorMessage = error.message || 'Failed to deploy'; + deployFinished = true; } } @@ -148,28 +175,39 @@ if (overallStatus === 'deploying') return; open = false; overallStatus = 'idle'; - steps = []; - currentStep = null; + logLines = []; errorMessage = ''; - copied = false; + // Refresh the list on close, not on complete (see onComplete's doc). + if (deployFinished) { + deployFinished = false; + onComplete?.(); + } } - async function copyLogs() { - const lines = steps.map(s => s.message || s.status); - if (errorMessage) lines.push(`Error: ${errorMessage}`); - const ok = await copyToClipboard(lines.join('\n')); - if (!ok) return; - copied = true; - setTimeout(() => { copied = false; }, 2000); - } - - const progressPercentage = $derived( - currentStep?.step && currentStep?.totalSteps - ? Math.round((currentStep.step / currentStep.totalSteps) * 100) - : 0 - ); - const isDeploying = $derived(overallStatus === 'deploying'); + + // Map the popover's status to the shared header's state + status text. + const headerState = $derived<'idle' | 'running' | 'complete' | 'error'>( + overallStatus === 'complete' ? 'complete' + : overallStatus === 'error' ? 'error' + : isDeploying ? 'running' + : 'idle' + ); + const headerStatusLine = $derived( + overallStatus === 'complete' ? 'Succeeded' + : overallStatus === 'error' ? 'Failed' + : isDeploying ? 'Deploying...' + : '' + ); + + // Compact while confirming (just a prompt); wide + tall once a deploy runs and the + // full log needs room. Matches the compose output modal's size in the deploy state. + const isConfirming = $derived(overallStatus === 'confirming'); + const contentSizeClass = $derived( + isConfirming + ? 'max-w-lg' + : 'max-w-[min(90rem,calc(100vw-4rem))] h-[85vh]' + ); @@ -179,48 +217,25 @@ { if (!isOpen) handleClose(); }}>
-
- {#if overallStatus === 'complete'} - - {:else if overallStatus === 'error'} - - {:else if isDeploying} - - {:else} - - {/if} - Git deploy - {stackName} - {#if overallStatus === 'complete'} - Complete - {:else if overallStatus === 'error'} - Failed - {:else if isDeploying} - - {#if currentStep?.step && currentStep?.totalSteps} - {currentStep.step}/{currentStep.totalSteps} - {:else} - Deploying... - {/if} - - {/if} -
- {#if isDeploying && currentStep?.totalSteps} -
- -
- {/if} +
- -
+ +
{#if overallStatus === 'confirming'}
@@ -232,29 +247,29 @@

- {:else if steps.length === 0 && isDeploying} + {:else if logLines.length === 0 && isDeploying}
Initializing...
- {:else} -
- {#each steps as step, index (index)} - {@const StepIcon = getStepIcon(step.status)} - {@const isCurrentStep = index === steps.length - 1 && isDeploying} -
- - - {step.message || step.status} - -
- {/each} + {/if} + + {#if logLines.length > 0} +
+
{/if} - {#if errorMessage} + {#if errorMessage && logLines.length === 0} +
@@ -266,20 +281,10 @@
- +
{#if overallStatus === 'confirming'} - {:else if steps.length > 0} - {/if}
diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index 16108f6..77a150d 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -1,23 +1,27 @@