choose which stack operations show the full compose log

This commit is contained in:
Jarek Krochmalski committed 2026-09-13 14:50:02 +02:00
1 parent cd811d5117
commit fb37d931ae
6 files changed
+249 -20

No files matched your search

+18 -2
View File
@@ -7,6 +7,11 @@ import {
parseTimestamp,
type DateTimeFormatters
} from '$lib/utils/date-format';
import {
DEFAULT_STACK_LOG_OPERATIONS,
sanitizeStackLogOperations,
type StackLogOperation
} from '$lib/utils/stack-log-operations';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
@@ -59,6 +64,7 @@ export interface AppSettings {
// Scanner Advanced settings (#1219). Empty values = use auto-detection.
defaultScannerNetworkMode: string; // '' | 'host' | 'bridge' | 'none' | <custom-network>
defaultScannerDns: string[]; // ['1.1.1.1', '8.8.8.8']; empty = inherit
stackLogOperations: StackLogOperation[]; // stack ops that show the log popover (#1558)
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -102,6 +108,7 @@ const DEFAULT_SETTINGS: AppSettings = {
protectScannerImages: true,
defaultScannerNetworkMode: '',
defaultScannerDns: [],
stackLogOperations: DEFAULT_STACK_LOG_OPERATIONS,
defaultComposeTemplate: `version: "3.8"
services:
@@ -195,7 +202,8 @@ function createSettingsStore() {
showWhatsNew: settings.showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: settings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: Array.isArray(settings.defaultScannerDns) ? settings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
defaultScannerDns: Array.isArray(settings.defaultScannerDns) ? settings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns,
stackLogOperations: sanitizeStackLogOperations(settings.stackLogOperations)
});
}
} catch {
@@ -257,7 +265,8 @@ function createSettingsStore() {
showWhatsNew: updatedSettings.showWhatsNew ?? DEFAULT_SETTINGS.showWhatsNew,
protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: updatedSettings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: Array.isArray(updatedSettings.defaultScannerDns) ? updatedSettings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
defaultScannerDns: Array.isArray(updatedSettings.defaultScannerDns) ? updatedSettings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns,
stackLogOperations: sanitizeStackLogOperations(updatedSettings.stackLogOperations)
});
}
} catch (error) {
@@ -291,6 +300,13 @@ function createSettingsStore() {
return newSettings;
});
},
setStackLogOperations: (value: StackLogOperation[]) => {
const clean = sanitizeStackLogOperations(value);
update((current) => {
saveSettings({ stackLogOperations: clean });
return { ...current, stackLogOperations: clean };
});
},
setShowStoppedContainers: (value: boolean) => {
update((current) => {
const newSettings = { ...current, showStoppedContainers: value };
+65
View File
@@ -0,0 +1,65 @@
/**
* Which stack operations show the full compose-log popover (#1558).
*
* Some users find the log popover on every start/stop noisy and just want the
* buttons + a toast. This setting lists the operations for which the popover IS
* shown; operations not in the list run "quiet" (toast only), and their log is
* opened automatically only if the operation FAILS, so errors are never hidden.
*
* Stored as a JSON array of operation keys in the settings KV. Pure + shared so
* the default, the key set, and the "should show?" decision are one source of
* truth for the server, the store, and the stacks page.
*/
// Only operations that actually stream a compose log are listed. Remove is deliberately
// absent: it never opens the log popover (plain toast + error dialog), so a checkbox for it
// would control nothing.
export type StackLogOperation = 'start' | 'stop' | 'restart' | 'deploy' | 'down';
/** All operations, in display order, with a human label for the settings list. */
export const STACK_LOG_OPERATIONS: { key: StackLogOperation; label: string }[] = [
{ key: 'start', label: 'Start' },
{ key: 'stop', label: 'Stop' },
{ key: 'restart', label: 'Restart / recreate' },
{ key: 'deploy', label: 'Deploy / redeploy' },
{ key: 'down', label: 'Down' }
];
const ALL_KEYS = STACK_LOG_OPERATIONS.map((o) => o.key);
/**
* Default: show the log for everything EXCEPT the simple start/stop, matching the
* 1.0.47 behavior for the heavier operations while giving start/stop the quiet
* treatment the request asked for.
*/
export const DEFAULT_STACK_LOG_OPERATIONS: StackLogOperation[] = ['restart', 'deploy', 'down'];
/** Keep only valid, de-duplicated operation keys (defends the KV value + API body). */
export function sanitizeStackLogOperations(value: unknown): StackLogOperation[] {
if (!Array.isArray(value)) return [...DEFAULT_STACK_LOG_OPERATIONS];
const seen = new Set<StackLogOperation>();
for (const v of value) {
if (typeof v === 'string' && (ALL_KEYS as string[]).includes(v)) seen.add(v as StackLogOperation);
}
return ALL_KEYS.filter((k) => seen.has(k)); // canonical order
}
/** Whether the log popover should be shown for this operation. */
export function shouldShowStackLog(ops: StackLogOperation[], op: StackLogOperation): boolean {
return ops.includes(op);
}
/**
* Decode the stored KV value (a JSON array string) into the operation list. The
* unset-vs-empty distinction is load-bearing: a null/absent value means the user never
* touched the setting -> use the default; a stored "[]" means the user turned every op
* off -> honor it as "log nothing" (NOT the default). Malformed JSON falls back to the
* default rather than throwing.
*/
export function parseStackLogOperationsStorage(raw: string | null | undefined): StackLogOperation[] {
if (raw == null) return [...DEFAULT_STACK_LOG_OPERATIONS];
try {
return sanitizeStackLogOperations(JSON.parse(raw));
} catch {
return [...DEFAULT_STACK_LOG_OPERATIONS];
}
}
+28 -8
View File
@@ -40,6 +40,7 @@ import { refreshSystemJobs } from '$lib/server/scheduler';
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
import { DEFAULT_HELPER_IMAGE } from '$lib/server/backups/restic';
import { DEFAULT_STACK_LOG_OPERATIONS, sanitizeStackLogOperations, parseStackLogOperationsStorage, type StackLogOperation } from '$lib/utils/stack-log-operations';
// The real engine default (version-pinned, `-baseline`-aware). NOT a hardcoded
// `:latest` — that would advertise a floating tag the backup engine never uses and,
@@ -132,6 +133,8 @@ export interface GeneralSettings {
// Scanner Advanced settings (#1219). Empty = use auto-detection.
defaultScannerNetworkMode: string;
defaultScannerDns: string[];
// Stack operations that show the full compose-log popover (#1558).
stackLogOperations: StackLogOperation[];
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled' | 'deployLogReconcileCron' | 'deployLogReconcileEnabled'> = {
@@ -176,6 +179,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
protectScannerImages: true,
defaultScannerNetworkMode: '',
defaultScannerDns: [],
stackLogOperations: DEFAULT_STACK_LOG_OPERATIONS,
defaultComposeTemplate: `version: "3.8"
services:
@@ -293,7 +297,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
editorIndentGuides,
protectScannerImages,
defaultScannerNetworkMode,
defaultScannerDnsRaw
defaultScannerDnsRaw,
stackLogOperationsRaw
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -347,7 +352,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('editor_indent_guides'),
getSetting('protect_scanner_images'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
getSetting('default_scanner_dns'),
getSetting('stack_log_operations')
]);
const settings: GeneralSettings = {
@@ -405,7 +411,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
editorIndentGuides: editorIndentGuides ?? DEFAULT_SETTINGS.editorIndentGuides,
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRaw)
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRaw),
stackLogOperations: parseStackLogOperationsStorage(stackLogOperationsRaw)
};
return json(settings);
@@ -419,7 +426,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
* @openapi
* summary: Update global general settings (all fields optional; only supplied keys are written)
* description: A large flat settings bag - theme/fonts, scanner defaults, cleanup schedules, event/metrics collection, editor options (e.g. editorIndentGuides), and more.
* body: {animateIcons:boolean, editorIndentGuides:boolean, coloredActionButtons:boolean, lightTheme:string, darkTheme:string, defaultTimezone:string, logBufferSizeKb:integer, externalStackPaths:string, actionIconSize:string, compactPorts:boolean, confirmDestructive:boolean, dateFormat:string, defaultBackupImage:string, defaultComposeTemplate:string, defaultGrypeArgs:string, defaultGrypeImage:string, defaultScannerDns:array<string>, defaultScannerNetworkMode:string, defaultTrivyArgs:string, defaultTrivyImage:string, deployLogReconcileCron:string, deployLogReconcileEnabled:boolean, downloadFormat:string, editorFont:string, eventCleanupCron:string, eventCleanupEnabled:boolean, eventCollectionMode:string, eventPollInterval:integer, eventRetentionDays:integer, font:string, fontSize:string, formatLogTimestamps:boolean, gridFontSize:string, highlightUpdates:boolean, honorProxyLabels:boolean, labelFilterMode:string, logMaxLines:integer, metricsCollectionInterval:integer, primaryStackLocation:string, protectScannerImages:boolean, scannerCleanupCron:string, scannerCleanupEnabled:boolean, scheduleCleanupCron:string, scheduleCleanupEnabled:boolean, scheduleRetentionDays:integer, showExposedPorts:boolean, showGitCommitHash:boolean, showImageChangelogLinks:boolean, showStoppedContainers:boolean, showWhatsNew:boolean, terminalFont:string, timeFormat:string, useSelfhstIcons:boolean}
* body: {animateIcons:boolean, editorIndentGuides:boolean, coloredActionButtons:boolean, lightTheme:string, darkTheme:string, defaultTimezone:string, logBufferSizeKb:integer, externalStackPaths:string, actionIconSize:string, compactPorts:boolean, confirmDestructive:boolean, dateFormat:string, defaultBackupImage:string, defaultComposeTemplate:string, defaultGrypeArgs:string, defaultGrypeImage:string, defaultScannerDns:array<string>, defaultScannerNetworkMode:string, defaultTrivyArgs:string, defaultTrivyImage:string, deployLogReconcileCron:string, deployLogReconcileEnabled:boolean, downloadFormat:string, editorFont:string, eventCleanupCron:string, eventCleanupEnabled:boolean, eventCollectionMode:string, eventPollInterval:integer, eventRetentionDays:integer, font:string, fontSize:string, formatLogTimestamps:boolean, gridFontSize:string, highlightUpdates:boolean, honorProxyLabels:boolean, labelFilterMode:string, logMaxLines:integer, metricsCollectionInterval:integer, primaryStackLocation:string, protectScannerImages:boolean, scannerCleanupCron:string, scannerCleanupEnabled:boolean, scheduleCleanupCron:string, scheduleCleanupEnabled:boolean, scheduleRetentionDays:integer, showExposedPorts:boolean, showGitCommitHash:boolean, showImageChangelogLinks:boolean, showStoppedContainers:boolean, showWhatsNew:boolean, terminalFont:string, timeFormat:string, useSelfhstIcons:boolean, stackLogOperations:array<string>}
* resp-403: Permission denied (needs settings:edit)
* resp-500: Failed to save settings
*/
@@ -431,7 +438,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, deployLogReconcileCron, deployLogReconcileEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, useSelfhstIcons, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, deployLogReconcileCron, deployLogReconcileEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, showGitCommitHash, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, defaultBackupImage, honorProxyLabels, showImageChangelogLinks, useSelfhstIcons, animateIcons, editorIndentGuides, protectScannerImages, showWhatsNew, defaultScannerNetworkMode, defaultScannerDns, stackLogOperations } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -626,6 +633,10 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
.slice(0, 10); // sane upper bound; Docker accepts more but no one needs it
await setSetting('default_scanner_dns', JSON.stringify(cleaned));
}
if (stackLogOperations !== undefined && Array.isArray(stackLogOperations)) {
// Store the sanitized list verbatim; an empty array is a valid "log nothing" choice.
await setSetting('stack_log_operations', JSON.stringify(sanitizeStackLogOperations(stackLogOperations)));
}
// Fetch all settings in parallel for the response
const [
@@ -647,6 +658,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupEnabledVal,
scannerCleanupCronVal,
scannerCleanupEnabledVal,
deployLogReconcileCronVal,
deployLogReconcileEnabledVal,
logBufferSizeKbVal,
logMaxLinesVal,
defaultTimezoneVal,
@@ -679,7 +692,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
editorIndentGuidesVal,
protectScannerImagesVal,
defaultScannerNetworkModeVal,
defaultScannerDnsRawVal
defaultScannerDnsRawVal,
stackLogOperationsRawVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -699,6 +713,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getEventCleanupEnabled(),
getScannerCleanupCron(),
getScannerCleanupEnabled(),
getDeployLogReconcileCron(),
getDeployLogReconcileEnabled(),
getSetting('log_buffer_size_kb'),
getSetting('log_max_lines'),
getDefaultTimezone(),
@@ -731,7 +747,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('editor_indent_guides'),
getSetting('protect_scanner_images'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
getSetting('default_scanner_dns'),
getSetting('stack_log_operations')
]);
const settings: GeneralSettings = {
@@ -753,6 +770,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
eventCleanupEnabled: eventCleanupEnabledVal,
scannerCleanupCron: scannerCleanupCronVal,
scannerCleanupEnabled: scannerCleanupEnabledVal,
deployLogReconcileCron: deployLogReconcileCronVal,
deployLogReconcileEnabled: deployLogReconcileEnabledVal,
logBufferSizeKb: logBufferSizeKbVal ?? DEFAULT_SETTINGS.logBufferSizeKb,
logMaxLines: (typeof logMaxLinesVal === 'number' && logMaxLinesVal > 0)
? Math.min(2000, Math.max(100, logMaxLinesVal))
@@ -787,7 +806,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons,
editorIndentGuides: editorIndentGuidesVal ?? DEFAULT_SETTINGS.editorIndentGuides,
defaultScannerNetworkMode: defaultScannerNetworkModeVal ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal)
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal),
stackLogOperations: parseStackLogOperationsStorage(stackLogOperationsRawVal)
};
return json(settings);
+34 -1
View File
@@ -8,7 +8,8 @@
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown, Compass } from 'lucide-svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown, Compass, Layers } from 'lucide-svelte';
import { STACK_LOG_OPERATIONS, type StackLogOperation } from '$lib/utils/stack-log-operations';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
@@ -42,6 +43,14 @@
let defaultTrivyImage = $derived($appSettings.defaultTrivyImage);
let defaultScannerNetworkMode = $derived($appSettings.defaultScannerNetworkMode);
let defaultScannerDns = $derived($appSettings.defaultScannerDns);
let stackLogOperations = $derived($appSettings.stackLogOperations);
function toggleStackLogOperation(op: StackLogOperation, show: boolean) {
const next = show
? [...stackLogOperations, op]
: stackLogOperations.filter((o) => o !== op);
appSettings.setStackLogOperations(next);
}
let showAdvancedScannerSettings = $state(false);
let defaultComposeTemplate = $derived($appSettings.defaultComposeTemplate);
let labelFilterMode = $derived($appSettings.labelFilterMode);
@@ -781,6 +790,30 @@ services:
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Layers class="w-4 h-4" />
Stack operation logs
</Card.Title>
<p class="text-xs text-muted-foreground">Choose which stack operations open the full compose-log popover. Unchecked operations run quietly with just a toast; their log still opens automatically if the operation fails.</p>
</Card.Header>
<Card.Content>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-3">
{#each STACK_LOG_OPERATIONS as op}
<div class="flex items-center gap-3">
<TogglePill
checked={stackLogOperations.includes(op.key)}
onchange={(checked) => toggleStackLogOperation(op.key, checked)}
disabled={!$canAccess('settings', 'edit')}
/>
<Label>{op.label}</Label>
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
+29 -9
View File
@@ -22,6 +22,7 @@
import { extractPangolinUrls } from '$lib/utils/pangolin-urls';
import { extractCaddyUrls } from '$lib/utils/caddy-urls';
import { appSettings } from '$lib/stores/settings';
import { shouldShowStackLog, type StackLogOperation } from '$lib/utils/stack-log-operations';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import StackIcon from '$lib/components/StackIcon.svelte';
import ContainerIcon from '$lib/components/ContainerIcon.svelte';
@@ -538,12 +539,16 @@
let composeOutputStackName = $state<string | undefined>(undefined);
function startComposeOutput(title: string, stackName?: string) {
// `op` gates the log popover per the user's Settings choice (#1558): the output is
// always buffered (so it's ready if needed), but the popover only opens for ops the
// user opted into. `op` omitted = always show (deploy/git paths that pass no op).
// finishComposeOutput force-opens it on failure, so a quiet op still surfaces errors.
function startComposeOutput(title: string, stackName?: string, op?: StackLogOperation) {
composeOutputTitle = title;
composeOutputStackName = stackName;
composeOutputLines = [];
composeOutputRunning = true;
composeOutputOpen = true;
composeOutputOpen = op === undefined || shouldShowStackLog($appSettings.stackLogOperations, op);
composeOutputStartedAt = Date.now();
composeOutputOk = undefined;
composeOutputMs = undefined;
@@ -565,6 +570,9 @@
composeOutputOk = ok;
composeOutputMs = Date.now() - composeOutputStartedAt;
composeOutputExitCode = exitCode;
// A quiet op (popover suppressed by the setting) still surfaces its log on failure,
// so the user is never left with only a toast when something went wrong (#1558).
if (!ok) composeOutputOpen = true;
if (composeOutputLines.length === 0 && output) {
composeOutputLines = output.split('\n');
}
@@ -1024,7 +1032,7 @@
async function startStack(name: string) {
operationError = null;
stackActionLoading = name;
startComposeOutput(`Starting ${name}`, name);
startComposeOutput(`Starting ${name}`, name, 'start');
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/start`, envId), { method: 'POST' });
const data = await readJobResponse(response, appendComposeOutputLine);
@@ -1053,7 +1061,7 @@
async function stopStack(name: string) {
operationError = null;
stackActionLoading = name;
startComposeOutput(`Stopping ${name}`, name);
startComposeOutput(`Stopping ${name}`, name, 'stop');
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/stop`, envId), { method: 'POST' });
const data = await readJobResponse(response, appendComposeOutputLine);
@@ -1082,7 +1090,7 @@
async function restartStack(name: string, mode: 'restart' | 'ordered' | 'recreate' = 'restart') {
operationError = null;
stackActionLoading = name;
startComposeOutput(mode === 'recreate' ? `Recreating ${name}` : `Restarting ${name}`, name);
startComposeOutput(mode === 'recreate' ? `Recreating ${name}` : `Restarting ${name}`, name, 'restart');
try {
let url = appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/restart`, envId);
if (mode === 'recreate' || mode === 'ordered') {
@@ -1117,7 +1125,7 @@
async function redeployStack(name: string, options: { pull: boolean; build: boolean; forceRecreate: boolean }) {
operationError = null;
stackActionLoading = name;
startComposeOutput(`Redeploying ${name}`, name);
startComposeOutput(`Redeploying ${name}`, name, 'deploy');
// Record which redeploy options were chosen so the log shows them (they are not
// otherwise visible once the popover closes).
const chosen = [
@@ -1163,7 +1171,7 @@
operationError = null;
stackActionLoading = name;
stackDownLoading = name;
startComposeOutput(`Bringing down ${name}`, name);
startComposeOutput(`Bringing down ${name}`, name, 'down');
try {
const response = await fetch(appendEnvParam(`/api/stacks/${encodeURIComponent(name)}/down`, envId), { method: 'POST' });
const data = await readJobResponse(response, appendComposeOutputLine);
@@ -1879,6 +1887,7 @@
<RedeployPopover
stackName={stack.name}
{envId}
stackIcon={stackSources[stack.name]?.icon}
side="bottom"
align="start"
disabled={stackActionLoading === stack.name}
@@ -2208,6 +2217,7 @@
<RedeployPopover
stackName={stack.name}
{envId}
stackIcon={stackSources[stack.name]?.icon}
disabled={stackActionLoading === stack.name}
onDeploy={(options) => redeployStack(stack.name, options)}
>
@@ -2253,8 +2263,11 @@
align="end"
sideOffset={8}
>
<div class="flex flex-col gap-2 w-60">
<span class="text-xs text-muted-foreground">Restart stack <strong>{stack.name.length > 20 ? stack.name.slice(0, 20) + '...' : stack.name}</strong></span>
<div class="flex flex-col gap-2 w-72">
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
<StackIcon icon={stackSources[stack.name]?.icon} stackName={stack.name} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 shrink-0" />
<span class="truncate">Restart stack <strong class="font-semibold text-foreground">{stack.name}</strong></span>
</span>
<button class="flex flex-col items-start gap-0.5 rounded px-2 py-1.5 text-left hover:bg-muted" onclick={() => { restartPopoverOpen[stack.name] = false; restartStack(stack.name, 'restart'); }}>
<span class="text-xs font-medium">Restart</span>
<span class="text-[11px] text-muted-foreground">Fast in-place restart. Ignores depends_on ordering.</span>
@@ -2281,6 +2294,9 @@
onConfirm={() => stopStack(stack.name)}
onOpenChange={(open) => confirmStopName = open ? stack.name : null}
>
{#snippet icon()}
<StackIcon icon={stackSources[stack.name]?.icon} stackName={stack.name} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 shrink-0" />
{/snippet}
{#snippet children({ open })}
<Square class="grid-action-icon grid-action-stop {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
@@ -2298,6 +2314,9 @@
onConfirm={() => downStack(stack.name)}
onOpenChange={(open) => confirmDownName = open ? stack.name : null}
>
{#snippet icon()}
<StackIcon icon={stackSources[stack.name]?.icon} stackName={stack.name} envId={$currentEnvironment?.id ?? null} class="w-4 h-4 shrink-0" />
{/snippet}
{#snippet children({ open })}
<ArrowBigDown class="grid-action-icon grid-action-stop {stackDownLoading === stack.name ? 'animate-bounce text-orange-500' : open ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'}" />
{/snippet}
@@ -2871,6 +2890,7 @@
bind:open={showDeleteModal}
stackName={deleteStackName}
envId={envId ?? null}
stackIcon={stackSources[deleteStackName]?.icon}
onConfirm={(opts) => removeStack(deleteStackName, opts)}
/>
+75
View File
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'bun:test';
import {
STACK_LOG_OPERATIONS,
DEFAULT_STACK_LOG_OPERATIONS,
sanitizeStackLogOperations,
parseStackLogOperationsStorage,
shouldShowStackLog,
type StackLogOperation
} from '../src/lib/utils/stack-log-operations';
const ALL = STACK_LOG_OPERATIONS.map((o) => o.key);
describe('stack-log-operations defaults', () => {
it('default shows every heavy op but the simple start/stop (#1558)', () => {
expect(DEFAULT_STACK_LOG_OPERATIONS).toEqual(['restart', 'deploy', 'down']);
expect(DEFAULT_STACK_LOG_OPERATIONS).not.toContain('start');
expect(DEFAULT_STACK_LOG_OPERATIONS).not.toContain('stop');
});
it('remove is not a listed operation (it never opens the log popover)', () => {
expect(ALL).not.toContain('remove' as StackLogOperation);
});
});
describe('sanitizeStackLogOperations', () => {
it('a non-array (unset / garbage) falls back to the default', () => {
expect(sanitizeStackLogOperations(undefined)).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
expect(sanitizeStackLogOperations(null)).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
expect(sanitizeStackLogOperations('start')).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
});
it('an explicit empty array is honored as "log nothing", not defaulted', () => {
expect(sanitizeStackLogOperations([])).toEqual([]);
});
it('drops unknown keys and de-duplicates, in canonical order', () => {
expect(sanitizeStackLogOperations(['down', 'start', 'bogus', 'start', 'remove']))
.toEqual(['start', 'down']); // 'bogus'/'remove' dropped, dupes removed, canonical order
});
it('keeps a full valid selection in canonical order regardless of input order', () => {
expect(sanitizeStackLogOperations(['down', 'deploy', 'stop', 'restart', 'start']))
.toEqual(['start', 'stop', 'restart', 'deploy', 'down']);
});
});
describe('parseStackLogOperationsStorage (the unset-vs-empty KV invariant)', () => {
it('an UNSET value (null/undefined) uses the default, not "log nothing"', () => {
expect(parseStackLogOperationsStorage(null)).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
expect(parseStackLogOperationsStorage(undefined)).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
});
it('a stored empty array "[]" is honored as "log nothing", NOT reset to default', () => {
expect(parseStackLogOperationsStorage('[]')).toEqual([]);
});
it('decodes and sanitizes a stored selection (drops unknown/dupes, canonical order)', () => {
expect(parseStackLogOperationsStorage('["down","start","bogus","start","remove"]'))
.toEqual(['start', 'down']);
});
it('malformed JSON falls back to the default instead of throwing', () => {
expect(parseStackLogOperationsStorage('not json')).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
expect(parseStackLogOperationsStorage('{')).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
});
it('a non-array JSON value (object/number) sanitizes to the default', () => {
expect(parseStackLogOperationsStorage('{"a":1}')).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
expect(parseStackLogOperationsStorage('42')).toEqual(DEFAULT_STACK_LOG_OPERATIONS);
});
});
describe('shouldShowStackLog', () => {
it('shows only ops present in the list', () => {
const ops: StackLogOperation[] = ['restart', 'deploy', 'down'];
expect(shouldShowStackLog(ops, 'restart')).toBe(true);
expect(shouldShowStackLog(ops, 'start')).toBe(false);
expect(shouldShowStackLog(ops, 'stop')).toBe(false);
});
it('an empty list shows nothing', () => {
expect(shouldShowStackLog([], 'deploy')).toBe(false);
});
});