live compose output during deploys, plus a per-stack deploy history tab
This commit is contained in:
41 files changed
+2093
-1159
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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';
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
@@ -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('');
|
||||
}
|
||||
Reference in new issue
Block a user