This commit is contained in:
jarek committed 2026-06-27 15:15:54 +02:00
1 parent ce12d7ae9f
commit 685132ff89
63 files changed
+1324 -281

No files matched your search

+9
View File
@@ -163,6 +163,15 @@ RUN chmod +x ./scripts/*.sh ./scripts/**/*.sh 2>/dev/null || true
RUN mkdir -p /home/dockhand/.dockhand/stacks /app/data \
&& chown dockhand:dockhand /app/data /home/dockhand /home/dockhand/.dockhand /home/dockhand/.dockhand/stacks
# OCI image annotations (#1217) — lets tooling map this image back to its
# source repo (image registry name differs from GitHub repo name).
LABEL org.opencontainers.image.source="https://github.com/Finsys/dockhand" \
org.opencontainers.image.url="https://dockhand.pro" \
org.opencontainers.image.title="Dockhand" \
org.opencontainers.image.description="Docker management" \
org.opencontainers.image.vendor="Finsys" \
org.opencontainers.image.licenses="BUSL-1.1"
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
+1 -1
View File
@@ -1 +1 @@
v1.0.35
v1.0.36
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
"version": "1.0.35",
"version": "1.0.36",
"type": "module",
"scripts": {
"dev": "npx vite dev",
+45 -7
View File
@@ -58,6 +58,38 @@ html {
font-size: calc(12px * var(--grid-font-size-scale, 1)) !important;
}
/* Grid action icons (#1072) — size is independent of grid font size.
* Driven by --action-icon-size set from Settings > General. Default 12px
* matches the historical Tailwind w-3 size; users can bump up for high-DPI. */
.grid-action-icon {
width: var(--action-icon-size, 12px) !important;
height: var(--action-icon-size, 12px) !important;
}
/* Colored action icons (#1072) — opt-in via Settings > General.
* When html.colored-actions is set, semantic .grid-action-* classes paint
* their icons. Default (no class) leaves them muted-foreground gray. */
html.colored-actions .grid-action-start { color: rgb(22 163 74); }
html.colored-actions.dark .grid-action-start { color: rgb(74 222 128); }
html.colored-actions .grid-action-stop { color: rgb(217 119 6); }
html.colored-actions.dark .grid-action-stop { color: rgb(251 191 36); }
html.colored-actions .grid-action-pause { color: rgb(217 119 6); }
html.colored-actions.dark .grid-action-pause { color: rgb(251 191 36); }
html.colored-actions .grid-action-restart { color: rgb(37 99 235); }
html.colored-actions.dark .grid-action-restart { color: rgb(96 165 250); }
html.colored-actions .grid-action-delete { color: rgb(220 38 38); }
html.colored-actions.dark .grid-action-delete { color: rgb(248 113 113); }
html.colored-actions .grid-action-edit { color: rgb(37 99 235); }
html.colored-actions.dark .grid-action-edit { color: rgb(96 165 250); }
html.colored-actions .grid-action-info { color: rgb(71 85 105); }
html.colored-actions.dark .grid-action-info { color: rgb(148 163 184); }
html.colored-actions .grid-action-logs { color: rgb(37 99 235); }
html.colored-actions.dark .grid-action-logs { color: rgb(96 165 250); }
html.colored-actions .grid-action-terminal { color: rgb(22 163 74); }
html.colored-actions.dark .grid-action-terminal { color: rgb(74 222 128); }
html.colored-actions .grid-action-transfer { color: rgb(37 99 235); }
html.colored-actions.dark .grid-action-transfer { color: rgb(96 165 250); }
/* State badge - width scales with grid font size to stay consistent */
.state-badge {
width: calc(70px * var(--grid-font-size-scale, 1));
@@ -1341,13 +1373,19 @@ html {
line-height: 14px;
}
/* Icon animation toggle (#1169): when html.no-icon-animation is set, the
common Tailwind animation utilities collapse to no-op. This keeps the
layout (spinners still occupy space) but removes the motion. */
html.no-icon-animation .animate-spin,
html.no-icon-animation .animate-pulse,
html.no-icon-animation .animate-bounce,
html.no-icon-animation .animate-ping {
/* Icon animation toggle (#1169): when html.no-icon-animation is set, kill
the ambient/decorative spinners but leave active-operation indicators
alone. Lucide icons that ONLY exist as "operation in progress" markers
— Loader2 (renders as lucide-loader-circle), RefreshCw, RefreshCcw,
RotateCw, RotateCcw — are excluded regardless of the toggle. So is
anything explicitly tagged `animate-action`. Permanent ambient
spinners (e.g. the "update available" CircleArrowUp glyph in container
list rows) still stop. */
html.no-icon-animation
.animate-spin:not(.lucide-loader-circle):not(.lucide-refresh-cw):not(.lucide-refresh-ccw):not(.lucide-rotate-cw):not(.lucide-rotate-ccw):not(.animate-action),
html.no-icon-animation .animate-pulse:not(.animate-action),
html.no-icon-animation .animate-bounce:not(.animate-action),
html.no-icon-animation .animate-ping:not(.animate-action) {
animation: none !important;
}
@@ -0,0 +1,34 @@
<script lang="ts">
import { Label } from '$lib/components/ui/label';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { themeStore } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
interface Props {
userId?: number; // omit for global default (login page / auth-disabled)
}
let { userId }: Props = $props();
const skipApply = $derived($authStore.loading ? true : ($authStore.authEnabled && !userId));
let checked = $state(false);
$effect(() => {
checked = $themeStore.coloredActionButtons;
});
function onToggle(value: boolean) {
checked = value;
themeStore.setPreference('coloredActionButtons', value, userId, skipApply);
toast.success(value ? 'Action buttons colored' : 'Action buttons reset to default');
}
</script>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Colored grid buttons</Label>
<TogglePill {checked} onchange={onToggle} />
</div>
<p class="text-xs text-muted-foreground">Use semantic colors instead of muted gray</p>
</div>
+43 -2
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Sun, Moon, Type, AArrowUp, Table, Terminal, CodeXml } from 'lucide-svelte';
import { Sun, Moon, Type, AArrowUp, Table, Terminal, CodeXml, MousePointerClick } from 'lucide-svelte';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { lightThemes, darkThemes, fonts, monospaceFonts } from '$lib/themes';
import { themeStore, applyTheme, type FontSize } from '$lib/stores/theme';
import { themeStore, applyTheme, type FontSize, type ActionIconSize } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth';
// Preload all monospace Google Fonts so dropdown previews render correctly
@@ -20,6 +20,13 @@
{ id: 'xlarge', name: 'Extra Large' }
];
const actionIconSizes: { id: ActionIconSize; name: string }[] = [
{ id: 'small', name: 'Small' },
{ id: 'normal', name: 'Normal' },
{ id: 'large', name: 'Large' },
{ id: 'xlarge', name: 'Extra Large' }
];
interface Props {
userId?: number; // Pass userId for per-user settings, undefined for global
}
@@ -39,6 +46,7 @@
let selectedFont = $state('system');
let selectedFontSize = $state<FontSize>('normal');
let selectedGridFontSize = $state<FontSize>('normal');
let selectedActionIconSize = $state<ActionIconSize>('normal');
let selectedTerminalFont = $state('system-mono');
let selectedEditorFont = $state('system-mono');
@@ -66,6 +74,7 @@
selectedFont = $themeStore.font;
selectedFontSize = $themeStore.fontSize;
selectedGridFontSize = $themeStore.gridFontSize;
selectedActionIconSize = $themeStore.actionIconSize;
selectedTerminalFont = $themeStore.terminalFont;
selectedEditorFont = $themeStore.editorFont;
} else {
@@ -79,6 +88,7 @@
selectedFont = data.font || 'system';
selectedFontSize = data.fontSize || 'normal';
selectedGridFontSize = data.gridFontSize || 'normal';
selectedActionIconSize = data.actionIconSize || 'normal';
selectedTerminalFont = data.terminalFont || 'system-mono';
selectedEditorFont = data.editorFont || 'system-mono';
}
@@ -96,6 +106,7 @@
selectedFont = $themeStore.font;
selectedFontSize = $themeStore.fontSize;
selectedGridFontSize = $themeStore.gridFontSize;
selectedActionIconSize = $themeStore.actionIconSize;
selectedTerminalFont = $themeStore.terminalFont;
selectedEditorFont = $themeStore.editorFont;
}
@@ -131,6 +142,12 @@
await themeStore.setPreference('gridFontSize', value as FontSize, userId, skipApply);
}
async function handleActionIconSizeChange(value: string | undefined) {
if (!value) return;
selectedActionIconSize = value as ActionIconSize;
await themeStore.setPreference('actionIconSize', value as ActionIconSize, userId, skipApply);
}
async function handleTerminalFontChange(value: string | undefined) {
if (!value) return;
selectedTerminalFont = value;
@@ -290,6 +307,30 @@
</Select.Root>
</div>
<!-- Grid Buttons Size (#1072) -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<MousePointerClick class="w-4 h-4 text-muted-foreground" />
<Label>Grid buttons</Label>
</div>
<Select.Root type="single" value={selectedActionIconSize} onValueChange={handleActionIconSizeChange}>
<Select.Trigger class="w-56">
{#each actionIconSizes as size}
{#if size.id === selectedActionIconSize}
<span>{size.name}</span>
{/if}
{/each}
</Select.Trigger>
<Select.Content>
{#each actionIconSizes as size}
<Select.Item value={size.id}>
<span>{size.name}</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Terminal Font -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
+68 -18
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import * as Sidebar from '$lib/components/ui/sidebar';
@@ -23,16 +24,25 @@
ClipboardList,
Activity,
Timer,
LibraryBig
LibraryBig,
CircleArrowUp
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
import { selfUpdate } from '$lib/stores/self-update';
import { appSettings } from '$lib/stores/settings';
import * as Avatar from '$lib/components/ui/avatar';
import * as Tooltip from '$lib/components/ui/tooltip';
const appVersion = __APP_VERSION__ || 'unknown';
const buildCommit = __BUILD_COMMIT__ ?? null;
onMount(() => {
// One-shot Dockhand update check (#1146). Result is cached for the
// browser session — the Settings → About page reads the same store.
selfUpdate.checkOnce();
});
import type { Permissions } from '$lib/stores/auth';
// TypeScript interface for menu items
@@ -111,6 +121,21 @@
] as const;
</script>
{#snippet versionTooltip()}
<div class="space-y-0.5 text-left">
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
{#if buildCommit}
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
{/if}
{#if $selfUpdate.updateAvailable && $selfUpdate.latestVersion}
<div class="flex items-center gap-1.5 pt-1 text-amber-500">
<CircleArrowUp class="w-3.5 h-3.5 shrink-0" />
Update available: <span class="font-mono">v{$selfUpdate.latestVersion}</span>
</div>
{/if}
</div>
{/snippet}
<Sidebar.Root collapsible="icon">
<Sidebar.Header class="overflow-visible flex items-center justify-center p-0">
<!-- Expanded state: logo + collapse button -->
@@ -161,23 +186,48 @@
</Sidebar.Group>
</Sidebar.Content>
<!-- Version (expanded sidebar only) -->
<div class="group-data-[state=collapsed]:hidden px-3 py-2 mt-auto text-center">
<Tooltip.Root>
<Tooltip.Trigger>
<span class="text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
{appVersion}
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" align="start" sideOffset={8} class="text-xs">
<div class="space-y-0.5">
<div class="flex items-center gap-1.5"><svg class="w-4 h-4 shrink-0" viewBox="0 0 24 18" fill="currentColor"><path d="M23.76 8.68c-.26-.18-.86-.58-1.53-.58-.24 0-.48.04-.72.12-.12-.84-.68-1.56-1.34-2.14l-.28-.22-.24.26c-.28.34-.48.72-.56 1.14-.1.42-.06.82.1 1.2-.42.22-.88.36-1.32.42-.24.04-.48.06-.72.06H.78a.77.77 0 0 0-.78.78c-.02 1.46.22 2.9.72 4.24.56 1.44 1.4 2.5 2.5 3.16 1.26.74 3.32 1.16 5.64 1.16.98 0 2-.1 2.98-.3a11.5 11.5 0 0 0 3.3-1.3 9.67 9.67 0 0 0 2.54-2.34c1.16-1.42 1.86-3.02 2.34-4.38h.2c1.22 0 1.98-.48 2.4-.9.28-.26.5-.58.64-.94l.08-.24-.28-.2zM2.74 8.84H4.7c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H2.74c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.72 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zM5.46 6.2h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H5.46c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18H8.22c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 0h1.96c.1 0 .18-.08.18-.18V4.38c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm0-2.64h1.96c.1 0 .18-.08.18-.18V1.74c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18zm2.76 5.28h1.96c.1 0 .18-.08.18-.18V7.02c0-.1-.08-.18-.18-.18h-1.96c-.1 0-.18.08-.18.18v1.64c0 .1.08.18.18.18z"/></svg><span class="font-mono">fnsys/dockhand:{appVersion}</span></div>
{#if buildCommit}
<div>Commit: <span class="font-mono">{buildCommit.slice(0, 7)}</span></div>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
<!-- Collapsed-only update indicator at the bottom. Same CSS-popover trick
as the expanded version row — anchored to the icon's top edge, grows
upward, opens to the right of the narrow sidebar. -->
{#if $selfUpdate.updateAvailable}
<div class="hidden group-data-[state=collapsed]:flex justify-center mt-auto pb-2 relative group/update">
<a
href="/settings?tab=about"
class="inline-flex p-1 rounded-md hover:bg-sidebar-accent transition-colors"
aria-label="Dockhand update available"
>
<CircleArrowUp class="w-4 h-4 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
</a>
<div
role="tooltip"
class="pointer-events-none absolute left-0 bottom-full mb-1 whitespace-nowrap rounded-md border bg-popover text-popover-foreground text-xs px-3 py-1.5 shadow-lg opacity-0 group-hover/update:opacity-100 transition-opacity z-50"
>
{@render versionTooltip()}
</div>
</div>
{/if}
<!-- Version (expanded sidebar only). Pure-CSS popover anchored to the row's
top edge, so it always grows upward into available space. -->
<div class="group-data-[state=collapsed]:hidden px-3 py-1 mt-auto text-center relative group/version">
<span class="inline-flex items-center gap-1.5 text-[10px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-default">
{appVersion}
{#if $selfUpdate.updateAvailable}
<a
href="/settings?tab=about"
class="inline-flex"
aria-label="Dockhand update available"
>
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
</a>
{/if}
</span>
<div
role="tooltip"
class="pointer-events-none absolute left-0 bottom-full mb-1 whitespace-nowrap rounded-md border bg-popover text-popover-foreground text-xs px-3 py-1.5 shadow-lg opacity-0 group-hover/version:opacity-100 transition-opacity z-50"
>
{@render versionTooltip()}
</div>
</div>
<!-- User info footer (only when auth is enabled) -->
+10 -8
View File
@@ -172,22 +172,24 @@
}
});
// Generate hours array based on time format preference
// Generate hours array based on time format preference. 24h shows just the
// 2-digit hour so the combined picker reads "at 03 :15" rather than
// the confusing "at 03:00 :15" (#1198).
const hours = $derived(
Array.from({ length: 24 }, (_, i) => ({
value: String(i),
label: is12Hour
? i === 0 ? '12 AM' : i < 12 ? `${i} AM` : i === 12 ? '12 PM' : `${i - 12} PM`
: i.toString().padStart(2, '0') + ':00'
: i.toString().padStart(2, '0')
}))
);
const minutes = [
{ value: '0', label: ':00' },
{ value: '15', label: ':15' },
{ value: '30', label: ':30' },
{ value: '45', label: ':45' }
];
// 5-minute granularity — fine enough to stagger notifications without
// drowning the dropdown in 60 entries (#1198).
const minutes = Array.from({ length: 12 }, (_, i) => ({
value: String(i * 5),
label: ':' + (i * 5).toString().padStart(2, '0')
}));
const daysOfWeek = [
{ value: '1', label: 'Monday' },
+22
View File
@@ -1,4 +1,26 @@
[
{
"version": "1.0.36",
"date": "2026-06-27",
"changes": [
{ "type": "feature", "text": "sidebar shows an amber update indicator when a newer Dockhand image is available (#1146)" },
{ "type": "fix", "text": "Tag image modal too narrow for images identified only by SHA (#1205)" },
{ "type": "fix", "text": "self-hosted ntfy: accept raw `tk_...` access tokens in `?auth=` (#1209)" },
{ "type": "fix", "text": "dashboard: a single failed DB stats query no longer poisons the whole environment tile (#1210)" },
{ "type": "fix", "text": "UI dates and times now honor the configured default timezone instead of the browser's timezone (#1183)" },
{ "type": "fix", "text": "add missing `reset-mfa.sh` emergency script referenced by the manual (#1214)" },
{ "type": "fix", "text": "registry browser: copy between registries no longer duplicates the host (#1220)" },
{ "type": "improvement", "text": "published image carries standard OCI annotations (source, url, title, description, vendor, licenses) (#1217)" },
{ "type": "feature", "text": "scanner: configurable network mode and DNS servers for vulnerability scans (#1219)" },
{ "type": "feature", "text": "template tiles now show a `Project` link that opens the upstream project page (#1211)" },
{ "type": "improvement", "text": "cron picker: hour shows just `HH` (was `HH:00`), minute granularity bumped from 15 to 5 (#1198)" },
{ "type": "feature", "text": "grid buttons: configurable size and optional semantic colors (#1072)" },
{ "type": "fix", "text": "copy buttons (git deploy logs, API tokens) now work over plain HTTP (#1222)" },
{ "type": "fix", "text": "Podman pod-infra containers no longer trigger update-check warnings (#1221)" },
{ "type": "fix", "text": "terminal exec works even when the browser sends a cookie with stray `%` characters (#1224)" }
],
"imageTag": "fnsys/dockhand:v1.0.36"
},
{
"version": "1.0.35",
"date": "2026-06-19",
+1 -2
View File
@@ -46,12 +46,11 @@ function cacheKey(rawToken: string): string {
return createHash('sha256').update(rawToken).digest('hex');
}
// Pre-computed dummy hash for timing protection on invalid prefixes
let dummyHash: string | null = null;
async function getDummyHash(): Promise<string> {
if (!dummyHash) {
dummyHash = await hashPassword('dh_dummy_token_for_timing_protection');
dummyHash = await hashPassword('dh_init_seed');
}
return dummyHash;
}
+41
View File
@@ -36,6 +36,7 @@
*/
import type { Cookies } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';
import type { Permissions } from './db';
import { getUserAccessibleEnvironments, userCanAccessEnvironment, userHasAdminRole } from './db';
import { validateSession, isAuthEnabled, checkPermission, type AuthenticatedUser } from './auth';
@@ -105,6 +106,27 @@ export interface AuthorizationContext {
* - (User is admin OR has audit_logs view permission)
*/
canViewAuditLog: () => Promise<boolean>;
/**
* Gate handler: require a permission. Returns a 403 Response on denial,
* null when allowed. Use at the top of a handler with `??`-chaining for
* one-line auth/env checks:
*
* const denied = (await auth.requirePermission('schedules', 'edit'))
* ?? (await auth.requireEnvAccess(envId));
* if (denied) return denied;
*
* No-op when authEnabled is false.
*/
requirePermission: (resource: keyof Permissions, action: string, environmentId?: number) => Promise<Response | null>;
/**
* Gate handler: require access to the given environment. Returns a 403
* Response on denial, null when allowed. Pass null/undefined to no-op
* (e.g. system-scoped operations that don't target an env). No-op when
* authEnabled is false.
*/
requireEnvAccess: (environmentId: number | null | undefined) => Promise<Response | null>;
}
/**
@@ -232,6 +254,25 @@ export async function authorize(cookies: Cookies): Promise<AuthorizationContext>
// Check for audit_logs permission
return checkPermission(user, 'audit_logs' as keyof Permissions, 'view');
},
async requirePermission(
resource: keyof Permissions,
action: string,
environmentId?: number
): Promise<Response | null> {
if (!authEnabled) return null;
return (await ctx.can(resource, action, environmentId))
? null
: json({ error: 'Permission denied' }, { status: 403 });
},
async requireEnvAccess(environmentId: number | null | undefined): Promise<Response | null> {
if (!authEnabled) return null;
if (environmentId === null || environmentId === undefined) return null;
return (await ctx.canAccessEnvironment(environmentId))
? null
: json({ error: 'Access denied to this environment' }, { status: 403 });
}
};
+17 -4
View File
@@ -389,8 +389,10 @@ export async function getUserThemePreferences(userId: number): Promise<{
terminalFont: string;
editorFont: string;
animateIcons: boolean;
coloredActionButtons: boolean;
actionIconSize: string;
}> {
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons] = await Promise.all([
const [lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, animateIcons, coloredActionButtons, actionIconSize] = await Promise.all([
getUserSetting(userId, 'light_theme'),
getUserSetting(userId, 'dark_theme'),
getUserSetting(userId, 'font'),
@@ -398,7 +400,9 @@ export async function getUserThemePreferences(userId: number): Promise<{
getUserSetting(userId, 'grid_font_size'),
getUserSetting(userId, 'terminal_font'),
getUserSetting(userId, 'editor_font'),
getUserSetting(userId, 'animate_icons')
getUserSetting(userId, 'animate_icons'),
getUserSetting(userId, 'colored_action_buttons'),
getUserSetting(userId, 'action_icon_size')
]);
return {
lightTheme: lightTheme || 'default',
@@ -409,13 +413,16 @@ export async function getUserThemePreferences(userId: number): Promise<{
terminalFont: terminalFont || 'system-mono',
editorFont: editorFont || 'system-mono',
// Default ON — only false when explicitly stored
animateIcons: animateIcons === 'false' ? false : true
animateIcons: animateIcons === 'false' ? false : true,
// Default OFF — only true when explicitly stored
coloredActionButtons: coloredActionButtons === 'true',
actionIconSize: actionIconSize || 'normal'
};
}
export async function setUserThemePreferences(
userId: number,
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean }
prefs: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string }
): Promise<void> {
const updates: Promise<void>[] = [];
if (prefs.lightTheme !== undefined) {
@@ -442,6 +449,12 @@ export async function setUserThemePreferences(
if (prefs.animateIcons !== undefined) {
updates.push(setUserSetting(userId, 'animate_icons', prefs.animateIcons ? 'true' : 'false'));
}
if (prefs.coloredActionButtons !== undefined) {
updates.push(setUserSetting(userId, 'colored_action_buttons', prefs.coloredActionButtons ? 'true' : 'false'));
}
if (prefs.actionIconSize !== undefined) {
updates.push(setUserSetting(userId, 'action_icon_size', prefs.actionIconSize));
}
await Promise.all(updates);
}
+18 -3
View File
@@ -6,14 +6,14 @@
*/
import { homedir } from 'node:os';
import { existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import * as http from 'node:http';
import * as https from 'node:https';
import * as tls from 'node:tls';
import { createHash } from 'node:crypto';
import type { Environment } from './db';
import { getStackEnvVarsAsRecord, getSetting } from './db';
import { getSetting } from './db';
import { getAdditionalVolumeBinds } from './mount-dedupe';
import { encodeRegistryAuth } from './registry-auth';
import { isSystemContainer } from './scheduler/tasks/update-utils';
@@ -4501,6 +4501,8 @@ export async function runContainer(options: {
extraHosts?: string[];
name?: string;
envId?: number | null;
networkMode?: string; // Docker network mode (e.g., 'host', 'bridge', custom network name)
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
}): Promise<{ stdout: string; stderr: string }> {
// Add random suffix to avoid naming conflicts
const baseName = options.name || `dockhand-temp-${Date.now()}`;
@@ -4524,6 +4526,14 @@ export async function runContainer(options: {
containerConfig.HostConfig.ExtraHosts = options.extraHosts;
}
if (options.networkMode) {
containerConfig.HostConfig.NetworkMode = options.networkMode;
}
if (options.dns && options.dns.length > 0) {
containerConfig.HostConfig.Dns = options.dns;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{
@@ -4591,6 +4601,7 @@ export async function runContainerWithStreaming(options: {
onStderr?: (data: string) => void;
timeout?: number; // Overall timeout in ms (0 or undefined = no timeout)
networkMode?: string; // Docker network mode (e.g., network name for TCP access)
dns?: string[]; // Custom DNS servers; undefined = inherit from Docker daemon
}): Promise<string> {
const baseName = options.name || `dockhand-stream-${Date.now()}`;
const containerName = `${baseName}-${randomSuffix()}`;
@@ -4625,6 +4636,10 @@ export async function runContainerWithStreaming(options: {
containerConfig.HostConfig.NetworkMode = options.networkMode;
}
if (options.dns && options.dns.length > 0) {
containerConfig.HostConfig.Dns = options.dns;
}
const createResult = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
+29 -3
View File
@@ -1,13 +1,40 @@
/** ntfy.sh + self-hosted ntfy. ntfy:// or ntfys:// (HTTPS). */
import { drainResponse, type NotificationPayload, type NotificationResult } from './shared';
// HTTP header values must be ByteString (every char ≤ 0xFF). Reject anything
// else so we never blow up fetch() with a 65533 (U+FFFD) replacement char.
function isHeaderSafe(value: string): boolean {
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) > 0xff) return false;
}
return true;
}
// ?auth= accepts either a raw ntfy access token (typically `tk_...`) or a
// base64-encoded `Bearer <token>` / `Basic <creds>` string. We try base64 first
// only when the input looks plausibly base64; if decoding produces non-ASCII
// garbage we fall back to treating the value as a raw token.
export function resolveQueryAuth(queryAuth: string): string {
if (queryAuth.startsWith('tk_')) {
return `Bearer ${queryAuth}`;
}
const looksBase64 = /^[A-Za-z0-9+/=_-]+$/.test(queryAuth);
if (looksBase64) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
if (isHeaderSafe(decoded) && (decoded.startsWith('Bearer ') || decoded.startsWith('Basic '))) {
return decoded;
}
}
return `Bearer ${queryAuth}`;
}
export async function sendNtfy(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
// Supported formats:
// ntfy://topic (public ntfy.sh)
// ntfy://host/topic (custom server, no auth)
// ntfy://user:pass@host/topic (custom server with basic auth)
// ntfy://token@host/topic (custom server with bearer token)
// ntfy://host/topic?auth=BASE64 (custom server with base64-encoded bearer token)
// ntfy://host/topic?auth=TOKEN (raw token, e.g. tk_..., or base64-encoded "Bearer <token>")
// Query params: ?tags=ship,whale &title=Custom &priority=5
// ntfys:// variants for HTTPS
const isSecure = appriseUrl.startsWith('ntfys');
@@ -53,8 +80,7 @@ export async function sendNtfy(appriseUrl: string, payload: NotificationPayload)
}
if (!authHeader && queryAuth) {
const decoded = Buffer.from(queryAuth, 'base64').toString();
authHeader = decoded.startsWith('Bearer ') ? decoded : `Bearer ${decoded}`;
authHeader = resolveQueryAuth(queryAuth);
}
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
+53 -7
View File
@@ -28,6 +28,7 @@ import {
} from './host-path';
import { resolve } from 'node:path';
import { mkdir, chown, rm } from 'node:fs/promises';
import { resolveScannerOverrides } from '$lib/utils/scanner-overrides';
export type ScannerType = 'none' | 'grype' | 'trivy' | 'both';
@@ -164,24 +165,47 @@ export interface ScanProgress {
output?: string; // Line of scanner output
}
function parseDnsSetting(raw: string | null | undefined): string[] {
if (!raw) return [];
const trimmed = raw.trim();
if (!trimmed) return [];
// Tolerate either JSON-array storage (preferred) or comma-separated string
// (defensive — UI submits JSON, but legacy / hand-edited values may exist).
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
} catch {
return [];
}
}
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
}
// Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults)
export async function getGlobalScannerDefaults(): Promise<{
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
networkMode: string;
dns: string[];
}> {
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
const [grypeArgs, trivyArgs, grypeImage, trivyImage, networkMode, dns] = await Promise.all([
getSetting('default_grype_args'),
getSetting('default_trivy_args'),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
getSetting('default_trivy_image'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
]);
return {
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE,
networkMode: networkMode ?? '',
dns: parseDnsSetting(dns)
};
}
@@ -747,6 +771,19 @@ async function runScannerContainerCore(
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
}
// Apply user-configured overrides on top of auto-detection (#1219).
// Empty user settings → resolver returns the auto-detected values unchanged.
const userScannerSettings = await getGlobalScannerDefaults();
const overrides = resolveScannerOverrides(
{ networkMode: scannerNetworkMode, extraHosts: scannerExtraHosts },
{ networkMode: userScannerSettings.networkMode, dns: userScannerSettings.dns }
);
if (userScannerSettings.networkMode || userScannerSettings.dns.length > 0) {
console.log(
`[Scanner] User overrides applied — network=${overrides.networkMode ?? 'default'}, dns=${overrides.dns?.join(',') ?? 'default'}`
);
}
console.log(`[Scanner] Running ${scannerType} with cache mounted at ${basePath}`);
console.log(`[Scanner] Container command: ${cmd.join(' ')}`);
// Run the scanner container with a 10-minute timeout to prevent indefinite hangs
@@ -755,10 +792,11 @@ async function runScannerContainerCore(
cmd,
binds,
env: envVars,
extraHosts: scannerExtraHosts,
extraHosts: overrides.extraHosts,
name: `dockhand-${scannerType}-${Date.now()}`,
envId,
networkMode: scannerNetworkMode,
networkMode: overrides.networkMode,
dns: overrides.dns,
timeout: 600_000, // 10 minutes
onStderr: (data) => {
// Stream stderr lines for real-time progress output
@@ -1076,14 +1114,22 @@ async function getScannerVersion(
);
if (!hasImage) return null;
// Create temporary container to get version
// Create temporary container to get version. Apply user overrides so
// version probe works in environments where default networking fails
// (#1219 — host needs --network host for scanner images to resolve DNS).
const versionCmd = scannerType === 'grype' ? ['version'] : ['--version'];
const overrides = resolveScannerOverrides(
{},
{ networkMode: defaults.networkMode, dns: defaults.dns }
);
console.log(`[Scanner] Getting ${scannerType} version with cmd:`, versionCmd);
const { stdout, stderr } = await runContainer({
image: scannerImage,
cmd: versionCmd,
name: `dockhand-${scannerType}-version-${Date.now()}`,
envId
envId,
networkMode: overrides.networkMode,
dns: overrides.dns
});
console.log(`[Scanner] ${scannerType} version check result: stdout="${stdout.substring(0, 100)}", stderr="${stderr.substring(0, 100)}"`);
@@ -37,7 +37,7 @@ import {
} from '../../docker';
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
import { sendEventNotification } from '../../notifications';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
// =============================================================================
@@ -343,6 +343,19 @@ export async function runContainerUpdate(
const inspectData = await inspectContainer(container.id, envId) as any;
const imageNameFromConfig = inspectData.Config?.Image;
// Podman pod-infra containers may have an empty Config.Image — silently
// skip them before the "Could not determine image name" failure (#1221).
if (isPodmanInfraContainer(imageNameFromConfig, inspectData.Config?.Labels)) {
log(`Skipping Podman pod-infra container`);
await updateScheduleExecution(execution.id, {
status: 'skipped',
completedAt: new Date().toISOString(),
duration: Date.now() - startTime,
details: { reason: 'Podman pod-infra container' }
});
return;
}
if (!imageNameFromConfig) {
log(`Could not determine image name from container config`);
await updateScheduleExecution(execution.id, {
@@ -30,7 +30,7 @@ import {
} from '../../docker';
import { sendEventNotification } from '../../notifications';
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer, isPodmanInfraContainer } from './update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '../../container-labels';
import { recreateContainer } from './container-update';
@@ -108,9 +108,12 @@ export async function runEnvUpdateCheckJob(
// Get all containers in this environment, excluding ones hidden via
// dockhand.hidden=true (consistent with manual check-updates, #1083).
const allContainers = await listContainers(true, environmentId);
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
// Skip hidden + Podman pod-infra (#1083, #1221)
const containers = allContainers.filter(
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
);
const hiddenCount = allContainers.length - containers.length;
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden by label)` : ''}`);
await log(`Found ${containers.length} containers${hiddenCount ? ` (${hiddenCount} hidden/infra)` : ''}`);
const updatesAvailable: UpdateInfo[] = [];
let checkedCount = 0;
@@ -127,6 +127,22 @@ export function isSystemContainer(imageName: string): SystemContainerType | null
return null;
}
/**
* Podman creates a hidden "pod-infra" container for every pod it runs (#1221).
* The image (`localhost/podman-pause`) is generated locally and never published
* to any registry, so update checks always fail noisily. The label is the
* authoritative signal — but Podman may set Config.Image empty for these
* containers, so the name fallback catches that too.
*/
export function isPodmanInfraContainer(
imageName: string | undefined,
labels: Record<string, string> | undefined
): boolean {
if (labels?.['io.podman.annotations.infra'] === 'true') return true;
if (imageName && /podman-pause/i.test(imageName)) return true;
return false;
}
/**
* Combine multiple scan summaries by taking the maximum of each severity level.
*/
+1 -14
View File
@@ -2,6 +2,7 @@ import { validateSessionById, isAuthEnabled, SESSION_COOKIE } from './auth';
import { validateApiToken } from './api-tokens';
import { isEnterprise } from './license';
import { userHasAdminRole, userCanAccessEnvironment } from './db';
import { parseCookieHeader } from '$lib/utils/cookie-parse';
export interface WsUpgradeAuth {
userId: number;
@@ -10,20 +11,6 @@ export interface WsUpgradeAuth {
authDisabled: boolean;
}
function parseCookieHeader(header: string | undefined): Record<string, string> {
if (!header) return {};
const out: Record<string, string> = {};
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
let v = part.slice(eq + 1).trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (k) out[k] = decodeURIComponent(v);
}
return out;
}
type LowercasedHeaders = Record<string, string | string[] | undefined>;
function pickHeader(headers: LowercasedHeaders, name: string): string | undefined {
+98
View File
@@ -0,0 +1,98 @@
/**
* Session-scoped Dockhand self-update check.
*
* Fires once per browser session (first sidebar mount). The result is shared
* across every consumer (sidebar indicator, Settings → About) so we never
* hit the registry more than once unless the user clicks "Check now."
*
* Endpoint requires admin; non-admin users see {error, updateAvailable: false}
* and the consumers render nothing. No retry, no polling.
*/
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
export interface SelfUpdateState {
checked: boolean;
updateAvailable: boolean;
currentImage?: string;
currentDigest?: string;
registryDigest?: string;
/** Parsed semver like "1.0.36" — present when latestVersion came back from the server. */
latestVersion?: string;
/** Resolved newer image tag (registry/repo:newTag). */
newImage?: string;
}
const initial: SelfUpdateState = {
checked: false,
updateAvailable: false
};
function createStore() {
const { subscribe, set } = writable<SelfUpdateState>(initial);
let inFlight: Promise<void> | null = null;
let didCheck = false;
async function checkOnce(force = false): Promise<void> {
if (!browser) return;
if (inFlight) return inFlight;
if (didCheck && !force) return;
inFlight = (async () => {
try {
const res = await fetch('/api/self-update/check', {
headers: force ? { 'Cache-Control': 'no-cache' } : undefined
});
if (!res.ok) {
set({ ...initial, checked: true });
return;
}
const data = await res.json();
set({
checked: true,
updateAvailable: !!data.updateAvailable,
currentImage: data.currentImage,
currentDigest: data.currentDigest,
registryDigest: data.registryDigest,
latestVersion: data.latestVersion,
newImage: data.newImage
});
} catch {
set({ ...initial, checked: true });
} finally {
didCheck = true;
inFlight = null;
}
})();
return inFlight;
}
return {
subscribe,
checkOnce,
/** Force a fresh check (e.g. user clicked "Check now" in About). */
refresh: () => checkOnce(true),
/** Hand-set the result when a caller already fetched it (avoids
* a duplicate /api/self-update/check on the wire). */
setFromResponse: (data: {
updateAvailable?: boolean;
currentImage?: string;
currentDigest?: string;
registryDigest?: string;
latestVersion?: string;
newImage?: string;
}) =>
set({
checked: true,
updateAvailable: !!data.updateAvailable,
currentImage: data.currentImage,
currentDigest: data.currentDigest,
registryDigest: data.registryDigest,
latestVersion: data.latestVersion,
newImage: data.newImage
})
};
}
export const selfUpdate = createStore();
+51 -33
View File
@@ -1,5 +1,11 @@
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
import {
buildFormatters,
formatDatePartWith,
formatTimePartWith,
type DateTimeFormatters
} from '$lib/utils/date-format';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
@@ -42,6 +48,9 @@ export interface AppSettings {
honorProxyLabels: boolean;
showImageChangelogLinks: boolean;
protectScannerImages: boolean;
// 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
}
const DEFAULT_SETTINGS: AppSettings = {
@@ -78,6 +87,8 @@ const DEFAULT_SETTINGS: AppSettings = {
honorProxyLabels: true,
showImageChangelogLinks: true,
protectScannerImages: true,
defaultScannerNetworkMode: '',
defaultScannerDns: [],
defaultComposeTemplate: `version: "3.8"
services:
@@ -160,7 +171,9 @@ function createSettingsStore() {
labelFilterMode: settings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: settings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: settings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
protectScannerImages: settings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: settings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: Array.isArray(settings.defaultScannerDns) ? settings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
});
}
} catch {
@@ -214,7 +227,9 @@ function createSettingsStore() {
labelFilterMode: updatedSettings.labelFilterMode ?? DEFAULT_SETTINGS.labelFilterMode,
honorProxyLabels: updatedSettings.honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: updatedSettings.showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
protectScannerImages: updatedSettings.protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: updatedSettings.defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: Array.isArray(updatedSettings.defaultScannerDns) ? updatedSettings.defaultScannerDns : DEFAULT_SETTINGS.defaultScannerDns
});
}
} catch (error) {
@@ -297,6 +312,20 @@ function createSettingsStore() {
return newSettings;
});
},
setDefaultScannerNetworkMode: (value: string) => {
update((current) => {
const newSettings = { ...current, defaultScannerNetworkMode: value };
saveSettings({ defaultScannerNetworkMode: value });
return newSettings;
});
},
setDefaultScannerDns: (value: string[]) => {
update((current) => {
const newSettings = { ...current, defaultScannerDns: value };
saveSettings({ defaultScannerDns: value });
return newSettings;
});
},
setScheduleRetentionDays: (value: number) => {
update((current) => {
const newSettings = { ...current, scheduleRetentionDays: value };
@@ -492,12 +521,30 @@ export const appSettings = createSettingsStore();
// Cache current settings for synchronous access (updated reactively)
let cachedTimeFormat: TimeFormat = DEFAULT_SETTINGS.timeFormat;
let cachedDateFormat: DateFormat = DEFAULT_SETTINGS.dateFormat;
let cachedDefaultTimezone: string = DEFAULT_SETTINGS.defaultTimezone;
// Intl.DateTimeFormat construction is expensive; build once per timezone
// change and reuse for every format call (activity log can render hundreds of
// rows). Built lazily on first format call so the heavy work runs only when
// needed and after the store subscription has populated cachedDefaultTimezone.
let formatters: DateTimeFormatters | null = null;
function getFormatters(): DateTimeFormatters {
if (formatters === null) {
formatters = buildFormatters(cachedDefaultTimezone);
}
return formatters;
}
// Subscribe once to keep cache updated
if (browser) {
appSettings.subscribe((s) => {
cachedTimeFormat = s.timeFormat;
cachedDateFormat = s.dateFormat;
if (s.defaultTimezone !== cachedDefaultTimezone) {
cachedDefaultTimezone = s.defaultTimezone;
formatters = buildFormatters(cachedDefaultTimezone);
}
});
}
@@ -506,21 +553,7 @@ if (browser) {
* This is a low-level helper - prefer formatDateTime for most uses.
*/
function formatDatePart(d: Date): string {
const day = d.getDate().toString().padStart(2, '0');
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const year = d.getFullYear();
switch (cachedDateFormat) {
case 'MM/DD/YYYY':
return `${month}/${day}/${year}`;
case 'DD/MM/YYYY':
return `${day}/${month}/${year}`;
case 'YYYY-MM-DD':
return `${year}-${month}-${day}`;
case 'DD.MM.YYYY':
default:
return `${day}.${month}.${year}`;
}
return formatDatePartWith(d, getFormatters().date, cachedDateFormat);
}
/**
@@ -528,22 +561,7 @@ function formatDatePart(d: Date): string {
* This is a low-level helper - prefer formatDateTime for most uses.
*/
function formatTimePart(d: Date, includeSeconds = false): string {
const hours = d.getHours();
const minutes = d.getMinutes().toString().padStart(2, '0');
const seconds = d.getSeconds().toString().padStart(2, '0');
if (cachedTimeFormat === '12h') {
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const ampm = hours >= 12 ? 'PM' : 'AM';
return includeSeconds
? `${hour12}:${minutes}:${seconds} ${ampm}`
: `${hour12}:${minutes} ${ampm}`;
} else {
const hour24 = hours.toString().padStart(2, '0');
return includeSeconds
? `${hour24}:${minutes}:${seconds}`
: `${hour24}:${minutes}`;
}
return formatTimePartWith(d, getFormatters().time, cachedTimeFormat, includeSeconds);
}
/**
+31 -2
View File
@@ -11,6 +11,17 @@ import { writable, get } from 'svelte/store';
import { getFont, getMonospaceFont, type FontMeta } from '$lib/themes';
export type FontSize = 'xsmall' | 'small' | 'normal' | 'medium' | 'large' | 'xlarge';
export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge';
// Pixel values for each action icon size (#1072).
// Normal = 12px matches the historical Tailwind w-3 default — picking
// Normal is a no-op for existing users.
export const ACTION_ICON_SIZE_PX: Record<ActionIconSize, number> = {
small: 10,
normal: 12,
large: 16,
xlarge: 20
};
export interface ThemePreferences {
lightTheme: string;
@@ -21,6 +32,8 @@ export interface ThemePreferences {
terminalFont: string;
editorFont: string;
animateIcons: boolean;
coloredActionButtons: boolean;
actionIconSize: ActionIconSize;
}
const STORAGE_KEY = 'dockhand-theme';
@@ -33,7 +46,9 @@ const defaultPrefs: ThemePreferences = {
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono',
animateIcons: true
animateIcons: true,
coloredActionButtons: false,
actionIconSize: 'normal'
};
// Font size scale mapping
@@ -107,7 +122,10 @@ function createThemeStore() {
animateIcons:
data.animateIcons === undefined && data.animate_icons === undefined
? true
: !!(data.animateIcons ?? data.animate_icons)
: !!(data.animateIcons ?? data.animate_icons),
// Default OFF (#1072)
coloredActionButtons: !!(data.coloredActionButtons ?? data.colored_action_buttons ?? false),
actionIconSize: (data.actionIconSize || data.action_icon_size || 'normal') as ActionIconSize
};
set(prefs);
saveToStorage(prefs);
@@ -208,6 +226,17 @@ export function applyTheme(prefs: ThemePreferences) {
// Apply icon animation toggle (#1169) — single class on <html> drives a CSS rule in app.css
document.documentElement.classList.toggle('no-icon-animation', !prefs.animateIcons);
// Apply colored grid action buttons + action icon size (#1072)
applyActionButtonStyles(prefs.coloredActionButtons, prefs.actionIconSize);
}
// Apply colored-action class and action icon size CSS var
function applyActionButtonStyles(colored: boolean, size: ActionIconSize) {
if (typeof document === 'undefined') return;
document.documentElement.classList.toggle('colored-actions', colored);
const px = ACTION_ICON_SIZE_PX[size] ?? ACTION_ICON_SIZE_PX.small;
document.documentElement.style.setProperty('--action-icon-size', `${px}px`);
}
// Apply font to document
+32
View File
@@ -0,0 +1,32 @@
/**
* Safe cookie header parsing for WebSocket upgrades.
*
* Extracted from ws-auth so it can be unit-tested without pulling in the
* DB/SvelteKit graph (#1224).
*/
export function safeDecode(v: string): string {
// Browsers freely send cookies with stray `%` from third-party trackers and
// legacy code. decodeURIComponent throws URIError on `%` not followed by two
// hex digits, which would crash the entire WS upgrade — including the
// session cookie we actually care about. Fall back to the raw value (#1224).
try {
return decodeURIComponent(v);
} catch {
return v;
}
}
export function parseCookieHeader(header: string | undefined): Record<string, string> {
if (!header) return {};
const out: Record<string, string> = {};
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const k = part.slice(0, eq).trim();
let v = part.slice(eq + 1).trim();
if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1);
if (k) out[k] = safeDecode(v);
}
return out;
}
+84
View File
@@ -0,0 +1,84 @@
/**
* Pure date/time formatting helpers — no SvelteKit / store imports.
*
* settings.ts wraps these with cached preferences and a reactive subscription;
* keeping the implementation here lets the formatters be unit-tested without
* dragging in $app/environment or svelte/store.
*/
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
export interface DateTimeFormatters {
date: Intl.DateTimeFormat;
time: Intl.DateTimeFormat;
}
// Intl.DateTimeFormat construction is expensive; the caller caches one pair per
// timezone and rebuilds only when the timezone setting changes.
export function buildFormatters(timeZone: string | undefined): DateTimeFormatters {
const tz = timeZone || undefined;
return {
date: new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit'
}),
time: new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
};
}
function pickPart(parts: Intl.DateTimeFormatPart[], type: Intl.DateTimeFormatPartTypes): string {
return parts.find((p) => p.type === type)?.value ?? '';
}
export function formatDatePartWith(d: Date, dateFormatter: Intl.DateTimeFormat, dateFormat: DateFormat): string {
const parts = dateFormatter.formatToParts(d);
const day = pickPart(parts, 'day');
const month = pickPart(parts, 'month');
const year = pickPart(parts, 'year');
switch (dateFormat) {
case 'MM/DD/YYYY':
return `${month}/${day}/${year}`;
case 'DD/MM/YYYY':
return `${day}/${month}/${year}`;
case 'YYYY-MM-DD':
return `${year}-${month}-${day}`;
case 'DD.MM.YYYY':
default:
return `${day}.${month}.${year}`;
}
}
export function formatTimePartWith(
d: Date,
timeFormatter: Intl.DateTimeFormat,
timeFormat: TimeFormat,
includeSeconds = false
): string {
const parts = timeFormatter.formatToParts(d);
const hours = parseInt(pickPart(parts, 'hour'), 10);
const minutes = pickPart(parts, 'minute');
const seconds = pickPart(parts, 'second');
if (timeFormat === '12h') {
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
const ampm = hours >= 12 ? 'PM' : 'AM';
return includeSeconds
? `${hour12}:${minutes}:${seconds} ${ampm}`
: `${hour12}:${minutes} ${ampm}`;
} else {
const hour24 = hours.toString().padStart(2, '0');
return includeSeconds
? `${hour24}:${minutes}:${seconds}`
: `${hour24}:${minutes}`;
}
}
+5 -3
View File
@@ -11,12 +11,14 @@
* cause breakage are rejected.
*/
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @
// Allowed characters: ASCII letters/digits + _ . - ( ) space + @ '
// First char: any allowed except space (no leading whitespace).
// Last char: any allowed except space and dot (no trailing whitespace,
// no trailing dot to avoid Windows-style "hidden" trailing-dot issues).
// Length: 1..64.
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-() +@](?:[A-Za-z0-9_.\-() +@]{0,62}[A-Za-z0-9_\-)+@])?$/;
// Apostrophe (#1228): safe — env.name only lands in path.join() and
// array-form spawn(), never in a shell-interpolated string.
export const ENV_NAME_RE = /^(?! )[A-Za-z0-9_.\-()' +@](?:[A-Za-z0-9_.\-()' +@]{0,62}[A-Za-z0-9_\-)'+@])?$/;
export const ENV_NAME_MAX_LENGTH = 64;
@@ -39,7 +41,7 @@ export function validateEnvName(name: unknown): ValidationResult {
return {
ok: false,
reason:
'Name may contain letters, digits, spaces, and any of - _ . ( ) + @ (no leading/trailing whitespace, no trailing dot, no slashes, no wildcards)'
'Name can\'t contain slashes, wildcards, or other shell-special characters, and can\'t start with whitespace or end with whitespace or a dot'
};
}
return { ok: true };
+3
View File
@@ -23,6 +23,9 @@
*
* dockhand.url labels override this — Pangolin extraction is a fallback,
* never a winner over an explicit user-provided URL.
*
* Earlier v1.0.34 used `pangolin.proxy-resources.*` — that label was never
* recognised by Pangolin itself and is no longer accepted here.
*/
export interface PangolinUrl {
url: string;
+22
View File
@@ -0,0 +1,22 @@
/**
* Path-relative redirect target validation.
*
* Returns true only for targets that are path-relative URLs ('/path?query#hash').
* Rejects absolute URLs ('https://...', 'http://...'), protocol-relative URLs
* ('//example.com'), backslash-prefixed forms ('\\example.com' — some browsers
* normalize these), and non-string inputs.
*/
export function isSafeRedirect(target: unknown): target is string {
if (typeof target !== 'string' || target.length === 0) return false;
if (!target.startsWith('/')) return false;
if (target.startsWith('//') || target.startsWith('/\\')) return false;
return true;
}
/**
* Return the input when it passes isSafeRedirect, otherwise '/'. Use this on
* any post-login / post-callback redirect target derived from user input.
*/
export function safeRedirectOrRoot(target: unknown): string {
return isSafeRedirect(target) ? target : '/';
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Pure scanner override resolver (#1219).
*
* Decides which networkMode + DNS values to pass to the scanner container,
* given (auto-detected, user-settings) inputs. Kept SvelteKit/server-free so
* the unit tests can import it without triggering DB initialisation.
*/
export function resolveScannerOverrides(
autoDetected: { networkMode?: string; extraHosts?: string[] },
userSettings: { networkMode?: string; dns?: string[] }
): { networkMode?: string; dns?: string[]; extraHosts?: string[] } {
const userNet = userSettings.networkMode?.trim();
const networkMode = userNet ? userNet : autoDetected.networkMode || undefined;
const cleanedDns = (userSettings.dns ?? [])
.map((d) => d.trim())
.filter((d) => d.length > 0);
const dns = cleanedDns.length > 0 ? cleanedDns : undefined;
const extraHosts =
autoDetected.extraHosts && autoDetected.extraHosts.length > 0
? autoDetected.extraHosts
: undefined;
return { networkMode, dns, extraHosts };
}
+1 -1
View File
@@ -852,7 +852,7 @@
{:else if column.id === 'actions'}
<div class="flex items-center justify-end">
<Button variant="ghost" size="icon" class="h-6 w-6" onclick={(e) => { e.stopPropagation(); showDetails(event); }}>
<Eye class="w-3.5 h-3.5" />
<Eye class="grid-action-icon grid-action-info" />
</Button>
</div>
{/if}
@@ -2,6 +2,7 @@ import { json, redirect } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import { buildOidcAuthorizationUrl, isAuthEnabled } from '$lib/server/auth';
import { getOidcConfig } from '$lib/server/db';
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
export const GET: RequestHandler = async ({ params, url }) => {
@@ -15,8 +16,8 @@ export const GET: RequestHandler = async ({ params, url }) => {
return json({ error: 'Invalid configuration ID' }, { status: 400 });
}
// Get redirect URL from query params
const redirectUrl = url.searchParams.get('redirect') || '/';
// Get redirect URL from query params (validated path-relative only)
const redirectUrl = safeRedirectOrRoot(url.searchParams.get('redirect'));
try {
const config = await getOidcConfig(id);
@@ -56,7 +57,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
try {
const body = await request.json().catch(() => ({}));
const redirectUrl = body.redirect || '/';
const redirectUrl = safeRedirectOrRoot(body.redirect);
const config = await getOidcConfig(id);
if (!config || !config.enabled) {
+3 -2
View File
@@ -3,6 +3,7 @@ import type { RequestHandler } from '@sveltejs/kit';
import { handleOidcCallback, createUserSession, isAuthEnabled } from '$lib/server/auth';
import { auditAuth } from '$lib/server/audit';
import { getClientIp } from '$lib/server/client-ip';
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
// GET /api/auth/oidc/callback - Handle OIDC callback from IdP
export const GET: RequestHandler = async (event) => {
@@ -54,8 +55,8 @@ export const GET: RequestHandler = async (event) => {
providerName: result.providerName
});
// Redirect to the original destination or home
const redirectUrl = result.redirectUrl || '/';
// Redirect to the original destination or home (validated path-relative)
const redirectUrl = safeRedirectOrRoot(result.redirectUrl);
throw redirect(302, redirectUrl);
} catch (error: any) {
// Re-throw redirect
@@ -9,12 +9,20 @@ import {
import { registerSchedule, unregisterSchedule } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
const permDenied = await auth.requirePermission('schedules', 'view');
if (permDenied) return permDenied;
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const envDenied = await auth.requireEnvAccess(envId);
if (envDenied) return envDenied;
const setting = await getAutoUpdateSetting(containerName, envId);
if (!setting) {
@@ -41,15 +49,18 @@ export const GET: RequestHandler = async ({ params, url }) => {
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const permDenied = await auth.requirePermission('schedules', 'edit');
if (permDenied) return permDenied;
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const envDenied = await auth.requireEnvAccess(envId);
if (envDenied) return envDenied;
const body = await request.json();
// Accept both camelCase and snake_case for backward compatibility
const enabled = body.enabled;
@@ -109,15 +120,18 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const permDenied = await auth.requirePermission('schedules', 'edit');
if (permDenied) return permDenied;
try {
const containerName = decodeURIComponent(params.containerName);
const envIdParam = url.searchParams.get('env');
const envId = envIdParam ? parseInt(envIdParam) : undefined;
const envDenied = await auth.requireEnvAccess(envId);
if (envDenied) return envDenied;
// Get the setting ID before deleting
const setting = await getAutoUpdateSetting(containerName, envId);
const settingId = setting?.id;
@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isSystemContainer, isPodmanInfraContainer } from '$lib/server/scheduler/tasks/update-utils';
import { isUpdateDisabledByLabel, isHiddenByLabel } from '$lib/server/container-labels';
import { createJobResponse } from '$lib/server/sse';
@@ -42,9 +42,12 @@ export const POST: RequestHandler = async ({ url, cookies, request }) => {
}
const allContainers = await listContainers(true, envIdNum);
// Containers labeled dockhand.hidden=true are excluded from update checks —
// they're invisible to the user, so we won't alert on updates for them either (#1083).
const containers = allContainers.filter(c => !isHiddenByLabel(c.labels));
// Skip:
// - dockhand.hidden=true (invisible to user, so no update alerts) (#1083)
// - Podman pod-infra containers (localhost/podman-pause, never published) (#1221)
const containers = allContainers.filter(
(c) => !isHiddenByLabel(c.labels) && !isPodmanInfraContainer(c.image, c.labels)
);
send('progress', { checked: 0, total: containers.length });
+6 -4
View File
@@ -258,11 +258,13 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
envStats.stacks.partial = stacks.filter((s: any) => s.status === 'partial').length;
envStats.stacks.stopped = stacks.filter((s: any) => s.status === 'stopped').length;
// Get latest metrics, event stats, and pending updates in parallel
// Get latest metrics, event stats, and pending updates in parallel.
// Each call has its own catch so one failed DB query (e.g. a corrupt
// container_events index, #1210) doesn't poison the whole tile.
const [latestMetrics, eventStats, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getPendingContainerUpdates(env.id)
getLatestHostMetrics(env.id).catch(() => null),
getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
getPendingContainerUpdates(env.id).catch(() => [])
]);
if (latestMetrics) {
@@ -195,13 +195,15 @@ async function getEnvironmentStatsProgressive(
// Get all database stats in parallel for better performance
// NOTE: We do NOT block on getDockerInfo() here - slow environments would block all others
// Instead, we determine online status from whether listContainers succeeds
// Instead, we determine online status from whether listContainers succeeds.
// Each call has its own catch so one failed DB query (e.g. a corrupt
// container_events index, #1210) doesn't poison the whole tile.
const [latestMetrics, eventStats, recentEventsResult, metricsHistory, pendingUpdates] = await Promise.all([
getLatestHostMetrics(env.id),
getContainerEventStats(env.id),
getContainerEvents({ environmentId: env.id, limit: 10 }),
getHostMetrics(metricsPointCount, env.id),
getPendingContainerUpdates(env.id)
getLatestHostMetrics(env.id).catch(() => null),
getContainerEventStats(env.id).catch(() => ({ total: 0, today: 0, byAction: {} })),
getContainerEvents({ environmentId: env.id, limit: 10 }).catch(() => ({ events: [], total: 0, limit: 10, offset: 0 })),
getHostMetrics(metricsPointCount, env.id).catch(() => []),
getPendingContainerUpdates(env.id).catch(() => [])
]);
if (latestMetrics) {
+10 -1
View File
@@ -1,11 +1,20 @@
import type { RequestHandler } from './$types';
import { getDockerEvents, EnvironmentNotFoundError } from '$lib/server/docker';
import { getEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
const permDenied = await auth.requirePermission('activity', 'view');
if (permDenied) return permDenied;
export const GET: RequestHandler = async ({ url }) => {
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
const envDenied = await auth.requireEnvAccess(envIdNum);
if (envDenied) return envDenied;
// Early return if no environment specified
if (!envIdNum) {
return new Response(
+26 -21
View File
@@ -1,38 +1,43 @@
/**
* Database Health Check Endpoint
*
* Returns detailed information about the database schema state,
* including migration status, table existence, and connection info.
* Public endpoint suitable for external monitoring. The public payload reports
* enough detail to detect schema drift and table loss without exposing
* connection details (host, port, db name, user) or the running migration tag.
*
* Authenticated callers with settings:view get the full payload — connection
* string (password masked) and schema version included — which is useful for
* operators debugging from the admin UI.
*
* GET /api/health/database
*
* Response:
* {
* healthy: boolean,
* database: 'sqlite' | 'postgresql',
* connection: string,
* migrationsTable: boolean,
* appliedMigrations: number,
* pendingMigrations: number,
* schemaVersion: string | null,
* tables: {
* expected: number,
* found: number,
* missing: string[]
* },
* timestamp: string
* }
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { checkSchemaHealth } from '$lib/server/db/drizzle';
import { authorize } from '$lib/server/authorize';
export const GET: RequestHandler = async () => {
export const GET: RequestHandler = async ({ cookies }) => {
try {
const health = await checkSchemaHealth();
return json(health, {
const auth = await authorize(cookies);
const showFullDetail = !auth.authEnabled
|| (auth.isAuthenticated && await auth.can('settings', 'view'));
const payload = showFullDetail
? health
: {
healthy: health.healthy,
database: health.database,
migrationsTable: health.migrationsTable,
appliedMigrations: health.appliedMigrations,
pendingMigrations: health.pendingMigrations,
tables: health.tables,
timestamp: health.timestamp
};
return json(payload, {
status: health.healthy ? 200 : 503,
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
+17 -1
View File
@@ -45,7 +45,9 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
const validTerminalFontIds = monospaceFonts.map(f => f.id);
const validFontSizes = ['xsmall', 'small', 'normal', 'medium', 'large', 'xlarge'];
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean } = {};
const validActionIconSizes = ['small', 'normal', 'large', 'xlarge'];
const updates: { lightTheme?: string; darkTheme?: string; font?: string; fontSize?: string; gridFontSize?: string; terminalFont?: string; editorFont?: string; animateIcons?: boolean; coloredActionButtons?: boolean; actionIconSize?: string } = {};
if (data.lightTheme !== undefined) {
if (!validLightThemeIds.includes(data.lightTheme)) {
@@ -103,6 +105,20 @@ export const PUT: RequestHandler = async ({ request, cookies }) => {
updates.animateIcons = data.animateIcons;
}
if (data.coloredActionButtons !== undefined) {
if (typeof data.coloredActionButtons !== 'boolean') {
return json({ error: 'Invalid coloredActionButtons' }, { status: 400 });
}
updates.coloredActionButtons = data.coloredActionButtons;
}
if (data.actionIconSize !== undefined) {
if (!validActionIconSizes.includes(data.actionIconSize)) {
return json({ error: 'Invalid actionIconSize' }, { status: 400 });
}
updates.actionIconSize = data.actionIconSize;
}
await setUserThemePreferences(currentUser.id, updates);
// Return updated preferences
@@ -1,8 +1,14 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { setDefaultRegistry, getRegistry } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
export const POST: RequestHandler = async ({ params }) => {
try {
const id = parseInt(params.id);
if (isNaN(id)) {
+7 -1
View File
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getRegistry } from '$lib/server/db';
import { getRegistryAuth } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
function isDockerHub(url: string): boolean {
const lower = url.toLowerCase();
@@ -18,7 +19,12 @@ const MANIFEST_TYPES = [
'application/vnd.oci.image.index.v1+json'
];
export const DELETE: RequestHandler = async ({ url }) => {
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('settings', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const registryId = url.searchParams.get('registry');
const imageName = url.searchParams.get('image');
+19 -3
View File
@@ -22,6 +22,7 @@ import {
} from '$lib/server/db';
import { getNextRun, getSystemSchedules } from '$lib/server/scheduler';
import { getGlobalScannerDefaults, getScannerSettingsWithDefaults } from '$lib/server/scanner';
import { authorize } from '$lib/server/authorize';
export interface ScheduleInfo {
id: number;
@@ -43,7 +44,12 @@ export interface ScheduleInfo {
vulnerabilityCriteria?: VulnerabilityCriteria | null;
}
export const GET: RequestHandler = async () => {
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
const permDenied = await auth.requirePermission('schedules', 'view');
if (permDenied) return permDenied;
try {
const schedules: ScheduleInfo[] = [];
@@ -233,13 +239,23 @@ export const GET: RequestHandler = async () => {
);
schedules.push(...sysSchedules);
// Filter by per-env access (enterprise): caller sees only schedules
// for envs they can access, plus all system schedules (env null).
// Free edition / admin: getAccessibleEnvironmentIds returns null,
// no filtering applied.
const accessibleEnvIds = await auth.getAccessibleEnvironmentIds();
const filtered = accessibleEnvIds === null
? schedules
: schedules.filter(s =>
s.environmentId === null || accessibleEnvIds.includes(s.environmentId));
// Sort: system jobs last, then by name
schedules.sort((a, b) => {
filtered.sort((a, b) => {
if (a.isSystem !== b.isSystem) return a.isSystem ? 1 : -1;
return a.name.localeCompare(b.name);
});
return json({ schedules });
return json({ schedules: filtered });
} catch (error: any) {
console.error('Failed to get schedules:', error);
return json({ error: error.message }, { status: 500 });
@@ -8,6 +8,7 @@ import type { RequestHandler } from './$types';
import {
getAutoUpdateSettingById,
deleteAutoUpdateSchedule,
getGitStack,
updateGitStack,
deleteEnvUpdateCheckSettings,
deleteImagePruneSettings
@@ -17,9 +18,9 @@ import { authorize } from '$lib/server/authorize';
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const permDenied = await auth.requirePermission('schedules', 'edit');
if (permDenied) return permDenied;
try {
const { type, id } = params;
@@ -33,6 +34,8 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
// Hard delete container schedule
const schedule = await getAutoUpdateSettingById(scheduleId);
if (schedule) {
const envDenied = await auth.requireEnvAccess(schedule.environmentId);
if (envDenied) return envDenied;
await deleteAutoUpdateSchedule(schedule.containerName, schedule.environmentId ?? undefined);
// Unregister from croner
unregisterSchedule(scheduleId, 'container_update');
@@ -40,6 +43,12 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
return json({ success: true });
} else if (type === 'git_stack_sync') {
const stack = await getGitStack(scheduleId);
if (!stack) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
const envDenied = await auth.requireEnvAccess(stack.environmentId);
if (envDenied) return envDenied;
// Disable auto-update for git stack (don't delete the stack itself)
await updateGitStack(scheduleId, {
autoUpdate: false,
@@ -52,12 +61,16 @@ export const DELETE: RequestHandler = async ({ params, cookies }) => {
} else if (type === 'env_update_check') {
// Delete env update check settings (scheduleId is environmentId)
const envDenied = await auth.requireEnvAccess(scheduleId);
if (envDenied) return envDenied;
await deleteEnvUpdateCheckSettings(scheduleId);
unregisterSchedule(scheduleId, 'env_update_check');
return json({ success: true });
} else if (type === 'image_prune') {
// Delete image prune settings (scheduleId is environmentId)
const envDenied = await auth.requireEnvAccess(scheduleId);
if (envDenied) return envDenied;
await deleteImagePruneSettings(scheduleId);
unregisterSchedule(scheduleId, 'image_prune');
return json({ success: true });
@@ -12,12 +12,13 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { triggerContainerUpdate, triggerGitStackSync, triggerSystemJob, triggerEnvUpdateCheck, triggerImagePrune } from '$lib/server/scheduler';
import { authorize } from '$lib/server/authorize';
import { getAutoUpdateSettingById, getGitStack } from '$lib/server/db';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'run')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const permDenied = await auth.requirePermission('schedules', 'run');
if (permDenied) return permDenied;
try {
const { type, id } = params;
@@ -27,6 +28,37 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ error: 'Invalid schedule ID' }, { status: 400 });
}
// Resolve schedule → environmentId so we can enforce per-env access
// before triggering. System schedules (env null) are gated only by
// the global schedules:run check above.
let scheduleEnvId: number | null = null;
switch (type) {
case 'container_update': {
const setting = await getAutoUpdateSettingById(scheduleId);
if (!setting) return json({ error: 'Schedule not found' }, { status: 404 });
scheduleEnvId = setting.environmentId;
break;
}
case 'git_stack_sync': {
const stack = await getGitStack(scheduleId);
if (!stack) return json({ error: 'Schedule not found' }, { status: 404 });
scheduleEnvId = stack.environmentId;
break;
}
case 'env_update_check':
case 'image_prune':
scheduleEnvId = scheduleId;
break;
case 'system_cleanup':
scheduleEnvId = null;
break;
default:
return json({ error: 'Invalid schedule type' }, { status: 400 });
}
const envDenied = await auth.requireEnvAccess(scheduleEnvId);
if (envDenied) return envDenied;
let result: { success: boolean; executionId?: number; error?: string };
switch (type) {
@@ -46,6 +78,7 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
result = await triggerImagePrune(scheduleId);
break;
default:
// Unreachable — validated in the resolution switch above.
return json({ error: 'Invalid schedule type' }, { status: 400 });
}
@@ -11,9 +11,9 @@ import { authorize } from '$lib/server/authorize';
export const POST: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('schedules', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const permDenied = await auth.requirePermission('schedules', 'edit');
if (permDenied) return permDenied;
try {
const { type, id } = params;
@@ -28,6 +28,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
if (!setting) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
const envDenied = await auth.requireEnvAccess(setting.environmentId);
if (envDenied) return envDenied;
const newEnabled = !setting.enabled;
await updateAutoUpdateSettingById(scheduleId, {
@@ -47,6 +49,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
if (!stack) {
return json({ error: 'Schedule not found' }, { status: 404 });
}
const envDenied = await auth.requireEnvAccess(stack.environmentId);
if (envDenied) return envDenied;
const newEnabled = !stack.autoUpdate;
await updateGitStack(scheduleId, {
@@ -63,6 +67,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ success: true, enabled: newEnabled });
} else if (type === 'env_update_check') {
// scheduleId is environmentId for env update check
const envDenied = await auth.requireEnvAccess(scheduleId);
if (envDenied) return envDenied;
const config = await getEnvUpdateCheckSettings(scheduleId);
if (!config) {
return json({ error: 'Schedule not found' }, { status: 404 });
@@ -84,6 +90,8 @@ export const POST: RequestHandler = async ({ params, cookies }) => {
return json({ success: true, enabled: newEnabled });
} else if (type === 'image_prune') {
// scheduleId is environmentId for image prune
const envDenied = await auth.requireEnvAccess(scheduleId);
if (envDenied) return envDenied;
const config = await getImagePruneSettings(scheduleId);
if (!config) {
return json({ error: 'Schedule not found' }, { status: 404 });
+81 -7
View File
@@ -40,11 +40,16 @@ 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' | 'raw';
export type EventCollectionMode = 'stream' | 'poll';
export type ActionIconSize = 'small' | 'normal' | 'large' | 'xlarge';
const VALID_ACTION_ICON_SIZES: ActionIconSize[] = ['small', 'normal', 'large', 'xlarge'];
export interface GeneralSettings {
confirmDestructive: boolean;
showStoppedContainers: boolean;
highlightUpdates: boolean;
coloredActionButtons: boolean;
actionIconSize: ActionIconSize;
timeFormat: TimeFormat;
dateFormat: DateFormat;
downloadFormat: DownloadFormat;
@@ -102,12 +107,17 @@ export interface GeneralSettings {
animateIcons: boolean;
// Skip Dockhand's scanner images (grype, trivy) during 'prune all unused' (#625)
protectScannerImages: boolean;
// Scanner Advanced settings (#1219). Empty = use auto-detection.
defaultScannerNetworkMode: string;
defaultScannerDns: string[];
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled' | 'scannerCleanupCron' | 'scannerCleanupEnabled'> = {
confirmDestructive: true,
showStoppedContainers: true,
highlightUpdates: true,
coloredActionButtons: false,
actionIconSize: 'normal' as const,
timeFormat: '24h',
dateFormat: 'DD.MM.YYYY',
downloadFormat: 'tar',
@@ -138,6 +148,8 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
showImageChangelogLinks: true,
animateIcons: true,
protectScannerImages: true,
defaultScannerNetworkMode: '',
defaultScannerDns: [],
defaultComposeTemplate: `version: "3.8"
services:
@@ -167,6 +179,24 @@ const VALID_EDITOR_FONTS = VALID_TERMINAL_FONTS;
const VALID_DATE_FORMATS: DateFormat[] = ['MM/DD/YYYY', 'DD/MM/YYYY', 'YYYY-MM-DD', 'DD.MM.YYYY'];
// Scanner DNS is stored as a JSON-stringified array in the settings KV store
// (consistent with externalStackPaths). Tolerate the legacy/hand-edited
// comma-separated form too.
function parseScannerDnsStorage(raw: string | null | undefined): string[] {
if (!raw) return [];
const trimmed = raw.trim();
if (!trimmed) return [];
if (trimmed.startsWith('[')) {
try {
const parsed = JSON.parse(trimmed);
return Array.isArray(parsed) ? parsed.filter((v) => typeof v === 'string') : [];
} catch {
return [];
}
}
return trimmed.split(',').map((s) => s.trim()).filter(Boolean);
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
// UI preferences (time format, date format) should be available to all authenticated users
@@ -181,6 +211,8 @@ export const GET: RequestHandler = async ({ cookies }) => {
confirmDestructive,
showStoppedContainers,
highlightUpdates,
coloredActionButtons,
actionIconSize,
timeFormat,
dateFormat,
downloadFormat,
@@ -219,11 +251,15 @@ export const GET: RequestHandler = async ({ cookies }) => {
honorProxyLabels,
showImageChangelogLinks,
animateIcons,
protectScannerImages
protectScannerImages,
defaultScannerNetworkMode,
defaultScannerDnsRaw
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
getSetting('highlight_updates'),
getSetting('colored_action_buttons'),
getSetting('action_icon_size'),
getSetting('time_format'),
getSetting('date_format'),
getSetting('download_format'),
@@ -262,13 +298,17 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons'),
getSetting('protect_scanner_images')
getSetting('protect_scanner_images'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
]);
const settings: GeneralSettings = {
confirmDestructive: confirmDestructive ?? DEFAULT_SETTINGS.confirmDestructive,
showStoppedContainers: showStoppedContainers ?? DEFAULT_SETTINGS.showStoppedContainers,
highlightUpdates: highlightUpdates ?? DEFAULT_SETTINGS.highlightUpdates,
coloredActionButtons: coloredActionButtons ?? DEFAULT_SETTINGS.coloredActionButtons,
actionIconSize: (VALID_ACTION_ICON_SIZES.includes(actionIconSize as ActionIconSize) ? actionIconSize : DEFAULT_SETTINGS.actionIconSize) as ActionIconSize,
timeFormat: timeFormat ?? DEFAULT_SETTINGS.timeFormat,
dateFormat: dateFormat ?? DEFAULT_SETTINGS.dateFormat,
downloadFormat: downloadFormat ?? DEFAULT_SETTINGS.downloadFormat,
@@ -309,7 +349,9 @@ export const GET: RequestHandler = async ({ cookies }) => {
honorProxyLabels: honorProxyLabels ?? DEFAULT_SETTINGS.honorProxyLabels,
showImageChangelogLinks: showImageChangelogLinks ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIcons ?? DEFAULT_SETTINGS.animateIcons,
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages
protectScannerImages: protectScannerImages ?? DEFAULT_SETTINGS.protectScannerImages,
defaultScannerNetworkMode: defaultScannerNetworkMode ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRaw)
};
return json(settings);
@@ -327,7 +369,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, protectScannerImages } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, coloredActionButtons, actionIconSize, 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, defaultScannerNetworkMode, defaultScannerDns } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -338,6 +380,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (highlightUpdates !== undefined) {
await setSetting('highlight_updates', highlightUpdates);
}
if (coloredActionButtons !== undefined) {
await setSetting('colored_action_buttons', coloredActionButtons);
}
if (actionIconSize !== undefined && VALID_ACTION_ICON_SIZES.includes(actionIconSize)) {
await setSetting('action_icon_size', actionIconSize);
}
if (timeFormat !== undefined && (timeFormat === '12h' || timeFormat === '24h')) {
await setSetting('time_format', timeFormat);
}
@@ -479,12 +527,28 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (protectScannerImages !== undefined && typeof protectScannerImages === 'boolean') {
await setSetting('protect_scanner_images', protectScannerImages);
}
if (defaultScannerNetworkMode !== undefined && typeof defaultScannerNetworkMode === 'string') {
// Free-form network mode (host / bridge / none / custom-network-name / '').
// Trim and cap length to avoid pathological values from a corrupted UI.
const trimmed = defaultScannerNetworkMode.trim().slice(0, 200);
await setSetting('default_scanner_network_mode', trimmed);
}
if (defaultScannerDns !== undefined && Array.isArray(defaultScannerDns)) {
const cleaned = defaultScannerDns
.filter((d) => typeof d === 'string')
.map((d: string) => d.trim())
.filter((d) => d.length > 0)
.slice(0, 10); // sane upper bound; Docker accepts more but no one needs it
await setSetting('default_scanner_dns', JSON.stringify(cleaned));
}
// Fetch all settings in parallel for the response
const [
confirmDestructiveVal,
showStoppedContainersVal,
highlightUpdatesVal,
coloredActionButtonsVal,
actionIconSizeVal,
timeFormatVal,
dateFormatVal,
downloadFormatVal,
@@ -523,11 +587,15 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
honorProxyLabelsVal,
showImageChangelogLinksVal,
animateIconsVal,
protectScannerImagesVal
protectScannerImagesVal,
defaultScannerNetworkModeVal,
defaultScannerDnsRawVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
getSetting('highlight_updates'),
getSetting('colored_action_buttons'),
getSetting('action_icon_size'),
getSetting('time_format'),
getSetting('date_format'),
getSetting('download_format'),
@@ -566,13 +634,17 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('honor_proxy_labels'),
getSetting('show_image_changelog_links'),
getSetting('animate_icons'),
getSetting('protect_scanner_images')
getSetting('protect_scanner_images'),
getSetting('default_scanner_network_mode'),
getSetting('default_scanner_dns')
]);
const settings: GeneralSettings = {
confirmDestructive: confirmDestructiveVal ?? DEFAULT_SETTINGS.confirmDestructive,
showStoppedContainers: showStoppedContainersVal ?? DEFAULT_SETTINGS.showStoppedContainers,
highlightUpdates: highlightUpdatesVal ?? DEFAULT_SETTINGS.highlightUpdates,
coloredActionButtons: coloredActionButtonsVal ?? DEFAULT_SETTINGS.coloredActionButtons,
actionIconSize: (VALID_ACTION_ICON_SIZES.includes(actionIconSizeVal as ActionIconSize) ? actionIconSizeVal : DEFAULT_SETTINGS.actionIconSize) as ActionIconSize,
timeFormat: timeFormatVal ?? DEFAULT_SETTINGS.timeFormat,
dateFormat: dateFormatVal ?? DEFAULT_SETTINGS.dateFormat,
downloadFormat: downloadFormatVal ?? DEFAULT_SETTINGS.downloadFormat,
@@ -613,7 +685,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
honorProxyLabels: honorProxyLabelsVal ?? DEFAULT_SETTINGS.honorProxyLabels,
protectScannerImages: protectScannerImagesVal ?? DEFAULT_SETTINGS.protectScannerImages,
showImageChangelogLinks: showImageChangelogLinksVal ?? DEFAULT_SETTINGS.showImageChangelogLinks,
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons
animateIcons: animateIconsVal ?? DEFAULT_SETTINGS.animateIcons,
defaultScannerNetworkMode: defaultScannerNetworkModeVal ?? DEFAULT_SETTINGS.defaultScannerNetworkMode,
defaultScannerDns: parseScannerDnsStorage(defaultScannerDnsRawVal)
};
return json(settings);
+12 -4
View File
@@ -14,7 +14,9 @@ const DEFAULT_THEME_SETTINGS = {
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono',
animateIcons: true
animateIcons: true,
coloredActionButtons: false,
actionIconSize: 'normal'
};
export const GET: RequestHandler = async () => {
@@ -27,7 +29,9 @@ export const GET: RequestHandler = async () => {
gridFontSize,
terminalFont,
editorFont,
animateIcons
animateIcons,
coloredActionButtons,
actionIconSize
] = await Promise.all([
getSetting('theme_light'),
getSetting('theme_dark'),
@@ -36,7 +40,9 @@ export const GET: RequestHandler = async () => {
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('animate_icons')
getSetting('animate_icons'),
getSetting('colored_action_buttons'),
getSetting('action_icon_size')
]);
return json({
@@ -47,7 +53,9 @@ export const GET: RequestHandler = async () => {
gridFontSize: gridFontSize ?? DEFAULT_THEME_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_THEME_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_THEME_SETTINGS.editorFont,
animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons
animateIcons: animateIcons ?? DEFAULT_THEME_SETTINGS.animateIcons,
coloredActionButtons: coloredActionButtons ?? DEFAULT_THEME_SETTINGS.coloredActionButtons,
actionIconSize: actionIconSize ?? DEFAULT_THEME_SETTINGS.actionIconSize
});
} catch (error) {
console.error('Failed to get theme settings:', error);
+55 -2
View File
@@ -20,6 +20,47 @@ export interface TemplateItem {
stars?: number;
pulls?: number;
network?: string;
/** Free-form maintainer / install notes from the upstream catalog. */
note?: string;
/** Project / homepage URL the user can click to learn more or report issues (#1211). */
projectUrl?: string;
}
/**
* Best-effort first-URL extraction from a template's description or note.
* Order matters: markdown link first, then HTML href, then a bare URL.
*/
function extractFirstUrl(text: string | undefined | null): string | null {
if (!text) return null;
const md = text.match(/\[[^\]]+\]\((https?:\/\/[^)]+)\)/);
if (md) return md[1];
const html = text.match(/<a\s+[^>]*href=["']([^"']+)["']/i);
if (html) return html[1];
const plain = text.match(/(https?:\/\/[^\s<>"')]+)/);
if (plain) return plain[1];
return null;
}
/**
* Resolve the "Project" URL surfaced on the card.
* Priority:
* 1. URL embedded in `description` — LSIO-style templates have a markdown
* link to the actual project repo here, which is what users want most.
* 2. URL embedded in `note` — Portainer v3 catalogs often put a homepage
* link in the install notes.
* 3. `maintainer` field — Lissy93's templates carry a per-template
* maintainer URL; not always the project itself but usually close.
* 4. `repository.url` — only for stack-type templates, points to the repo
* hosting the compose file (closest thing we have).
*/
function resolveProjectUrl(entry: any, description: string, note: string): string | undefined {
return (
extractFirstUrl(description) ||
extractFirstUrl(note) ||
(typeof entry.maintainer === 'string' ? entry.maintainer.trim() : null) ||
(entry.repository && typeof entry.repository.url === 'string' ? entry.repository.url : null) ||
undefined
);
}
// Server-side cache: url → { data, fetchedAt }
@@ -43,14 +84,18 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte
if (entry.type === 2) return null;
const title = entry.title || entry.name || '';
const description = entry.description || '';
const note = typeof entry.note === 'string' ? entry.note : '';
const template: TemplateItem = {
id: hashId(sourceName, title),
type: entry.type === 3 ? 'stack' : 'container',
title,
description: entry.description || '',
description,
logo: entry.logo || '',
categories: Array.isArray(entry.categories) ? entry.categories : [],
source: sourceName,
note: note || undefined,
projectUrl: resolveProjectUrl(entry, description, note)
};
if (entry.type === 3 && entry.repository) {
@@ -77,11 +122,18 @@ function normalizePortainerTemplate(entry: any, sourceName: string): TemplateIte
function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
if (!entry.name || entry.deprecated) return null;
const description = entry.description || '';
// LSIO fleet entries sometimes carry a project_url; prefer it over regex sniffing.
const projectUrl =
(typeof entry.project_url === 'string' && entry.project_url) ||
extractFirstUrl(description) ||
`https://github.com/linuxserver/docker-${entry.name}`;
return {
id: hashId('LinuxServer.io', entry.name),
type: 'container',
title: entry.name,
description: entry.description || '',
description,
logo: entry.project_logo || '',
categories: entry.category ? entry.category.split(',').map((c: string) => c.trim()).filter(Boolean) : [],
source: 'LinuxServer.io',
@@ -94,6 +146,7 @@ function normalizeLinuxServerTemplate(entry: any): TemplateItem | null {
restartPolicy: 'unless-stopped',
stars: entry.stars,
pulls: entry.monthly_pulls,
projectUrl
};
}
+1 -1
View File
@@ -906,7 +906,7 @@
{:else if column.id === 'actions'}
<div class="flex items-center justify-end">
<Button variant="ghost" size="icon" class="h-6 w-6" onclick={(e) => { e.stopPropagation(); showDetails(log); }}>
<Info class="w-3.5 h-3.5" />
<Info class="grid-action-icon grid-action-info" />
</Button>
</div>
{/if}
+12 -12
View File
@@ -2091,7 +2091,7 @@
onOpenChange={(open) => confirmUpdateId = open ? container.id : null}
>
{#snippet children({ open })}
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
<CircleArrowUp class="grid-action-icon text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
@@ -2112,7 +2112,7 @@
title="Open logs"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FileText class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<FileText class="grid-action-icon grid-action-logs text-muted-foreground hover:text-foreground" />
</button>
{/if}
{/if}
@@ -2135,7 +2135,7 @@
onclick={(e: MouseEvent) => e.stopPropagation()}
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Terminal class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Terminal class="grid-action-icon grid-action-terminal text-muted-foreground hover:text-foreground" />
</Popover.Trigger>
<Popover.Content class="w-56 p-0" align="end" sideOffset={5}>
<div class="px-3 py-2 border-b bg-muted/50">
@@ -2244,7 +2244,7 @@
title="Browse files"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FolderOpen class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<FolderOpen class="grid-action-icon grid-action-info text-muted-foreground hover:text-foreground" />
</button>
{/if}
<button
@@ -2253,7 +2253,7 @@
title="View details"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Eye class="grid-action-icon grid-action-info text-muted-foreground hover:text-foreground" />
</button>
{#if $canAccess('containers', 'create')}
<button
@@ -2262,7 +2262,7 @@
title="Edit"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if !container.systemContainer}
@@ -2274,7 +2274,7 @@
title="Unpause"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
<Play class="grid-action-icon grid-action-start text-muted-foreground hover:text-green-500" />
</button>
{/if}
{:else if container.state !== 'running' && container.state !== 'restarting'}
@@ -2285,7 +2285,7 @@
title="Start"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
<Play class="grid-action-icon grid-action-start text-muted-foreground hover:text-green-500" />
</button>
{/if}
{/if}
@@ -2301,7 +2301,7 @@
onOpenChange={(open) => confirmRestartId = open ? container.id : null}
>
{#snippet children({ open })}
<RotateCw class="w-3 h-3 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restartingId === container.id ? 'animate-spin text-foreground' : ''}" />
<RotateCw class="grid-action-icon grid-action-restart {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restartingId === container.id ? 'animate-spin text-foreground' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
@@ -2313,7 +2313,7 @@
title="Pause"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pause class="w-3 h-3 text-muted-foreground hover:text-yellow-500" />
<Pause class="grid-action-icon grid-action-pause text-muted-foreground hover:text-yellow-500" />
</button>
{/if}
{#if $canAccess('containers', 'stop')}
@@ -2327,7 +2327,7 @@
onOpenChange={(open) => confirmStopId = open ? container.id : null}
>
{#snippet children({ open })}
<Square class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stoppingId === container.id ? 'animate-pulse text-destructive' : ''}" />
<Square class="grid-action-icon grid-action-stop {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stoppingId === container.id ? 'animate-pulse text-destructive' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
@@ -2344,7 +2344,7 @@
onOpenChange={(open) => confirmDeleteId = open ? container.id : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
+17 -17
View File
@@ -589,14 +589,14 @@
}
async function tagImage() {
if (!tagNewRepo.trim()) return;
if (!tagNewRepo.trim() || !tagNewTag.trim()) return;
tagging = true;
const newTag = `${tagNewRepo.trim()}:${tagNewTag.trim() || 'latest'}`;
const newTag = `${tagNewRepo.trim()}:${tagNewTag.trim()}`;
try {
const response = await fetch(appendEnvParam(`/api/images/${encodeURIComponent(tagImageId)}/tag`, envId), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: tagNewRepo.trim(), tag: tagNewTag.trim() || 'latest' })
body: JSON.stringify({ repo: tagNewRepo.trim(), tag: tagNewTag.trim() })
});
if (response.ok) {
toast.success(`Tagged as ${newTag}`);
@@ -1007,7 +1007,7 @@
title="Run container"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3.5 h-3.5 text-muted-foreground hover:text-green-600" />
<Play class="grid-action-icon grid-action-start text-muted-foreground hover:text-green-600" />
</button>
{/if}
{#if scannerEnabled && $canAccess('images', 'inspect')}
@@ -1017,7 +1017,7 @@
title="Scan for vulnerabilities"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<ShieldCheck class="w-3.5 h-3.5 text-muted-foreground hover:text-blue-500" />
<ShieldCheck class="grid-action-icon grid-action-info text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{#if $canAccess('images', 'push')}
@@ -1027,7 +1027,7 @@
title="Push to registry"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Upload class="w-3.5 h-3.5 text-muted-foreground hover:text-foreground" />
<Upload class="grid-action-icon grid-action-transfer text-muted-foreground hover:text-foreground" />
</button>
{/if}
</div>
@@ -1092,7 +1092,7 @@
title="View layers"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Layers class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Layers class="grid-action-icon grid-action-info text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('containers', 'create')}
@@ -1102,7 +1102,7 @@
title="Run container"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-600" />
<Play class="grid-action-icon grid-action-start text-muted-foreground hover:text-green-600" />
</button>
{/if}
{#if scannerEnabled && $canAccess('images', 'inspect')}
@@ -1112,7 +1112,7 @@
title="Scan for vulnerabilities"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<ShieldCheck class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
<ShieldCheck class="grid-action-icon grid-action-info text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{#if $canAccess('images', 'push')}
@@ -1122,7 +1122,7 @@
title="Push to registry"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Upload class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Upload class="grid-action-icon grid-action-transfer text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('images', 'inspect')}
@@ -1133,7 +1133,7 @@
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer {exportingId === tagInfo.fullRef ? 'animate-pulse' : ''}"
disabled={exportingId === tagInfo.fullRef}
>
<Download class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Download class="grid-action-icon grid-action-transfer text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('images', 'build')}
@@ -1143,7 +1143,7 @@
title="Tag image"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Tag class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Tag class="grid-action-icon grid-action-edit text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('images', 'remove') && tagInfo.containers === 0}
@@ -1158,7 +1158,7 @@
onOpenChange={(open) => confirmDeleteId = open ? tagInfo.fullRef : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
</div>
@@ -1235,14 +1235,14 @@
<!-- Tag Image Dialog -->
<Dialog.Root bind:open={showTagModal}>
<Dialog.Content class="max-w-md">
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2">
<Tag class="w-5 h-5" />
Tag image
</Dialog.Title>
<Dialog.Description>
Add a new tag to <span class="font-mono text-foreground">{tagImageCurrentName}</span>
Add a new tag to <span class="font-mono text-foreground truncate" title={tagImageCurrentName}>{tagImageCurrentName.startsWith('sha256:') ? tagImageCurrentName.slice(0, 19) : tagImageCurrentName}</span>
</Dialog.Description>
</Dialog.Header>
<div class="py-4 space-y-4">
@@ -1263,7 +1263,7 @@
placeholder="e.g., latest, v1.0.0"
class="mt-2"
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' && !tagging && tagNewRepo.trim()) {
if (e.key === 'Enter' && !tagging && tagNewRepo.trim() && tagNewTag.trim()) {
tagImage();
}
}}
@@ -1276,7 +1276,7 @@
</Button>
<Button
onclick={tagImage}
disabled={tagging || !tagNewRepo.trim()}
disabled={tagging || !tagNewRepo.trim() || !tagNewTag.trim()}
>
{#if tagging}
<RefreshCw class="w-4 h-4 mr-2 animate-spin" />
+3 -2
View File
@@ -12,6 +12,7 @@
import { appSettings } from '$lib/stores/settings';
import * as Alert from '$lib/components/ui/alert';
import { themeStore, applyTheme } from '$lib/stores/theme';
import { safeRedirectOrRoot } from '$lib/utils/safe-redirect';
interface AuthProvider {
id: string;
@@ -31,8 +32,8 @@
let selectedProvider = $state('local');
let loadingProviders = $state(true);
// Get redirect URL from query params
const redirectUrl = $derived($page.url.searchParams.get('redirect') || '/');
// Get redirect URL from query params (validated path-relative only)
const redirectUrl = $derived(safeRedirectOrRoot($page.url.searchParams.get('redirect')));
// Get error from query params (from OIDC callback)
const urlError = $derived($page.url.searchParams.get('error'));
+5 -5
View File
@@ -670,7 +670,7 @@
title="View details"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Eye class="grid-action-icon grid-action-info text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if !isProtected && $canAccess('networks', 'connect')}
@@ -680,7 +680,7 @@
title="Connect container"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Link class="w-3 h-3 text-muted-foreground hover:text-green-600" />
<Link class="grid-action-icon grid-action-start text-muted-foreground hover:text-green-600" />
</button>
{/if}
<button
@@ -689,7 +689,7 @@
title="Copy network ID"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Copy class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Copy class="grid-action-icon grid-action-info text-muted-foreground hover:text-foreground" />
</button>
{#if !isProtected && $canAccess('networks', 'create')}
<button
@@ -698,7 +698,7 @@
title="Duplicate network"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<CopyPlus class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<CopyPlus class="grid-action-icon grid-action-edit text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if !isProtected && $canAccess('networks', 'remove')}
@@ -712,7 +712,7 @@
onOpenChange={(open) => confirmDeleteId = open ? network.id : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
+2
View File
@@ -39,6 +39,7 @@
import * as Table from '$lib/components/ui/table';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
import { themeStore } from '$lib/stores/theme';
import PageHeader from '$lib/components/PageHeader.svelte';
@@ -726,6 +727,7 @@
</Card.Header>
<Card.Content class="space-y-4">
<ThemeSelector userId={profile.id} />
<ColoredActionsToggle userId={profile.id} />
<AnimateIconsToggle userId={profile.id} />
</Card.Content>
</Card.Root>
+5 -7
View File
@@ -6,6 +6,7 @@
import * as Select from '$lib/components/ui/select';
import * as Alert from '$lib/components/ui/alert';
import { KeyRound, Copy, Check, TriangleAlert } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
let {
open = $bindable(false),
@@ -100,13 +101,10 @@
}
async function copyToken() {
try {
await navigator.clipboard.writeText(createdToken);
copied = true;
setTimeout(() => copied = false, 2000);
} catch {
// Fallback: select the input text
}
const ok = await copyToClipboard(createdToken);
if (!ok) return;
copied = true;
setTimeout(() => copied = false, 2000);
}
function handleClose() {
@@ -84,7 +84,10 @@
const urlObj = new URL(sourceRegistry.url);
// Include both host and path (e.g., registry.example.com/organization)
const hostWithPath = urlObj.host + (urlObj.pathname !== '/' ? urlObj.pathname.replace(/\/$/, '') : '');
return `${hostWithPath}/${imageWithTag}`;
// Caller (e.g. registry browser) may already pass an image name with the host prefixed (#1220).
return imageWithTag.startsWith(hostWithPath + '/')
? imageWithTag
: `${hostWithPath}/${imageWithTag}`;
}
return imageWithTag;
});
+6 -6
View File
@@ -1305,7 +1305,7 @@
title="View last execution logs"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FileText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
<FileText class="grid-action-icon grid-action-logs text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{#if canEditSchedules}
@@ -1316,9 +1316,9 @@
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
{#if schedule.enabled}
<Pause class="w-3 h-3 text-muted-foreground hover:text-amber-500" />
<Pause class="grid-action-icon grid-action-pause text-muted-foreground hover:text-amber-500" />
{:else}
<PlayCircle class="w-3 h-3 text-muted-foreground hover:text-green-500" />
<PlayCircle class="grid-action-icon grid-action-start text-muted-foreground hover:text-green-500" />
{/if}
</button>
{/if}
@@ -1329,7 +1329,7 @@
title="Run now"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
<Play class="grid-action-icon grid-action-start text-muted-foreground hover:text-green-500" />
</button>
{/if}
{#if canEditSchedules && !schedule.isSystem}
@@ -1344,7 +1344,7 @@
onOpenChange={(open) => confirmDeleteId = open ? scheduleKey : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-red-500'}" />
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-red-500'}" />
{/snippet}
</ConfirmPopover>
{/if}
@@ -1435,7 +1435,7 @@
title="View logs"
class="p-0.5 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FileText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
<FileText class="grid-action-icon grid-action-logs text-muted-foreground hover:text-blue-500" />
</button>
{#if canEditSchedules}
<button
@@ -8,6 +8,7 @@
import * as Tabs from '$lib/components/ui/tabs';
import { onMount, onDestroy } from 'svelte';
import { licenseStore } from '$lib/stores/license';
import { selfUpdate as selfUpdateStore } from '$lib/stores/self-update';
import { formatBytes } from '$lib/utils/format';
import { browser } from '$app/environment';
import LicenseModal from './LicenseModal.svelte';
@@ -308,6 +309,10 @@
updateAvailable = data.updateAvailable;
updateCheckDone = true;
// Mirror into the shared store so the sidebar indicator stays
// in sync with the result of a manual "Check now" (#1146).
selfUpdateStore.setFromResponse(data);
if (data.updateAvailable) {
updateInfo = {
currentImage: data.currentImage,
@@ -893,7 +893,8 @@
if (!formName.trim()) {
formErrors.name = 'Name is required';
hasErrors = true;
} else {
} else if (formName.trim() !== environment.name) {
// Only validate name format on rename — existing names with legacy characters are allowed
const nameCheck = validateEnvName(formName.trim());
if (!nameCheck.ok) {
formErrors.name = nameCheck.reason!;
+74 -1
View File
@@ -8,13 +8,14 @@
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 } from 'lucide-svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, ChevronRight, ChevronDown } from 'lucide-svelte';
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';
import { toast } from 'svelte-sonner';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
// General settings state - these derive from the store
@@ -32,6 +33,9 @@
let defaultTrivyArgs = $derived($appSettings.defaultTrivyArgs);
let defaultGrypeImage = $derived($appSettings.defaultGrypeImage);
let defaultTrivyImage = $derived($appSettings.defaultTrivyImage);
let defaultScannerNetworkMode = $derived($appSettings.defaultScannerNetworkMode);
let defaultScannerDns = $derived($appSettings.defaultScannerDns);
let showAdvancedScannerSettings = $state(false);
let defaultComposeTemplate = $derived($appSettings.defaultComposeTemplate);
let labelFilterMode = $derived($appSettings.labelFilterMode);
let composeTemplateWIP = $state('');
@@ -207,6 +211,29 @@ services:
}
}
function handleScannerNetworkModeChange(value: string) {
const trimmed = (value ?? '').trim();
if (trimmed !== defaultScannerNetworkMode) {
appSettings.setDefaultScannerNetworkMode(trimmed);
toast.success(trimmed ? `Scanner network mode set to ${trimmed}` : 'Scanner network mode cleared');
}
}
function handleScannerDnsBlur(e: Event) {
const raw = (e.target as HTMLInputElement).value.trim();
const cleaned = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const sameAsCurrent =
cleaned.length === defaultScannerDns.length &&
cleaned.every((v, i) => v === defaultScannerDns[i]);
if (!sameAsCurrent) {
appSettings.setDefaultScannerDns(cleaned);
toast.success(cleaned.length ? `Scanner DNS set to ${cleaned.join(', ')}` : 'Scanner DNS cleared');
}
}
// Anything above 2K starts feeling laggy in browsers without virtualized rendering.
const logMaxLinesOptions = [
{ value: '500', label: '500 lines' },
@@ -434,6 +461,7 @@ services:
<!-- Right column: Theme settings (always shown, with hint when auth enabled) -->
<div class="space-y-4">
<ThemeSelector />
<ColoredActionsToggle />
<AnimateIconsToggle />
{#if $authStore.authEnabled}
<div class="text-xs text-muted-foreground flex items-start gap-1.5 mt-2 p-2 bg-muted/50 rounded-md">
@@ -671,6 +699,51 @@ services:
/>
<p class="text-xs text-muted-foreground">Use <code class="bg-muted px-1 rounded">{'{image}'}</code> as placeholder for the image name</p>
</div>
<div class="pt-2">
<button
type="button"
class="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1 select-none"
onclick={() => (showAdvancedScannerSettings = !showAdvancedScannerSettings)}
>
{#if showAdvancedScannerSettings}
<ChevronDown class="w-3.5 h-3.5" />
{:else}
<ChevronRight class="w-3.5 h-3.5" />
{/if}
Advanced settings
</button>
</div>
{#if showAdvancedScannerSettings}
<div class="space-y-2">
<Label for="scanner-network-mode">Network mode</Label>
<Select.Root
type="single"
value={defaultScannerNetworkMode}
onValueChange={handleScannerNetworkModeChange}
>
<Select.Trigger id="scanner-network-mode" class="w-full" disabled={!$canAccess('settings', 'edit')}>
<span>{defaultScannerNetworkMode || 'Default (auto-detect)'}</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="">Default (auto-detect)</Select.Item>
<Select.Item value="host">host</Select.Item>
<Select.Item value="bridge">bridge</Select.Item>
<Select.Item value="none">none</Select.Item>
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">Override the Docker network mode for vulnerability scanner containers. Use <code class="bg-muted px-1 rounded">host</code> on hosts where the default bridge can't reach the internet (e.g. iptables disabled, SELinux restricted).</p>
</div>
<div class="space-y-2">
<Label for="scanner-dns">DNS servers</Label>
<Input
id="scanner-dns"
value={defaultScannerDns.join(', ')}
onblur={handleScannerDnsBlur}
disabled={!$canAccess('settings', 'edit')}
/>
<p class="text-xs text-muted-foreground">Comma-separated DNS IPs for scanner containers. Empty = inherit from the Docker daemon.</p>
</div>
{/if}
<div class="pt-2 border-t">
<div class="flex items-center justify-between">
<div>
+13 -13
View File
@@ -1750,7 +1750,7 @@
title="Edit git stack"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-purple-500" />
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground hover:text-purple-500" />
</button>
<GitDeployProgressPopover
stackId={source.gitStack.id}
@@ -1763,7 +1763,7 @@
title="Deploy"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Rocket class="w-3 h-3 text-muted-foreground hover:text-violet-500" />
<Rocket class="grid-action-icon grid-action-start text-muted-foreground hover:text-violet-500" />
</button>
{/snippet}
</GitDeployProgressPopover>
@@ -1780,7 +1780,7 @@
title="Sync from Git"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<RefreshCw class="w-3 h-3 text-muted-foreground hover:text-purple-500" />
<RefreshCw class="grid-action-icon grid-action-restart text-muted-foreground hover:text-purple-500" />
</button>
{/snippet}
</GitDeployProgressPopover>
@@ -1793,7 +1793,7 @@
title="Edit git stack"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-purple-500" />
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground hover:text-purple-500" />
</button>
{:else}
<!-- Internal stacks (including those needing file location) -->
@@ -1803,7 +1803,7 @@
title="Edit"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Pencil class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
<Pencil class="grid-action-icon grid-action-edit text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{/if}
@@ -1814,7 +1814,7 @@
title="View logs"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<ScrollText class="w-3 h-3 text-muted-foreground hover:text-blue-500" />
<ScrollText class="grid-action-icon grid-action-logs text-muted-foreground hover:text-blue-500" />
</button>
{/if}
{#if source.sourceType !== 'git' && source.sourceType !== 'external' && $canAccess('stacks', 'start')}
@@ -1825,13 +1825,13 @@
onDeploy={(options) => redeployStack(stack.name, options)}
>
{#snippet children()}
<Rocket class="w-3 h-3 text-muted-foreground hover:text-violet-500" />
<Rocket class="grid-action-icon grid-action-start text-muted-foreground hover:text-violet-500" />
{/snippet}
</RedeployPopover>
{/if}
{#if stackActionLoading === stack.name}
<div class="p-1">
<Loader2 class="w-3 h-3 animate-spin text-muted-foreground" />
<Loader2 class="grid-action-icon animate-spin text-muted-foreground" />
</div>
{:else if stack.status !== 'running' && stack.status !== 'partial' && stack.status !== 'restarting'}
{#if $canAccess('stacks', 'start')}
@@ -1841,7 +1841,7 @@
title="Start"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Play class="w-3 h-3 text-muted-foreground hover:text-green-500" />
<Play class="grid-action-icon grid-action-start text-muted-foreground hover:text-green-500" />
</button>
{/if}
{:else}
@@ -1856,7 +1856,7 @@
onclick={(e) => { e.stopPropagation(); restartPopoverOpen[stack.name] = !restartPopoverOpen[stack.name]; }}
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer inline-flex items-center"
>
<RotateCcw class="w-3 h-3 {restartPopoverOpen[stack.name] ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
<RotateCcw class="grid-action-icon grid-action-restart {restartPopoverOpen[stack.name] ? 'text-amber-500' : 'text-muted-foreground hover:text-amber-500'}" />
</button>
{/snippet}
</Popover.Trigger>
@@ -1891,7 +1891,7 @@
onOpenChange={(open) => confirmStopName = open ? stack.name : null}
>
{#snippet children({ open })}
<Square class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
<Square class="grid-action-icon grid-action-stop {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
@@ -1908,7 +1908,7 @@
onOpenChange={(open) => confirmDownName = open ? stack.name : null}
>
{#snippet children({ open })}
<ArrowBigDown class="w-3 h-3 {stackDownLoading === stack.name ? 'animate-bounce text-orange-500' : open ? 'text-orange-500' : 'text-muted-foreground hover:text-orange-500'}" />
<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}
</ConfirmPopover>
{/if}
@@ -1929,7 +1929,7 @@
</label>
{/snippet}
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
@@ -22,6 +22,7 @@
import type { Snippet } from 'svelte';
import { appSettings } from '$lib/stores/settings';
import { watchJob } from '$lib/utils/sse-fetch';
import { copyToClipboard } from '$lib/utils/clipboard';
interface Props {
stackId: number;
@@ -155,7 +156,8 @@
async function copyLogs() {
const lines = steps.map(s => s.message || s.status);
if (errorMessage) lines.push(`Error: ${errorMessage}`);
await navigator.clipboard.writeText(lines.join('\n'));
const ok = await copyToClipboard(lines.join('\n'));
if (!ok) return;
copied = true;
setTimeout(() => { copied = false; }, 2000);
}
+38 -20
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge';
import { Package, Star, Download, Loader2 } from 'lucide-svelte';
import { Package, Star, Download, Loader2, ExternalLink } from 'lucide-svelte';
import type { TemplateItem } from '../api/templates/+server';
interface Props {
@@ -71,13 +71,17 @@
</div>
</Card.Header>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<Card.Content class="px-3 pb-2 pt-0" onclick={(e: MouseEvent) => { if ((e.target as HTMLElement).tagName === 'A') e.stopPropagation(); }}>
<Card.Content class="px-3 pb-2 pt-0" onclick={(e: MouseEvent) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<p class="text-xs text-muted-foreground line-clamp-2">
{@html renderDescription(template.description) || 'No description available'}
</p>
</Card.Content>
<Card.Footer class="px-3 pb-3 pt-0 flex items-center gap-1.5 flex-wrap">
<!-- svelte-ignore a11y_click_events_have_key_events -->
<Card.Footer
class="px-3 pb-3 pt-0 flex items-center gap-1.5 flex-wrap"
onclick={(e: MouseEvent) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}
>
{#each visibleCategories as category}
<Badge variant="secondary" class="text-2xs px-1.5 py-0">
{category}
@@ -86,23 +90,37 @@
{#if overflowCount > 0}
<span class="text-2xs text-muted-foreground">+{overflowCount}</span>
{/if}
<!-- LinuxServer metadata -->
{#if template.stars || template.pulls}
<div class="ml-auto flex items-center gap-2 text-2xs text-muted-foreground">
{#if template.stars}
<span class="flex items-center gap-0.5">
<Star class="w-3 h-3" />
{template.stars}
</span>
{/if}
{#if template.pulls}
<span class="flex items-center gap-0.5">
<Download class="w-3 h-3" />
{formatPulls(template.pulls)}
</span>
{/if}
</div>
{/if}
<div class="ml-auto flex items-center gap-2 text-2xs text-muted-foreground">
{#if template.projectUrl}
<a
href={template.projectUrl}
target="_blank"
rel="noopener"
class="flex items-center gap-0.5 hover:text-primary hover:underline"
title={`Open project page: ${template.projectUrl}`}
onclick={(e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
window.open(template.projectUrl, '_blank', 'noopener');
}}
>
<ExternalLink class="w-3 h-3" />
Project
</a>
{/if}
{#if template.stars}
<span class="flex items-center gap-0.5">
<Star class="w-3 h-3" />
{template.stars}
</span>
{/if}
{#if template.pulls}
<span class="flex items-center gap-0.5">
<Download class="w-3 h-3" />
{formatPulls(template.pulls)}
</span>
{/if}
</div>
</Card.Footer>
</Card.Root>
</button>
+5 -5
View File
@@ -599,7 +599,7 @@
title="View details"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Eye class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Eye class="grid-action-icon grid-action-info text-muted-foreground hover:text-foreground" />
</button>
<button
type="button"
@@ -607,7 +607,7 @@
title="Browse files"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<FolderOpen class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<FolderOpen class="grid-action-icon grid-action-info text-muted-foreground hover:text-foreground" />
</button>
<button
type="button"
@@ -616,7 +616,7 @@
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer {exportingVolume === volume.name ? 'animate-pulse' : ''}"
disabled={exportingVolume === volume.name}
>
<Download class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Download class="grid-action-icon grid-action-transfer text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('volumes', 'create')}
@@ -626,7 +626,7 @@
title="Clone volume"
class="p-1 rounded hover:bg-muted transition-colors opacity-70 hover:opacity-100 cursor-pointer"
>
<Stamp class="w-3 h-3 text-muted-foreground hover:text-foreground" />
<Stamp class="grid-action-icon grid-action-edit text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if $canAccess('volumes', 'remove')}
@@ -641,7 +641,7 @@
onOpenChange={(open) => confirmDeleteName = open ? volume.name : null}
>
{#snippet children({ open })}
<Trash2 class="w-3 h-3 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
<Trash2 class="grid-action-icon grid-action-delete {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{#if deleteError?.name === volume.name}