This commit is contained in:
jarek committed 2026-06-19 08:21:22 +02:00
1 parent efb634701c
commit ce12d7ae9f
17 files changed
+284 -203

No files matched your search

@@ -33,47 +33,64 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
try {
const containerId = params.id;
const availableShells: string[] = [];
const shellNames = SHELLS_TO_CHECK.map(s => s.path.split('/').pop()!);
const namedPaths = new Map<string, string>(); // name → resolved absolute path
// Check each shell by testing if the file exists and is executable
// Use a single command to check all shells at once for efficiency
const checkCommand = SHELLS_TO_CHECK.map(s =>
`test -x ${s.path} && echo "${s.path}"`
).join('; ');
// Resolve each shell through the container's own PATH using `command -v`,
// so that images with shells at non-standard locations (e.g. /usr/bin/sh
// where /bin/sh isn't a symlink, or PATH ordering that shadows the
// shell-builtin `test`) still report correctly. `exit 0` at the end
// keeps the exec exit code clean even when some shells are absent —
// otherwise execInContainer treats non-zero as a thrown error and the
// valid output is discarded (issue #1189).
const probe =
`for s in ${shellNames.join(' ')}; do ` +
`p=$(command -v $s 2>/dev/null) && [ -n "$p" ] && echo "$s:$p"; ` +
`done; exit 0`;
try {
const output = await execInContainer(
containerId,
['sh', '-c', checkCommand],
['sh', '-c', probe],
envIdNum
);
// Parse output - each line is an available shell path
const lines = output.trim().split('\n').filter(Boolean);
availableShells.push(...lines);
for (const line of output.trim().split('\n').filter(Boolean)) {
const colon = line.indexOf(':');
if (colon < 0) continue;
const name = line.slice(0, colon);
const resolved = line.slice(colon + 1);
if (resolved.startsWith('/')) namedPaths.set(name, resolved);
}
} catch {
// If even sh fails, try checking with test commands individually
// This handles edge cases where sh might not be available
// `sh` itself was not invocable. Fall back to probing the canonical
// paths directly with a non-shell-resolved test — exec each one
// against its absolute path and rely on docker exec's own
// "executable not found" failure to indicate absence. This handles
// the rare image where there's no `sh` at all but bash exists.
for (const shell of SHELLS_TO_CHECK) {
try {
await execInContainer(
containerId,
['test', '-x', shell.path],
[shell.path, '-c', 'exit 0'],
envIdNum
);
availableShells.push(shell.path);
const name = shell.path.split('/').pop()!;
namedPaths.set(name, shell.path);
} catch {
// Shell not available, continue to next
// Shell not available at this path; try the next.
}
}
}
const availableShells = Array.from(namedPaths.values());
// Determine default shell - prefer bash, then sh, then first available
let defaultShell: string | null = null;
if (availableShells.includes('/bin/bash')) {
defaultShell = '/bin/bash';
} else if (availableShells.includes('/bin/sh')) {
defaultShell = '/bin/sh';
if (namedPaths.has('bash')) {
defaultShell = namedPaths.get('bash')!;
} else if (namedPaths.has('sh')) {
defaultShell = namedPaths.get('sh')!;
} else if (availableShells.length > 0) {
defaultShell = availableShells[0];
}
@@ -81,11 +98,15 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json({
shells: availableShells,
defaultShell,
allShells: SHELLS_TO_CHECK.map(s => ({
path: s.path,
label: s.label,
available: availableShells.includes(s.path)
}))
allShells: SHELLS_TO_CHECK.map(s => {
const name = s.path.split('/').pop()!;
const resolved = namedPaths.get(name);
return {
path: resolved ?? s.path,
label: s.label,
available: namedPaths.has(name)
};
})
});
} catch (error) {
console.error('Error detecting shells:', error);
+22 -9
View File
@@ -38,7 +38,7 @@ import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export type DownloadFormat = 'tar' | 'tar.gz';
export type DownloadFormat = 'tar' | 'tar.gz' | 'raw';
export type EventCollectionMode = 'stream' | 'poll';
export interface GeneralSettings {
@@ -91,7 +91,8 @@ export interface GeneralSettings {
// Label filter mode
labelFilterMode: 'any' | 'all';
// Whether to surface URLs inferred from reverse-proxy labels — currently
// Traefik (traefik.http.routers.*) and Pangolin (pangolin.proxy-resources.*).
// Traefik (traefik.http.routers.*) and Pangolin
// (pangolin.{public,private}-resources.*).
// When false both parsers are bypassed and no proxy-derived pills are rendered.
honorProxyLabels: boolean;
// Whether to surface a "view changelog" link next to the update badge.
@@ -99,6 +100,8 @@ export interface GeneralSettings {
showImageChangelogLinks: boolean;
// Whether spinning icons (animate-spin etc.) are animated (#1169)
animateIcons: boolean;
// Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625)
protectScannerImages: boolean;
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
@@ -134,6 +137,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
honorProxyLabels: true,
showImageChangelogLinks: true,
animateIcons: true,
protectScannerImages: true,
defaultComposeTemplate: `version: "3.8"
services:
@@ -214,7 +218,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
labelFilterMode,
honorProxyLabels,
showImageChangelogLinks,
animateIcons
animateIcons,
protectScannerImages
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -256,7 +261,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('label_filter_mode'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons')
getSetting('animate_icons'),
getSetting('protect_scanner_images')
]);
const settings: GeneralSettings = {
@@ -302,7 +308,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
labelFilterMode: labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons,
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
};
return json(settings);
@@ -320,7 +327,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, scannerCleanupCron, scannerCleanupEnabled, logBufferSizeKb, logMaxLines, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, showExposedPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage, defaultComposeTemplate, labelFilterMode, honorProxyLabels, showImageChangelogLinks, animateIcons, protectScannerImages } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -337,7 +344,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (dateFormat !== undefined && VALID_DATE_FORMATS.includes(dateFormat)) {
await setSetting('date_format', dateFormat);
}
if (downloadFormat !== undefined && (downloadFormat === 'tar' || downloadFormat === 'tar.gz')) {
if (downloadFormat !== undefined && (downloadFormat === 'tar' || downloadFormat === 'tar.gz' || downloadFormat === 'raw')) {
await setSetting('download_format', downloadFormat);
}
if (defaultGrypeArgs !== undefined && typeof defaultGrypeArgs === 'string') {
@@ -469,6 +476,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (animateIcons !== undefined && typeof animateIcons === 'boolean') {
await setSetting('animate_icons', animateIcons);
}
if (protectScannerImages !== undefined && typeof protectScannerImages === 'boolean') {
await setSetting('protect_scanner_images', protectScannerImages);
}
// Fetch all settings in parallel for the response
const [
@@ -512,7 +522,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
labelFilterModeVal,
honorProxyLabelsVal,
showImageChangelogLinksVal,
animateIconsVal
animateIconsVal,
protectScannerImagesVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -554,7 +565,8 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('label_filter_mode'),
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons')
getSetting('animate_icons'),
getSetting('protect_scanner_images')
]);
const settings: GeneralSettings = {
@@ -599,6 +611,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
defaultComposeTemplate: defaultComposeTemplateVal ?? DEFAULT_SETTINGS.defaultComposeTemplate,
labelFilterMode: labelFilterModeVal ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
};
+9 -9
View File
@@ -91,31 +91,31 @@
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="general" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'general'}<GeneralTab />{/if}
</Tabs.Content>
<Tabs.Content value="environments" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="environments" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'environments'}<EnvironmentsTab {editEnvId} {newEnv} />{/if}
</Tabs.Content>
<Tabs.Content value="labels" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="labels" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'labels'}<LabelsTab />{/if}
</Tabs.Content>
<Tabs.Content value="registries" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="registries" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'registries'}<RegistriesTab />{/if}
</Tabs.Content>
<Tabs.Content value="git" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="git" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'git'}<GitTab />{/if}
</Tabs.Content>
<Tabs.Content value="config-sets" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="config-sets" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'config-sets'}<ConfigSetsTab />{/if}
</Tabs.Content>
<Tabs.Content value="notifications" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="notifications" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'notifications'}<NotificationsTab />{/if}
</Tabs.Content>
@@ -123,11 +123,11 @@
{#if activeTab === 'auth'}<AuthTab onTabChange={handleTabChange} />{/if}
</Tabs.Content>
<Tabs.Content value="license" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="license" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'license'}<LicenseTab />{/if}
</Tabs.Content>
<Tabs.Content value="about" class="flex-1 min-h-0 overflow-y-auto">
<Tabs.Content value="about" class="flex-1 min-h-0 overflow-y-auto pr-3">
{#if activeTab === 'about'}<AboutTab />{/if}
</Tabs.Content>
</Tabs.Root>
@@ -1586,7 +1586,7 @@
</Tabs.Trigger>
</Tabs.List>
<div class="overflow-y-auto py-4 h-[520px] [scrollbar-gutter:stable] pr-1">
<div class="overflow-y-auto py-4 h-[520px] [scrollbar-gutter:stable] pr-3">
<!-- General Tab (Connection Settings) -->
<Tabs.Content value="general" class="space-y-4 mt-0 h-full">
<!-- Name field -->
+27 -7
View File
@@ -309,7 +309,7 @@ services:
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<Tooltip.Content side="top" class="w-96 max-w-[90vw]">
<p>Surface a release-notes link next to the image name on rows with updates available. The link is resolved from the image's <code>org.opencontainers.image.source</code> label, from the <code>ghcr.io</code> registry path, or from an explicit <code>dockhand.changelog.url</code> label override.</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -345,7 +345,7 @@ services:
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<Tooltip.Content side="top" class="w-96 max-w-[90vw]">
<p>Shows internal container ports (from EXPOSE directives) that are not published to the host. These appear in the container list with an amber badge to distinguish them from published port mappings.</p>
</Tooltip.Content>
</Tooltip.Root>
@@ -367,8 +367,8 @@ services:
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-xs">
<p>Parse <code>traefik.http.routers.&lt;name&gt;.rule</code> and <code>pangolin.proxy-resources.&lt;name&gt;.full-domain</code> labels and surface the resulting URLs as clickable pills next to ports. When off, only explicit <code>dockhand.url</code> labels are shown.</p>
<Tooltip.Content side="top" class="w-96 max-w-[90vw]">
<p>Parse <code>traefik.http.routers.&lt;name&gt;.rule</code>, <code>pangolin.public-resources.&lt;name&gt;.full-domain</code>, and <code>pangolin.private-resources.&lt;name&gt;.full-domain</code> labels and surface the resulting URLs as clickable pills next to ports. When off, only explicit <code>dockhand.url</code> labels are shown.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
@@ -882,9 +882,6 @@ services:
onchange={handleScannerCleanupEnabledChange}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to reclaim disk space</p>
<div class="flex items-center gap-2 mt-2">
<div class="ml-auto">
<CronEditor
value={scannerCleanupCron}
@@ -893,6 +890,29 @@ services:
/>
</div>
</div>
<p class="text-xs text-muted-foreground">Remove cached vulnerability databases to reclaim disk space</p>
</div>
<div class="space-y-1 pt-2 border-t">
<div class="flex items-center gap-3">
<Label>Protect scanner images from prune</Label>
<Tooltip.Root>
<Tooltip.Trigger>
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="top" class="w-96 max-w-[90vw]">
<p>When ON, "Prune all unused" skips Dockhand's grype and trivy scanner images so the next scan doesn't have to re-pull them (and re-download the ~100MB vuln database). When OFF, prune behaves like vanilla Docker and may remove them.</p>
</Tooltip.Content>
</Tooltip.Root>
<TogglePill
checked={$appSettings.protectScannerImages}
onchange={(checked) => {
appSettings.setProtectScannerImages(checked);
toast.success(checked ? 'Scanner images will be skipped during prune' : 'Scanner images will be pruned with everything else');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Skip grype and trivy images during "Prune all unused"</p>
</div>
</Card.Content>
</Card.Root>