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('');
}