1.0.35
This commit is contained in:
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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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.<name>.rule</code> and <code>pangolin.proxy-resources.<name>.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.<name>.rule</code>, <code>pangolin.public-resources.<name>.full-domain</code>, and <code>pangolin.private-resources.<name>.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>
|
||||
|
||||
Reference in new issue
Block a user