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) => `
`;
+ })
.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}
- import { onMount } from 'svelte';
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
- import { Loader2 } from 'lucide-svelte';
import LogViewer from '$lib/components/LogViewer.svelte';
+ import DeployOutputHeader from './DeployOutputHeader.svelte';
+ import DeploySummaryBar from './DeploySummaryBar.svelte';
import { formatRunStatus } from '$lib/utils/run-status';
// A plain display window for compose output. It owns none of the polling —
@@ -15,32 +15,65 @@
title: string;
lines: string[];
running: boolean;
+ // Optional stack identity so the header shows the stack's icon (matches the list).
+ stackName?: string;
+ stackIcon?: string | null;
+ envId?: number | null;
ok?: boolean;
ms?: number;
exitCode?: number;
}
- let { open = $bindable(false), title, lines, running, ok, ms, exitCode }: Props = $props();
+ let { open = $bindable(false), title, lines, running, ok, ms, exitCode, stackName, stackIcon = null, envId = null }: Props = $props();
let statusLine = $derived(formatRunStatus({ running, ok, ms, exitCode }));
- // This modal is opened straight from the stack list, with no editor in scope to
- // borrow a theme from (unlike the docked panel and Deploys tab inside StackModal,
- // which both follow `dockhand-editor-theme`). Its Dialog.Content already tracks
- // the app-wide light/dark switch via bg-background, so the log viewer inside
- // follows the same switch instead of introducing a third, unrelated toggle.
- // Same read-once-on-mount + system-preference-fallback pattern as
- // ComposeGraphViewer's graphTheme -- this component is mounted once at the page
- // root and toggled via `open`, so onMount runs once per page load.
+ // The header takes a verb + name separately (so the stack icon sits between them);
+ // split the stackName off the title, and map running/ok to the shared state enum.
+ let headerVerb = $derived(
+ stackName && title.endsWith(stackName) ? title.slice(0, -stackName.length).trim() : title
+ );
+ let headerState = $derived<'running' | 'complete' | 'error'>(
+ running ? 'running' : ok === false ? 'error' : 'complete'
+ );
+
+ // Compose output ends abruptly on the last state line; once the run settles, append
+ // a colored closing line (green on success, red on failure) so the end state reads
+ // at a glance. LogViewer colors via ansi_to_html, so this wraps the text in an ANSI
+ // SGR sequence. 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 = lines.join('\n');
+ if (running || ok === undefined) 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 above, not the edge.
+ const lastReal = [...lines].reverse().find((l) => l.trim().length > 0);
+ const indent = lastReal ? (lastReal.match(/^\s*/)?.[0] ?? '') : '';
+ const color = ok ? ANSI.green : ANSI.red;
+ const suffix = indent + color + statusLine + ANSI.reset;
+ return base ? base + '\n' + suffix : suffix;
+ });
+
+ // This modal has no editor in scope to borrow a theme from (unlike StackModal's
+ // docked panel). It follows the app-wide light/dark switch, whose source of truth is
+ // the `.dark` class on (Tailwind dark mode). Read it EACH time the modal opens:
+ // this component mounts once at the page root and is re-shown via `open`, so the value
+ // must track the current theme, not whatever it was at mount.
let outputTheme = $state<'light' | 'dark'>('dark');
- onMount(() => {
- const appTheme = localStorage.getItem('theme');
- if (appTheme === 'dark' || appTheme === 'light') {
- outputTheme = appTheme;
- } else {
- outputTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ function currentAppTheme(): 'light' | 'dark' {
+ if (typeof document !== 'undefined') {
+ return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
}
+ return 'dark';
+ }
+
+ $effect(() => {
+ if (open) outputTheme = currentAppTheme();
});
function handleClose() {
@@ -58,19 +91,17 @@
laptop screen: min() picks the smaller of the two, and the 4rem keeps a margin. -->
-
- {#if running}
-
- {/if}
- {title}
+
+
+
+
-
- {statusLine}
-
+ {statusLine}
+ import { Loader2, CheckCircle2, XCircle, Rocket, Layers } from 'lucide-svelte';
+ import StackIcon from '$lib/components/StackIcon.svelte';
+ import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
+ import { environments } from '$lib/stores/environment';
+
+ // The one identity line shared by every deploy/compose output view (the docked panel
+ // in StackModal, the standalone ComposeOutputModal, and the git-deploy popover), so
+ // they all read the same and stay aligned:
+ // " on "
+ // One component = one place to change sizing/spacing/fallback (no per-dialog drift).
+ interface Props {
+ // The action word, e.g. "Bringing down" or "Git deploy".
+ verb: string;
+ stackName?: string;
+ // Icon value (lucide/selfhst/custom). When unset, a generic Layers glyph shows --
+ // never StackIcon's Boxes fallback, which reads as a different icon.
+ stackIcon?: string | null;
+ envId?: number | null;
+ // Drives the leading icon. 'idle' shows a rocket (a not-yet-started deploy prompt).
+ state: 'idle' | 'running' | 'complete' | 'error';
+ // Optional run status ("Running...", "Succeeded - 1.2s"), muted, same size/baseline.
+ statusLine?: string;
+ // Icon box size; the docked panel is denser than the modals.
+ iconClass?: string;
+ }
+ let { verb, stackName, stackIcon = null, envId = null, state, statusLine, iconClass = 'w-4 h-4' }: Props = $props();
+
+ let env = $derived(envId != null ? $environments.find((e) => e.id === envId) ?? null : null);
+
+
+
+ {#if state === 'running'}
+
+ {:else if state === 'complete'}
+
+ {:else if state === 'error'}
+
+ {:else}
+
+ {/if}
+ {verb}
+ {#if stackName}
+ {#if stackIcon}
+
+ {:else}
+
+ {/if}
+ {stackName}
+ {/if}
+ {#if env}
+ on
+
+ {env.name}
+ {/if}
+ {#if statusLine}
+ {statusLine}
+ {/if}
+
diff --git a/src/routes/stacks/DeploySummaryBar.svelte b/src/routes/stacks/DeploySummaryBar.svelte
new file mode 100644
index 0000000..d5c9a74
--- /dev/null
+++ b/src/routes/stacks/DeploySummaryBar.svelte
@@ -0,0 +1,49 @@
+
+
+{#if show}
+