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('');
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getGitStack } from '$lib/server/db';
|
||||
import { deployGitStack } from '$lib/server/git';
|
||||
import { auditGitStack } from '$lib/server/audit';
|
||||
import { verifyWebhookSignature } from '$lib/server/webhook-signature';
|
||||
import { decideWebhookSecretPolicy, allowSecretlessWebhook } from '$lib/server/webhook-secret-policy';
|
||||
|
||||
function detectSource(request: Request): string {
|
||||
if (request.headers.get('x-hub-signature-256')) return 'github';
|
||||
@@ -43,21 +44,32 @@ export const POST: RequestHandler = async (event) => {
|
||||
|
||||
const source = detectSource(request);
|
||||
|
||||
// A secret is mandatory: reject if none is configured.
|
||||
if (!gitStack.webhookSecret) {
|
||||
const policy = decideWebhookSecretPolicy(!!gitStack.webhookSecret, allowSecretlessWebhook());
|
||||
if (policy.action === 'reject-no-secret') {
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'POST', source, error: 'no_secret_configured'
|
||||
});
|
||||
return json({ error: 'Webhook secret is not configured for this stack' }, { status: 401 });
|
||||
}
|
||||
if (policy.action === 'deploy-unverified') {
|
||||
// ALLOW_WEBHOOKS_WITHOUT_SECRET opt-in (isolated network): deploy without
|
||||
// verification, but record the unverified trigger in the audit trail.
|
||||
const result = await deployGitStack(id, { force: false, triggeredBy: 'webhook' });
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'POST', source, verification: 'skipped_no_secret', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
}
|
||||
|
||||
// policy.action === 'verify' here, so a secret is present.
|
||||
const webhookSecret = gitStack.webhookSecret as string;
|
||||
const payload = await request.text();
|
||||
const githubSignature = request.headers.get('x-hub-signature-256');
|
||||
const gitlabToken = request.headers.get('x-gitlab-token');
|
||||
|
||||
const signature = githubSignature || gitlabToken;
|
||||
|
||||
if (!verifyWebhookSignature(payload, signature, gitStack.webhookSecret)) {
|
||||
if (!verifyWebhookSignature(payload, signature, webhookSecret)) {
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'POST', source, error: 'invalid_signature'
|
||||
});
|
||||
@@ -107,13 +119,21 @@ export const GET: RequestHandler = async (event) => {
|
||||
return json({ error: 'Webhook is not enabled for this stack' }, { status: 403 });
|
||||
}
|
||||
|
||||
// A secret is mandatory (see POST handler). Reject if none is configured.
|
||||
if (!gitStack.webhookSecret) {
|
||||
const policy = decideWebhookSecretPolicy(!!gitStack.webhookSecret, allowSecretlessWebhook());
|
||||
if (policy.action === 'reject-no-secret') {
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'GET', source: 'get', error: 'no_secret_configured'
|
||||
});
|
||||
return json({ error: 'Webhook secret is not configured for this stack' }, { status: 401 });
|
||||
}
|
||||
if (policy.action === 'deploy-unverified') {
|
||||
// ALLOW_WEBHOOKS_WITHOUT_SECRET opt-in: deploy without verification, audited.
|
||||
const result = await deployGitStack(id, { force: false, triggeredBy: 'webhook' });
|
||||
await auditGitStack(event, 'webhook', id, gitStack.stackName, gitStack.environmentId, {
|
||||
method: 'GET', source: 'get', verification: 'skipped_no_secret', result: result.skipped ? 'skipped' : result.success ? 'deployed' : 'failed'
|
||||
});
|
||||
return json(result);
|
||||
}
|
||||
|
||||
// Verify secret via query parameter for GET requests
|
||||
const secret = url.searchParams.get('secret');
|
||||
|
||||
@@ -17,8 +17,14 @@
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import {
|
||||
resourceForScheduleType,
|
||||
viewableScheduleTypes
|
||||
} from '$lib/server/schedule-execution-access-core';
|
||||
import {
|
||||
getScheduleExecutions,
|
||||
ALL_SCHEDULE_TYPES,
|
||||
type ScheduleType,
|
||||
type ScheduleTrigger,
|
||||
type ScheduleStatus
|
||||
@@ -38,11 +44,47 @@ import {
|
||||
* query: limit:integer Page size (default 50)
|
||||
* query: offset:integer Page offset (default 0)
|
||||
* resp-200: {executions:array<{id:integer!, scheduleType:string!, status:string!, startedAt:string!}>!, total:integer!, limit:integer!, offset:integer!}
|
||||
* resp-401: Authentication required
|
||||
* resp-403: Permission denied (the :view permission for the requested schedule type's resource - stacks/backups/schedules)
|
||||
* resp-500: Unexpected error while loading execution history
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
const scheduleType = url.searchParams.get('scheduleType') as ScheduleType | null;
|
||||
|
||||
// A row's errorMessage/details are as sensitive as the feature that produced
|
||||
// them, so gate on THAT resource's :view (a stack_deploy needs stacks:view, a
|
||||
// backup needs backups:view), not a blanket schedules:view.
|
||||
//
|
||||
// With an explicit scheduleType, gate on that one type's resource. WITHOUT it
|
||||
// the response would mix types, so we must NOT settle for schedules:view alone:
|
||||
// compute the types the caller may view from their held resource perms and
|
||||
// constrain the query to exactly those (viewableTypes below). 403 only when the
|
||||
// caller can view no type at all.
|
||||
let viewableTypes: ScheduleType[] | null = null; // null = all types (no filter)
|
||||
if (auth.authEnabled) {
|
||||
if (scheduleType) {
|
||||
if (!(await auth.can(resourceForScheduleType(scheduleType), 'view'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
} else {
|
||||
const viewable = {
|
||||
schedules: await auth.can('schedules', 'view'),
|
||||
stacks: await auth.can('stacks', 'view'),
|
||||
backups: await auth.can('backups', 'view')
|
||||
};
|
||||
viewableTypes = viewableScheduleTypes(viewable, ALL_SCHEDULE_TYPES) as ScheduleType[] | null;
|
||||
if (viewableTypes !== null && viewableTypes.length === 0) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const scheduleType = url.searchParams.get('scheduleType') as ScheduleType | null;
|
||||
const scheduleIdParam = url.searchParams.get('scheduleId');
|
||||
const environmentIdParam = url.searchParams.get('environmentId');
|
||||
const status = url.searchParams.get('status') as ScheduleStatus | null;
|
||||
@@ -68,6 +110,24 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||
if (limitParam) filters.limit = parseInt(limitParam, 10);
|
||||
if (offsetParam) filters.offset = parseInt(offsetParam, 10);
|
||||
|
||||
// No explicit scheduleType: bound the mixed list to the types the caller may
|
||||
// view, so a schedules:view-only user never receives stack_deploy/backup
|
||||
// errorMessage/details. null = all types allowed (no filter needed).
|
||||
if (viewableTypes !== null) {
|
||||
filters.scheduleTypes = viewableTypes;
|
||||
}
|
||||
|
||||
// Enterprise env-scoped RBAC: a user without all-environment access sees
|
||||
// only their accessible environments' runs (plus unattributed/null-env
|
||||
// rows -- see getScheduleExecutions' environmentIds filter). This matters
|
||||
// because a run's errorMessage can carry (redacted) compose stderr.
|
||||
if (auth.authEnabled && auth.isEnterprise) {
|
||||
const accessible = await auth.getAccessibleEnvironmentIds();
|
||||
if (accessible !== null) {
|
||||
filters.environmentIds = accessible;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await getScheduleExecutions(filters);
|
||||
|
||||
return json(result);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { getScheduleExecution, deleteScheduleExecution } from '$lib/server/db';
|
||||
import { authorize } from '$lib/server/authorize';
|
||||
import { resourceForScheduleType } from '$lib/server/schedule-execution-access-core';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -16,10 +17,17 @@ import { authorize } from '$lib/server/authorize';
|
||||
* path: id:integer! Execution id (from GET /api/schedules/executions)
|
||||
* resp-200: {id:integer!, scheduleType:string!, status:string!, startedAt:string!, log:string}
|
||||
* resp-400: Invalid execution id
|
||||
* resp-401: Authentication required
|
||||
* resp-403: Permission denied (the :view permission for the row's resource), or no access to its environment (enterprise)
|
||||
* resp-404: Execution not found
|
||||
* resp-500: Unexpected error while loading the execution
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const id = parseInt(params.id, 10);
|
||||
if (isNaN(id)) {
|
||||
@@ -31,6 +39,24 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
return json({ error: 'Execution not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// The row's logs/errorMessage are as sensitive as the feature that ran, so
|
||||
// gate on that resource's :view (and, in enterprise, on access to the row's
|
||||
// own environment -- a null env is unattributed and not env-scoped, matching
|
||||
// deploy-run-access.ts).
|
||||
if (auth.authEnabled) {
|
||||
const resource = resourceForScheduleType(execution.scheduleType);
|
||||
if (!(await auth.can(resource, 'view'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
if (
|
||||
execution.environmentId != null &&
|
||||
auth.isEnterprise &&
|
||||
!(await auth.canAccessEnvironment(execution.environmentId))
|
||||
) {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
return json(execution);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to get schedule execution:', error);
|
||||
@@ -44,13 +70,15 @@ export const GET: RequestHandler = async ({ params }) => {
|
||||
* path: id:integer! Execution id (from GET /api/schedules/executions)
|
||||
* resp-200: {success:boolean!}
|
||||
* resp-400: Invalid execution id
|
||||
* resp-403: Permission denied (RBAC 'schedules:edit' missing)
|
||||
* resp-401: Authentication required
|
||||
* resp-403: Permission denied (the :edit permission for the row's resource), or no access to its environment (enterprise)
|
||||
* resp-404: Execution not found
|
||||
* resp-500: Unexpected error while deleting the execution
|
||||
*/
|
||||
export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
const auth = await authorize(cookies);
|
||||
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||
return json({ error: 'Authentication required' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -59,6 +87,28 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
|
||||
return json({ error: 'Invalid execution ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const execution = await getScheduleExecution(id);
|
||||
if (!execution) {
|
||||
return json({ error: 'Execution not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Deleting a run record is gated by the :edit of the feature that ran it
|
||||
// (a stack_deploy needs stacks:edit, a backup needs backups:edit), plus
|
||||
// env access in enterprise -- symmetric with the GET above.
|
||||
if (auth.authEnabled) {
|
||||
const resource = resourceForScheduleType(execution.scheduleType);
|
||||
if (!(await auth.can(resource, 'edit'))) {
|
||||
return json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
if (
|
||||
execution.environmentId != null &&
|
||||
auth.isEnterprise &&
|
||||
!(await auth.canAccessEnvironment(execution.environmentId))
|
||||
) {
|
||||
return json({ error: 'Access denied to this environment' }, { status: 403 });
|
||||
}
|
||||
}
|
||||
|
||||
await deleteScheduleExecution(id);
|
||||
|
||||
return json({ success: true });
|
||||
|
||||
@@ -1137,13 +1137,29 @@ services:
|
||||
<div class="space-y-1 pt-2 border-t">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>Deploy log reconcile</Label>
|
||||
<Tooltip.Provider delayDuration={100}>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-4 h-4 text-muted-foreground cursor-help" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content side="right" sideOffset={8} class="!w-80">
|
||||
Every deploy from Dockhand keeps a log file on disk, linked to its run in the
|
||||
Deploys tab. This job runs on a schedule to keep the two in sync: it deletes
|
||||
orphaned log files whose deploy run was already removed, and marks a run whose
|
||||
log file has gone missing (so the Deploys tab shows "log unavailable" instead
|
||||
of a blank). It never deletes a deploy run itself.
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
<TogglePill
|
||||
checked={deployLogReconcileEnabled}
|
||||
onchange={handleDeployLogReconcileEnabledChange}
|
||||
disabled={!$canAccess('settings', 'edit')}
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">Delete deploy-log files that no longer have a matching deploy record; deploy records that lost their log file are only flagged, never deleted</p>
|
||||
<p class="text-xs text-muted-foreground">Keeps deploy-log files in sync with their deploy records: removes logs whose run is gone, and flags runs whose log went missing (never deletes a run).</p>
|
||||
{#if deployLogReconcileEnabled}
|
||||
<div class="mt-2">
|
||||
<CronEditor
|
||||
|
||||
@@ -536,8 +536,11 @@
|
||||
let composeOutputMs = $state<number | undefined>(undefined);
|
||||
let composeOutputExitCode = $state<number | undefined>(undefined);
|
||||
|
||||
function startComposeOutput(title: string) {
|
||||
let composeOutputStackName = $state<string | undefined>(undefined);
|
||||
|
||||
function startComposeOutput(title: string, stackName?: string) {
|
||||
composeOutputTitle = title;
|
||||
composeOutputStackName = stackName;
|
||||
composeOutputLines = [];
|
||||
composeOutputRunning = true;
|
||||
composeOutputOpen = true;
|
||||
@@ -557,7 +560,7 @@
|
||||
// network error, from the catch blocks below) — the payload does not carry an
|
||||
// exit code today (StackOperationResult has no such field, see stacks.ts), so
|
||||
// `exitCode` stays undefined in practice until the server starts sending one.
|
||||
function finishComposeOutput(output: string | undefined, ok: boolean, exitCode?: number) {
|
||||
function finishComposeOutput(output: string | undefined, ok: boolean, exitCode?: number, error?: string) {
|
||||
composeOutputRunning = false;
|
||||
composeOutputOk = ok;
|
||||
composeOutputMs = Date.now() - composeOutputStartedAt;
|
||||
@@ -565,6 +568,15 @@
|
||||
if (composeOutputLines.length === 0 && output) {
|
||||
composeOutputLines = output.split('\n');
|
||||
}
|
||||
// On failure the reason lives in `error` (compose's stderr), which the streamed
|
||||
// log lines don't carry -- append it so the modal shows WHY it failed instead of
|
||||
// ending on a bare "Started" with the cause hidden in a separate dialog.
|
||||
if (!ok && error) {
|
||||
const errLines = error.split('\n').map((l) => l.trimEnd()).filter(Boolean);
|
||||
const already = new Set(composeOutputLines.map((l) => l.trim()));
|
||||
const fresh = errLines.filter((l) => !already.has(l.trim()));
|
||||
if (fresh.length > 0) composeOutputLines = [...composeOutputLines, ...fresh];
|
||||
}
|
||||
}
|
||||
|
||||
// Container-level confirmation popover state
|
||||
@@ -1012,26 +1024,27 @@
|
||||
async function startStack(name: string) {
|
||||
operationError = null;
|
||||
stackActionLoading = name;
|
||||
startComposeOutput(`Starting ${name}`);
|
||||
startComposeOutput(`Starting ${name}`, name);
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
|
||||
const data = await readJobResponse(response, appendComposeOutputLine);
|
||||
finishComposeOutput(
|
||||
typeof data.output === 'string' ? data.output : undefined,
|
||||
Boolean(data.success),
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined,
|
||||
typeof data.error === 'string' ? data.error : undefined
|
||||
);
|
||||
if (!data.success) {
|
||||
showErrorDialog(`Failed to start ${name}`, data.error || 'Failed to start stack');
|
||||
toast.error(`Failed to start ${name}`);
|
||||
return;
|
||||
}
|
||||
toast.success(`Started ${name}`);
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
finishComposeOutput(undefined, false);
|
||||
console.error('Failed to start stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to start stack';
|
||||
showErrorDialog(`Failed to start ${name}`, errorMsg);
|
||||
finishComposeOutput(undefined, false, undefined, errorMsg);
|
||||
toast.error(`Failed to start ${name}`);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
@@ -1040,26 +1053,27 @@
|
||||
async function stopStack(name: string) {
|
||||
operationError = null;
|
||||
stackActionLoading = name;
|
||||
startComposeOutput(`Stopping ${name}`);
|
||||
startComposeOutput(`Stopping ${name}`, name);
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
|
||||
const data = await readJobResponse(response, appendComposeOutputLine);
|
||||
finishComposeOutput(
|
||||
typeof data.output === 'string' ? data.output : undefined,
|
||||
Boolean(data.success),
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined,
|
||||
typeof data.error === 'string' ? data.error : undefined
|
||||
);
|
||||
if (!data.success) {
|
||||
showErrorDialog(`Failed to stop ${name}`, data.error || 'Failed to stop stack');
|
||||
toast.error(`Failed to stop ${name}`);
|
||||
return;
|
||||
}
|
||||
toast.success(`Stopped ${name}`);
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
finishComposeOutput(undefined, false);
|
||||
console.error('Failed to stop stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to stop stack';
|
||||
showErrorDialog(`Failed to stop ${name}`, errorMsg);
|
||||
finishComposeOutput(undefined, false, undefined, errorMsg);
|
||||
toast.error(`Failed to stop ${name}`);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
@@ -1068,7 +1082,7 @@
|
||||
async function restartStack(name: string, mode: 'restart' | 'ordered' | 'recreate' = 'restart') {
|
||||
operationError = null;
|
||||
stackActionLoading = name;
|
||||
startComposeOutput(mode === 'recreate' ? `Recreating ${name}` : `Restarting ${name}`);
|
||||
startComposeOutput(mode === 'recreate' ? `Recreating ${name}` : `Restarting ${name}`, name);
|
||||
try {
|
||||
let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId);
|
||||
if (mode === 'recreate' || mode === 'ordered') {
|
||||
@@ -1079,19 +1093,22 @@
|
||||
finishComposeOutput(
|
||||
typeof data.output === 'string' ? data.output : undefined,
|
||||
Boolean(data.success),
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined,
|
||||
typeof data.error === 'string' ? data.error : undefined
|
||||
);
|
||||
if (!data.success) {
|
||||
showErrorDialog(`Failed to restart ${name}`, data.error || 'Failed to restart stack');
|
||||
// The reason is now in the output modal's log; a toast points the user to it
|
||||
// without a second, redundant error dialog stacked over the same modal.
|
||||
toast.error(`Failed to restart ${name}`);
|
||||
return;
|
||||
}
|
||||
toast.success(mode === 'recreate' ? `Recreated ${name}` : `Restarted ${name}`);
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
finishComposeOutput(undefined, false);
|
||||
console.error('Failed to restart stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to restart stack';
|
||||
showErrorDialog(`Failed to restart ${name}`, errorMsg);
|
||||
finishComposeOutput(undefined, false, undefined, errorMsg);
|
||||
toast.error(`Failed to restart ${name}`);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
@@ -1100,7 +1117,15 @@
|
||||
async function redeployStack(name: string, options: { pull: boolean; build: boolean; forceRecreate: boolean }) {
|
||||
operationError = null;
|
||||
stackActionLoading = name;
|
||||
startComposeOutput(`Redeploying ${name}`);
|
||||
startComposeOutput(`Redeploying ${name}`, name);
|
||||
// Record which redeploy options were chosen so the log shows them (they are not
|
||||
// otherwise visible once the popover closes).
|
||||
const chosen = [
|
||||
options.pull && 'pull images',
|
||||
options.build && 'build images',
|
||||
options.forceRecreate && 'force recreate'
|
||||
].filter(Boolean);
|
||||
appendComposeOutputLine(`Options: ${chosen.length ? chosen.join(', ') : 'none'}`);
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/deploy`, envId), {
|
||||
method: 'POST',
|
||||
@@ -1111,19 +1136,24 @@
|
||||
finishComposeOutput(
|
||||
typeof data.output === 'string' ? data.output : undefined,
|
||||
Boolean(data.success),
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined,
|
||||
typeof data.error === 'string' ? data.error : undefined
|
||||
);
|
||||
if (!data.success) {
|
||||
showErrorDialog(`Failed to redeploy ${name}`, data.error || 'Failed to redeploy stack');
|
||||
// The compose output window carries the full log (incl. the failure reason)
|
||||
// plus the failed status, so a separate error dialog would duplicate it.
|
||||
toast.error(`Failed to redeploy ${name}`);
|
||||
return;
|
||||
}
|
||||
toast.success(`Redeployed ${name}`);
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
finishComposeOutput(undefined, false);
|
||||
console.error('Failed to redeploy stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to redeploy stack';
|
||||
showErrorDialog(`Failed to redeploy ${name}`, errorMsg);
|
||||
// A throw before any output streams would otherwise leave the window with
|
||||
// only the Options line and a red status, so put the error text in it.
|
||||
finishComposeOutput(undefined, false, undefined, errorMsg);
|
||||
toast.error(`Failed to redeploy ${name}`);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
}
|
||||
@@ -1133,26 +1163,27 @@
|
||||
operationError = null;
|
||||
stackActionLoading = name;
|
||||
stackDownLoading = name;
|
||||
startComposeOutput(`Bringing down ${name}`);
|
||||
startComposeOutput(`Bringing down ${name}`, name);
|
||||
try {
|
||||
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
|
||||
const data = await readJobResponse(response, appendComposeOutputLine);
|
||||
finishComposeOutput(
|
||||
typeof data.output === 'string' ? data.output : undefined,
|
||||
Boolean(data.success),
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined
|
||||
typeof data.exitCode === 'number' ? data.exitCode : undefined,
|
||||
typeof data.error === 'string' ? data.error : undefined
|
||||
);
|
||||
if (!data.success) {
|
||||
showErrorDialog(`Failed to bring down ${name}`, data.error || 'Failed to bring down stack');
|
||||
toast.error(`Failed to bring down ${name}`);
|
||||
return;
|
||||
}
|
||||
toast.success(`Brought down ${name}`);
|
||||
await fetchStacks();
|
||||
} catch (error) {
|
||||
finishComposeOutput(undefined, false);
|
||||
console.error('Failed to bring down stack:', error);
|
||||
const errorMsg = error instanceof Error ? error.message : 'Failed to bring down stack';
|
||||
showErrorDialog(`Failed to bring down ${name}`, errorMsg);
|
||||
finishComposeOutput(undefined, false, undefined, errorMsg);
|
||||
toast.error(`Failed to bring down ${name}`);
|
||||
} finally {
|
||||
stackActionLoading = null;
|
||||
stackDownLoading = null;
|
||||
@@ -1825,6 +1856,8 @@
|
||||
<GitDeployProgressPopover
|
||||
stackId={source.gitStack.id}
|
||||
stackName={stack.name}
|
||||
stackIcon={source.icon}
|
||||
envId={$currentEnvironment?.id ?? null}
|
||||
onComplete={fetchStacks}
|
||||
>
|
||||
{#snippet children()}
|
||||
@@ -2102,48 +2135,43 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if (stack.status === 'not deployed' || stack.status === 'created') && source.gitStack}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openGitModal(source.gitStack)}
|
||||
title="Edit git stack"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground hover:text-purple-500" />
|
||||
</button>
|
||||
{#if source.sourceType === 'git' && source.gitStack}
|
||||
<!-- One deploy popover for a git stack in ANY state (only the trigger
|
||||
icon differs by status), so the instance is stable: a status
|
||||
flip mid-deploy must not remount it and drop its open dialog. -->
|
||||
{#if stack.status === 'not deployed' || stack.status === 'created'}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openGitModal(source.gitStack)}
|
||||
title="Edit git stack"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground hover:text-purple-500" />
|
||||
</button>
|
||||
{/if}
|
||||
<GitDeployProgressPopover
|
||||
stackId={source.gitStack.id}
|
||||
stackName={stack.name}
|
||||
stackIcon={source.icon}
|
||||
envId={$currentEnvironment?.id ?? null}
|
||||
onComplete={fetchStacks}
|
||||
>
|
||||
{#snippet children()}
|
||||
<button
|
||||
type="button"
|
||||
title="Deploy"
|
||||
title={(stack.status === 'not deployed' || stack.status === 'created') ? 'Deploy' : 'Sync from Git'}
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<Rocket class="grid-action-icon grid-action-start text-muted-foreground hover:text-violet-500" />
|
||||
{#if stack.status === 'not deployed' || stack.status === 'created'}
|
||||
<Rocket class="grid-action-icon grid-action-start text-muted-foreground hover:text-violet-500" />
|
||||
{:else}
|
||||
<RefreshCw class="grid-action-icon grid-action-restart text-muted-foreground hover:text-purple-500" />
|
||||
{/if}
|
||||
</button>
|
||||
{/snippet}
|
||||
</GitDeployProgressPopover>
|
||||
{:else}
|
||||
{#if source.sourceType === 'git' && source.gitStack}
|
||||
<GitDeployProgressPopover
|
||||
stackId={source.gitStack.id}
|
||||
stackName={stack.name}
|
||||
onComplete={fetchStacks}
|
||||
>
|
||||
{#snippet children()}
|
||||
<button
|
||||
type="button"
|
||||
title="Sync from Git"
|
||||
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
|
||||
>
|
||||
<RefreshCw class="grid-action-icon grid-action-restart text-muted-foreground hover:text-purple-500" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</GitDeployProgressPopover>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if stack.status !== 'not deployed' && stack.status !== 'created'}
|
||||
{#if $canAccess('stacks', 'edit')}
|
||||
{#if source.sourceType === 'git' && source.gitStack}
|
||||
<button
|
||||
@@ -2854,6 +2882,9 @@
|
||||
ok={composeOutputOk}
|
||||
ms={composeOutputMs}
|
||||
exitCode={composeOutputExitCode}
|
||||
stackName={composeOutputStackName}
|
||||
stackIcon={composeOutputStackName ? stackSources[composeOutputStackName]?.icon ?? null : null}
|
||||
envId={$currentEnvironment?.id ?? null}
|
||||
/>
|
||||
|
||||
<ContainerInspectModal
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Loader2 } from 'lucide-svelte';
|
||||
import LogViewer from '$lib/components/LogViewer.svelte';
|
||||
import DeployOutputHeader from './DeployOutputHeader.svelte';
|
||||
import DeploySummaryBar from './DeploySummaryBar.svelte';
|
||||
import { formatRunStatus } from '$lib/utils/run-status';
|
||||
|
||||
// A plain display window for compose output. It owns none of the polling —
|
||||
@@ -15,32 +15,65 @@
|
||||
title: string;
|
||||
lines: string[];
|
||||
running: boolean;
|
||||
// Optional stack identity so the header shows the stack's icon (matches the list).
|
||||
stackName?: string;
|
||||
stackIcon?: string | null;
|
||||
envId?: number | null;
|
||||
ok?: boolean;
|
||||
ms?: number;
|
||||
exitCode?: number;
|
||||
}
|
||||
|
||||
let { open = $bindable(false), title, lines, running, ok, ms, exitCode }: Props = $props();
|
||||
let { open = $bindable(false), title, lines, running, ok, ms, exitCode, stackName, stackIcon = null, envId = null }: Props = $props();
|
||||
|
||||
let statusLine = $derived(formatRunStatus({ running, ok, ms, exitCode }));
|
||||
|
||||
// This modal is opened straight from the stack list, with no editor in scope to
|
||||
// borrow a theme from (unlike the docked panel and Deploys tab inside StackModal,
|
||||
// which both follow `dockhand-editor-theme`). Its Dialog.Content already tracks
|
||||
// the app-wide light/dark switch via bg-background, so the log viewer inside
|
||||
// follows the same switch instead of introducing a third, unrelated toggle.
|
||||
// Same read-once-on-mount + system-preference-fallback pattern as
|
||||
// ComposeGraphViewer's graphTheme -- this component is mounted once at the page
|
||||
// root and toggled via `open`, so onMount runs once per page load.
|
||||
// The header takes a verb + name separately (so the stack icon sits between them);
|
||||
// split the stackName off the title, and map running/ok to the shared state enum.
|
||||
let headerVerb = $derived(
|
||||
stackName && title.endsWith(stackName) ? title.slice(0, -stackName.length).trim() : title
|
||||
);
|
||||
let headerState = $derived<'running' | 'complete' | 'error'>(
|
||||
running ? 'running' : ok === false ? 'error' : 'complete'
|
||||
);
|
||||
|
||||
// Compose output ends abruptly on the last state line; once the run settles, append
|
||||
// a colored closing line (green on success, red on failure) so the end state reads
|
||||
// at a glance. LogViewer colors via ansi_to_html, so this wraps the text in an ANSI
|
||||
// SGR sequence. The ESC byte is built from its code point to keep the source ASCII.
|
||||
const ANSI = {
|
||||
green: String.fromCharCode(27) + '[32m',
|
||||
red: String.fromCharCode(27) + '[31m',
|
||||
reset: String.fromCharCode(27) + '[0m'
|
||||
};
|
||||
let logText = $derived.by(() => {
|
||||
const base = lines.join('\n');
|
||||
if (running || ok === undefined) return base;
|
||||
// Match the closing line's indent to the log's own lines (compose prefixes a
|
||||
// space, other producers don't) so it lines up with the text above, not the edge.
|
||||
const lastReal = [...lines].reverse().find((l) => l.trim().length > 0);
|
||||
const indent = lastReal ? (lastReal.match(/^\s*/)?.[0] ?? '') : '';
|
||||
const color = ok ? ANSI.green : ANSI.red;
|
||||
const suffix = indent + color + statusLine + ANSI.reset;
|
||||
return base ? base + '\n' + suffix : suffix;
|
||||
});
|
||||
|
||||
// This modal has no editor in scope to borrow a theme from (unlike StackModal's
|
||||
// docked panel). It follows the app-wide light/dark switch, whose source of truth is
|
||||
// the `.dark` class on <html> (Tailwind dark mode). Read it EACH time the modal opens:
|
||||
// this component mounts once at the page root and is re-shown via `open`, so the value
|
||||
// must track the current theme, not whatever it was at mount.
|
||||
let outputTheme = $state<'light' | 'dark'>('dark');
|
||||
|
||||
onMount(() => {
|
||||
const appTheme = localStorage.getItem('theme');
|
||||
if (appTheme === 'dark' || appTheme === 'light') {
|
||||
outputTheme = appTheme;
|
||||
} else {
|
||||
outputTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
function currentAppTheme(): 'light' | 'dark' {
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) outputTheme = currentAppTheme();
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
@@ -58,19 +91,17 @@
|
||||
laptop screen: min() picks the smaller of the two, and the 4rem keeps a margin. -->
|
||||
<Dialog.Content class="max-w-[min(90rem,calc(100vw-4rem))] h-[85vh] overflow-hidden flex flex-col">
|
||||
<Dialog.Header class="shrink-0">
|
||||
<Dialog.Title class="flex items-center gap-2">
|
||||
{#if running}
|
||||
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
{/if}
|
||||
{title}
|
||||
<!-- Identity, run status and the run recap all share one line to save
|
||||
vertical space; the recap wraps under it only on a narrow dialog. -->
|
||||
<Dialog.Title class="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm font-normal">
|
||||
<DeployOutputHeader verb={headerVerb} {stackName} {stackIcon} {envId} state={headerState} {statusLine} />
|
||||
<DeploySummaryBar {lines} />
|
||||
</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{statusLine}
|
||||
</Dialog.Description>
|
||||
<Dialog.Description class="sr-only">{statusLine}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<LogViewer
|
||||
logs={lines.join('\n')}
|
||||
logs={logText}
|
||||
{title}
|
||||
autoRefresh={false}
|
||||
autoScroll={running}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { Loader2, CheckCircle2, XCircle, Rocket, Layers } from 'lucide-svelte';
|
||||
import StackIcon from '$lib/components/StackIcon.svelte';
|
||||
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||
import { environments } from '$lib/stores/environment';
|
||||
|
||||
// The one identity line shared by every deploy/compose output view (the docked panel
|
||||
// in StackModal, the standalone ComposeOutputModal, and the git-deploy popover), so
|
||||
// they all read the same and stay aligned:
|
||||
// "<state icon> <verb> <stack icon> <name> on <env icon> <env> <status>"
|
||||
// One component = one place to change sizing/spacing/fallback (no per-dialog drift).
|
||||
interface Props {
|
||||
// The action word, e.g. "Bringing down" or "Git deploy".
|
||||
verb: string;
|
||||
stackName?: string;
|
||||
// Icon value (lucide/selfhst/custom). When unset, a generic Layers glyph shows --
|
||||
// never StackIcon's Boxes fallback, which reads as a different icon.
|
||||
stackIcon?: string | null;
|
||||
envId?: number | null;
|
||||
// Drives the leading icon. 'idle' shows a rocket (a not-yet-started deploy prompt).
|
||||
state: 'idle' | 'running' | 'complete' | 'error';
|
||||
// Optional run status ("Running...", "Succeeded - 1.2s"), muted, same size/baseline.
|
||||
statusLine?: string;
|
||||
// Icon box size; the docked panel is denser than the modals.
|
||||
iconClass?: string;
|
||||
}
|
||||
let { verb, stackName, stackIcon = null, envId = null, state, statusLine, iconClass = 'w-4 h-4' }: Props = $props();
|
||||
|
||||
let env = $derived(envId != null ? $environments.find((e) => e.id === envId) ?? null : null);
|
||||
</script>
|
||||
|
||||
<span class="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm font-normal">
|
||||
{#if state === 'running'}
|
||||
<Loader2 class="{iconClass} shrink-0 animate-spin text-muted-foreground" />
|
||||
{:else if state === 'complete'}
|
||||
<CheckCircle2 class="{iconClass} shrink-0 text-emerald-500" />
|
||||
{:else if state === 'error'}
|
||||
<XCircle class="{iconClass} shrink-0 text-destructive" />
|
||||
{:else}
|
||||
<Rocket class="{iconClass} shrink-0 text-violet-500" />
|
||||
{/if}
|
||||
<span class="shrink-0 font-medium">{verb}</span>
|
||||
{#if stackName}
|
||||
{#if stackIcon}
|
||||
<StackIcon icon={stackIcon} {stackName} {envId} class="{iconClass} shrink-0 text-muted-foreground" />
|
||||
{:else}
|
||||
<Layers class="{iconClass} shrink-0 text-muted-foreground" />
|
||||
{/if}
|
||||
<span class="truncate font-medium">{stackName}</span>
|
||||
{/if}
|
||||
{#if env}
|
||||
<span class="shrink-0 text-muted-foreground">on</span>
|
||||
<EnvironmentIcon icon={env.icon || 'server'} envId={env.id} class="{iconClass} shrink-0 text-muted-foreground" />
|
||||
<span class="truncate font-medium text-amber-600 dark:text-amber-400">{env.name}</span>
|
||||
{/if}
|
||||
{#if statusLine}
|
||||
<span class="ml-1 shrink-0 truncate text-muted-foreground">{statusLine}</span>
|
||||
{/if}
|
||||
</span>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw, Plus, Play, Download, Hammer } from 'lucide-svelte';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import { summarize } from '$lib/utils/deploy-summary-core';
|
||||
|
||||
// A compact, horizontal recap of a compose run, derived from its output lines
|
||||
// (the same pure summarizer the recorded runs use). Renders nothing until there
|
||||
// is at least one container/image fact, so it never adds empty chrome.
|
||||
interface Props {
|
||||
lines: string[];
|
||||
class?: string;
|
||||
}
|
||||
let { lines, class: className = '' }: Props = $props();
|
||||
|
||||
let s = $derived(summarize(lines));
|
||||
let hasContainers = $derived(s.containersRecreated + s.containersCreated + s.containersStarted > 0);
|
||||
let hasImages = $derived(s.imagesPulled.length + s.imagesBuilt.length > 0);
|
||||
let show = $derived(hasContainers || hasImages);
|
||||
</script>
|
||||
|
||||
{#if show}
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm font-normal text-muted-foreground {className}">
|
||||
{#if s.containersRecreated > 0}
|
||||
<span class="inline-flex items-center gap-1"><RefreshCw class="h-3 w-3" />{s.containersRecreated} recreated</span>
|
||||
{/if}
|
||||
{#if s.containersCreated > 0}
|
||||
<span class="inline-flex items-center gap-1"><Plus class="h-3 w-3" />{s.containersCreated} created</span>
|
||||
{/if}
|
||||
{#if s.containersStarted > 0}
|
||||
<span class="inline-flex items-center gap-1"><Play class="h-3 w-3" />{s.containersStarted} started</span>
|
||||
{/if}
|
||||
{#if s.imagesPulled.length > 0}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Download class="h-3 w-3 shrink-0" />
|
||||
{#each s.imagesPulled as img}
|
||||
<span class="inline-flex items-center gap-1"><ContainerIcon image={img} showFallbackWhenOff class="h-3 w-3 shrink-0" />{img}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
{#if s.imagesBuilt.length > 0}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Hammer class="h-3 w-3 shrink-0" />
|
||||
{#each s.imagesBuilt as img}
|
||||
<span class="inline-flex items-center gap-1"><ContainerIcon image={img} showFallbackWhenOff class="h-3 w-3 shrink-0" />{img}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,41 +1,27 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Deploys tab: the recorded history of deploy runs for this stack (GET
|
||||
* /api/stacks/{name}/deploys). Each run is a collapsed row by default --
|
||||
* status, relative time, duration, and a one-line summary -- expandable for the
|
||||
* full picture (containers, build/cache, images, options, a truncated notice,
|
||||
* and on failure the shortened last error line). All of that text composition
|
||||
* lives in deploy-run-view.ts, not here -- this component only fetches and binds.
|
||||
* /api/stacks/{name}/deploys), rendered as a dense DataGrid. Each run is one
|
||||
* grid row -- status, when, duration, trigger, and a merged summary column
|
||||
* (container/build recap + option pills + a shortened error). Expanding a row
|
||||
* reveals the LOG beneath it plus the run's details (containers, build/cache,
|
||||
* images, options, a truncated notice, the last error line). All text
|
||||
* composition lives in deploy-run-view.ts -- this component only fetches, binds,
|
||||
* and lays the grid out.
|
||||
*
|
||||
* Mirrors BackupPanel/ExecutionHistoryList's shape (loading / empty / list),
|
||||
* the sibling tab in this same modal -- see StackModal.svelte's Backups tab
|
||||
* for the tab-gating convention this one follows.
|
||||
* The log is fetched (GET .../deploys/{id}/log) only when a row expands, never
|
||||
* while the list loads: a run's log can be large, and the list is meant to stay
|
||||
* quick. buildDeployLogPanelState decides what that fetch should attempt -- a run
|
||||
* already known to be missing its log file never triggers the request.
|
||||
*
|
||||
* Expanding a row additionally loads that run's full protocol text (GET
|
||||
* .../deploys/{id}/log) into the same LogViewer the live output window and
|
||||
* the docked panel use -- but only on expand, never while the list itself
|
||||
* loads: a run's log can be large, and the list is meant to stay quick.
|
||||
* buildDeployLogPanelState (deploy-run-view.ts) decides what that fetch
|
||||
* should even attempt: a run already known to be missing its log file
|
||||
* (reconciled by deploy-log-reconcile) never triggers the request at all,
|
||||
* since it would just 404.
|
||||
*
|
||||
* `envId === null` is NOT "nothing to ask for" -- it is the LOCAL
|
||||
* environment (see the list route's own doc comment, `.../deploys/+server.ts`):
|
||||
* appendEnvParam() only appends `env` when envId is truthy, so a
|
||||
* single-environment install's own "deploy" calls never send one either,
|
||||
* and every one of ITS runs is recorded with environmentId === null. The
|
||||
* route now resolves an omitted `env` to exactly that filter. onMount
|
||||
* below fetches unconditionally for that reason -- there is no longer a
|
||||
* distinct "no environment selected" state to guard against here; the
|
||||
* type this component receives (number | null) has no third value that
|
||||
* could mean anything else.
|
||||
* `envId === null` is the LOCAL environment, not "nothing to ask for" (see the
|
||||
* list route's doc comment): appendEnvParam() omits `env` when envId is falsy,
|
||||
* and every LOCAL run is recorded with environmentId === null, so the grid loads
|
||||
* unconditionally.
|
||||
*/
|
||||
import { onMount } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
@@ -48,7 +34,12 @@
|
||||
} from 'lucide-svelte';
|
||||
import { formatRelativeTime, formatDateTime } from '$lib/stores/settings';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { buildDeployRunView, buildDeployLogPanelState, type DeployRun } from '$lib/utils/deploy-run-view';
|
||||
import { buildDeployRunView, buildDeployLogPanelState, deployTallyFromRuns, type DeployRun } from '$lib/utils/deploy-run-view';
|
||||
import { DataGrid } from '$lib/components/data-grid';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { GripHorizontal } from 'lucide-svelte';
|
||||
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
|
||||
import LogViewer from '$lib/components/LogViewer.svelte';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
|
||||
@@ -58,13 +49,39 @@
|
||||
// Forwarded to LogViewer so an expanded run's log follows the caller's editor
|
||||
// theme toggle instead of LogViewer's own 'dark' default.
|
||||
theme?: 'light' | 'dark';
|
||||
// Bumped by the parent after a deploy finishes so the history re-fetches.
|
||||
reloadKey?: number;
|
||||
// Reports the run tally to the parent for the tab badge (total + ok/failed split).
|
||||
onTally?: (t: { total: number; ok: number; failed: number }) => void;
|
||||
}
|
||||
let { stackName, envId, theme = 'dark' }: Props = $props();
|
||||
let { stackName, envId, theme = 'dark', reloadKey = 0, onTally }: Props = $props();
|
||||
|
||||
let runs = $state<DeployRun[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let expanded = $state<Set<number>>(new Set());
|
||||
let expandedKeys = $state<Set<unknown>>(new Set());
|
||||
|
||||
// Column sort. Default: newest first (the API's own order). startedAt/duration
|
||||
// compare numerically; status/triggeredBy compare as strings.
|
||||
let sortState = $state<{ field: string; direction: 'asc' | 'desc' }>({
|
||||
field: 'startedAt',
|
||||
direction: 'desc'
|
||||
});
|
||||
const sortedRuns = $derived.by(() => {
|
||||
const dir = sortState.direction === 'desc' ? -1 : 1;
|
||||
const numeric = sortState.field === 'startedAt' || sortState.field === 'duration';
|
||||
return [...runs].sort((a, b) => {
|
||||
let cmp: number;
|
||||
if (numeric) {
|
||||
const va = sortState.field === 'startedAt' ? (a.startedAt ? Date.parse(a.startedAt) : 0) : (a.duration ?? 0);
|
||||
const vb = sortState.field === 'startedAt' ? (b.startedAt ? Date.parse(b.startedAt) : 0) : (b.duration ?? 0);
|
||||
cmp = va - vb;
|
||||
} else {
|
||||
cmp = String((a as any)[sortState.field] ?? '').localeCompare(String((b as any)[sortState.field] ?? ''));
|
||||
}
|
||||
return cmp * dir;
|
||||
});
|
||||
});
|
||||
|
||||
/** One entry per run whose log has been requested. 'loading'/'loaded'/'missing'/'error'
|
||||
* -- deliberately not reused for "not yet requested" (absent from the map), so a
|
||||
@@ -78,15 +95,24 @@
|
||||
let confirmDeleteId = $state<number | null>(null);
|
||||
let deletingIds = $state<Set<number>>(new Set());
|
||||
|
||||
function toggle(run: DeployRun) {
|
||||
const s = new Set(expanded);
|
||||
if (s.has(run.id)) {
|
||||
s.delete(run.id);
|
||||
} else {
|
||||
// Fired by the grid when a row's expand chevron is clicked. Loads the log on
|
||||
// open; nothing to do on collapse (the fetched text is kept for a re-open).
|
||||
function onExpandChange(key: unknown, expanded: boolean) {
|
||||
if (!expanded) return;
|
||||
const run = runs.find((r) => r.id === key);
|
||||
if (run) void loadLog(run);
|
||||
}
|
||||
|
||||
// Whole-row click toggles expansion (the grid only wires the chevron itself),
|
||||
// loading the log on open -- same effect as the chevron path.
|
||||
function toggleRun(run: DeployRun) {
|
||||
const s = new Set(expandedKeys);
|
||||
if (s.has(run.id)) s.delete(run.id);
|
||||
else {
|
||||
s.add(run.id);
|
||||
void loadLog(run);
|
||||
}
|
||||
expanded = s;
|
||||
expandedKeys = s;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,9 +165,9 @@
|
||||
return;
|
||||
}
|
||||
runs = runs.filter((r) => r.id !== run.id);
|
||||
const s = new Set(expanded);
|
||||
const s = new Set(expandedKeys);
|
||||
s.delete(run.id);
|
||||
expanded = s;
|
||||
expandedKeys = s;
|
||||
const l = new Map(logs);
|
||||
l.delete(run.id);
|
||||
logs = l;
|
||||
@@ -156,7 +182,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
async function loadRuns() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/deploys`, envId));
|
||||
if (!res.ok) {
|
||||
@@ -171,185 +199,299 @@
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load on mount and whenever the parent bumps reloadKey (a deploy finished).
|
||||
$effect(() => {
|
||||
void reloadKey;
|
||||
loadRuns();
|
||||
});
|
||||
|
||||
function statusBadge(status: string) {
|
||||
// Keep the parent's tab badge in sync: total + a success/failure split. Only
|
||||
// report once a load has settled -- reporting the initial empty `runs` while
|
||||
// still loading would momentarily clobber the parent's own pre-fetched tally
|
||||
// (making the badge flicker to nothing when the tab is first opened).
|
||||
$effect(() => {
|
||||
if (loading) return;
|
||||
onTally?.(deployTallyFromRuns(runs));
|
||||
});
|
||||
|
||||
// Drag-resizable log height (px), shared by every expanded row's LogViewer and
|
||||
// persisted per browser. A handle below the log drags this between the bounds.
|
||||
const LOG_HEIGHT_KEY = 'dockhand-deploy-log-height';
|
||||
const LOG_MIN = 120, LOG_MAX = 720;
|
||||
let logHeight = $state(
|
||||
typeof localStorage !== 'undefined'
|
||||
? clampLog(Number(localStorage.getItem(LOG_HEIGHT_KEY)) || 256)
|
||||
: 256
|
||||
);
|
||||
let dragging = $state(false);
|
||||
let dragStartY = 0;
|
||||
let dragStartH = 0;
|
||||
|
||||
function clampLog(h: number): number {
|
||||
return Math.min(LOG_MAX, Math.max(LOG_MIN, h));
|
||||
}
|
||||
|
||||
function startLogResize(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
dragging = true;
|
||||
dragStartY = e.clientY;
|
||||
dragStartH = logHeight;
|
||||
window.addEventListener('mousemove', onLogResize);
|
||||
window.addEventListener('mouseup', endLogResize);
|
||||
}
|
||||
function onLogResize(e: MouseEvent) {
|
||||
if (!dragging) return;
|
||||
logHeight = clampLog(dragStartH + (e.clientY - dragStartY));
|
||||
}
|
||||
function endLogResize() {
|
||||
if (!dragging) return;
|
||||
dragging = false;
|
||||
window.removeEventListener('mousemove', onLogResize);
|
||||
window.removeEventListener('mouseup', endLogResize);
|
||||
try {
|
||||
localStorage.setItem(LOG_HEIGHT_KEY, String(logHeight));
|
||||
} catch { /* private mode - keep the in-memory value */ }
|
||||
}
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('mousemove', onLogResize);
|
||||
window.removeEventListener('mouseup', endLogResize);
|
||||
});
|
||||
|
||||
// Badge styles mirror the Schedules execution-history (getStatusBadge/getTriggerBadge)
|
||||
// so the Deploys grid reads the same as the rest of the app.
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return { cls: 'bg-emerald-500/15 text-emerald-500', icon: Check };
|
||||
return { cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400', icon: Check, spin: false };
|
||||
case 'failed':
|
||||
return { cls: 'bg-red-500/15 text-red-500', icon: X };
|
||||
return { cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', icon: X, spin: false };
|
||||
case 'running':
|
||||
return { cls: 'bg-sky-500/15 text-sky-500', icon: Loader2 };
|
||||
return { cls: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400', icon: Loader2, spin: true };
|
||||
default:
|
||||
return { cls: 'bg-muted text-muted-foreground', icon: AlertTriangle };
|
||||
return { cls: 'bg-slate-100 text-slate-700 dark:bg-slate-900/30 dark:text-slate-400', icon: AlertTriangle, spin: false };
|
||||
}
|
||||
}
|
||||
|
||||
function triggerIcon(triggeredBy: string) {
|
||||
function triggerMeta(triggeredBy: string) {
|
||||
switch (triggeredBy) {
|
||||
case 'cron':
|
||||
return Timer;
|
||||
return { icon: Timer, cls: 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400' };
|
||||
case 'webhook':
|
||||
return Webhook;
|
||||
return { icon: Webhook, cls: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' };
|
||||
case 'startup':
|
||||
return Power;
|
||||
return { icon: Power, cls: 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400' };
|
||||
default:
|
||||
return Hand;
|
||||
return { icon: Hand, cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<div class="flex justify-center py-8"><Loader2 class="h-5 w-5 animate-spin text-muted-foreground" /></div>
|
||||
{:else if loadError}
|
||||
{#if loadError}
|
||||
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
|
||||
<AlertTriangle class="mb-3 h-10 w-10 text-muted-foreground/40" />
|
||||
<p class="text-sm text-muted-foreground">{loadError}</p>
|
||||
</div>
|
||||
{:else if runs.length === 0}
|
||||
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
|
||||
<Clock class="mb-3 h-10 w-10 text-muted-foreground/40" />
|
||||
<p class="text-sm text-muted-foreground">No deploy runs yet.</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Deploy this stack — its run history appears here.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-1.5">
|
||||
{#each runs as run (run.id)}
|
||||
<DataGrid
|
||||
data={sortedRuns}
|
||||
keyField="id"
|
||||
gridId="deploys"
|
||||
{loading}
|
||||
expandable
|
||||
bind:expandedKeys
|
||||
{onExpandChange}
|
||||
{sortState}
|
||||
onSortChange={(state) => (sortState = state)}
|
||||
onRowClick={(run, e) => {
|
||||
// Ignore clicks that originated on an interactive control (delete popover).
|
||||
if ((e.target as HTMLElement)?.closest('button')) return;
|
||||
toggleRun(run);
|
||||
}}
|
||||
class="border-none"
|
||||
wrapperClass="border rounded-lg"
|
||||
>
|
||||
{#snippet emptyState()}
|
||||
<div class="flex min-h-[40vh] flex-col items-center justify-center py-10 text-center">
|
||||
<Clock class="mb-3 h-10 w-10 text-muted-foreground/40" />
|
||||
<p class="text-sm text-muted-foreground">No deploy runs yet.</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Deploy this stack — its run history appears here.</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet cell(column, run, _rowState)}
|
||||
{@const view = buildDeployRunView(run)}
|
||||
{#if column.id === 'status'}
|
||||
{@const meta = statusMeta(run.status)}
|
||||
{@const StatusIcon = meta.icon}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Badge variant="default" class={meta.cls}>
|
||||
<StatusIcon class="h-3.5 w-3.5 {meta.spin ? 'animate-spin' : ''}" />
|
||||
</Badge>
|
||||
<span class="truncate text-xs">{view.statusLabel}</span>
|
||||
</span>
|
||||
{:else if column.id === 'when'}
|
||||
{#if run.startedAt}
|
||||
<span class="flex w-full items-center justify-between gap-2">
|
||||
<span>{formatRelativeTime(run.startedAt)}</span>
|
||||
<span class="text-muted-foreground tabular-nums">{formatDateTime(run.startedAt)}</span>
|
||||
</span>
|
||||
{:else}
|
||||
<span>—</span>
|
||||
{/if}
|
||||
{:else if column.id === 'duration'}
|
||||
<span class="block text-right text-muted-foreground tabular-nums">{view.duration}</span>
|
||||
{:else if column.id === 'trigger'}
|
||||
{@const tmeta = triggerMeta(run.triggeredBy)}
|
||||
{@const TrigIcon = tmeta.icon}
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<Badge variant="default" class={tmeta.cls}>
|
||||
<TrigIcon class="h-3.5 w-3.5" />
|
||||
</Badge>
|
||||
<span class="truncate">{view.trigger}</span>
|
||||
</span>
|
||||
{:else if column.id === 'summary'}
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="shrink-0 text-muted-foreground">{view.containerSummary} · {view.buildStatus}</span>
|
||||
{#if view.optionChips.length > 0}
|
||||
<span class="flex shrink-0 flex-wrap gap-1">
|
||||
{#each view.optionChips as chip}
|
||||
<span class="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">{chip}</span>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
{#if view.errorSummary}
|
||||
<span class="min-w-0 truncate text-destructive" title={run.errorMessage || ''}>{view.errorSummary}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if column.id === 'actions'}
|
||||
{@const panelState = buildDeployLogPanelState(run)}
|
||||
{#if panelState.deletable}
|
||||
<div class="flex justify-end">
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteId === run.id}
|
||||
action="Delete"
|
||||
itemType="deploy run"
|
||||
title="Delete this run"
|
||||
position="left"
|
||||
disabled={deletingIds.has(run.id)}
|
||||
onConfirm={() => deleteRun(run)}
|
||||
onOpenChange={(open) => (confirmDeleteId = open ? run.id : null)}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
{#if deletingIds.has(run.id)}
|
||||
<Loader2 class="h-3.5 w-3.5 animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
<Trash2 class="h-3.5 w-3.5 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
{#snippet expandedRow(run, _rowState)}
|
||||
{@const view = buildDeployRunView(run)}
|
||||
{@const badge = statusBadge(run.status)}
|
||||
{@const BadgeIcon = badge.icon}
|
||||
{@const TrigIcon = triggerIcon(run.triggeredBy)}
|
||||
{@const isOpen = expanded.has(run.id)}
|
||||
{@const panelState = buildDeployLogPanelState(run)}
|
||||
{@const logEntry = logs.get(run.id)}
|
||||
<div class="rounded-md border border-zinc-200 dark:border-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs hover:bg-muted/30"
|
||||
onclick={() => toggle(run)}
|
||||
>
|
||||
<ChevronDown class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform {isOpen ? '' : '-rotate-90'}" />
|
||||
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded {badge.cls}" title={view.statusLabel}>
|
||||
<BadgeIcon class="h-3 w-3 {run.status === 'running' ? 'animate-spin' : ''}" />
|
||||
</span>
|
||||
<span class="shrink-0" title={run.startedAt ? formatDateTime(run.startedAt, true) : ''}>
|
||||
{run.startedAt ? formatRelativeTime(run.startedAt) : '—'}
|
||||
</span>
|
||||
<span class="shrink-0 text-muted-foreground">{view.duration}</span>
|
||||
<span class="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-muted-foreground" title={view.trigger}>
|
||||
<TrigIcon class="h-3 w-3" />
|
||||
</span>
|
||||
<span class="min-w-0 flex-1 truncate text-muted-foreground">
|
||||
{view.containerSummary} · {view.buildStatus}
|
||||
</span>
|
||||
{#if view.errorSummary}
|
||||
<span class="max-w-[300px] shrink truncate text-destructive" title={run.errorMessage || ''}>{view.errorSummary}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if isOpen}
|
||||
<div class="border-t border-zinc-200 px-3 py-2.5 text-xs dark:border-zinc-700">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-muted-foreground">Containers</div>
|
||||
<div>{view.containerSummary}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">Build</div>
|
||||
<div>{view.buildStatus}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">Triggered by</div>
|
||||
<div>{view.trigger}</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if view.optionChips.length > 0}
|
||||
<div class="mt-2 flex flex-wrap gap-1">
|
||||
{#each view.optionChips as chip}
|
||||
<span class="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">{chip}</span>
|
||||
<div class="w-full p-4 pl-12 shadow-inner bg-muted text-xs">
|
||||
<div class="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
|
||||
<div>
|
||||
<div class="text-muted-foreground">Containers</div>
|
||||
<div>{view.containerSummary}</div>
|
||||
{#if view.containerNames.length > 0}
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{#each view.containerNames as cname}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ContainerIcon image="" name={cname} showFallbackWhenOff class="w-3 h-3 shrink-0" />
|
||||
{cname}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0}
|
||||
<div class="mt-2 space-y-1">
|
||||
{#if view.imagesBuilt.length > 0}
|
||||
<div><span class="text-muted-foreground">Built:</span> {view.imagesBuilt.join(', ')}</div>
|
||||
{/if}
|
||||
{#if view.imagesPulled.length > 0}
|
||||
<div><span class="text-muted-foreground">Pulled:</span> {view.imagesPulled.join(', ')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">Build</div>
|
||||
<div>{view.buildStatus}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted-foreground">Triggered by</div>
|
||||
<div>{view.trigger}</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if view.imagesBuilt.length > 0 || view.imagesPulled.length > 0}
|
||||
<div class="mt-2 space-y-1">
|
||||
{#if view.imagesBuilt.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span class="text-muted-foreground">Built:</span>
|
||||
{#each view.imagesBuilt as img}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ContainerIcon image={img} showFallbackWhenOff class="w-3 h-3 shrink-0" />
|
||||
{img}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if view.errorSummary}
|
||||
<!-- The task-17 spec called for the shortened last error line here (same
|
||||
text the collapsed row already truncates to) -- not the raw errorMessage,
|
||||
which is compose's full stderr and, for a failed run, duplicates almost
|
||||
everything the log panel below shows. One line of "what went wrong" is a
|
||||
useful recap next to the run's other details; the full text belongs in
|
||||
exactly one place, the LogViewer beneath it. -->
|
||||
<div class="mt-2 rounded-md border border-l-[3px] border-destructive/40 border-l-destructive bg-destructive/5 px-2 py-1.5">
|
||||
<span class="font-mono text-[11px] text-destructive/90">{view.errorSummary}</span>
|
||||
{#if view.imagesPulled.length > 0}
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span class="text-muted-foreground">Pulled:</span>
|
||||
{#each view.imagesPulled as img}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<ContainerIcon image={img} showFallbackWhenOff class="w-3 h-3 shrink-0" />
|
||||
{img}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-muted-foreground">Log</span>
|
||||
{#if panelState.deletable}
|
||||
<ConfirmPopover
|
||||
open={confirmDeleteId === run.id}
|
||||
action="Delete"
|
||||
itemType="deploy run"
|
||||
title="Delete this run"
|
||||
position="left"
|
||||
disabled={deletingIds.has(run.id)}
|
||||
onConfirm={() => deleteRun(run)}
|
||||
onOpenChange={(open) => (confirmDeleteId = open ? run.id : null)}
|
||||
>
|
||||
{#snippet children({ open })}
|
||||
{#if deletingIds.has(run.id)}
|
||||
<Loader2 class="h-3 w-3 animate-spin text-muted-foreground" />
|
||||
{:else}
|
||||
<Trash2 class="h-3 w-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ConfirmPopover>
|
||||
{/if}
|
||||
</div>
|
||||
{#if panelState.truncated}
|
||||
<!-- The notice belongs directly above the viewer it describes, not
|
||||
among the run's other metadata -- it says something about the
|
||||
text below it, not about the run as a whole. -->
|
||||
<div class="mb-1 inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle class="h-2.5 w-2.5" /> Log truncated
|
||||
</div>
|
||||
{/if}
|
||||
{#if panelState.logMissing || logEntry?.status === 'missing'}
|
||||
<!-- A confirmed-gone log file reads the same as an empty one if it's
|
||||
just handed to LogViewer with logs="" -- the two mean opposite
|
||||
things (nothing happened vs. the record of what happened is
|
||||
gone), so this case gets its own, unambiguous message instead. -->
|
||||
<div class="rounded-md border border-dashed border-zinc-300 px-3 py-6 text-center text-muted-foreground dark:border-zinc-700">
|
||||
Protocol no longer available.
|
||||
</div>
|
||||
{:else if logEntry?.status === 'error'}
|
||||
<div class="rounded-md border border-dashed border-destructive/40 px-3 py-6 text-center text-destructive">
|
||||
{logEntry.error}
|
||||
</div>
|
||||
{:else}
|
||||
<LogViewer
|
||||
logs={logEntry?.text ?? ''}
|
||||
loading={logEntry?.status !== 'loaded'}
|
||||
title={`${stackName}-deploy-${run.id}`}
|
||||
autoRefresh={false}
|
||||
autoScroll={false}
|
||||
class="h-64"
|
||||
{theme}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-3">
|
||||
{#if panelState.truncated}
|
||||
<div class="mb-1">
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle class="h-2.5 w-2.5" /> Log truncated
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if panelState.logMissing || logEntry?.status === 'missing'}
|
||||
<!-- A confirmed-gone log file reads the same as an empty one if handed to
|
||||
LogViewer with logs="" -- the two mean opposite things, so this case
|
||||
gets its own message. -->
|
||||
<div class="rounded-md border border-dashed border-zinc-300 px-3 py-6 text-center text-muted-foreground dark:border-zinc-700">
|
||||
Protocol no longer available.
|
||||
</div>
|
||||
{:else if logEntry?.status === 'error'}
|
||||
<div class="rounded-md border border-dashed border-destructive/40 px-3 py-6 text-center text-destructive">
|
||||
{logEntry.error}
|
||||
</div>
|
||||
{:else}
|
||||
<div style="height: {logHeight}px">
|
||||
<LogViewer
|
||||
logs={logEntry?.text ?? ''}
|
||||
loading={logEntry?.status !== 'loaded'}
|
||||
title={`${stackName}-deploy-${run.id}`}
|
||||
autoRefresh={false}
|
||||
autoScroll={false}
|
||||
class="h-full"
|
||||
{theme}
|
||||
/>
|
||||
</div>
|
||||
<!-- Drag up/down to resize the log height (persisted). -->
|
||||
<div
|
||||
class="group flex h-2 cursor-row-resize items-center justify-center rounded-b bg-zinc-200 transition-colors hover:bg-blue-400 dark:bg-zinc-700 dark:hover:bg-blue-500 {dragging ? 'bg-blue-500 dark:bg-blue-400' : ''}"
|
||||
onmousedown={startLogResize}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
tabindex="0"
|
||||
title="Drag to resize"
|
||||
>
|
||||
<GripHorizontal class="h-3 w-3 text-white opacity-0 transition-opacity group-hover:opacity-100 {dragging ? 'opacity-100' : ''}" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/snippet}
|
||||
</DataGrid>
|
||||
{/if}
|
||||
@@ -2,80 +2,92 @@
|
||||
import { formatErrorLines } from '$lib/utils/format';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Progress } from '$lib/components/ui/progress';
|
||||
import {
|
||||
Rocket,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
GitBranch,
|
||||
FileCode,
|
||||
Server,
|
||||
Link,
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
Check,
|
||||
Database,
|
||||
KeyRound
|
||||
AlertTriangle
|
||||
} from 'lucide-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import { watchJob } from '$lib/utils/sse-fetch';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import LogViewer from '$lib/components/LogViewer.svelte';
|
||||
import DeployOutputHeader from './DeployOutputHeader.svelte';
|
||||
import { GIT_LINE_MARKER } from '$lib/utils/log-lines';
|
||||
|
||||
interface Props {
|
||||
stackId: number;
|
||||
stackName: string;
|
||||
// Stack icon value (lucide/selfhst/custom) + env, so the header shows the stack's
|
||||
// own icon and the target environment -- matching the compose output modal.
|
||||
stackIcon?: string | null;
|
||||
envId?: number | null;
|
||||
// Called when the dialog closes after a deploy ran, not when the deploy completes:
|
||||
// this component is embedded in a table row, so refreshing the list mid-deploy
|
||||
// would remount it and drop its open dialog.
|
||||
onComplete?: () => void;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { stackId, stackName, onComplete, children }: Props = $props();
|
||||
let { stackId, stackName, stackIcon = null, envId = null, onComplete, children }: Props = $props();
|
||||
|
||||
let deployFinished = $state(false);
|
||||
|
||||
// The deploy-stream job emits these fields; the UI reads status/message/error/logLine
|
||||
// (step/totalSteps are still sent by the server but no longer rendered as steps).
|
||||
interface StepProgress {
|
||||
status: 'connecting' | 'cloning' | 'fetching' | 'reading' | 'env' | 'secrets' | 'deploying' | 'complete' | 'error';
|
||||
message?: string;
|
||||
step?: number;
|
||||
totalSteps?: number;
|
||||
error?: string;
|
||||
// A raw (already redacted) compose output line.
|
||||
logLine?: string;
|
||||
}
|
||||
|
||||
let open = $state(false);
|
||||
let overallStatus = $state<'idle' | 'confirming' | 'deploying' | 'complete' | 'error'>('idle');
|
||||
let currentStep = $state<StepProgress | null>(null);
|
||||
let steps = $state<StepProgress[]>([]);
|
||||
// Everything the deploy emits -- git stage messages AND compose output -- lands in
|
||||
// one log stream (matching the compose output modal), so there is no separate step
|
||||
// list. Git stage lines carry the GIT_LINE_MARKER so LogViewer draws a git glyph.
|
||||
let logLines = $state<string[]>([]);
|
||||
let errorMessage = $state('');
|
||||
let copied = $state(false);
|
||||
|
||||
const confirmDestructive = $derived($appSettings.confirmDestructive);
|
||||
|
||||
function getStepIcon(status: string) {
|
||||
switch (status) {
|
||||
case 'connecting': return Link;
|
||||
case 'cloning': return GitBranch;
|
||||
case 'fetching': return GitBranch;
|
||||
case 'reading': return FileCode;
|
||||
case 'env': return Database;
|
||||
case 'secrets': return KeyRound;
|
||||
case 'deploying': return Server;
|
||||
case 'complete': return CheckCircle2;
|
||||
case 'error': return XCircle;
|
||||
default: return Loader2;
|
||||
}
|
||||
}
|
||||
// Close the log with a colored status line once the deploy settles, matching the
|
||||
// compose output modal. LogViewer colors via ansi_to_html; the ESC byte is built
|
||||
// from its code point to keep the source ASCII.
|
||||
const ANSI = {
|
||||
green: String.fromCharCode(27) + '[32m',
|
||||
red: String.fromCharCode(27) + '[31m',
|
||||
reset: String.fromCharCode(27) + '[0m'
|
||||
};
|
||||
let logText = $derived.by(() => {
|
||||
const base = logLines.join('\n');
|
||||
if (overallStatus !== 'complete' && overallStatus !== 'error') return base;
|
||||
// Match the closing line's indent to the log's own lines (compose prefixes a
|
||||
// space, other producers don't) so it lines up with the text, not the edge.
|
||||
const lastReal = [...logLines].reverse().find((l) => l.trim().length > 0);
|
||||
const indent = lastReal ? (lastReal.match(/^\s*/)?.[0] ?? '') : '';
|
||||
const [color, label] = overallStatus === 'complete' ? [ANSI.green, 'Succeeded'] : [ANSI.red, 'Failed'];
|
||||
return base + (base ? '\n' : '') + indent + color + label + ANSI.reset;
|
||||
});
|
||||
|
||||
function getStepColor(status: string, isCurrentStep: boolean): string {
|
||||
if (status === 'complete') return 'text-green-600 dark:text-green-400';
|
||||
if (status === 'error') return 'text-red-600 dark:text-red-400';
|
||||
if (isCurrentStep) return 'text-violet-600 dark:text-violet-400';
|
||||
return 'text-muted-foreground';
|
||||
// The log viewer follows the app-wide light/dark switch (source of truth: the `.dark`
|
||||
// class on <html>). Read it each time the dialog opens so it tracks the current theme,
|
||||
// not whatever it was when this page-root component first mounted.
|
||||
let logTheme = $state<'light' | 'dark'>('dark');
|
||||
function currentAppTheme(): 'light' | 'dark' {
|
||||
if (typeof document !== 'undefined') {
|
||||
return document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
}
|
||||
return 'dark';
|
||||
}
|
||||
$effect(() => {
|
||||
if (open) logTheme = currentAppTheme();
|
||||
});
|
||||
|
||||
async function startDeploy() {
|
||||
steps = [];
|
||||
currentStep = null;
|
||||
logLines = [];
|
||||
overallStatus = 'deploying';
|
||||
errorMessage = '';
|
||||
|
||||
@@ -95,19 +107,33 @@
|
||||
await watchJob(jobId, (line) => {
|
||||
try {
|
||||
const data = line.data as StepProgress;
|
||||
// Compose output line: append verbatim.
|
||||
if (typeof data.logLine === 'string') {
|
||||
logLines = [...logLines, data.logLine];
|
||||
return;
|
||||
}
|
||||
if (data.status === 'complete') {
|
||||
// Keep the final "Successfully deployed ..." line -- no git icon, it's
|
||||
// a compose-side boundary, not a git operation.
|
||||
if (typeof data.message === 'string') {
|
||||
logLines = [...logLines, data.message];
|
||||
}
|
||||
overallStatus = 'complete';
|
||||
currentStep = data;
|
||||
steps = [...steps, data];
|
||||
onComplete?.();
|
||||
deployFinished = true;
|
||||
} else if (data.status === 'error') {
|
||||
overallStatus = 'error';
|
||||
errorMessage = data.error || 'Unknown error occurred';
|
||||
currentStep = data;
|
||||
steps = [...steps, data];
|
||||
} else {
|
||||
currentStep = data;
|
||||
steps = [...steps, data];
|
||||
// Put the reason in the log too (as plain, uniconed lines) so a
|
||||
// failure that happens mid-stream shows WHY, not just "Failed".
|
||||
for (const l of errorMessage.split('\n')) {
|
||||
if (l.trim()) logLines = [...logLines, l];
|
||||
}
|
||||
deployFinished = true;
|
||||
} else if (typeof data.message === 'string') {
|
||||
// Only the actual git stages (clone/fetch/read) get the git glyph; the
|
||||
// "Deploying <stack>..." boundary is compose-side, so it stays plain.
|
||||
const isGitStage = ['connecting', 'cloning', 'fetching', 'reading'].includes(data.status);
|
||||
logLines = [...logLines, (isGitStage ? GIT_LINE_MARKER : '') + data.message];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to process job line:', e);
|
||||
@@ -116,12 +142,13 @@
|
||||
|
||||
if (overallStatus === 'deploying') {
|
||||
overallStatus = 'complete';
|
||||
onComplete?.();
|
||||
deployFinished = true;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to deploy git stack:', error);
|
||||
overallStatus = 'error';
|
||||
errorMessage = error.message || 'Failed to deploy';
|
||||
deployFinished = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,28 +175,39 @@
|
||||
if (overallStatus === 'deploying') return;
|
||||
open = false;
|
||||
overallStatus = 'idle';
|
||||
steps = [];
|
||||
currentStep = null;
|
||||
logLines = [];
|
||||
errorMessage = '';
|
||||
copied = false;
|
||||
// Refresh the list on close, not on complete (see onComplete's doc).
|
||||
if (deployFinished) {
|
||||
deployFinished = false;
|
||||
onComplete?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLogs() {
|
||||
const lines = steps.map(s => s.message || s.status);
|
||||
if (errorMessage) lines.push(`Error: ${errorMessage}`);
|
||||
const ok = await copyToClipboard(lines.join('\n'));
|
||||
if (!ok) return;
|
||||
copied = true;
|
||||
setTimeout(() => { copied = false; }, 2000);
|
||||
}
|
||||
|
||||
const progressPercentage = $derived(
|
||||
currentStep?.step && currentStep?.totalSteps
|
||||
? Math.round((currentStep.step / currentStep.totalSteps) * 100)
|
||||
: 0
|
||||
);
|
||||
|
||||
const isDeploying = $derived(overallStatus === 'deploying');
|
||||
|
||||
// Map the popover's status to the shared header's state + status text.
|
||||
const headerState = $derived<'idle' | 'running' | 'complete' | 'error'>(
|
||||
overallStatus === 'complete' ? 'complete'
|
||||
: overallStatus === 'error' ? 'error'
|
||||
: isDeploying ? 'running'
|
||||
: 'idle'
|
||||
);
|
||||
const headerStatusLine = $derived(
|
||||
overallStatus === 'complete' ? 'Succeeded'
|
||||
: overallStatus === 'error' ? 'Failed'
|
||||
: isDeploying ? 'Deploying...'
|
||||
: ''
|
||||
);
|
||||
|
||||
// Compact while confirming (just a prompt); wide + tall once a deploy runs and the
|
||||
// full log needs room. Matches the compose output modal's size in the deploy state.
|
||||
const isConfirming = $derived(overallStatus === 'confirming');
|
||||
const contentSizeClass = $derived(
|
||||
isConfirming
|
||||
? 'max-w-lg'
|
||||
: 'max-w-[min(90rem,calc(100vw-4rem))] h-[85vh]'
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- Trigger wrapper: intercepts click to open the dialog -->
|
||||
@@ -179,48 +217,25 @@
|
||||
|
||||
<Dialog.Root bind:open onOpenChange={(isOpen) => { if (!isOpen) handleClose(); }}>
|
||||
<Dialog.Content
|
||||
class="max-w-2xl flex flex-col gap-0 p-0 overflow-hidden"
|
||||
class="{contentSizeClass} flex flex-col gap-0 p-0 overflow-hidden"
|
||||
showCloseButton={false}
|
||||
interactOutsideBehavior={isDeploying ? 'ignore' : 'close'}
|
||||
escapeKeydownBehavior={isDeploying ? 'ignore' : 'close'}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b shrink-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{#if overallStatus === 'complete'}
|
||||
<CheckCircle2 class="w-5 h-5 text-green-500 shrink-0" />
|
||||
{:else if overallStatus === 'error'}
|
||||
<XCircle class="w-5 h-5 text-red-500 shrink-0" />
|
||||
{:else if isDeploying}
|
||||
<Loader2 class="w-5 h-5 text-violet-500 animate-spin shrink-0" />
|
||||
{:else}
|
||||
<Rocket class="w-5 h-5 text-violet-500 shrink-0" />
|
||||
{/if}
|
||||
<span class="text-base font-semibold">Git deploy</span>
|
||||
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1 truncate">{stackName}</code>
|
||||
{#if overallStatus === 'complete'}
|
||||
<Badge variant="outline" class="ml-auto shrink-0 text-green-600 border-green-600/30">Complete</Badge>
|
||||
{:else if overallStatus === 'error'}
|
||||
<Badge variant="outline" class="ml-auto shrink-0 text-red-600 border-red-600/30">Failed</Badge>
|
||||
{:else if isDeploying}
|
||||
<Badge variant="secondary" class="ml-auto shrink-0 tabular-nums text-xs">
|
||||
{#if currentStep?.step && currentStep?.totalSteps}
|
||||
{currentStep.step}/{currentStep.totalSteps}
|
||||
{:else}
|
||||
Deploying...
|
||||
{/if}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isDeploying && currentStep?.totalSteps}
|
||||
<div class="mt-3">
|
||||
<Progress value={progressPercentage} class="h-1.5 [&>[data-progress]]:bg-violet-600" />
|
||||
</div>
|
||||
{/if}
|
||||
<DeployOutputHeader
|
||||
verb="Git deploy"
|
||||
{stackName}
|
||||
{stackIcon}
|
||||
{envId}
|
||||
state={headerState}
|
||||
statusLine={headerStatusLine}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Body: steps log -->
|
||||
<div class="overflow-y-auto px-4 py-3" style="max-height: 55vh; min-height: 12rem;">
|
||||
<!-- Body: one log stream (git stages + compose output), filling the dialog -->
|
||||
<div class="flex-1 min-h-0 flex flex-col px-4 py-3">
|
||||
{#if overallStatus === 'confirming'}
|
||||
<div class="flex items-start gap-3 py-2 px-2">
|
||||
<AlertTriangle class="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
|
||||
@@ -232,29 +247,29 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if steps.length === 0 && isDeploying}
|
||||
{:else if logLines.length === 0 && isDeploying}
|
||||
<div class="flex items-center gap-3 text-muted-foreground py-2 px-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin shrink-0" />
|
||||
<span class="text-sm">Initializing...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-0.5">
|
||||
{#each steps as step, index (index)}
|
||||
{@const StepIcon = getStepIcon(step.status)}
|
||||
{@const isCurrentStep = index === steps.length - 1 && isDeploying}
|
||||
<div class="flex items-center gap-3 py-1.5 px-2 rounded-md text-sm hover:bg-muted/40 transition-colors">
|
||||
<StepIcon
|
||||
class="w-4 h-4 shrink-0 {getStepColor(step.status, isCurrentStep)} {isCurrentStep && step.status !== 'complete' && step.status !== 'error' ? 'animate-spin' : ''}"
|
||||
/>
|
||||
<span class="{getStepColor(step.status, isCurrentStep)}">
|
||||
{step.message || step.status}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if logLines.length > 0}
|
||||
<div class="flex-1 min-h-0">
|
||||
<LogViewer
|
||||
logs={logText}
|
||||
title={`${stackName}-git-deploy`}
|
||||
autoRefresh={false}
|
||||
autoScroll={isDeploying}
|
||||
class="h-full"
|
||||
theme={logTheme}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if errorMessage}
|
||||
{#if errorMessage && logLines.length === 0}
|
||||
<!-- Only when the log itself doesn't already carry the reason (e.g. a throw
|
||||
before any compose output) -- otherwise this box just duplicates it. -->
|
||||
<div class="mt-3 mx-2 p-3 rounded-md bg-destructive/10 border border-destructive/20">
|
||||
<div class="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertCircle class="w-4 h-4 shrink-0 mt-0.5" />
|
||||
@@ -266,20 +281,10 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-2">
|
||||
<!-- Left: copy / cancel -->
|
||||
<!-- Left: cancel (confirm step only). The log has its own copy button. -->
|
||||
<div>
|
||||
{#if overallStatus === 'confirming'}
|
||||
<Button variant="outline" onclick={handleCancelConfirm}>Cancel</Button>
|
||||
{:else if steps.length > 0}
|
||||
<Button variant="outline" size="sm" onclick={copyLogs} class="gap-1.5">
|
||||
{#if copied}
|
||||
<Check class="w-3.5 h-3.5" />
|
||||
Copied!
|
||||
{:else}
|
||||
<Copy class="w-3.5 h-3.5" />
|
||||
Copy logs
|
||||
{/if}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive } from 'lucide-svelte';
|
||||
import { Loader2, GitBranch, RefreshCw, Webhook, Rocket, RefreshCcw, Copy, Check, XCircle, FolderGit2, Github, Key, KeyRound, Lock, FileText, HelpCircle, GripVertical, X, Download, Hammer, ArrowDownToLine, Zap, FolderOpen, Ban, TriangleAlert, Settings2, Archive, History } from 'lucide-svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { page } from '$app/stores'; // BETA GATE: backups feature flag
|
||||
import BackupPanel from '../containers/BackupPanel.svelte';
|
||||
import DeploysPanel from './DeploysPanel.svelte';
|
||||
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
import { fetchBackupExecutions } from '$lib/utils/backup';
|
||||
import { deployTallyFromRuns } from '$lib/utils/deploy-run-view';
|
||||
import { copyToClipboard } from '$lib/utils/clipboard';
|
||||
import CronEditor from '$lib/components/cron-editor.svelte';
|
||||
import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte';
|
||||
import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte';
|
||||
import BranchCombobox from './BranchCombobox.svelte';
|
||||
import IconPickerModal from './IconPickerModal.svelte';
|
||||
import ComposeOutputModal from './ComposeOutputModal.svelte';
|
||||
import StackIcon from '$lib/components/StackIcon.svelte';
|
||||
import { appendEnvParam } from '$lib/stores/environment';
|
||||
import { persistStackIcon } from '$lib/utils/stack-icon';
|
||||
@@ -111,8 +115,26 @@
|
||||
let formNewRepoBranch = $state('main');
|
||||
let formNewRepoCredentialId = $state<number | null>(null);
|
||||
|
||||
// Tabs: Settings (the deploy form) and Backups (edit mode + feature flag only).
|
||||
let activeTab = $state<'settings' | 'backups'>('settings');
|
||||
// Tabs: Settings (the deploy form), Deploys (recorded run history, edit mode),
|
||||
// and Backups (edit mode + feature flag only).
|
||||
let activeTab = $state<'settings' | 'deploys' | 'backups'>('settings');
|
||||
// Bumped after a deploy finishes so the Deploys tab re-fetches the new run.
|
||||
let deploysReloadKey = $state(0);
|
||||
// Deploys tab badge tally (total + ok/failed). Fetched cheaply when the modal
|
||||
// opens so the badge shows immediately; DeploysPanel's onTally keeps it fresh.
|
||||
let deploysTally = $state<{ total: number; ok: number; failed: number }>({ total: 0, ok: 0, failed: 0 });
|
||||
|
||||
// Live compose output for "Save and deploy" (mirrors StackModal's deploy output;
|
||||
// shown in the shared ComposeOutputModal). Without this the streamed lines from
|
||||
// the deploy job are consumed and discarded, so the user sees no live output.
|
||||
let outputOpen = $state(false);
|
||||
let outputTitle = $state('');
|
||||
let outputLines = $state<string[]>([]);
|
||||
let outputRunning = $state(false);
|
||||
let outputOk = $state<boolean | undefined>(undefined);
|
||||
let outputMs = $state<number | undefined>(undefined);
|
||||
let outputExitCode = $state<number | undefined>(undefined);
|
||||
let outputStartedAt = 0;
|
||||
// The stack's volumes, loaded lazily when the Backups tab opens (git stacks
|
||||
// don't otherwise need them). Feeds the backup volume picker.
|
||||
let stackVolumes = $state<VolumeInfo[]>([]);
|
||||
@@ -154,6 +176,30 @@
|
||||
if (activeTab === 'backups') void loadStackVolumes();
|
||||
});
|
||||
|
||||
async function loadDeploysCount() {
|
||||
if (!gitStack) {
|
||||
deploysTally = { total: 0, ok: 0, failed: 0 };
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(gitStack.stackName)}/deploys`, effectiveEnvId));
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
deploysTally = deployTallyFromRuns(Array.isArray(data?.runs) ? data.runs : []);
|
||||
} catch {
|
||||
// Non-fatal: the badge just stays at its current value.
|
||||
}
|
||||
}
|
||||
|
||||
// Show the Deploys badge count as soon as the modal opens (not only once the tab
|
||||
// is viewed); re-count after a deploy bumps the key.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
void deploysReloadKey;
|
||||
void loadDeploysCount();
|
||||
}
|
||||
});
|
||||
|
||||
// Form state - stack deployment config
|
||||
let formStackName = $state('');
|
||||
let formStackNameUserModified = $state(false);
|
||||
@@ -457,6 +503,16 @@
|
||||
async function resetForm() {
|
||||
// Clear state BEFORE async loads to avoid race conditions
|
||||
activeTab = 'settings';
|
||||
// Reset the deploy-output overlay so a previous run's window (bound to
|
||||
// outputOpen) can't reappear over a freshly opened modal -- the main modal is
|
||||
// deliberately left open behind it after a deploy, so closing the main modal
|
||||
// first would otherwise leave outputOpen=true with stale lines.
|
||||
outputOpen = false;
|
||||
outputLines = [];
|
||||
outputRunning = false;
|
||||
outputOk = undefined;
|
||||
outputMs = undefined;
|
||||
outputExitCode = undefined;
|
||||
stackVolumes = [];
|
||||
stackVolumesLoaded = false;
|
||||
backupTally = { ok: 0, failed: 0 };
|
||||
@@ -684,32 +740,71 @@
|
||||
: '/api/git/stacks';
|
||||
const method = gitStack ? 'PUT' : 'POST';
|
||||
|
||||
// Live-stream the compose output into the shared window when deploying, so
|
||||
// "Save and deploy" shows progress like StackModal's "Save & redeploy".
|
||||
// (A plain save with no deploy has no output to show.)
|
||||
if (deployAfterSave) {
|
||||
outputTitle = `Deploying ${formStackName.trim()}`;
|
||||
outputLines = [];
|
||||
outputRunning = true;
|
||||
outputOk = undefined;
|
||||
outputMs = undefined;
|
||||
outputExitCode = undefined;
|
||||
outputStartedAt = Date.now();
|
||||
outputOpen = true;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
const data = await readJobResponse(response);
|
||||
const data = deployAfterSave
|
||||
? await readJobResponse(response, (line) => (outputLines = [...outputLines, line]))
|
||||
: await readJobResponse(response);
|
||||
|
||||
if (!response.ok) {
|
||||
if (deployAfterSave) {
|
||||
// A pre-deploy failure (e.g. git sync) streams no lines, so surface
|
||||
// the error text in the window instead of "No logs available".
|
||||
if (outputLines.length === 0 && data.error) outputLines = String(data.error).split('\n');
|
||||
outputRunning = false;
|
||||
outputOk = false;
|
||||
outputMs = Date.now() - outputStartedAt;
|
||||
}
|
||||
formError = data.error || 'Failed to save git stack';
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if deployment failed
|
||||
const deployResult = data.deployResult as { success?: boolean; error?: string } | undefined;
|
||||
if (deployAfterSave) {
|
||||
const ok = !(deployResult && !deployResult.success);
|
||||
// The sync phase (clone/pull) fails before any compose line streams, so
|
||||
// on a failure with no streamed output, show the error text.
|
||||
if (!ok && outputLines.length === 0 && deployResult?.error) {
|
||||
outputLines = String(deployResult.error).split('\n');
|
||||
}
|
||||
outputRunning = false;
|
||||
outputOk = ok;
|
||||
outputMs = Date.now() - outputStartedAt;
|
||||
}
|
||||
if (deployResult && !deployResult.success) {
|
||||
toast.error('Deployment failed', {
|
||||
description: deployResult.error || 'Unknown error'
|
||||
});
|
||||
deploysReloadKey++; // the failed run is recorded; refresh the Deploys tab
|
||||
onSaved(); // Still refresh the list to show the new stack
|
||||
onClose(); // Close modal, error shown as toast
|
||||
// Keep the modal open so the user can read the output window; they close it.
|
||||
return;
|
||||
}
|
||||
|
||||
deploysReloadKey++; // a new run was recorded; refresh the Deploys tab
|
||||
onSaved();
|
||||
onClose();
|
||||
// With a deploy, leave the modal open behind the output window so the user
|
||||
// can review the log; a plain save closes as before.
|
||||
if (!deployAfterSave) onClose();
|
||||
} catch (error) {
|
||||
formError = 'Failed to save git stack';
|
||||
} finally {
|
||||
@@ -809,10 +904,10 @@
|
||||
</div>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Tabs: Settings (deploy form) + Backups. Backups only in edit mode AND when
|
||||
the backups feature flag is on (BETA GATE) — a git stack must exist before
|
||||
it can be backed up, and the feature must be enabled. -->
|
||||
{#if gitStack && $page.data.backupsEnabled}
|
||||
<!-- Tabs (edit mode only - a git stack must exist first): Settings (deploy form),
|
||||
Deploys (recorded run history), and Backups. Backups is additionally gated on
|
||||
the backups feature flag (BETA GATE). -->
|
||||
{#if gitStack}
|
||||
<div class="flex items-center gap-1 border-b border-zinc-200 px-5 dark:border-zinc-700 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@@ -823,13 +918,31 @@
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'backups' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => (activeTab = 'backups')}
|
||||
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'deploys' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => (activeTab = 'deploys')}
|
||||
>
|
||||
<Archive class="h-3.5 w-3.5" /> Backups
|
||||
{#if backupTally.ok > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="w-2.5 h-2.5" />{backupTally.ok}</span>{/if}
|
||||
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
|
||||
<History class="h-3.5 w-3.5" /> Deploys
|
||||
{#if deploysTally.ok > 0}
|
||||
<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="h-2.5 w-2.5" />{deploysTally.ok}</span>
|
||||
{/if}
|
||||
{#if deploysTally.failed > 0}
|
||||
<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="h-2.5 w-2.5" />{deploysTally.failed}</span>
|
||||
{/if}
|
||||
{#if deploysTally.total > 0 && deploysTally.ok === 0 && deploysTally.failed === 0}
|
||||
<Badge variant="secondary" class="ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums">{deploysTally.total}</Badge>
|
||||
{/if}
|
||||
</button>
|
||||
{#if $page.data.backupsEnabled}
|
||||
<button
|
||||
type="button"
|
||||
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'backups' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => (activeTab = 'backups')}
|
||||
>
|
||||
<Archive class="h-3.5 w-3.5" /> Backups
|
||||
{#if backupTally.ok > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="w-2.5 h-2.5" />{backupTally.ok}</span>{/if}
|
||||
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -843,6 +956,10 @@
|
||||
onTally={(t) => (backupTally = t)}
|
||||
/>
|
||||
</div>
|
||||
{:else if activeTab === 'deploys' && gitStack}
|
||||
<div class="flex min-h-0 flex-1 flex-col p-5">
|
||||
<DeploysPanel stackName={gitStack.stackName} envId={effectiveEnvId} reloadKey={deploysReloadKey} onTally={(t) => (deploysTally = t)} />
|
||||
</div>
|
||||
{:else}
|
||||
<div bind:this={containerRef} class="flex-1 min-h-0 flex {isDraggingSplit ? 'select-none' : ''}">
|
||||
<!-- Left column: Form fields -->
|
||||
@@ -1467,3 +1584,17 @@
|
||||
</Dialog.Root>
|
||||
|
||||
<IconPickerModal bind:open={showIconPicker} value={formIcon} onselect={onIconSelect} title="Choose a stack icon" />
|
||||
|
||||
<!-- Live compose output for "Save and deploy" (see saveGitStack). -->
|
||||
<ComposeOutputModal
|
||||
bind:open={outputOpen}
|
||||
title={outputTitle}
|
||||
lines={outputLines}
|
||||
running={outputRunning}
|
||||
ok={outputOk}
|
||||
ms={outputMs}
|
||||
exitCode={outputExitCode}
|
||||
stackName={formStackName}
|
||||
stackIcon={formIcon}
|
||||
envId={effectiveEnvId}
|
||||
/>
|
||||
@@ -67,13 +67,11 @@
|
||||
let deploying = $state(false);
|
||||
|
||||
async function handleDeploy() {
|
||||
deploying = true;
|
||||
try {
|
||||
await onDeploy({ pull, build, forceRecreate });
|
||||
} finally {
|
||||
deploying = false;
|
||||
open = false;
|
||||
}
|
||||
// Close the popover immediately: the output modal opens and owns the progress
|
||||
// display, so leaving the popover stuck on "Deploying..." behind it is noise.
|
||||
const opts = { pull, build, forceRecreate };
|
||||
open = false;
|
||||
await onDeploy(opts);
|
||||
}
|
||||
|
||||
function handleTriggerClick(e: MouseEvent) {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
import ComposeValidatePanel from './ComposeValidatePanel.svelte';
|
||||
import BackupPanel from '../containers/BackupPanel.svelte';
|
||||
import DeploysPanel from './DeploysPanel.svelte';
|
||||
import DeployOutputHeader from './DeployOutputHeader.svelte';
|
||||
import { deployTallyFromRuns } from '$lib/utils/deploy-run-view';
|
||||
import { volumesForStack, type VolumeInfo } from '$lib/utils/mounts';
|
||||
import { fetchBackupExecutions } from '$lib/utils/backup';
|
||||
import type { Component } from 'svelte';
|
||||
@@ -245,6 +247,48 @@
|
||||
outputLines = [...outputLines, line];
|
||||
}
|
||||
|
||||
// Bumped after a deploy finishes so the Deploys tab re-fetches and shows the new run.
|
||||
let deploysReloadKey = $state(0);
|
||||
// Deploys tab badge tally (total + ok/failed). Fetched cheaply when the modal
|
||||
// opens so the badge shows immediately (not only once the tab is first viewed);
|
||||
// DeploysPanel's onTally then keeps it fresh once the tab is open.
|
||||
let deploysTally = $state<{ total: number; ok: number; failed: number }>({ total: 0, ok: 0, failed: 0 });
|
||||
// Whether run history exists at all -- gates the Deploys tab for a read-only /
|
||||
// not-yet-synced stack. Set ONLY by the parent's own fetch below, never by the
|
||||
// panel's live onTally, so opening the tab (which briefly reports total 0 while
|
||||
// loading) can't make the tab hide itself out from under the user.
|
||||
let deploysHistoryExists = $state(false);
|
||||
|
||||
async function loadDeploysCount() {
|
||||
// Deploy history is keyed by stackName+env (schedule_executions), independent of
|
||||
// whether a local compose file exists -- so load it even for a read-only /
|
||||
// not-yet-synced stack (needsFileLocation), letting the Deploys tab appear when
|
||||
// there ARE runs to show.
|
||||
if (mode !== 'edit' || !stackName) {
|
||||
deploysTally = { total: 0, ok: 0, failed: 0 };
|
||||
deploysHistoryExists = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const envId = $currentEnvironment?.id ?? null;
|
||||
const res = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/deploys`, envId));
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
deploysTally = deployTallyFromRuns(Array.isArray(data?.runs) ? data.runs : []);
|
||||
deploysHistoryExists = deploysTally.total > 0;
|
||||
} catch {
|
||||
// Non-fatal: the badge just stays at its current value.
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the badge count when the modal opens (and after a deploy bumps the key).
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
void deploysReloadKey; // re-count after a deploy finishes
|
||||
void loadDeploysCount();
|
||||
}
|
||||
});
|
||||
|
||||
function finishOutput(output: string | undefined, ok: boolean, exitCode?: number) {
|
||||
outputRunning = false;
|
||||
outputOk = ok;
|
||||
@@ -253,11 +297,30 @@
|
||||
if (outputLines.length === 0 && output) {
|
||||
outputLines = output.split('\n');
|
||||
}
|
||||
deploysReloadKey++;
|
||||
}
|
||||
|
||||
// Dismiss the output panel (only allowed once the run has finished; the run
|
||||
// keeps going regardless - this just hides its log).
|
||||
function closeOutput() {
|
||||
outputLines = [];
|
||||
outputTitle = '';
|
||||
outputOk = undefined;
|
||||
outputMs = undefined;
|
||||
outputExitCode = undefined;
|
||||
}
|
||||
|
||||
const outputStatusLine = $derived(
|
||||
formatRunStatus({ running: outputRunning, ok: outputOk, ms: outputMs, exitCode: outputExitCode })
|
||||
);
|
||||
// DeployOutputHeader takes a verb + state (not the old title/running/ok): split the
|
||||
// stackName off the title and map running/ok to the shared state enum.
|
||||
const outputVerb = $derived(
|
||||
stackName && outputTitle.endsWith(stackName) ? outputTitle.slice(0, -stackName.length).trim() : outputTitle
|
||||
);
|
||||
const outputState = $derived<'running' | 'complete' | 'error'>(
|
||||
outputRunning ? 'running' : outputOk === false ? 'error' : 'complete'
|
||||
);
|
||||
|
||||
// Stack exists warning dialog state
|
||||
let showExistsWarning = $state(false);
|
||||
@@ -1350,6 +1413,19 @@
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch stack containers:', e);
|
||||
}
|
||||
|
||||
// Load the stack's icon even when the compose isn't local (read-only git
|
||||
// stack): the icon is Dockhand metadata, not repo content, so the header
|
||||
// must still show a custom icon the user set.
|
||||
try {
|
||||
const sourcesRes = await fetch(appendEnvParam('/api/stacks/sources', envId));
|
||||
if (sourcesRes.ok) {
|
||||
const sourceMap = await sourcesRes.json();
|
||||
formIcon = sourceMap?.[stackName]?.icon ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to load stack icon:', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error((typeof data.error === 'string' ? data.error : data.message) || 'Failed to load compose file');
|
||||
@@ -2080,26 +2156,22 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !readonly}
|
||||
<button
|
||||
type="button"
|
||||
title="Change stack icon"
|
||||
onclick={() => (showIconPicker = true)}
|
||||
class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700 hover:ring-2 hover:ring-primary transition-shadow"
|
||||
>
|
||||
{#if pendingUploadImage}
|
||||
<img src={pendingUploadImage} alt="" class="w-4 h-4 rounded object-cover" />
|
||||
{:else if formIcon}
|
||||
<StackIcon icon={formIcon} {stackName} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
{:else}
|
||||
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
|
||||
<!-- The stack icon is Dockhand metadata (stored via the /icon API), not
|
||||
repo content, so it stays editable even for a read-only git stack. -->
|
||||
<button
|
||||
type="button"
|
||||
title="Change stack icon"
|
||||
onclick={() => (showIconPicker = true)}
|
||||
class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700 hover:ring-2 hover:ring-primary transition-shadow"
|
||||
>
|
||||
{#if pendingUploadImage}
|
||||
<img src={pendingUploadImage} alt="" class="w-4 h-4 rounded object-cover" />
|
||||
{:else if formIcon}
|
||||
<StackIcon icon={formIcon} {stackName} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
{:else}
|
||||
<Layers class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
{#if mode === 'create'}
|
||||
@@ -2167,17 +2239,27 @@
|
||||
{#if backupTally.failed > 0}<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="w-2.5 h-2.5" />{backupTally.failed}</span>{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Deploys tab: recorded run history for this stack (Task 17). Same gate
|
||||
shape as Backups above minus the beta flag -- hidden for untracked
|
||||
stacks (no compose file means no deploy history either) and only in
|
||||
edit mode, where stackName/envId are actually known. -->
|
||||
{#if mode === 'edit' && !needsFileLocation}
|
||||
<!-- Deploys tab: recorded run history (keyed by stackName+env, independent of
|
||||
the local compose file). Shown with a synced compose, OR -- for a
|
||||
read-only / not-yet-synced git stack with no local compose -- only when
|
||||
there is history to show, so an empty tab never appears. Edit mode only,
|
||||
where stackName/envId are known. -->
|
||||
{#if mode === 'edit' && (!needsFileLocation || deploysHistoryExists)}
|
||||
<button
|
||||
type="button"
|
||||
class="relative -mb-px flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm transition-colors {activeTab === 'deploys' ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'}"
|
||||
onclick={() => activeTab = 'deploys'}
|
||||
>
|
||||
<History class="h-3.5 w-3.5" /> Deploys
|
||||
{#if deploysTally.ok > 0}
|
||||
<span class="inline-flex items-center gap-0.5 rounded-full bg-emerald-500/15 px-1.5 text-[10px] font-medium text-emerald-500"><Check class="h-2.5 w-2.5" />{deploysTally.ok}</span>
|
||||
{/if}
|
||||
{#if deploysTally.failed > 0}
|
||||
<span class="inline-flex items-center gap-0.5 rounded-full bg-red-500/15 px-1.5 text-[10px] font-semibold text-red-500"><X class="h-2.5 w-2.5" />{deploysTally.failed}</span>
|
||||
{/if}
|
||||
{#if deploysTally.total > 0 && deploysTally.ok === 0 && deploysTally.failed === 0}
|
||||
<Badge variant="secondary" class="ml-0.5 h-4 min-w-4 justify-center rounded-full px-1 text-[10px] tabular-nums">{deploysTally.total}</Badge>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2522,10 +2604,11 @@
|
||||
onTally={(t) => (backupTally = t)}
|
||||
/>
|
||||
</div>
|
||||
{:else if activeTab === 'deploys' && !needsFileLocation}
|
||||
<!-- Deploys tab (never for untracked stacks — same reasoning as Backups above) -->
|
||||
<div class="h-full flex-1 overflow-auto p-4">
|
||||
<DeploysPanel {stackName} envId={$currentEnvironment?.id ?? null} theme={editorTheme} />
|
||||
{:else if activeTab === 'deploys' && (!needsFileLocation || deploysHistoryExists)}
|
||||
<!-- Deploys tab: shown with a synced compose, or when a read-only /
|
||||
not-yet-synced stack still has run history to show. -->
|
||||
<div class="flex h-full min-h-0 flex-1 flex-col p-4">
|
||||
<DeploysPanel {stackName} envId={$currentEnvironment?.id ?? null} theme={editorTheme} reloadKey={deploysReloadKey} onTally={(t) => (deploysTally = t)} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -2552,10 +2635,25 @@
|
||||
</div>
|
||||
<div class="shrink-0 flex flex-col min-h-0" style="height: {outputSplitRatio}%">
|
||||
<div class="px-5 py-1.5 text-xs text-zinc-500 dark:text-zinc-400 flex items-center gap-2 flex-shrink-0">
|
||||
{#if outputRunning}
|
||||
<Loader2 class="w-3 h-3 animate-spin" />
|
||||
<DeployOutputHeader
|
||||
verb={outputVerb}
|
||||
{stackName}
|
||||
stackIcon={formIcon}
|
||||
envId={$currentEnvironment?.id ?? null}
|
||||
state={outputState}
|
||||
statusLine={outputStatusLine}
|
||||
iconClass="w-3.5 h-3.5"
|
||||
/>
|
||||
{#if !outputRunning}
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeOutput}
|
||||
title="Close output"
|
||||
class="ml-auto p-0.5 rounded hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{/if}
|
||||
{outputTitle} — {outputStatusLine}
|
||||
</div>
|
||||
<LogViewer
|
||||
logs={outputLines.join('\n')}
|
||||
|
||||
Reference in new issue
Block a user