1.0.36
This commit is contained in:
63 files changed
+1324
-281
No files matched your search
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : '/';
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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 });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user