choose which stack operations show the full compose log
This commit is contained in:
6 files changed
+249
-20
No files matched your search
@@ -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 };
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user