live compose output during deploys, plus a per-stack deploy history tab

This commit is contained in:
Jarek Krochmalski committed 2026-09-08 07:47:30 +05:30
1 parent b2eb0b5dcb
commit 44295ea931
41 files changed
+2093 -1159

No files matched your search

+49 -14
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter, Hash } from 'lucide-svelte';
import { wrapHtmlLines } from '$lib/utils/log-lines';
import { RefreshCw, Copy, Check, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser, Filter, Hash } from 'lucide-svelte';
import { wrapHtmlLines, stripLogMarkers } from '$lib/utils/log-lines';
import { copyToClipboard } from '$lib/utils/clipboard';
import { downloadFileName, stripAnsi } from '$lib/utils/log-download-name';
import { isScrolledToBottom, shouldResetScrollPause } from '$lib/utils/scroll-position';
@@ -49,7 +49,13 @@
let logsRef: HTMLDivElement;
let wordWrap = $state(true);
let showLineNumbers = $state(typeof window !== 'undefined' && localStorage.getItem('dockhand-log-line-numbers') === 'true');
let fontSize = $state(12);
const FONT_SIZE_KEY = 'dockhand-log-font-size';
function initialFontSize(): number {
if (typeof window === 'undefined') return 12;
const n = Number(localStorage.getItem(FONT_SIZE_KEY));
return [10, 12, 14, 16].includes(n) ? n : 12;
}
let fontSize = $state(initialFontSize());
// RAF-based auto-scroll
let scrollRafPending = false;
@@ -125,17 +131,21 @@
userScrolledUp = !isScrolledToBottom(scrollTop, scrollHeight, clientHeight);
}
// Copy logs to clipboard
// Copy logs to clipboard, with a brief "copied" tick. copyToClipboard handles the
// non-secure-context (HTTP) fallback, so this works off localhost too.
let copied = $state(false);
async function copyLogs() {
if (logs) {
await copyToClipboard(stripAnsi(logs));
}
if (!logs) return;
const ok = await copyToClipboard(stripLogMarkers(stripAnsi(logs)));
if (!ok) return;
copied = true;
setTimeout(() => { copied = false; }, 1500);
}
// Download logs as txt file
function downloadLogs() {
if (logs) {
const blob = new Blob([stripAnsi(logs)], { type: 'text/plain' });
const blob = new Blob([stripLogMarkers(stripAnsi(logs))], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -283,10 +293,13 @@
>
<ArrowDownToLine class="w-3 h-3" />
</button>
<!-- Font size -->
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => fontSize = Number(v)}>
<Select.Trigger class="h-6 w-16 text-xs px-1.5 {dark ? 'bg-zinc-800 border-zinc-700 text-zinc-300' : 'bg-white border-gray-300 text-gray-700'}">
<Type class="w-3 h-3 mr-1 {dark ? 'text-zinc-400' : 'text-gray-400'}" />
<!-- Font size. The shadcn Select.Trigger base carries a border, shadow, h-9,
px-3 and a built-in chevron that make it stand out from the flat icon
buttons around it -- neutralize all of it with ! overrides and hide the
chevron so it reads as one more toolbar control. -->
<Select.Root type="single" value={String(fontSize)} onValueChange={(v) => { fontSize = Number(v); localStorage.setItem(FONT_SIZE_KEY, String(fontSize)); }}>
<Select.Trigger class="!h-6 !w-auto !gap-1 !rounded !border-0 !bg-transparent !px-1.5 !py-0.5 !text-xs !shadow-none transition-colors dark:!bg-transparent [&>svg:last-child]:hidden {dark ? 'text-zinc-500 hover:text-zinc-300 hover:!bg-zinc-800' : 'text-gray-500 hover:text-gray-700 hover:!bg-gray-300'}" title="Font size">
<Type class="w-3 h-3" />
<span>{fontSize}px</span>
</Select.Trigger>
<Select.Content>
@@ -361,9 +374,13 @@
<button
onclick={copyLogs}
class="p-1 rounded transition-colors {dark ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
title="Copy logs"
title={copied ? 'Copied!' : 'Copy logs'}
>
<Copy class="w-3 h-3 {dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
{#if copied}
<Check class="w-3 h-3 text-emerald-500" />
{:else}
<Copy class="w-3 h-3 {dark ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
{/if}
</button>
<!-- Download -->
<button
@@ -412,6 +429,24 @@
</div>
<style>
/* Git logo prefixing git-operation log lines (see wrapHtmlLines). The line reserves a
2ch left gutter and the icon is pulled into it with a matching negative margin, so
the TEXT after the icon starts at 2ch on every git line -- and compose lines (which
carry a leading space, ~1ch) are close enough to read as one column. Icon is
~1.1em (a touch larger than the text so it reads as a logo). Scoped to git lines. */
:global(.log-line-git) {
padding-left: 2ch;
}
:global(.log-git-icon) {
display: inline-block;
width: 1.1em;
height: 1.1em;
margin-left: -2ch;
margin-right: calc(2ch - 1.1em);
vertical-align: -0.2em;
opacity: 0.75;
}
:global(.search-match) {
background-color: rgba(234, 179, 8, 0.4);
color: #fef3c7;
@@ -2,9 +2,11 @@
// Generic git-repository glyph (non-GitHub/GitLab forge). The official Git
// logo (mdi-git) - distinct from lucide's GitBranch, which reads as a branch.
// currentColor so it inherits text color; `class` mirrors the lucide icon API.
// Path shared with the log-output icon via GIT_LOGO_PATH (single source).
import { GIT_LOGO_PATH } from '$lib/icons/git-logo-path';
let { class: className = '' }: { class?: string } = $props();
</script>
<svg class={className} viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="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" />
<path d={GIT_LOGO_PATH} />
</svg>
@@ -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;
}
@@ -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() {
+15 -1
View File
@@ -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<GridId, ColumnConfig[]> = {
containers: containerColumns,
@@ -222,7 +235,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
backupDestinations: backupDestinationColumns,
backups: backupColumns,
repoSnapshots: repoSnapshotColumns,
vulnerabilities: vulnerabilityColumns
vulnerabilities: vulnerabilityColumns,
deploys: deployColumns
};
// Get configurable columns (not fixed)
+10
View File
@@ -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';
+19 -1
View File
@@ -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"
+49
View File
@@ -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<ScheduleType, (typeof ALL_SCHEDULE_TYPES)[number]> 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));
}
+1 -1
View File
@@ -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.
+15 -3
View File
@@ -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
+155 -61
View File
@@ -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<string, string> = { ...(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<ReturnType<typeof createRunRecorder>> | 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<ReturnType<typeof deployStack>>;
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 };
}
+22 -11
View File
@@ -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,
@@ -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<string, ScheduleExecResource> = {
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;
}
+27 -3
View File
@@ -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<void>;
}
@@ -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 });
+1 -1
View File
@@ -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;
+22
View File
@@ -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<DeployRun, 'status'>[]): 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 };
}
@@ -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<string>();
const recreated = new Set<string>();
const started = new Set<string>();
// 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<string>();
const built = new Set<string>();
const pulled = new Set<string>();
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,
+32 -1
View File
@@ -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 =
'<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="log-git-icon" aria-hidden="true">' +
`<path d="${GIT_LOGO_PATH}"/></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) => `<div class="log-line">${line || ' '}</div>`)
.map((line) => {
if (line.startsWith(GIT_LINE_MARKER)) {
const rest = line.slice(GIT_LINE_MARKER.length);
return `<div class="log-line log-line-git">${GIT_ICON_SVG}${rest || ' '}</div>`;
}
return `<div class="log-line">${line || ' '}</div>`;
})
.join('');
}
@@ -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');
+62 -2
View File
@@ -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);
@@ -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 });
+17 -1
View File
@@ -1137,13 +1137,29 @@ services:
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
<Label>Deploy log reconcile</Label>
<Tooltip.Provider delayDuration={100}>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-4 h-4 text-muted-foreground cursor-help" />
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content side="right" sideOffset={8} class="!w-80">
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.
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<TogglePill
checked={deployLogReconcileEnabled}
onchange={handleDeployLogReconcileEnabledChange}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Delete deploy-log files that no longer have a matching deploy record; deploy records that lost their log file are only flagged, never deleted</p>
<p class="text-xs text-muted-foreground">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).</p>
{#if deployLogReconcileEnabled}
<div class="mt-2">
<CronEditor
+87 -56
View File
@@ -536,8 +536,11 @@
let composeOutputMs = $state<number | undefined>(undefined);
let composeOutputExitCode = $state<number | undefined>(undefined);
function startComposeOutput(title: string) {
let composeOutputStackName = $state<string | undefined>(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 @@
<GitDeployProgressPopover
stackId={source.gitStack.id}
stackName={stack.name}
stackIcon={source.icon}
envId={$currentEnvironment?.id ?? null}
onComplete={fetchStacks}
>
{#snippet children()}
@@ -2102,48 +2135,43 @@
</button>
</div>
{/if}
{#if (stack.status === 'not deployed' || stack.status === 'created') && source.gitStack}
<button
type="button"
onclick={() => openGitModal(source.gitStack)}
title="Edit git stack"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground hover:text-purple-500" />
</button>
{#if source.sourceType === 'git' && source.gitStack}
<!-- One deploy popover for a git stack in ANY state (only the trigger
icon differs by status), so the instance is stable: a status
flip mid-deploy must not remount it and drop its open dialog. -->
{#if stack.status === 'not deployed' || stack.status === 'created'}
<button
type="button"
onclick={() => openGitModal(source.gitStack)}
title="Edit git stack"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground hover:text-purple-500" />
</button>
{/if}
<GitDeployProgressPopover
stackId={source.gitStack.id}
stackName={stack.name}
stackIcon={source.icon}
envId={$currentEnvironment?.id ?? null}
onComplete={fetchStacks}
>
{#snippet children()}
<button
type="button"
title="Deploy"
title={(stack.status === 'not deployed' || stack.status === 'created') ? 'Deploy' : 'Sync from Git'}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Rocket class="grid-action-icon grid-action-start text-muted-foreground hover:text-violet-500" />
{#if stack.status === 'not deployed' || stack.status === 'created'}
<Rocket class="grid-action-icon grid-action-start text-muted-foreground hover:text-violet-500" />
{:else}
<RefreshCw class="grid-action-icon grid-action-restart text-muted-foreground hover:text-purple-500" />
{/if}
</button>
{/snippet}
</GitDeployProgressPopover>
{:else}
{#if source.sourceType === 'git' && source.gitStack}
<GitDeployProgressPopover
stackId={source.gitStack.id}
stackName={stack.name}
onComplete={fetchStacks}
>
{#snippet children()}
<button
type="button"
title="Sync from Git"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<RefreshCw class="grid-action-icon grid-action-restart text-muted-foreground hover:text-purple-500" />
</button>
{/snippet}
</GitDeployProgressPopover>
{/if}
{/if}
{#if stack.status !== 'not deployed' && stack.status !== 'created'}
{#if $canAccess('stacks', 'edit')}
{#if source.sourceType === 'git' && source.gitStack}
<button
@@ -2854,6 +2882,9 @@
ok={composeOutputOk}
ms={composeOutputMs}
exitCode={composeOutputExitCode}
stackName={composeOutputStackName}
stackIcon={composeOutputStackName ? stackSources[composeOutputStackName]?.icon ?? null : null}
envId={$currentEnvironment?.id ?? null}
/>
<ContainerInspectModal
+57 -26
View File
@@ -1,9 +1,9 @@
<script lang="ts">
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 <html> (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. -->
<Dialog.Content class="max-w-[min(90rem,calc(100vw-4rem))] h-[85vh] overflow-hidden flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
{#if running}
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
{/if}
{title}
<!-- Identity, run status and the run recap all share one line to save
vertical space; the recap wraps under it only on a narrow dialog. -->
<Dialog.Title class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-normal">
<DeployOutputHeader verb={headerVerb} {stackName} {stackIcon} {envId} state={headerState} {statusLine} />
<DeploySummaryBar {lines} />
</Dialog.Title>
<Dialog.Description>
{statusLine}
</Dialog.Description>
<Dialog.Description class="sr-only">{statusLine}</Dialog.Description>
</Dialog.Header>
<LogViewer
logs={lines.join('\n')}
logs={logText}
{title}
autoRefresh={false}
autoScroll={running}
@@ -0,0 +1,59 @@
<script lang="ts">
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:
// "<state icon> <verb> <stack icon> <name> on <env icon> <env> <status>"
// 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);
</script>
<span class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm font-normal">
{#if state === 'running'}
<Loader2 class="{iconClass} shrink-0 animate-spin text-muted-foreground" />
{:else if state === 'complete'}
<CheckCircle2 class="{iconClass} shrink-0 text-emerald-500" />
{:else if state === 'error'}
<XCircle class="{iconClass} shrink-0 text-destructive" />
{:else}
<Rocket class="{iconClass} shrink-0 text-violet-500" />
{/if}
<span class="shrink-0 font-medium">{verb}</span>
{#if stackName}
{#if stackIcon}
<StackIcon icon={stackIcon} {stackName} {envId} class="{iconClass} shrink-0 text-muted-foreground" />
{:else}
<Layers class="{iconClass} shrink-0 text-muted-foreground" />
{/if}
<span class="truncate font-medium">{stackName}</span>
{/if}
{#if env}
<span class="shrink-0 text-muted-foreground">on</span>
<EnvironmentIcon icon={env.icon || 'server'} envId={env.id} class="{iconClass} shrink-0 text-muted-foreground" />
<span class="truncate font-medium text-amber-600 dark:text-amber-400">{env.name}</span>
{/if}
{#if statusLine}
<span class="ml-1 shrink-0 truncate text-muted-foreground">{statusLine}</span>
{/if}
</span>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import { RefreshCw, Plus, Play, Download, Hammer } from 'lucide-svelte';
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
import { summarize } from '$lib/utils/deploy-summary-core';
// A compact, horizontal recap of a compose run, derived from its output lines
// (the same pure summarizer the recorded runs use). Renders nothing until there
// is at least one container/image fact, so it never adds empty chrome.
interface Props {
lines: string[];
class?: string;
}
let { lines, class: className = '' }: Props = $props();
let s = $derived(summarize(lines));
let hasContainers = $derived(s.containersRecreated + s.containersCreated + s.containersStarted > 0);
let hasImages = $derived(s.imagesPulled.length + s.imagesBuilt.length > 0);
let show = $derived(hasContainers || hasImages);
</script>
{#if show}
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-normal text-muted-foreground {className}">
{#if s.containersRecreated > 0}
<span class="inline-flex items-center gap-1"><RefreshCw class="h-3 w-3" />{s.containersRecreated} recreated</span>
{/if}
{#if s.containersCreated > 0}
<span class="inline-flex items-center gap-1"><Plus class="h-3 w-3" />{s.containersCreated} created</span>
{/if}
{#if s.containersStarted > 0}
<span class="inline-flex items-center gap-1"><Play class="h-3 w-3" />{s.containersStarted} started</span>
{/if}
{#if s.imagesPulled.length > 0}
<span class="inline-flex items-center gap-1.5">
<Download class="h-3 w-3 shrink-0" />
{#each s.imagesPulled as img}
<span class="inline-flex items-center gap-1"><ContainerIcon image={img} showFallbackWhenOff class="h-3 w-3 shrink-0" />{img}</span>
{/each}
</span>
{/if}
{#if s.imagesBuilt.length > 0}
<span class="inline-flex items-center gap-1.5">
<Hammer class="h-3 w-3 shrink-0" />
{#each s.imagesBuilt as img}
<span class="inline-flex items-center gap-1"><ContainerIcon image={img} showFallbackWhenOff class="h-3 w-3 shrink-0" />{img}</span>
{/each}
</span>
{/if}
</div>
{/if}
+327 -185
View File
@@ -1,41 +1,27 @@
<script lang="ts">
/**
* Deploys tab: the recorded history of deploy runs for this stack (GET
* /api/stacks/{name}/deploys). Each run is a collapsed row by default --
* status, relative time, duration, and a one-line summary -- expandable for the
* full picture (containers, build/cache, images, options, a truncated notice,
* and on failure the shortened last error line). All of that text composition
* lives in deploy-run-view.ts, not here -- this component only fetches and binds.
* /api/stacks/{name}/deploys), rendered as a dense DataGrid. Each run is one
* grid row -- status, when, duration, trigger, and a merged summary column
* (container/build recap + option pills + a shortened error). Expanding a row
* reveals the LOG beneath it plus the run's details (containers, build/cache,
* images, options, a truncated notice, the last error line). All text
* composition lives in deploy-run-view.ts -- this component only fetches, binds,
* and lays the grid out.
*
* Mirrors BackupPanel/ExecutionHistoryList's shape (loading / empty / list),
* the sibling tab in this same modal -- see StackModal.svelte's Backups tab
* for the tab-gating convention this one follows.
* The log is fetched (GET .../deploys/{id}/log) only when a row expands, never
* while the list loads: a run's log can be large, and the list is meant to stay
* quick. buildDeployLogPanelState decides what that fetch should attempt -- a run
* already known to be missing its log file never triggers the request.
*
* Expanding a row additionally loads that run's full protocol text (GET
* .../deploys/{id}/log) into the same LogViewer the live output window and
* the docked panel use -- but only on expand, never while the list itself
* loads: a run's log can be large, and the list is meant to stay quick.
* buildDeployLogPanelState (deploy-run-view.ts) decides what that fetch
* should even attempt: a run already known to be missing its log file
* (reconciled by deploy-log-reconcile) never triggers the request at all,
* since it would just 404.
*
* `envId === null` is NOT "nothing to ask for" -- it is the LOCAL
* environment (see the list route's own doc comment, `.../deploys/+server.ts`):
* appendEnvParam() only appends `env` when envId is truthy, so a
* single-environment install's own "deploy" calls never send one either,
* and every one of ITS runs is recorded with environmentId === null. The
* route now resolves an omitted `env` to exactly that filter. onMount
* below fetches unconditionally for that reason -- there is no longer a
* distinct "no environment selected" state to guard against here; the
* type this component receives (number | null) has no third value that
* could mean anything else.
* `envId === null` is the LOCAL environment, not "nothing to ask for" (see the
* list route's doc comment): appendEnvParam() omits `env` when envId is falsy,
* and every LOCAL run is recorded with environmentId === null, so the grid loads
* unconditionally.
*/
import { onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import {
Loader2,
ChevronDown,
Check,
X,
Clock,
@@ -48,7 +34,12 @@
} from 'lucide-svelte';
import { formatRelativeTime, formatDateTime } from '$lib/stores/settings';
import { appendEnvParam } from '$lib/stores/environment';
import { buildDeployRunView, buildDeployLogPanelState, type DeployRun } from '$lib/utils/deploy-run-view';
import { buildDeployRunView, buildDeployLogPanelState, deployTallyFromRuns, type DeployRun } from '$lib/utils/deploy-run-view';
import { DataGrid } from '$lib/components/data-grid';
import { Badge } from '$lib/components/ui/badge';
import { onDestroy } from 'svelte';
import { GripHorizontal } from 'lucide-svelte';
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
import LogViewer from '$lib/components/LogViewer.svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
@@ -58,13 +49,39 @@
// Forwarded to LogViewer so an expanded run's log follows the caller's editor
// theme toggle instead of LogViewer's own 'dark' default.
theme?: 'light' | 'dark';
// Bumped by the parent after a deploy finishes so the history re-fetches.
reloadKey?: number;
// Reports the run tally to the parent for the tab badge (total + ok/failed split).
onTally?: (t: { total: number; ok: number; failed: number }) => void;
}
let { stackName, envId, theme = 'dark' }: Props = $props();
let { stackName, envId, theme = 'dark', reloadKey = 0, onTally }: Props = $props();
let runs = $state<DeployRun[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
let expanded = $state<Set<number>>(new Set());
let expandedKeys = $state<Set<unknown>>(new Set());
// Column sort. Default: newest first (the API's own order). startedAt/duration
// compare numerically; status/triggeredBy compare as strings.
let sortState = $state<{ field: string; direction: 'asc' | 'desc' }>({
field: 'startedAt',
direction: 'desc'
});
const sortedRuns = $derived.by(() => {
const dir = sortState.direction === 'desc' ? -1 : 1;
const numeric = sortState.field === 'startedAt' || sortState.field === 'duration';
return [...runs].sort((a, b) => {
let cmp: number;
if (numeric) {
const va = sortState.field === 'startedAt' ? (a.startedAt ? Date.parse(a.startedAt) : 0) : (a.duration ?? 0);
const vb = sortState.field === 'startedAt' ? (b.startedAt ? Date.parse(b.startedAt) : 0) : (b.duration ?? 0);
cmp = va - vb;
} else {
cmp = String((a as any)[sortState.field] ?? '').localeCompare(String((b as any)[sortState.field] ?? ''));
}
return cmp * dir;
});
});
/** One entry per run whose log has been requested. 'loading'/'loaded'/'missing'/'error'
* -- deliberately not reused for "not yet requested" (absent from the map), so a
@@ -78,15 +95,24 @@
let confirmDeleteId = $state<number | null>(null);
let deletingIds = $state<Set<number>>(new Set());
function toggle(run: DeployRun) {
const s = new Set(expanded);
if (s.has(run.id)) {
s.delete(run.id);
} else {
// Fired by the grid when a row's expand chevron is clicked. Loads the log on
// open; nothing to do on collapse (the fetched text is kept for a re-open).
function onExpandChange(key: unknown, expanded: boolean) {
if (!expanded) return;
const run = runs.find((r) => r.id === key);
if (run) void loadLog(run);
}
// Whole-row click toggles expansion (the grid only wires the chevron itself),
// loading the log on open -- same effect as the chevron path.
function toggleRun(run: DeployRun) {
const s = new Set(expandedKeys);
if (s.has(run.id)) s.delete(run.id);
else {
s.add(run.id);
void loadLog(run);
}
expanded = s;
expandedKeys = s;
}
/**
@@ -139,9 +165,9 @@
return;
}
runs = runs.filter((r) => r.id !== run.id);
const s = new Set(expanded);
const s = new Set(expandedKeys);
s.delete(run.id);
expanded = s;
expandedKeys = s;
const l = new Map(logs);
l.delete(run.id);
logs = l;
@@ -156,7 +182,9 @@
}
}
onMount(async () => {
async function loadRuns() {
loading = true;
loadError = null;
try {
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/deploys`, envId));
if (!res.ok) {
@@ -171,185 +199,299 @@
} finally {
loading = false;
}
}
// Load on mount and whenever the parent bumps reloadKey (a deploy finished).
$effect(() => {
void reloadKey;
loadRuns();
});
function statusBadge(status: string) {
// Keep the parent's tab badge in sync: total + a success/failure split. Only
// report once a load has settled -- reporting the initial empty `runs` while
// still loading would momentarily clobber the parent's own pre-fetched tally
// (making the badge flicker to nothing when the tab is first opened).
$effect(() => {
if (loading) return;
onTally?.(deployTallyFromRuns(runs));
});
// Drag-resizable log height (px), shared by every expanded row's LogViewer and
// persisted per browser. A handle below the log drags this between the bounds.
const LOG_HEIGHT_KEY = 'dockhand-deploy-log-height';
const LOG_MIN = 120, LOG_MAX = 720;
let logHeight = $state(
typeof localStorage !== 'undefined'
? clampLog(Number(localStorage.getItem(LOG_HEIGHT_KEY)) || 256)
: 256
);
let dragging = $state(false);
let dragStartY = 0;
let dragStartH = 0;
function clampLog(h: number): number {
return Math.min(LOG_MAX, Math.max(LOG_MIN, h));
}
function startLogResize(e: MouseEvent) {
e.preventDefault();
dragging = true;
dragStartY = e.clientY;
dragStartH = logHeight;
window.addEventListener('mousemove', onLogResize);
window.addEventListener('mouseup', endLogResize);
}
function onLogResize(e: MouseEvent) {
if (!dragging) return;
logHeight = clampLog(dragStartH + (e.clientY - dragStartY));
}
function endLogResize() {
if (!dragging) return;
dragging = false;
window.removeEventListener('mousemove', onLogResize);
window.removeEventListener('mouseup', endLogResize);
try {
localStorage.setItem(LOG_HEIGHT_KEY, String(logHeight));
} catch { /* private mode - keep the in-memory value */ }
}
onDestroy(() => {
window.removeEventListener('mousemove', onLogResize);
window.removeEventListener('mouseup', endLogResize);
});
// Badge styles mirror the Schedules execution-history (getStatusBadge/getTriggerBadge)
// so the Deploys grid reads the same as the rest of the app.
function statusMeta(status: string) {
switch (status) {
case 'success':
return { cls: 'bg-emerald-500/15 text-emerald-500', icon: Check };
return { cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', icon: Check, spin: false };
case 'failed':
return { cls: 'bg-red-500/15 text-red-500', icon: X };
return { cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', icon: X, spin: false };
case 'running':
return { cls: 'bg-sky-500/15 text-sky-500', icon: Loader2 };
return { cls: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400', icon: Loader2, spin: true };
default:
return { cls: 'bg-muted text-muted-foreground', icon: AlertTriangle };
return { cls: 'bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400', icon: AlertTriangle, spin: false };
}
}
function triggerIcon(triggeredBy: string) {
function triggerMeta(triggeredBy: string) {
switch (triggeredBy) {
case 'cron':
return Timer;
return { icon: Timer, cls: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' };
case 'webhook':
return Webhook;
return { icon: Webhook, cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' };
case 'startup':
return Power;
return { icon: Power, cls: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' };
default:
return Hand;
return { icon: Hand, cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
}
}
</script>
{#if loading}
<div class="flex justify-center py-8"><Loader2 class="h-5 w-5 animate-spin text-muted-foreground" /></div>
{:else if loadError}
{#if loadError}
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
<AlertTriangle class="mb-3 h-10 w-10 text-muted-foreground/40" />
<p class="text-sm text-muted-foreground">{loadError}</p>
</div>
{:else if runs.length === 0}
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
<Clock class="mb-3 h-10 w-10 text-muted-foreground/40" />
<p class="text-sm text-muted-foreground">No deploy runs yet.</p>
<p class="mt-1 text-xs text-muted-foreground">Deploy this stack — its run history appears here.</p>
</div>
{:else}
<div class="space-y-1.5">
{#each runs as run (run.id)}
<DataGrid
data={sortedRuns}
keyField="id"
gridId="deploys"
{loading}
expandable
bind:expandedKeys
{onExpandChange}
{sortState}
onSortChange={(state) => (sortState = state)}
onRowClick={(run, e) => {
// Ignore clicks that originated on an interactive control (delete popover).
if ((e.target as HTMLElement)?.closest('button')) return;
toggleRun(run);
}}
class="border-none"
wrapperClass="border rounded-lg"
>
{#snippet emptyState()}
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
<Clock class="mb-3 h-10 w-10 text-muted-foreground/40" />
<p class="text-sm text-muted-foreground">No deploy runs yet.</p>
<p class="mt-1 text-xs text-muted-foreground">Deploy this stack — its run history appears here.</p>
</div>
{/snippet}
{#snippet cell(column, run, _rowState)}
{@const view = buildDeployRunView(run)}
{#if column.id === 'status'}
{@const meta = statusMeta(run.status)}
{@const StatusIcon = meta.icon}
<span class="inline-flex items-center gap-1.5">
<Badge variant="default" class={meta.cls}>
<StatusIcon class="h-3.5 w-3.5 {meta.spin ? 'animate-spin' : ''}" />
</Badge>
<span class="truncate text-xs">{view.statusLabel}</span>
</span>
{:else if column.id === 'when'}
{#if run.startedAt}
<span class="flex w-full items-center justify-between gap-2">
<span>{formatRelativeTime(run.startedAt)}</span>
<span class="text-muted-foreground tabular-nums">{formatDateTime(run.startedAt)}</span>
</span>
{:else}
<span>—</span>
{/if}
{:else if column.id === 'duration'}
<span class="block text-right text-muted-foreground tabular-nums">{view.duration}</span>
{:else if column.id === 'trigger'}
{@const tmeta = triggerMeta(run.triggeredBy)}
{@const TrigIcon = tmeta.icon}
<span class="inline-flex items-center gap-1.5">
<Badge variant="default" class={tmeta.cls}>
<TrigIcon class="h-3.5 w-3.5" />
</Badge>
<span class="truncate">{view.trigger}</span>
</span>
{:else if column.id === 'summary'}
<div class="flex min-w-0 items-center gap-2">
<span class="shrink-0 text-muted-foreground">{view.containerSummary} · {view.buildStatus}</span>
{#if view.optionChips.length > 0}
<span class="flex shrink-0 flex-wrap gap-1">
{#each view.optionChips as chip}
<span class="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">{chip}</span>
{/each}
</span>
{/if}
{#if view.errorSummary}
<span class="min-w-0 truncate text-destructive" title={run.errorMessage || ''}>{view.errorSummary}</span>
{/if}
</div>
{:else if column.id === 'actions'}
{@const panelState = buildDeployLogPanelState(run)}
{#if panelState.deletable}
<div class="flex justify-end">
<ConfirmPopover
open={confirmDeleteId === run.id}
action="Delete"
itemType="deploy run"
title="Delete this run"
position="left"
disabled={deletingIds.has(run.id)}
onConfirm={() => deleteRun(run)}
onOpenChange={(open) => (confirmDeleteId = open ? run.id : null)}
>
{#snippet children({ open })}
{#if deletingIds.has(run.id)}
<Loader2 class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
{:else}
<Trash2 class="h-3.5 w-3.5 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/if}
{/snippet}
</ConfirmPopover>
</div>
{/if}
{/if}
{/snippet}
{#snippet expandedRow(run, _rowState)}
{@const view = buildDeployRunView(run)}
{@const badge = statusBadge(run.status)}
{@const BadgeIcon = badge.icon}
{@const TrigIcon = triggerIcon(run.triggeredBy)}
{@const isOpen = expanded.has(run.id)}
{@const panelState = buildDeployLogPanelState(run)}
{@const logEntry = logs.get(run.id)}
<div class="rounded-md border border-zinc-200 dark:border-zinc-700">
<button
type="button"
class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs hover:bg-muted/30"
onclick={() => toggle(run)}
>
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform {isOpen ? '' : '-rotate-90'}" />
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded {badge.cls}" title={view.statusLabel}>
<BadgeIcon class="h-3 w-3 {run.status === 'running' ? 'animate-spin' : ''}" />
</span>
<span class="shrink-0" title={run.startedAt ? formatDateTime(run.startedAt, true) : ''}>
{run.startedAt ? formatRelativeTime(run.startedAt) : '—'}
</span>
<span class="shrink-0 text-muted-foreground">{view.duration}</span>
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground" title={view.trigger}>
<TrigIcon class="h-3 w-3" />
</span>
<span class="min-w-0 flex-1 truncate text-muted-foreground">
{view.containerSummary} · {view.buildStatus}
</span>
{#if view.errorSummary}
<span class="max-w-[300px] shrink truncate text-destructive" title={run.errorMessage || ''}>{view.errorSummary}</span>
{/if}
</button>
{#if isOpen}
<div class="border-t border-zinc-200 px-3 py-2.5 text-xs dark:border-zinc-700">
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
<div>
<div class="text-muted-foreground">Containers</div>
<div>{view.containerSummary}</div>
</div>
<div>
<div class="text-muted-foreground">Build</div>
<div>{view.buildStatus}</div>
</div>
<div>
<div class="text-muted-foreground">Triggered by</div>
<div>{view.trigger}</div>
</div>
</div>
{#if view.optionChips.length > 0}
<div class="mt-2 flex flex-wrap gap-1">
{#each view.optionChips as chip}
<span class="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">{chip}</span>
<div class="w-full p-4 pl-12 shadow-inner bg-muted text-xs">
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
<div>
<div class="text-muted-foreground">Containers</div>
<div>{view.containerSummary}</div>
{#if view.containerNames.length > 0}
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
{#each view.containerNames as cname}
<span class="inline-flex items-center gap-1">
<ContainerIcon image="" name={cname} showFallbackWhenOff class="w-3 h-3 shrink-0" />
{cname}
</span>
{/each}
</div>
{/if}
{#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0}
<div class="mt-2 space-y-1">
{#if view.imagesBuilt.length > 0}
<div><span class="text-muted-foreground">Built:</span> {view.imagesBuilt.join(', ')}</div>
{/if}
{#if view.imagesPulled.length > 0}
<div><span class="text-muted-foreground">Pulled:</span> {view.imagesPulled.join(', ')}</div>
{/if}
</div>
<div>
<div class="text-muted-foreground">Build</div>
<div>{view.buildStatus}</div>
</div>
<div>
<div class="text-muted-foreground">Triggered by</div>
<div>{view.trigger}</div>
</div>
</div>
{#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0}
<div class="mt-2 space-y-1">
{#if view.imagesBuilt.length > 0}
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span class="text-muted-foreground">Built:</span>
{#each view.imagesBuilt as img}
<span class="inline-flex items-center gap-1">
<ContainerIcon image={img} showFallbackWhenOff class="w-3 h-3 shrink-0" />
{img}
</span>
{/each}
</div>
{/if}
{#if view.errorSummary}
<!-- The task-17 spec called for the shortened last error line here (same
text the collapsed row already truncates to) -- not the raw errorMessage,
which is compose's full stderr and, for a failed run, duplicates almost
everything the log panel below shows. One line of "what went wrong" is a
useful recap next to the run's other details; the full text belongs in
exactly one place, the LogViewer beneath it. -->
<div class="mt-2 rounded-md border border-l-[3px] border-destructive/40 border-l-destructive bg-destructive/5 px-2 py-1.5">
<span class="font-mono text-[11px] text-destructive/90">{view.errorSummary}</span>
{#if view.imagesPulled.length > 0}
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span class="text-muted-foreground">Pulled:</span>
{#each view.imagesPulled as img}
<span class="inline-flex items-center gap-1">
<ContainerIcon image={img} showFallbackWhenOff class="w-3 h-3 shrink-0" />
{img}
</span>
{/each}
</div>
{/if}
<div class="mt-3">
<div class="mb-1 flex items-center justify-between">
<span class="text-muted-foreground">Log</span>
{#if panelState.deletable}
<ConfirmPopover
open={confirmDeleteId === run.id}
action="Delete"
itemType="deploy run"
title="Delete this run"
position="left"
disabled={deletingIds.has(run.id)}
onConfirm={() => deleteRun(run)}
onOpenChange={(open) => (confirmDeleteId = open ? run.id : null)}
>
{#snippet children({ open })}
{#if deletingIds.has(run.id)}
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
{:else}
<Trash2 class="h-3 w-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/if}
{/snippet}
</ConfirmPopover>
{/if}
</div>
{#if panelState.truncated}
<!-- The notice belongs directly above the viewer it describes, not
among the run's other metadata -- it says something about the
text below it, not about the run as a whole. -->
<div class="mb-1 inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-2.5 w-2.5" /> Log truncated
</div>
{/if}
{#if panelState.logMissing || logEntry?.status === 'missing'}
<!-- A confirmed-gone log file reads the same as an empty one if it's
just handed to LogViewer with logs="" -- the two mean opposite
things (nothing happened vs. the record of what happened is
gone), so this case gets its own, unambiguous message instead. -->
<div class="rounded-md border border-dashed border-zinc-300 px-3 py-6 text-center text-muted-foreground dark:border-zinc-700">
Protocol no longer available.
</div>
{:else if logEntry?.status === 'error'}
<div class="rounded-md border border-dashed border-destructive/40 px-3 py-6 text-center text-destructive">
{logEntry.error}
</div>
{:else}
<LogViewer
logs={logEntry?.text ?? ''}
loading={logEntry?.status !== 'loaded'}
title={`${stackName}-deploy-${run.id}`}
autoRefresh={false}
autoScroll={false}
class="h-64"
{theme}
/>
{/if}
</div>
</div>
{/if}
<div class="mt-3">
{#if panelState.truncated}
<div class="mb-1">
<span class="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
<AlertTriangle class="h-2.5 w-2.5" /> Log truncated
</span>
</div>
{/if}
{#if panelState.logMissing || logEntry?.status === 'missing'}
<!-- A confirmed-gone log file reads the same as an empty one if handed to
LogViewer with logs="" -- the two mean opposite things, so this case
gets its own message. -->
<div class="rounded-md border border-dashed border-zinc-300 px-3 py-6 text-center text-muted-foreground dark:border-zinc-700">
Protocol no longer available.
</div>
{:else if logEntry?.status === 'error'}
<div class="rounded-md border border-dashed border-destructive/40 px-3 py-6 text-center text-destructive">
{logEntry.error}
</div>
{:else}
<div style="height: {logHeight}px">
<LogViewer
logs={logEntry?.text ?? ''}
loading={logEntry?.status !== 'loaded'}
title={`${stackName}-deploy-${run.id}`}
autoRefresh={false}
autoScroll={false}
class="h-full"
{theme}
/>
</div>
<!-- Drag up/down to resize the log height (persisted). -->
<div
class="group flex h-2 cursor-row-resize items-center justify-center rounded-b bg-zinc-200 transition-colors hover:bg-blue-400 dark:bg-zinc-700 dark:hover:bg-blue-500 {dragging ? 'bg-blue-500 dark:bg-blue-400' : ''}"
onmousedown={startLogResize}
role="separator"
aria-orientation="horizontal"
tabindex="0"
title="Drag to resize"
>
<GripHorizontal class="h-3 w-3 text-white opacity-0 transition-opacity group-hover:opacity-100 {dragging ? 'opacity-100' : ''}" />
</div>
{/if}
</div>
</div>
{/each}
</div>
{/snippet}
</DataGrid>
{/if}
+134 -129
View File
@@ -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<StepProgress | null>(null);
let steps = $state<StepProgress[]>([]);
// 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<string[]>([]);
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 <html>). 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 <stack>..." 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]'
);
</script>
<!-- Trigger wrapper: intercepts click to open the dialog -->
@@ -179,48 +217,25 @@
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen) handleClose(); }}>
<Dialog.Content
class="max-w-2xl flex flex-col gap-0 p-0 overflow-hidden"
class="{contentSizeClass} flex flex-col gap-0 p-0 overflow-hidden"
showCloseButton={false}
interactOutsideBehavior={isDeploying ? 'ignore' : 'close'}
escapeKeydownBehavior={isDeploying ? 'ignore' : 'close'}
>
<!-- Header -->
<div class="px-6 py-4 border-b shrink-0">
<div class="flex items-center gap-2 min-w-0">
{#if overallStatus === 'complete'}
<CheckCircle2 class="w-5 h-5 text-green-500 shrink-0" />
{:else if overallStatus === 'error'}
<XCircle class="w-5 h-5 text-red-500 shrink-0" />
{:else if isDeploying}
<Loader2 class="w-5 h-5 text-violet-500 animate-spin shrink-0" />
{:else}
<Rocket class="w-5 h-5 text-violet-500 shrink-0" />
{/if}
<span class="text-base font-semibold">Git deploy</span>
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1 truncate">{stackName}</code>
{#if overallStatus === 'complete'}
<Badge variant="outline" class="ml-auto shrink-0 text-green-600 border-green-600/30">Complete</Badge>
{:else if overallStatus === 'error'}
<Badge variant="outline" class="ml-auto shrink-0 text-red-600 border-red-600/30">Failed</Badge>
{:else if isDeploying}
<Badge variant="secondary" class="ml-auto shrink-0 tabular-nums text-xs">
{#if currentStep?.step && currentStep?.totalSteps}
{currentStep.step}/{currentStep.totalSteps}
{:else}
Deploying...
{/if}
</Badge>
{/if}
</div>
{#if isDeploying && currentStep?.totalSteps}
<div class="mt-3">
<Progress value={progressPercentage} class="h-1.5 [&>[data-progress]]:bg-violet-600" />
</div>
{/if}
<DeployOutputHeader
verb="Git deploy"
{stackName}
{stackIcon}
{envId}
state={headerState}
statusLine={headerStatusLine}
/>
</div>
<!-- Body: steps log -->
<div class="overflow-y-auto px-4 py-3" style="max-height: 55vh; min-height: 12rem;">
<!-- Body: one log stream (git stages + compose output), filling the dialog -->
<div class="flex-1 min-h-0 flex flex-col px-4 py-3">
{#if overallStatus === 'confirming'}
<div class="flex items-start gap-3 py-2 px-2">
<AlertTriangle class="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
@@ -232,29 +247,29 @@
</p>
</div>
</div>
{:else if steps.length === 0 && isDeploying}
{:else if logLines.length === 0 && isDeploying}
<div class="flex items-center gap-3 text-muted-foreground py-2 px-2">
<Loader2 class="w-4 h-4 animate-spin shrink-0" />
<span class="text-sm">Initializing...</span>
</div>
{:else}
<div class="space-y-0.5">
{#each steps as step, index (index)}
{@const StepIcon = getStepIcon(step.status)}
{@const isCurrentStep = index === steps.length - 1 && isDeploying}
<div class="flex items-center gap-3 py-1.5 px-2 rounded-md text-sm hover:bg-muted/40 transition-colors">
<StepIcon
class="w-4 h-4 shrink-0 {getStepColor(step.status, isCurrentStep)} {isCurrentStep && step.status !== 'complete' && step.status !== 'error' ? 'animate-spin' : ''}"
/>
<span class="{getStepColor(step.status, isCurrentStep)}">
{step.message || step.status}
</span>
</div>
{/each}
{/if}
{#if logLines.length > 0}
<div class="flex-1 min-h-0">
<LogViewer
logs={logText}
title={`${stackName}-git-deploy`}
autoRefresh={false}
autoScroll={isDeploying}
class="h-full"
theme={logTheme}
/>
</div>
{/if}
{#if errorMessage}
{#if errorMessage && logLines.length === 0}
<!-- Only when the log itself doesn't already carry the reason (e.g. a throw
before any compose output) -- otherwise this box just duplicates it. -->
<div class="mt-3 mx-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
<div class="flex items-start gap-2 text-sm text-destructive">
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
@@ -266,20 +281,10 @@
<!-- Footer -->
<div class="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-2">
<!-- Left: copy / cancel -->
<!-- Left: cancel (confirm step only). The log has its own copy button. -->
<div>
{#if overallStatus === 'confirming'}
<Button variant="outline" onclick={handleCancelConfirm}>Cancel</Button>
{:else if steps.length > 0}
<Button variant="outline" size="sm" onclick={copyLogs} class="gap-1.5">
{#if copied}
<Check class="w-3.5 h-3.5" />
Copied!
{:else}
<Copy class="w-3.5 h-3.5" />
Copy logs
{/if}
</Button>
{/if}
</div>
+146 -15
View File
@@ -1,23 +1,27 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import * as Dialog from '$lib/components/ui/dialog';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { Input } from '$lib/components/ui/input';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive } from 'lucide-svelte';
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive, History } from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import { page } from '$app/stores'; // BETA GATE: backups feature flag
import BackupPanel from '../containers/BackupPanel.svelte';
import DeploysPanel from './DeploysPanel.svelte';
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
import { fetchBackupExecutions } from '$lib/utils/backup';
import { deployTallyFromRuns } from '$lib/utils/deploy-run-view';
import { copyToClipboard } from '$lib/utils/clipboard';
import CronEditor from '$lib/components/cron-editor.svelte';
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte';
import BranchCombobox from './BranchCombobox.svelte';
import IconPickerModal from './IconPickerModal.svelte';
import ComposeOutputModal from './ComposeOutputModal.svelte';
import StackIcon from '$lib/components/StackIcon.svelte';
import { appendEnvParam } from '$lib/stores/environment';
import { persistStackIcon } from '$lib/utils/stack-icon';
@@ -111,8 +115,26 @@
let formNewRepoBranch = $state('main');
let formNewRepoCredentialId = $state<number | null>(null);
// Tabs: Settings (the deploy form) and Backups (edit mode + feature flag only).
let activeTab = $state<'settings' | 'backups'>('settings');
// Tabs: Settings (the deploy form), Deploys (recorded run history, edit mode),
// and Backups (edit mode + feature flag only).
let activeTab = $state<'settings' | 'deploys' | 'backups'>('settings');
// Bumped after a deploy finishes so the Deploys tab re-fetches the new run.
let deploysReloadKey = $state(0);
// Deploys tab badge tally (total + ok/failed). Fetched cheaply when the modal
// opens so the badge shows immediately; DeploysPanel's onTally keeps it fresh.
let deploysTally = $state<{ total: number; ok: number; failed: number }>({ total: 0, ok: 0, failed: 0 });
// Live compose output for "Save and deploy" (mirrors StackModal's deploy output;
// shown in the shared ComposeOutputModal). Without this the streamed lines from
// the deploy job are consumed and discarded, so the user sees no live output.
let outputOpen = $state(false);
let outputTitle = $state('');
let outputLines = $state<string[]>([]);
let outputRunning = $state(false);
let outputOk = $state<boolean | undefined>(undefined);
let outputMs = $state<number | undefined>(undefined);
let outputExitCode = $state<number | undefined>(undefined);
let outputStartedAt = 0;
// The stack's volumes, loaded lazily when the Backups tab opens (git stacks
// don't otherwise need them). Feeds the backup volume picker.
let stackVolumes = $state<VolumeInfo[]>([]);
@@ -154,6 +176,30 @@
if (activeTab === 'backups') void loadStackVolumes();
});
async function loadDeploysCount() {
if (!gitStack) {
deploysTally = { total: 0, ok: 0, failed: 0 };
return;
}
try {
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(gitStack.stackName)}/deploys`, effectiveEnvId));
if (!res.ok) return;
const data = await res.json();
deploysTally = deployTallyFromRuns(Array.isArray(data?.runs) ? data.runs : []);
} catch {
// Non-fatal: the badge just stays at its current value.
}
}
// Show the Deploys badge count as soon as the modal opens (not only once the tab
// is viewed); re-count after a deploy bumps the key.
$effect(() => {
if (open) {
void deploysReloadKey;
void loadDeploysCount();
}
});
// Form state - stack deployment config
let formStackName = $state('');
let formStackNameUserModified = $state(false);
@@ -457,6 +503,16 @@
async function resetForm() {
// Clear state BEFORE async loads to avoid race conditions
activeTab = 'settings';
// Reset the deploy-output overlay so a previous run's window (bound to
// outputOpen) can't reappear over a freshly opened modal -- the main modal is
// deliberately left open behind it after a deploy, so closing the main modal
// first would otherwise leave outputOpen=true with stale lines.
outputOpen = false;
outputLines = [];
outputRunning = false;
outputOk = undefined;
outputMs = undefined;
outputExitCode = undefined;
stackVolumes = [];
stackVolumesLoaded = false;
backupTally = { ok: 0, failed: 0 };
@@ -684,32 +740,71 @@
: '/api/git/stacks';
const method = gitStack ? 'PUT' : 'POST';
// Live-stream the compose output into the shared window when deploying, so
// "Save and deploy" shows progress like StackModal's "Save & redeploy".
// (A plain save with no deploy has no output to show.)
if (deployAfterSave) {
outputTitle = `Deploying ${formStackName.trim()}`;
outputLines = [];
outputRunning = true;
outputOk = undefined;
outputMs = undefined;
outputExitCode = undefined;
outputStartedAt = Date.now();
outputOpen = true;
}
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await readJobResponse(response);
const data = deployAfterSave
? await readJobResponse(response, (line) => (outputLines = [...outputLines, line]))
: await readJobResponse(response);
if (!response.ok) {
if (deployAfterSave) {
// A pre-deploy failure (e.g. git sync) streams no lines, so surface
// the error text in the window instead of "No logs available".
if (outputLines.length === 0 && data.error) outputLines = String(data.error).split('\n');
outputRunning = false;
outputOk = false;
outputMs = Date.now() - outputStartedAt;
}
formError = data.error || 'Failed to save git stack';
return;
}
// Check if deployment failed
const deployResult = data.deployResult as { success?: boolean; error?: string } | undefined;
if (deployAfterSave) {
const ok = !(deployResult && !deployResult.success);
// The sync phase (clone/pull) fails before any compose line streams, so
// on a failure with no streamed output, show the error text.
if (!ok && outputLines.length === 0 && deployResult?.error) {
outputLines = String(deployResult.error).split('\n');
}
outputRunning = false;
outputOk = ok;
outputMs = Date.now() - outputStartedAt;
}
if (deployResult && !deployResult.success) {
toast.error('Deployment failed', {
description: deployResult.error || 'Unknown error'
});
deploysReloadKey++; // the failed run is recorded; refresh the Deploys tab
onSaved(); // Still refresh the list to show the new stack
onClose(); // Close modal, error shown as toast
// Keep the modal open so the user can read the output window; they close it.
return;
}
deploysReloadKey++; // a new run was recorded; refresh the Deploys tab
onSaved();
onClose();
// With a deploy, leave the modal open behind the output window so the user
// can review the log; a plain save closes as before.
if (!deployAfterSave) onClose();
} catch (error) {
formError = 'Failed to save git stack';
} finally {
@@ -809,10 +904,10 @@
</div>
</Dialog.Header>
<!-- Tabs: Settings (deploy form) + Backups. Backups only in edit mode AND when
the backups feature flag is on (BETA GATE) — a git stack must exist before
it can be backed up, and the feature must be enabled. -->
{#if gitStack && $page.data.backupsEnabled}
<!-- Tabs (edit mode only - a git stack must exist first): Settings (deploy form),
Deploys (recorded run history), and Backups. Backups is additionally gated on
the backups feature flag (BETA GATE). -->
{#if gitStack}
<div class="flex items-center gap-1 border-b border-zinc-200 px-5 dark:border-zinc-700 flex-shrink-0">
<button
type="button"
@@ -823,13 +918,31 @@
</button>
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'backups' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = 'backups')}
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'deploys' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = 'deploys')}
>
<Archive class="h-3.5 w-3.5" /> Backups
{#if backupTally.ok > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="w-2.5 h-2.5" />{backupTally.ok}</span>{/if}
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
<History class="h-3.5 w-3.5" /> Deploys
{#if deploysTally.ok > 0}
<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="h-2.5 w-2.5" />{deploysTally.ok}</span>
{/if}
{#if deploysTally.failed > 0}
<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="h-2.5 w-2.5" />{deploysTally.failed}</span>
{/if}
{#if deploysTally.total > 0 && deploysTally.ok === 0 && deploysTally.failed === 0}
<Badge variant="secondary" class="ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums">{deploysTally.total}</Badge>
{/if}
</button>
{#if $page.data.backupsEnabled}
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'backups' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => (activeTab = 'backups')}
>
<Archive class="h-3.5 w-3.5" /> Backups
{#if backupTally.ok > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="w-2.5 h-2.5" />{backupTally.ok}</span>{/if}
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
</button>
{/if}
</div>
{/if}
@@ -843,6 +956,10 @@
onTally={(t) => (backupTally = t)}
/>
</div>
{:else if activeTab === 'deploys' && gitStack}
<div class="flex min-h-0 flex-1 flex-col p-5">
<DeploysPanel stackName={gitStack.stackName} envId={effectiveEnvId} reloadKey={deploysReloadKey} onTally={(t) => (deploysTally = t)} />
</div>
{:else}
<div bind:this={containerRef} class="flex-1 min-h-0 flex {isDraggingSplit ? 'select-none' : ''}">
<!-- Left column: Form fields -->
@@ -1467,3 +1584,17 @@
</Dialog.Root>
<IconPickerModal bind:open={showIconPicker} value={formIcon} onselect={onIconSelect} title="Choose a stack icon" />
<!-- Live compose output for "Save and deploy" (see saveGitStack). -->
<ComposeOutputModal
bind:open={outputOpen}
title={outputTitle}
lines={outputLines}
running={outputRunning}
ok={outputOk}
ms={outputMs}
exitCode={outputExitCode}
stackName={formStackName}
stackIcon={formIcon}
envId={effectiveEnvId}
/>
+5 -7
View File
@@ -67,13 +67,11 @@
let deploying = $state(false);
async function handleDeploy() {
deploying = true;
try {
await onDeploy({ pull, build, forceRecreate });
} finally {
deploying = false;
open = false;
}
// Close the popover immediately: the output modal opens and owns the progress
// display, so leaving the popover stuck on "Deploying..." behind it is noise.
const opts = { pull, build, forceRecreate };
open = false;
await onDeploy(opts);
}
function handleTriggerClick(e: MouseEvent) {
+129 -31
View File
@@ -15,6 +15,8 @@
import ComposeValidatePanel from './ComposeValidatePanel.svelte';
import BackupPanel from '../containers/BackupPanel.svelte';
import DeploysPanel from './DeploysPanel.svelte';
import DeployOutputHeader from './DeployOutputHeader.svelte';
import { deployTallyFromRuns } from '$lib/utils/deploy-run-view';
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
import { fetchBackupExecutions } from '$lib/utils/backup';
import type { Component } from 'svelte';
@@ -245,6 +247,48 @@
outputLines = [...outputLines, line];
}
// Bumped after a deploy finishes so the Deploys tab re-fetches and shows the new run.
let deploysReloadKey = $state(0);
// Deploys tab badge tally (total + ok/failed). Fetched cheaply when the modal
// opens so the badge shows immediately (not only once the tab is first viewed);
// DeploysPanel's onTally then keeps it fresh once the tab is open.
let deploysTally = $state<{ total: number; ok: number; failed: number }>({ total: 0, ok: 0, failed: 0 });
// Whether run history exists at all -- gates the Deploys tab for a read-only /
// not-yet-synced stack. Set ONLY by the parent's own fetch below, never by the
// panel's live onTally, so opening the tab (which briefly reports total 0 while
// loading) can't make the tab hide itself out from under the user.
let deploysHistoryExists = $state(false);
async function loadDeploysCount() {
// Deploy history is keyed by stackName+env (schedule_executions), independent of
// whether a local compose file exists -- so load it even for a read-only /
// not-yet-synced stack (needsFileLocation), letting the Deploys tab appear when
// there ARE runs to show.
if (mode !== 'edit' || !stackName) {
deploysTally = { total: 0, ok: 0, failed: 0 };
deploysHistoryExists = false;
return;
}
try {
const envId = $currentEnvironment?.id ?? null;
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/deploys`, envId));
if (!res.ok) return;
const data = await res.json();
deploysTally = deployTallyFromRuns(Array.isArray(data?.runs) ? data.runs : []);
deploysHistoryExists = deploysTally.total > 0;
} catch {
// Non-fatal: the badge just stays at its current value.
}
}
// Refresh the badge count when the modal opens (and after a deploy bumps the key).
$effect(() => {
if (open) {
void deploysReloadKey; // re-count after a deploy finishes
void loadDeploysCount();
}
});
function finishOutput(output: string | undefined, ok: boolean, exitCode?: number) {
outputRunning = false;
outputOk = ok;
@@ -253,11 +297,30 @@
if (outputLines.length === 0 && output) {
outputLines = output.split('\n');
}
deploysReloadKey++;
}
// Dismiss the output panel (only allowed once the run has finished; the run
// keeps going regardless - this just hides its log).
function closeOutput() {
outputLines = [];
outputTitle = '';
outputOk = undefined;
outputMs = undefined;
outputExitCode = undefined;
}
const outputStatusLine = $derived(
formatRunStatus({ running: outputRunning, ok: outputOk, ms: outputMs, exitCode: outputExitCode })
);
// DeployOutputHeader takes a verb + state (not the old title/running/ok): split the
// stackName off the title and map running/ok to the shared state enum.
const outputVerb = $derived(
stackName && outputTitle.endsWith(stackName) ? outputTitle.slice(0, -stackName.length).trim() : outputTitle
);
const outputState = $derived<'running' | 'complete' | 'error'>(
outputRunning ? 'running' : outputOk === false ? 'error' : 'complete'
);
// Stack exists warning dialog state
let showExistsWarning = $state(false);
@@ -1350,6 +1413,19 @@
} catch (e) {
console.error('Failed to fetch stack containers:', e);
}
// Load the stack's icon even when the compose isn't local (read-only git
// stack): the icon is Dockhand metadata, not repo content, so the header
// must still show a custom icon the user set.
try {
const sourcesRes = await fetch(appendEnvParam('/api/stacks/sources', envId));
if (sourcesRes.ok) {
const sourceMap = await sourcesRes.json();
formIcon = sourceMap?.[stackName]?.icon ?? null;
}
} catch (e) {
console.warn('Failed to load stack icon:', e);
}
return;
}
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to load compose file');
@@ -2080,26 +2156,22 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
{#if !readonly}
<button
type="button"
title="Change stack icon"
onclick={() => (showIconPicker = true)}
class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700 hover:ring-2 hover:ring-primary transition-shadow"
>
{#if pendingUploadImage}
<img src={pendingUploadImage} alt="" class="w-4 h-4 rounded object-cover" />
{:else if formIcon}
<StackIcon icon={formIcon} {stackName} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
{:else}
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
{/if}
</button>
{:else}
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
<!-- The stack icon is Dockhand metadata (stored via the /icon API), not
repo content, so it stays editable even for a read-only git stack. -->
<button
type="button"
title="Change stack icon"
onclick={() => (showIconPicker = true)}
class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700 hover:ring-2 hover:ring-primary transition-shadow"
>
{#if pendingUploadImage}
<img src={pendingUploadImage} alt="" class="w-4 h-4 rounded object-cover" />
{:else if formIcon}
<StackIcon icon={formIcon} {stackName} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
{:else}
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
</div>
{/if}
{/if}
</button>
<div>
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
{#if mode === 'create'}
@@ -2167,17 +2239,27 @@
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
</button>
{/if}
<!-- Deploys tab: recorded run history for this stack (Task 17). Same gate
shape as Backups above minus the beta flag -- hidden for untracked
stacks (no compose file means no deploy history either) and only in
edit mode, where stackName/envId are actually known. -->
{#if mode === 'edit' && !needsFileLocation}
<!-- Deploys tab: recorded run history (keyed by stackName+env, independent of
the local compose file). Shown with a synced compose, OR -- for a
read-only / not-yet-synced git stack with no local compose -- only when
there is history to show, so an empty tab never appears. Edit mode only,
where stackName/envId are known. -->
{#if mode === 'edit' && (!needsFileLocation || deploysHistoryExists)}
<button
type="button"
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'deploys' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
onclick={() => activeTab = 'deploys'}
>
<History class="h-3.5 w-3.5" /> Deploys
{#if deploysTally.ok > 0}
<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="h-2.5 w-2.5" />{deploysTally.ok}</span>
{/if}
{#if deploysTally.failed > 0}
<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="h-2.5 w-2.5" />{deploysTally.failed}</span>
{/if}
{#if deploysTally.total > 0 && deploysTally.ok === 0 && deploysTally.failed === 0}
<Badge variant="secondary" class="ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums">{deploysTally.total}</Badge>
{/if}
</button>
{/if}
</div>
@@ -2522,10 +2604,11 @@
onTally={(t) => (backupTally = t)}
/>
</div>
{:else if activeTab === 'deploys' && !needsFileLocation}
<!-- Deploys tab (never for untracked stacks — same reasoning as Backups above) -->
<div class="h-full flex-1 overflow-auto p-4">
<DeploysPanel {stackName} envId={$currentEnvironment?.id ?? null} theme={editorTheme} />
{:else if activeTab === 'deploys' && (!needsFileLocation || deploysHistoryExists)}
<!-- Deploys tab: shown with a synced compose, or when a read-only /
not-yet-synced stack still has run history to show. -->
<div class="flex h-full min-h-0 flex-1 flex-col p-4">
<DeploysPanel {stackName} envId={$currentEnvironment?.id ?? null} theme={editorTheme} reloadKey={deploysReloadKey} onTally={(t) => (deploysTally = t)} />
</div>
{/if}
</div>
@@ -2552,10 +2635,25 @@
</div>
<div class="shrink-0 flex flex-col min-h-0" style="height: {outputSplitRatio}%">
<div class="px-5 py-1.5 text-xs text-zinc-500 dark:text-zinc-400 flex items-center gap-2 flex-shrink-0">
{#if outputRunning}
<Loader2 class="w-3 h-3 animate-spin" />
<DeployOutputHeader
verb={outputVerb}
{stackName}
stackIcon={formIcon}
envId={$currentEnvironment?.id ?? null}
state={outputState}
statusLine={outputStatusLine}
iconClass="w-3.5 h-3.5"
/>
{#if !outputRunning}
<button
type="button"
onclick={closeOutput}
title="Close output"
class="ml-auto p-0.5 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
>
<X class="w-3.5 h-3.5" />
</button>
{/if}
{outputTitle} — {outputStatusLine}
</div>
<LogViewer
logs={outputLines.join('\n')}