This commit is contained in:
jarek committed 2026-03-21 14:29:30 +01:00
1 parent aefa5e7925
commit b55e1e5aad
107 files changed
+2193 -443

No files matched your search

+9 -3
View File
@@ -82,9 +82,6 @@
// Check auth status
authStore.check();
// Check What's New popup
checkWhatsNew();
return () => {
disconnectSSE();
};
@@ -113,6 +110,15 @@
localStorage.setItem('dockhand-whats-new-version', currentVersion);
}
}
// Show What's New only after auth resolves — avoids leaking version info on login page (#717)
let whatsNewChecked = false;
$effect(() => {
if (!whatsNewChecked && !$authStore.loading && (!$authStore.authEnabled || $authStore.authenticated)) {
whatsNewChecked = true;
checkWhatsNew();
}
});
</script>
<svelte:head>
+131 -4
View File
@@ -5,7 +5,7 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { RefreshCw, LayoutGrid, Loader2, Server, Tags, Square, RectangleVertical, Rows3, LayoutTemplate, Maximize2, Plus } from 'lucide-svelte';
import { RefreshCw, LayoutGrid, Loader2, Server, Tags, Square, RectangleVertical, Rows3, LayoutTemplate, Maximize2, Plus, Lock, LockOpen, List, Search, Plug, Route, UndoDot } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import PageHeader from '$lib/components/PageHeader.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
@@ -14,11 +14,14 @@
import EnvironmentTile from './dashboard/EnvironmentTile.svelte';
import EnvironmentTileSkeleton from './dashboard/EnvironmentTileSkeleton.svelte';
import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.svelte';
import EnvironmentListView from './dashboard/EnvironmentListView.svelte';
import { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
import type { EnvironmentStats } from './api/dashboard/stats/+server';
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
import { Input } from '$lib/components/ui/input';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
@@ -52,6 +55,42 @@
const mobileWatcher = new IsMobile();
const isMobile = $derived.by(() => mobileWatcher.current);
// Dashboard lock and view mode from preferences
let locked = $state(false);
let viewMode = $state<'grid' | 'list'>('grid');
// List view filter state
let listSearchQuery = $state('');
let listConnectionFilter = $state<string[]>([]);
const connectionOptions = [
{ value: 'socket', label: 'Socket' },
{ value: 'direct', label: 'Direct', icon: Plug },
{ value: 'hawser-standard', label: 'Standard', icon: Route },
{ value: 'hawser-edge', label: 'Edge', icon: UndoDot }
];
// Count of list-filtered results (for header display)
const listFilteredCount = $derived.by(() => {
let result = filteredTiles;
if (listConnectionFilter.length > 0) {
result = result.filter(t => {
const type = t.stats?.connectionType || 'socket';
return listConnectionFilter.includes(type);
});
}
const q = listSearchQuery.trim().toLowerCase();
if (q) {
result = result.filter(t => {
const s = t.stats;
if (!s) return false;
return s.name.toLowerCase().includes(q) ||
s.host?.toLowerCase().includes(q) ||
s.labels?.some(l => l.toLowerCase().includes(q));
});
}
return result.length;
});
// Subscribe to environments store's loaded flag for quick "loaded" detection
// When loaded, immediately create skeleton tiles so the UI shows something useful
// The SSE stream will then update these tiles with real stats
@@ -166,6 +205,17 @@
}
});
// Filter tiles for list view based on selected labels
const filteredTiles = $derived.by(() => {
if (filterLabels.length === 0) {
return tiles;
}
return tiles.filter(t => {
const tileLabels = t.stats?.labels || [];
return tileLabels.some(label => filterLabels.includes(label));
});
});
// Filter grid items based on selected labels
const filteredGridItems = $derived.by(() => {
if (filterLabels.length === 0) {
@@ -217,6 +267,8 @@
// Subscribe to preferences store to load saved layout
const unsubscribePrefs = dashboardPreferences.subscribe(prefs => {
locked = prefs.locked;
viewMode = prefs.viewMode;
if (prefs.gridLayout.length > 0 && tiles.length > 0 && !prefsLoaded) {
// Apply saved layout
gridItems = prefs.gridLayout.map(item => ({
@@ -614,8 +666,27 @@
}
}
function toggleLocked() {
locked = !locked;
dashboardPreferences.setLocked(locked);
}
function switchToListView() {
viewMode = 'list';
dashboardPreferences.setViewMode('list');
// Remove focus from trigger button
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
// Apply autolayout - arrange all tiles with specified dimensions
function applyAutoLayout(width: number, height: number) {
// Switch to grid view when selecting a grid layout
if (viewMode !== 'grid') {
viewMode = 'grid';
dashboardPreferences.setViewMode('grid');
}
const tileIds = tiles.map(t => t.id);
const newGridItems: GridItemLayout[] = [];
@@ -928,7 +999,7 @@
<!-- Header -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
<div class="flex items-center gap-4">
<PageHeader icon={LayoutGrid} title="Environments" />
<PageHeader icon={LayoutGrid} title="Environments" count={tiles.length} />
<!-- Label filter toggles (only show if there are labels) -->
{#if allLabels.length > 0}
@@ -960,6 +1031,33 @@
</div>
<div class="flex items-center gap-1">
<!-- List view filters (search + connection type) -->
{#if viewMode === 'list'}
<div class="flex items-center gap-2 mr-2">
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Search environments..."
bind:value={listSearchQuery}
onkeydown={(e) => e.key === 'Escape' && (listSearchQuery = '')}
class="pl-8 h-8 w-52 text-sm"
/>
</div>
<MultiSelectFilter
bind:value={listConnectionFilter}
options={connectionOptions}
placeholder="All connections"
pluralLabel="connections"
width="w-48"
defaultIcon={Plug}
/>
{#if listSearchQuery || listConnectionFilter.length > 0}
<span class="text-xs text-muted-foreground whitespace-nowrap">{listFilteredCount} of {filteredTiles.length}</span>
{/if}
</div>
{/if}
<!-- Add environment button -->
<button
onclick={() => goto('/settings?tab=environments&new=true')}
@@ -969,6 +1067,21 @@
<Plus class="w-4 h-4" />
</button>
<!-- Lock toggle (only in grid view) -->
{#if viewMode === 'grid'}
<button
onclick={toggleLocked}
class="p-1.5 rounded hover:bg-muted transition-colors"
title={locked ? 'Unlock tiles' : 'Lock tiles'}
>
{#if locked}
<Lock class="w-4 h-4 text-primary" />
{:else}
<LockOpen class="w-4 h-4" />
{/if}
</button>
{/if}
<!-- Autolayout dropdown -->
<DropdownMenu.Root>
<DropdownMenu.Trigger>
@@ -976,7 +1089,7 @@
<button
{...props}
class="p-1.5 rounded hover:bg-muted transition-colors"
title="Auto-layout tiles"
title="Layout options"
>
<LayoutTemplate class="w-4 h-4" />
</button>
@@ -999,6 +1112,11 @@
<Maximize2 class="w-4 h-4" />
<span>Full</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={switchToListView} class="flex items-center gap-2 cursor-pointer">
<List class="w-4 h-4" />
<span>List</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -1032,8 +1150,16 @@
Go to Settings
</Button>
</div>
{:else if viewMode === 'list'}
<!-- List view -->
<EnvironmentListView
tiles={filteredTiles}
searchQuery={listSearchQuery}
connectionFilter={listConnectionFilter}
onrowclick={handleTileClick}
/>
{:else if filteredGridItems.length === 0}
<!-- Filter shows no results -->
<!-- Filter shows no results (grid view) -->
<div class="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div class="w-16 h-16 mb-4 rounded-2xl border-2 border-dashed border-muted-foreground/30 flex items-center justify-center">
<Tags class="w-8 h-8 opacity-40" />
@@ -1086,6 +1212,7 @@
maxW={2}
minH={1}
maxH={4}
{locked}
onchange={handleGridChange}
onitemclick={handleTileClick}
>
+8 -7
View File
@@ -36,7 +36,7 @@
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { canAccess } from '$lib/stores/auth';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { toast } from 'svelte-sonner';
@@ -683,14 +683,17 @@
<!-- Environment filter -->
{#if environments.length > 0}
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
{@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server}
<Select.Root
type="single"
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
>
<Select.Trigger size="sm" class="w-44 text-sm">
<SelectedEnvIcon class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{#if selectedEnv}
<EnvironmentIcon icon={selectedEnv.icon || 'globe'} envId={selectedEnv.id} class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{:else}
<Server class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{/if}
<span class="truncate">
{#if filterEnvironmentId === null}
Environment
@@ -705,9 +708,8 @@
All environments
</Select.Item>
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Select.Item value={String(env.id)}>
<EnvIcon class="w-4 h-4 mr-2 text-muted-foreground" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-4 h-4 mr-2 text-muted-foreground" />
{env.name}
</Select.Item>
{/each}
@@ -814,9 +816,8 @@
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(event.timestamp)}</span>
{:else if column.id === 'environment'}
{#if event.environmentName}
{@const EventEnvIcon = getIconComponent(event.environmentIcon || 'globe')}
<div class="flex items-center gap-1 text-xs">
<EventEnvIcon class="w-3 h-3 text-muted-foreground shrink-0" />
<EnvironmentIcon icon={event.environmentIcon || 'globe'} envId={event.environmentId || 0} class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{event.environmentName}</span>
</div>
{:else}
@@ -6,7 +6,7 @@ import { getOidcConfig } from '$lib/server/db';
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
export const GET: RequestHandler = async ({ params, url }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
@@ -45,7 +45,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect
export const POST: RequestHandler = async ({ params, request }) => {
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
return json({ error: 'Authentication is not enabled' }, { status: 400 });
}
@@ -6,7 +6,7 @@ import { validateSession, testOidcConnection, isAuthEnabled } from '$lib/server/
export const POST: RequestHandler = async ({ params, cookies }) => {
// When auth is disabled, allow access (for initial setup)
// When auth is enabled, require admin
if (isAuthEnabled()) {
if (await isAuthEnabled()) {
const user = await validateSession(cookies);
if (!user || !user.isAdmin) {
return json({ error: 'Admin access required' }, { status: 403 });
+1 -1
View File
@@ -7,7 +7,7 @@ import { auditAuth } from '$lib/server/audit';
export const GET: RequestHandler = async (event) => {
const { url, cookies } = event;
// Check if auth is enabled
if (!isAuthEnabled()) {
if (!await isAuthEnabled()) {
throw redirect(302, '/login?error=auth_disabled');
}
@@ -8,9 +8,13 @@ import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerU
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { unregisterSchedule } from '$lib/server/scheduler';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -41,6 +45,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
@@ -9,8 +9,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
if (auth.authEnabled && !auth.isAuthenticated) {
return json({ error: 'Unauthorized' }, { status: 401 });
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { listContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path') || '/';
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { chmodContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,12 +1,16 @@
import { json } from '@sveltejs/kit';
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
// Max file size for reading (1MB)
const MAX_FILE_SIZE = 1024 * 1024;
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -60,6 +64,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
};
export const PUT: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { deleteContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { gzipSync } from 'node:zlib';
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { renameContainerPath } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import { putContainerArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
/**
@@ -84,6 +85,9 @@ function createTarArchive(filename: string, content: Uint8Array): Uint8Array {
}
export const POST: RequestHandler = async ({ params, url, request, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { getContainerLogs } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const tail = parseInt(url.searchParams.get('tail') || '100');
@@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { authorize } from '$lib/server/authorize';
import { getEnvironment } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
@@ -254,6 +255,9 @@ async function handleEdgeLogsStream(containerId: string, tail: string, environme
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const containerId = params.id;
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { pauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,10 +3,14 @@ import { renameContainer, inspectContainer } from '$lib/server/docker';
import { renameAutoUpdateSchedule } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { restartContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,6 +1,7 @@
import { json } from '@sveltejs/kit';
import { execInContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
// Shell paths to check
@@ -12,6 +13,9 @@ const SHELLS_TO_CHECK = [
];
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { startContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { hasEnvironments } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
function calculateCpuPercent(stats: any): number {
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
@@ -70,6 +71,9 @@ function calculateMemoryUsage(memoryStats: any): { usage: number; raw: number; c
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { stopContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { execInContainer, getContainerTop } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
const lines = output.trim().split('\n').filter(line => line.trim());
@@ -28,6 +29,9 @@ function parsePsOutput(output: string): { Titles: string[]; Processes: string[][
}
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,10 +3,14 @@ import { pullImage, updateContainer, type CreateContainerOptions } from '$lib/se
import { authorize } from '$lib/server/authorize';
import { auditContainer } from '$lib/server/audit';
import { removePendingContainerUpdate } from '$lib/server/db';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async (event) => {
const { params, request, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'container');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -273,7 +273,6 @@ export const POST: RequestHandler = async (event) => {
let scanBlocked = false;
let blockReason = '';
let finalScanResult: ScanResult | undefined;
let individualScannerResults: ScannerResult[] = [];
try {
@@ -290,17 +289,7 @@ export const POST: RequestHandler = async (event) => {
});
if (scanResults.length > 0) {
const scanSummary = combineScanSummaries(scanResults);
finalScanResult = {
critical: scanSummary.critical,
high: scanSummary.high,
medium: scanSummary.medium,
low: scanSummary.low,
negligible: scanSummary.negligible,
unknown: scanSummary.unknown
};
// Build individual scanner results
// Build individual scanner results (used by frontend)
individualScannerResults = scanResults.map(result => ({
scanner: result.scanner as 'grype' | 'trivy',
critical: result.summary.critical,
@@ -333,8 +322,9 @@ export const POST: RequestHandler = async (event) => {
} catch { /* ignore save errors */ }
}
// Check if blocked
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
// Check if blocked (combineScanSummaries uses Math.max for security check)
const combinedForBlockCheck = combineScanSummaries(scanResults);
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, combinedForBlockCheck, undefined);
if (blocked) {
scanBlocked = true;
blockReason = reason;
@@ -355,15 +345,21 @@ export const POST: RequestHandler = async (event) => {
scanner: v.scanner
}));
// Build scan message from individual results
const totalCritical = individualScannerResults.reduce((s, r) => s + r.critical, 0);
const totalHigh = individualScannerResults.reduce((s, r) => s + r.high, 0);
const totalMedium = individualScannerResults.reduce((s, r) => s + r.medium, 0);
const totalLow = individualScannerResults.reduce((s, r) => s + r.low, 0);
const hasVulns = totalCritical + totalHigh + totalMedium + totalLow > 0;
sendData({
type: 'scan_complete',
containerId,
containerName,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
message: finalScanResult
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
message: hasVulns
? `Scan complete: ${totalCritical} critical, ${totalHigh} high, ${totalMedium} medium, ${totalLow} low`
: 'Scan complete: no vulnerabilities found'
});
@@ -398,7 +394,6 @@ export const POST: RequestHandler = async (event) => {
current: i + 1,
total: containerIds.length,
success: false,
scanResult: finalScanResult,
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
blockReason,
message: `Update blocked: ${blockReason}`
+62 -25
View File
@@ -1,26 +1,59 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getDashboardPreferences, saveDashboardPreferences } from '$lib/server/db';
import { getUserPreference, setUserPreference } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
// Store all dashboard prefs as a single JSON blob to avoid the chained .where() bug
// in getUserPreference/setUserPreference (chained .where() replaces instead of ANDing)
const DASHBOARD_PREFS_KEY = 'dashboard_prefs';
interface StoredDashboardPrefs {
gridLayout: any[];
locked: boolean;
viewMode: 'grid' | 'list';
}
async function getPrefs(userId: number | null): Promise<StoredDashboardPrefs> {
const stored = await getUserPreference<StoredDashboardPrefs>({
userId,
environmentId: null,
key: DASHBOARD_PREFS_KEY
});
if (stored && typeof stored === 'object' && Array.isArray(stored.gridLayout)) {
return {
gridLayout: stored.gridLayout,
locked: stored.locked ?? false,
viewMode: stored.viewMode ?? 'grid'
};
}
// Migration: try reading from old dashboard_layout key
const oldLayout = await getUserPreference<any[]>({
userId,
environmentId: null,
key: 'dashboard_layout'
});
return {
gridLayout: Array.isArray(oldLayout) ? oldLayout : [],
locked: false,
viewMode: 'grid'
};
}
async function savePrefs(userId: number | null, prefs: StoredDashboardPrefs): Promise<void> {
await setUserPreference(
{ userId, environmentId: null, key: DASHBOARD_PREFS_KEY },
prefs
);
}
export const GET: RequestHandler = async ({ cookies }) => {
const auth = await authorize(cookies);
try {
// Get user-specific preferences, or fall back to global preferences
const userId = auth.user?.id ?? null;
const prefs = await getDashboardPreferences(userId);
// If no preferences exist, return empty gridLayout
if (!prefs) {
return json({
id: 0,
userId: null,
gridLayout: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
});
}
const prefs = await getPrefs(userId);
return json(prefs);
} catch (error) {
console.error('Failed to get dashboard preferences:', error);
@@ -33,19 +66,23 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { gridLayout } = body;
const userId = auth.user?.id ?? null;
if (!gridLayout || !Array.isArray(gridLayout)) {
return json({ error: 'gridLayout is required and must be an array' }, { status: 400 });
// Load current prefs and merge changes
const current = await getPrefs(userId);
if (body.gridLayout && Array.isArray(body.gridLayout)) {
current.gridLayout = body.gridLayout;
}
if (body.locked !== undefined) {
current.locked = body.locked;
}
if (body.viewMode !== undefined) {
current.viewMode = body.viewMode;
}
const userId = auth.user?.id ?? null;
const prefs = await saveDashboardPreferences({
userId,
gridLayout
});
return json(prefs);
await savePrefs(userId, current);
return json(current);
} catch (error) {
console.error('Failed to save dashboard preferences:', error);
return json({ error: 'Failed to save dashboard preferences' }, { status: 500 });
+8 -2
View File
@@ -1,17 +1,23 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import dependencies from '$lib/data/dependencies.json';
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
// Extract version tag from image string (e.g., "anchore/grype:v0.110.0" -> "v0.110.0")
function imageTag(image: string): string {
return image.split(':')[1] || 'latest';
}
// External tools used by Dockhand (Docker images)
const externalTools = [
{
name: 'anchore/grype',
version: 'latest',
version: imageTag(DEFAULT_GRYPE_IMAGE),
license: 'Apache-2.0',
repository: 'https://github.com/anchore/grype'
},
{
name: 'aquasec/trivy',
version: 'latest',
version: imageTag(DEFAULT_TRIVY_IMAGE),
license: 'Apache-2.0',
repository: 'https://github.com/aquasecurity/trivy'
}
@@ -11,6 +11,7 @@ import { cleanPem } from '$lib/utils/pem';
import { unregisterSchedule } from '$lib/server/scheduler';
import { closeEdgeConnection } from '$lib/server/hawser';
import { computeAuditDiff } from '$lib/utils/diff';
import { deleteEnvironmentIcon } from '$lib/server/env-icons';
export const GET: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
@@ -167,6 +168,9 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Cannot delete this environment' }, { status: 400 });
}
// Clean up custom icon file if exists
deleteEnvironmentIcon(id);
// Clean up public IP entry for this environment
await deleteEnvironmentPublicIp(id);
@@ -0,0 +1,68 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getEnvironment, updateEnvironment } from '$lib/server/db';
import { authorize } from '$lib/server/authorize';
import { saveEnvironmentIcon, deleteEnvironmentIcon, getEnvironmentIconBuffer } from '$lib/server/env-icons';
export const GET: RequestHandler = async ({ params }) => {
const id = parseInt(params.id);
const buffer = getEnvironmentIconBuffer(id);
if (!buffer) {
return json({ error: 'No custom icon' }, { status: 404 });
}
return new Response(new Uint8Array(buffer), {
headers: {
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=3600'
}
});
};
export const POST: RequestHandler = async ({ params, request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
const data = await request.json();
if (!data.image || typeof data.image !== 'string') {
return json({ error: 'Missing image data' }, { status: 400 });
}
// Validate size (~200KB base64 limit)
if (data.image.length > 300_000) {
return json({ error: 'Image too large' }, { status: 400 });
}
saveEnvironmentIcon(id, data.image);
const iconValue = `custom:env-${id}.webp`;
await updateEnvironment(id, { icon: iconValue });
return json({ success: true, icon: iconValue });
};
export const DELETE: RequestHandler = async ({ params, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('environments', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const id = parseInt(params.id);
const env = await getEnvironment(id);
if (!env) {
return json({ error: 'Environment not found' }, { status: 404 });
}
deleteEnvironmentIcon(id);
await updateEnvironment(id, { icon: 'globe' });
return json({ success: true, icon: 'globe' });
};
@@ -13,7 +13,23 @@ const TIMEZONE_ALIASES: Record<string, string> = {
'Europe/Kyiv': 'Europe/Kiev',
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
'America/Nuuk': 'America/Godthab',
'Pacific/Kanton': 'Pacific/Enderbury'
'Pacific/Kanton': 'Pacific/Enderbury',
// Modern IANA names that Node.js ICU maps to legacy names
'Asia/Kolkata': 'Asia/Calcutta',
'Asia/Kathmandu': 'Asia/Katmandu',
'Asia/Yangon': 'Asia/Rangoon',
'Asia/Kashgar': 'Asia/Urumqi',
'Atlantic/Faroe': 'Atlantic/Faeroe',
'Europe/Uzhgorod': 'Europe/Kiev',
'Europe/Zaporozhye': 'Europe/Kiev',
'America/Atikokan': 'America/Coral_Harbour',
'America/Argentina/Buenos_Aires': 'America/Buenos_Aires',
'America/Argentina/Catamarca': 'America/Catamarca',
'America/Argentina/Cordoba': 'America/Cordoba',
'America/Argentina/Jujuy': 'America/Jujuy',
'America/Argentina/Mendoza': 'America/Mendoza',
'Pacific/Pohnpei': 'Pacific/Ponape',
'Pacific/Chuuk': 'Pacific/Truk'
};
function normalizeTimezone(tz: string): string {
@@ -4,9 +4,10 @@ import {
getGitRepository,
updateGitRepository,
deleteGitRepository,
getGitCredentials
getGitCredentials,
getGitStacksByRepositoryId
} from '$lib/server/db';
import { deleteRepositoryFiles } from '$lib/server/git';
import { deleteRepositoryFiles, deleteGitStackFiles } from '$lib/server/git';
import { authorize } from '$lib/server/authorize';
import { auditGitRepository } from '$lib/server/audit';
import { computeAuditDiff } from '$lib/utils/diff';
@@ -112,7 +113,13 @@ export const DELETE: RequestHandler = async (event) => {
return json({ error: 'Repository not found' }, { status: 404 });
}
// Delete repository files first
// Delete git stack clone directories before cascade deletes the DB rows
const stacks = await getGitStacksByRepositoryId(id);
for (const stack of stacks) {
await deleteGitStackFiles(stack.id, stack.stackName, stack.environmentId);
}
// Delete repository clone directory
deleteRepositoryFiles(id);
const deleted = await deleteGitRepository(id);
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab uses X-Gitlab-Token which should match exactly
+4 -4
View File
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
const sigBuf = Buffer.from(signature);
const expectedBuf = Buffer.from(expectedSignature);
if (sigBuf.length !== expectedBuf.length) return false;
return crypto.timingSafeEqual(sigBuf, expectedBuf);
}
// GitLab uses X-Gitlab-Token which should match exactly
+4
View File
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
import { removeImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditImage } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
@@ -3,9 +3,13 @@ import { exportImage, inspectImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { createGzip } from 'zlib';
import { Readable } from 'stream';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getImageHistory } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -1,9 +1,13 @@
import { json } from '@sveltejs/kit';
import { tagImage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'image');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+3 -1
View File
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Return as plain text if requested
if (url.searchParams.get('format') === 'text') {
return text(content);
return text(content, {
headers: { 'content-type': 'text/plain; charset=utf-8' }
});
}
return json({ content });
+3 -1
View File
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
// Return as plain text if requested
if (url.searchParams.get('format') === 'text') {
return text(content);
return text(content, {
headers: { 'content-type': 'text/plain; charset=utf-8' }
});
}
return json({ content });
+7
View File
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
import { removeNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -32,6 +36,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { connectContainerToNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, request, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -25,6 +29,9 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Container ID is required' }, { status: 400 });
}
const invalidContainer = validateDockerIdParam(containerId, 'container');
if (invalidContainer) return invalidContainer;
// Get network name for audit
let networkName = params.id;
try {
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
import { disconnectContainerFromNetwork, inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditNetwork } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, request, cookies } = event;
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -25,6 +29,9 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'Container ID is required' }, { status: 400 });
}
const invalidContainer = validateDockerIdParam(containerId, 'container');
if (invalidContainer) return invalidContainer;
// Get network name for audit
let networkName = params.id;
try {
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectNetwork } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'network');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+16 -11
View File
@@ -371,19 +371,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
...networkEnvVars
];
// Pass Docker API version so the updater CLI speaks a compatible version.
// Without this, newer CLI versions (e.g. API 1.53) fail against older
// daemons (e.g. Synology DSM shipping API 1.43).
const dockerApiVersion = process.env.DOCKER_API_VERSION;
if (dockerApiVersion) {
updaterEnv.push(`DOCKER_API_VERSION=${dockerApiVersion}`);
// Pin Docker API version so the updater's bundled Docker CLI
// doesn't request a version newer than the host daemon supports
// (e.g. Synology DSM with Docker 24.x / API 1.43)
if (process.env.DOCKER_API_VERSION) {
updaterEnv.push(`DOCKER_API_VERSION=${process.env.DOCKER_API_VERSION}`);
console.log(`[SelfUpdate] Forwarding explicit DOCKER_API_VERSION: ${process.env.DOCKER_API_VERSION}`);
} else {
const versionRes = await localDockerFetch('/version');
if (versionRes.ok) {
const vInfo = await versionRes.json() as { ApiVersion?: string };
if (vInfo.ApiVersion) {
updaterEnv.push(`DOCKER_API_VERSION=${vInfo.ApiVersion}`);
try {
const versionResp = await localDockerFetch('/version');
if (versionResp.ok) {
const versionInfo = await versionResp.json() as { ApiVersion?: string };
if (versionInfo.ApiVersion) {
updaterEnv.push(`DOCKER_API_VERSION=${versionInfo.ApiVersion}`);
console.log(`[SelfUpdate] Using negotiated Docker API version: ${versionInfo.ApiVersion}`);
}
}
} catch {
console.warn('[SelfUpdate] Could not detect Docker API version, updater will negotiate on its own');
}
}
@@ -35,6 +35,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
const containerId = getOwnContainerId();
if (!containerId) {
console.log('[SelfUpdate] Not running in Docker, skipping update check');
return json({
updateAvailable: false,
error: 'Not running in Docker'
@@ -45,6 +46,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Inspect own container to get current image info
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
if (!inspectResponse.ok) {
console.log(`[SelfUpdate] Failed to inspect container ${containerId.substring(0, 12)}: ${inspectResponse.status}`);
return json({
updateAvailable: false,
error: 'Failed to inspect own container'
@@ -61,7 +63,10 @@ export const GET: RequestHandler = async ({ cookies }) => {
const currentImageId = inspectData.Image || '';
const containerName = inspectData.Name?.replace(/^\//, '') || '';
console.log(`[SelfUpdate] Container: ${containerId.substring(0, 12)}, image: ${currentImage}, tag: ${currentImage.split(':').pop() || 'latest'}`);
if (!currentImage) {
console.log('[SelfUpdate] Could not determine current image from inspect data');
return json({
updateAvailable: false,
error: 'Could not determine current image'
@@ -73,6 +78,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Digest-based images (e.g. image@sha256:...) can't be checked for updates
if (currentImage.includes('@sha256:')) {
console.log('[SelfUpdate] Image pinned by digest, cannot check for updates');
return json({
updateAvailable: false,
currentImage,
@@ -94,6 +100,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
// Version-based check: compare against latest released version from changelog
const currentTagVersion = versionMatch[1];
const suffix = versionMatch[2] || ''; // '-baseline' or ''
console.log(`[SelfUpdate] Version-based check: current=${currentTagVersion}${suffix}`);
try {
const changelogResponse = await fetch(
@@ -102,6 +109,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
);
if (!changelogResponse.ok) {
console.log(`[SelfUpdate] Failed to fetch changelog from GitHub: ${changelogResponse.status}`);
return json({
updateAvailable: false,
currentImage,
@@ -122,6 +130,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
const latestRelease = changelog.find(entry => !entry.comingSoon);
if (!latestRelease) {
console.log('[SelfUpdate] No released version found in changelog');
return json({
updateAvailable: false,
currentImage,
@@ -133,12 +142,14 @@ export const GET: RequestHandler = async ({ cookies }) => {
const latestVersion = latestRelease.version;
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
console.log(`[SelfUpdate] Latest changelog version: ${latestVersion}, current: ${currentTagVersion}, hasNewer: ${hasNewer}`);
if (hasNewer) {
// Build new image tag preserving registry prefix and suffix
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
const newImage = `${imageWithoutTag}:${newTag}`;
console.log(`[SelfUpdate] Update available: ${currentImage} → ${newImage}`);
return json({
updateAvailable: true,
currentImage,
@@ -149,6 +160,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
});
}
console.log(`[SelfUpdate] Up to date (version ${currentTagVersion})`);
return json({
updateAvailable: false,
currentImage,
@@ -156,6 +168,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
isComposeManaged
});
} catch (err) {
console.log(`[SelfUpdate] Version check failed: ${err}`);
return json({
updateAvailable: false,
currentImage,
@@ -167,10 +180,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
// Digest-based check for mutable tags (:latest, :baseline, etc.)
console.log(`[SelfUpdate] Digest-based check for mutable tag: ${tag}`);
// Inspect image via local Docker socket to get RepoDigests
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
if (!imageResponse.ok) {
console.log(`[SelfUpdate] Failed to inspect image ${currentImageId}: ${imageResponse.status}`);
return json({
updateAvailable: false,
currentImage,
@@ -192,6 +207,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
.filter(Boolean) as string[];
if (localDigests.length === 0) {
console.log('[SelfUpdate] No RepoDigests found — local/untagged image, cannot check registry');
return json({
updateAvailable: false,
currentImage,
@@ -202,9 +218,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
});
}
console.log(`[SelfUpdate] Local digests: ${localDigests.map(d => d.substring(0, 19)).join(', ')}`);
// Query registry for latest digest
const registryDigest = await getRegistryManifestDigest(currentImage);
if (!registryDigest) {
console.log(`[SelfUpdate] Could not query registry for ${currentImage}`);
return json({
updateAvailable: false,
currentImage,
@@ -216,6 +235,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
}
const hasUpdate = !localDigests.includes(registryDigest);
console.log(`[SelfUpdate] Registry digest: ${registryDigest.substring(0, 19)}, match: ${!hasUpdate}, updateAvailable: ${hasUpdate}`);
return json({
updateAvailable: hasUpdate,
@@ -227,6 +247,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
isComposeManaged
});
} catch (err) {
console.log(`[SelfUpdate] Check failed with error: ${err}`);
return json({
updateAvailable: false,
error: 'Check failed: ' + String(err)
+46 -8
View File
@@ -30,6 +30,7 @@ import {
import { authorize } from '$lib/server/authorize';
import { refreshSystemJobs } from '$lib/server/scheduler';
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
import { DEFAULT_GRYPE_IMAGE, DEFAULT_TRIVY_IMAGE } from '$lib/server/scanner';
export type TimeFormat = '12h' | '24h';
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
@@ -67,10 +68,15 @@ export interface GeneralSettings {
editorFont: string;
// Compact ports
compactPorts: boolean;
// Log timestamp formatting
formatLogTimestamps: boolean;
// External stack paths
externalStackPaths: string[];
// Primary stack location
primaryStackLocation: string | null;
// Scanner images
defaultGrypeImage: string;
defaultTrivyImage: string;
}
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
@@ -88,13 +94,18 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
compactPorts: false,
formatLogTimestamps: false,
lightTheme: 'default',
darkTheme: 'default',
font: 'system',
fontSize: 'normal',
gridFontSize: 'normal',
terminalFont: 'system-mono',
editorFont: 'system-mono'
editorFont: 'system-mono',
externalStackPaths: [],
primaryStackLocation: null,
defaultGrypeImage: DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: DEFAULT_TRIVY_IMAGE
};
const VALID_LIGHT_THEMES = ['default', 'catppuccin', 'rose-pine', 'nord', 'solarized', 'gruvbox', 'alucard', 'github', 'material', 'atom-one'];
@@ -144,8 +155,11 @@ export const GET: RequestHandler = async ({ cookies }) => {
terminalFont,
editorFont,
compactPorts,
formatLogTimestamps,
externalStackPaths,
primaryStackLocation
primaryStackLocation,
defaultGrypeImage,
defaultTrivyImage
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -174,8 +188,11 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getSetting('format_log_timestamps'),
getExternalStackPaths(),
getPrimaryStackLocation()
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
]);
const settings: GeneralSettings = {
@@ -206,8 +223,11 @@ export const GET: RequestHandler = async ({ cookies }) => {
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
formatLogTimestamps: formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths,
primaryStackLocation
primaryStackLocation,
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
};
return json(settings);
@@ -225,7 +245,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, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, externalStackPaths, primaryStackLocation } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, formatLogTimestamps, externalStackPaths, primaryStackLocation, defaultGrypeImage, defaultTrivyImage } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -321,6 +341,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (compactPorts !== undefined) {
await setSetting('compact_ports', compactPorts);
}
if (formatLogTimestamps !== undefined) {
await setSetting('format_log_timestamps', formatLogTimestamps);
}
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
// Filter to valid non-empty strings
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
@@ -335,6 +358,12 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
await setPrimaryStackLocation(null);
}
}
if (defaultGrypeImage !== undefined && typeof defaultGrypeImage === 'string') {
await setSetting('default_grype_image', defaultGrypeImage);
}
if (defaultTrivyImage !== undefined && typeof defaultTrivyImage === 'string') {
await setSetting('default_trivy_image', defaultTrivyImage);
}
// Fetch all settings in parallel for the response
const [
@@ -365,8 +394,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
terminalFontVal,
editorFontVal,
compactPortsVal,
formatLogTimestampsVal,
externalStackPathsVal,
primaryStackLocationVal
primaryStackLocationVal,
defaultGrypeImageVal,
defaultTrivyImageVal
] = await Promise.all([
getSetting('confirm_destructive'),
getSetting('show_stopped_containers'),
@@ -395,8 +427,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getSetting('format_log_timestamps'),
getExternalStackPaths(),
getPrimaryStackLocation()
getPrimaryStackLocation(),
getSetting('default_grype_image'),
getSetting('default_trivy_image')
]);
const settings: GeneralSettings = {
@@ -427,8 +462,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
formatLogTimestamps: formatLogTimestampsVal ?? DEFAULT_SETTINGS.formatLogTimestamps,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal
primaryStackLocation: primaryStackLocationVal,
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
};
return json(settings);
+20 -5
View File
@@ -1,5 +1,5 @@
import { json, type RequestHandler } from '@sveltejs/kit';
import { getEnvSetting, setEnvSetting, getEnvironment } from '$lib/server/db';
import { getEnvSetting, setEnvSetting, getEnvironment, setSetting } from '$lib/server/db';
import {
checkScannerAvailability,
getScannerVersions,
@@ -15,6 +15,8 @@ export interface ScannerSettings {
scanner: ScannerType;
grypeArgs: string;
trivyArgs: string;
grypeImage: string;
trivyImage: string;
}
export const GET: RequestHandler = async ({ url, cookies }) => {
@@ -39,7 +41,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
const settings: ScannerSettings = {
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs,
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs,
grypeImage: globalDefaults.grypeImage,
trivyImage: globalDefaults.trivyImage
};
// Fast path: return just settings without Docker checks
@@ -80,7 +84,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
try {
const body = await request.json();
const { scanner, grypeArgs, trivyArgs, envId } = body;
const { scanner, grypeArgs, trivyArgs, grypeImage, trivyImage, envId } = body;
const parsedEnvId = envId ? parseInt(envId) : undefined;
// Permission check with environment context
@@ -104,6 +108,12 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
if (trivyArgs !== undefined) {
await setEnvSetting('trivy_cli_args', trivyArgs, parsedEnvId);
}
if (grypeImage !== undefined && typeof grypeImage === 'string') {
await setSetting('default_grype_image', grypeImage);
}
if (trivyImage !== undefined && typeof trivyImage === 'string') {
await setSetting('default_trivy_image', trivyImage);
}
// Get global defaults for fallback
const globalDefaults = await getGlobalScannerDefaults();
@@ -113,7 +123,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
settings: {
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs,
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs
trivyArgs: await getEnvSetting('trivy_cli_args', parsedEnvId) || globalDefaults.trivyArgs,
grypeImage: globalDefaults.grypeImage,
trivyImage: globalDefaults.trivyImage
}
});
} catch (error) {
@@ -154,6 +166,9 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
const removed: string[] = [];
const errors: string[] = [];
// Get configured scanner images
const globalDefaults = await getGlobalScannerDefaults();
// Determine which images to remove
const scannersToRemove: ('grype' | 'trivy')[] =
scanner === 'grype' ? ['grype'] :
@@ -161,7 +176,7 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
['grype', 'trivy'];
for (const scannerType of scannersToRemove) {
const imageName = scannerType === 'grype' ? 'anchore/grype' : 'aquasec/trivy';
const imageName = scannerType === 'grype' ? globalDefaults.grypeImage.split(':')[0] : globalDefaults.trivyImage.split(':')[0];
// Find the image
const image = images.find((img) =>
+45 -2
View File
@@ -1,7 +1,7 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { readdirSync, statSync, existsSync } from 'node:fs';
import { join, basename } from 'node:path';
import { readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
import { join, basename, isAbsolute } from 'node:path';
import { authorize } from '$lib/server/authorize';
export interface FileEntry {
@@ -13,6 +13,49 @@ export interface FileEntry {
mode: string;
}
/**
* POST /api/system/files
* Create a directory
*
* Body: { path: string }
*/
export const POST: RequestHandler = async ({ request, cookies }) => {
const auth = await authorize(cookies);
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
const body = await request.json();
const path = body.path;
if (!path || typeof path !== 'string') {
return json({ error: 'Path is required' }, { status: 400 });
}
if (!isAbsolute(path)) {
return json({ error: 'Path must be absolute' }, { status: 400 });
}
if (path.includes('..')) {
return json({ error: 'Path must not contain ..' }, { status: 400 });
}
if (existsSync(path)) {
return json({ error: 'Path already exists' }, { status: 409 });
}
mkdirSync(path, { recursive: true });
return json({ success: true, path });
} catch (error) {
console.error('Error creating directory:', error);
const message = error instanceof Error ? error.message : 'Unknown error';
return json({ error: `Failed to create directory: ${message}` }, { status: 500 });
}
};
/**
* GET /api/system/files
* Browse Dockhand's local filesystem (for mount browsing)
+7
View File
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
import { removeVolume, inspectVolume } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditVolume } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -33,6 +37,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
export const DELETE: RequestHandler = async (event) => {
const { params, url, cookies } = event;
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const force = url.searchParams.get('force') === 'true';
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { listVolumeDirectory, getVolumeUsage } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,11 +2,15 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { readVolumeFile } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
// Max file size for reading (1MB)
const MAX_FILE_SIZE = 1024 * 1024;
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
@@ -2,12 +2,16 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { releaseVolumeHelperContainer } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
/**
* Release the cached volume helper container when done browsing.
* This is called when the volume browser modal is closed.
*/
export const POST: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+48 -1
View File
@@ -1,11 +1,15 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectVolume, createVolume, type CreateVolumeOptions } from '$lib/server/docker';
import { inspectVolume, createVolume, type CreateVolumeOptions, ensureVolumeHelperImage, dockerFetch, dockerJsonRequest, drainResponse } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { auditVolume } from '$lib/server/audit';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const POST: RequestHandler = async (event) => {
const { params, url, request, cookies } = event;
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -38,6 +42,49 @@ export const POST: RequestHandler = async (event) => {
const newVolume = await createVolume(options, envIdNum);
// Copy data from source to destination using a temporary busybox container
// Mount source read-only at /src and destination read-write at /dst
await ensureVolumeHelperImage(envIdNum);
const containerName = `dockhand-clone-${Date.now().toString(36)}`;
const containerConfig = {
Image: 'busybox:latest',
Cmd: ['cp', '-a', '/src/.', '/dst/'],
HostConfig: {
Binds: [
`${params.name}:/src:ro`,
`${newName}:/dst`
],
AutoRemove: false
},
Labels: { 'dockhand.volume.helper': 'true' }
};
let copyCtrId: string | undefined;
try {
const createRes = await dockerJsonRequest<{ Id: string }>(
`/containers/create?name=${encodeURIComponent(containerName)}`,
{ method: 'POST', body: JSON.stringify(containerConfig) },
envIdNum
);
copyCtrId = createRes.Id;
await drainResponse(await dockerFetch(`/containers/${copyCtrId}/start`, { method: 'POST' }, envIdNum));
// Wait for the copy to finish (must drain response to ensure wait completes)
const waitRes = await dockerFetch(`/containers/${copyCtrId}/wait`, { method: 'POST' }, envIdNum);
const waitBody = await waitRes.json().catch(() => ({ StatusCode: -1 }));
if (waitBody.StatusCode !== 0) {
throw new Error(`Volume copy failed with exit code ${waitBody.StatusCode}`);
}
} finally {
if (copyCtrId) {
await drainResponse(
await dockerFetch(`/containers/${copyCtrId}?force=true`, { method: 'DELETE' }, envIdNum)
).catch(() => { /* best effort cleanup */ });
}
}
// Audit log
await auditVolume(event, 'clone', newVolume.Name, `${params.name} → ${newName}`, envIdNum, {
source: params.name,
@@ -3,8 +3,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getVolumeArchive } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { inspectVolume } from '$lib/server/docker';
import { authorize } from '$lib/server/authorize';
import { validateDockerIdParam } from '$lib/server/docker-validation';
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.name, 'volume');
if (invalid) return invalid;
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
+8 -7
View File
@@ -51,7 +51,7 @@
FileX
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import {
auditSseConnected,
connectAuditSSE,
@@ -701,14 +701,17 @@
<!-- Environment filter -->
{#if environments.length > 0}
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
{@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server}
<Select.Root
type="single"
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
>
<Select.Trigger size="sm" class="w-40 text-sm">
<SelectedEnvIcon class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{#if selectedEnv}
<EnvironmentIcon icon={selectedEnv.icon || 'globe'} envId={selectedEnv.id} class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{:else}
<Server class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
{/if}
<span class="truncate">
{#if filterEnvironmentId === null}
Environment
@@ -723,9 +726,8 @@
All environments
</Select.Item>
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Select.Item value={String(env.id)}>
<EnvIcon class="w-4 h-4 mr-2 text-muted-foreground" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-4 h-4 mr-2 text-muted-foreground" />
{env.name}
</Select.Item>
{/each}
@@ -870,9 +872,8 @@
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(log.createdAt)}</span>
{:else if column.id === 'environment'}
{#if log.environmentName}
{@const LogEnvIcon = getIconComponent(log.environmentIcon || 'globe')}
<div class="flex items-center gap-1 text-xs">
<LogEnvIcon class="w-3 h-3 text-muted-foreground shrink-0" />
<EnvironmentIcon icon={log.environmentIcon || 'globe'} envId={log.environmentId || 0} class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{log.environmentName}</span>
</div>
{:else}
+52 -8
View File
@@ -265,6 +265,7 @@
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
let updateCheckProgress = $state({ checked: 0, total: 0 });
let updateCheckBtnEl = $state<HTMLButtonElement | null>(null);
let failedUpdateChecks = $state<Array<{ containerName: string; imageName: string; error: string }>>([]);
let showBatchUpdateModal = $state(false);
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
@@ -424,9 +425,24 @@
}, 3000));
}
function showFailedChecksToast(failed: typeof failedUpdateChecks, prefix: string) {
const details = failed.map(f => `• ${f.containerName}: ${f.error}`).join('\n');
toast.warning(`${prefix} (${failed.length} failed to check)`, {
description: details,
descriptionClass: 'whitespace-pre-line',
class: '!w-[28rem] !max-w-[28rem]',
duration: Infinity,
action: {
label: 'OK',
onClick: () => {}
}
});
}
async function checkForUpdates() {
updateCheckStatus = 'checking';
updateCheckProgress = { checked: 0, total: 0 };
failedUpdateChecks = [];
// Lock button width to prevent layout shift
if (updateCheckBtnEl) {
@@ -455,18 +471,24 @@
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
const failedChecks = data.results.filter((r: any) => r.error && !r.hasUpdate).length;
const failedSuffix = failedChecks > 0 ? ` (${failedChecks} failed to check)` : '';
const failed = data.results.filter((r: any) => r.error && !r.hasUpdate);
failedUpdateChecks = failed.map((r: any) => ({
containerName: r.containerName,
imageName: r.imageName,
error: r.error
}));
if (containersWithUpdates.length === 0) {
updateCheckStatus = 'none';
containerStore.setPendingUpdates([], new Map());
if (failedChecks > 0) {
toast.warning(`All containers are up to date${failedSuffix}`);
if (failed.length > 0) {
updateCheckStatus = 'none';
showFailedChecksToast(failedUpdateChecks, 'All containers are up to date');
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
} else {
updateCheckStatus = 'none';
toast.success('All containers are up to date');
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
}
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
return;
}
@@ -476,7 +498,11 @@
new Map(containersWithUpdates.map((r: any) => [r.containerId, r.containerName]))
);
updateCheckStatus = 'found';
toast.info(`${containersWithUpdates.length} update(s) available${failedSuffix}`);
if (failed.length > 0) {
showFailedChecksToast(failedUpdateChecks, `${containersWithUpdates.length} update(s) available`);
} else {
toast.info(`${containersWithUpdates.length} update(s) available`);
}
} catch (error) {
updateCheckStatus = 'error';
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
@@ -1823,7 +1849,25 @@
{/if}
</div>
{:else if column.id === 'ip'}
<code class="text-xs">{getContainerIp(container.networks)}</code>
{@const networkEntries = container.networks ? Object.entries(container.networks) : []}
{@const primaryIp = getContainerIp(container.networks)}
{#if networkEntries.length > 1 && primaryIp !== '-'}
<Tooltip.Root>
<Tooltip.Trigger>
<span class="inline-flex items-center gap-1">
<code class="text-xs">{primaryIp}</code>
<span class="text-2xs px-1 py-0.5 rounded bg-muted text-muted-foreground font-medium">+{networkEntries.length - 1}</span>
</span>
</Tooltip.Trigger>
<Tooltip.Content side="top" class="max-w-none">
{#each networkEntries as [name, net]}
<div class="font-mono text-xs">{name}: {net.ipAddress || 'no IP'}</div>
{/each}
</Tooltip.Content>
</Tooltip.Root>
{:else}
<code class="text-xs">{primaryIp}</code>
{/if}
{:else if column.id === 'ports'}
{#if ports.length > 0}
{@const compactPorts = $appSettings.compactPorts}
+2 -48
View File
@@ -3,7 +3,7 @@
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Progress } from '$lib/components/ui/progress';
import * as Tooltip from '$lib/components/ui/tooltip';
import { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte';
import { appendEnvParam } from '$lib/stores/environment';
import type { VulnerabilityCriteria } from '$lib/server/db';
@@ -68,7 +68,6 @@
error?: string;
pullLogs: PullLogEntry[];
scanLogs: ScanLogEntry[];
scanResult?: ScanResult;
scannerResults?: ScannerResult[];
vulnerabilities?: VulnerabilityEntry[];
blockReason?: string;
@@ -223,7 +222,6 @@
if (data.message && data.scannerResults && data.scannerResults.length > 1) {
containerProgress.scanLogs.push({ message: data.message });
}
containerProgress.scanResult = data.scanResult;
containerProgress.scannerResults = data.scannerResults;
containerProgress.vulnerabilities = data.vulnerabilities;
progress = [...progress];
@@ -234,7 +232,6 @@
if (existingIndex >= 0) {
progress[existingIndex].step = 'blocked';
progress[existingIndex].success = false;
progress[existingIndex].scanResult = data.scanResult;
progress[existingIndex].scannerResults = data.scannerResults;
progress[existingIndex].blockReason = data.blockReason;
progress = [...progress];
@@ -465,52 +462,9 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
{/if}
</div>
<!-- Scan result badges - show per scanner when available -->
<!-- Scan result badges per scanner -->
{#if item.scannerResults && item.scannerResults.length > 0}
<ScannerSeverityPills results={item.scannerResults} />
{:else if item.scanResult}
<div class="flex items-center gap-1 text-xs shrink-0">
{#if item.scanResult.critical > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="destructive" class="px-1.5 py-0 cursor-help">C:{item.scanResult.critical}</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{item.scanResult.critical} Critical vulnerabilities</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if item.scanResult.high > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="destructive" class="px-1.5 py-0 bg-orange-500 cursor-help">H:{item.scanResult.high}</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{item.scanResult.high} High severity vulnerabilities</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if item.scanResult.medium > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="secondary" class="px-1.5 py-0 bg-amber-500 text-white cursor-help">M:{item.scanResult.medium}</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{item.scanResult.medium} Medium severity vulnerabilities</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
{#if item.scanResult.low > 0}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant="secondary" class="px-1.5 py-0 cursor-help">L:{item.scanResult.low}</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p>{item.scanResult.low} Low severity vulnerabilities</p>
</Tooltip.Content>
</Tooltip.Root>
{/if}
</div>
{/if}
{#if item.success === true}
+19 -5
View File
@@ -20,6 +20,7 @@
maxW?: number;
minH?: number;
maxH?: number;
locked?: boolean;
onchange?: (items: GridItemLayout[]) => void;
onitemclick?: (id: number) => void;
children: import('svelte').Snippet<[{ item: GridItemLayout; width: number; height: number }]>;
@@ -34,6 +35,7 @@
maxW = 2,
minH = 1,
maxH = 4,
locked = false,
onchange,
onitemclick,
children
@@ -202,6 +204,11 @@
if (e.button !== 0) return;
e.preventDefault();
if (locked) {
onitemclick?.(item.id);
return;
}
dragItem = item;
dragStartX = e.clientX;
dragStartY = e.clientY;
@@ -409,6 +416,11 @@
class:resizing={isResizing}
style="left: {gridToPixel(item.x, colWidth)}px; top: {gridToPixel(item.y, rowHeight)}px; width: {currentWidth}px; height: {currentHeight}px; {isDragTarget ? `transform: translate(${dragOffsetX}px, ${dragOffsetY}px);` : ''}"
onpointerdown={(e) => {
if (locked) {
e.preventDefault();
onitemclick?.(item.id);
return;
}
// Check if clicking on resize handle area (bottom-right 28x28 corner)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left;
@@ -428,11 +440,13 @@
</div>
<!-- Resize handle visual indicator -->
<div class="tile-resize-handle">
<svg viewBox="0 0 10 10" fill="currentColor">
<path d="M9 1v8H1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
</svg>
</div>
{#if !locked}
<div class="tile-resize-handle">
<svg viewBox="0 0 10 10" fill="currentColor">
<path d="M9 1v8H1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
</svg>
</div>
{/if}
</div>
{/each}
</div>
@@ -0,0 +1,293 @@
<script lang="ts">
import { Loader2, Circle, Route, UndoDot, Plug, Icon, CircleArrowUp } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { getLabelColors } from '$lib/utils/label-colors';
import { DataGrid } from '$lib/components/data-grid';
import type { DataGridRowState, DataGridSortState } from '$lib/components/data-grid/types';
import type { ColumnConfig } from '$lib/types';
import type { TileItem } from '$lib/stores/dashboard';
interface Props {
tiles: TileItem[];
searchQuery?: string;
connectionFilter?: string[];
onrowclick?: (envId: number) => void;
}
let { tiles, searchQuery = '', connectionFilter = [], onrowclick }: Props = $props();
// Sort state
let sortState = $state<DataGridSortState>({ field: 'name', direction: 'asc' });
function connectionLabel(type: string | undefined): string {
switch (type) {
case 'hawser-standard': return 'Standard';
case 'hawser-edge': return 'Edge';
case 'direct': return 'Direct';
case 'socket': return 'Socket';
default: return 'Socket';
}
}
function formatPercent(value: number | undefined | null): string {
if (value == null || value < 0) return '-';
return `${value.toFixed(1)}%`;
}
function getSortValue(tile: TileItem, field: string): number | string {
const s = tile.stats;
if (!s) return '';
switch (field) {
case 'name': return s.name.toLowerCase();
case 'status': return s.online === true ? 0 : s.online === false ? 2 : 1;
case 'connection': return s.connectionType || '';
case 'host': return s.host || '';
case 'containers': return s.containers.running;
case 'cpu': return s.metrics?.cpuPercent ?? -1;
case 'memory': return s.metrics?.memoryPercent ?? -1;
case 'images': return s.images.total;
case 'volumes': return s.volumes.total;
case 'stacks': return s.stacks.running;
case 'updates': return s.containers.pendingUpdates ?? 0;
case 'events': return s.events.today;
default: return '';
}
}
// Filter by search query and connection type
const filteredTiles = $derived.by(() => {
let result = tiles;
// Connection type filter
if (connectionFilter.length > 0) {
result = result.filter(t => {
const type = t.stats?.connectionType || 'socket';
return connectionFilter.includes(type);
});
}
// Search filter
const q = searchQuery.trim().toLowerCase();
if (q) {
result = result.filter(t => {
const s = t.stats;
if (!s) return false;
if (s.name.toLowerCase().includes(q)) return true;
if (s.host?.toLowerCase().includes(q)) return true;
if (connectionLabel(s.connectionType).toLowerCase().includes(q)) return true;
if (s.labels?.some(l => l.toLowerCase().includes(q))) return true;
return false;
});
}
return result;
});
// Sort filtered tiles
const sortedTiles = $derived.by(() => {
const field = sortState.field || 'name';
const dir = sortState.direction || 'asc';
return [...filteredTiles].sort((a, b) => {
const aVal = getSortValue(a, field);
const bVal = getSortValue(b, field);
if (aVal < bVal) return dir === 'asc' ? -1 : 1;
if (aVal > bVal) return dir === 'asc' ? 1 : -1;
return 0;
});
});
</script>
<div class="flex-1 min-h-0 flex flex-col overflow-hidden">
<!-- DataGrid -->
<DataGrid
data={sortedTiles}
keyField="id"
gridId="environments"
loading={false}
{sortState}
onSortChange={(state) => { sortState = state; }}
onRowClick={(tile, e) => onrowclick?.(tile.id)}
rowHeight={36}
>
{#snippet cell(column, tile, rowState)}
{@const s = tile.stats}
{@const isOnline = s?.online === true}
{@const isOffline = s?.online === false}
{#if column.id === 'status'}
{#if tile.loading && !s}
<Loader2 class="w-3.5 h-3.5 animate-spin text-muted-foreground" />
{:else if isOnline}
<Circle class="w-3 h-3 fill-emerald-500 text-emerald-500" />
{:else if isOffline}
<Circle class="w-3 h-3 fill-destructive text-destructive" />
{:else}
<Circle class="w-3 h-3 fill-amber-500 text-amber-500" />
{/if}
{:else if column.id === 'name'}
{#if s}
<div class="flex items-center gap-2 min-w-0">
<EnvironmentIcon icon={s.icon || 'globe'} envId={s.id} class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="font-medium truncate">{s.name}</span>
</div>
{:else if tile.info}
<div class="flex items-center gap-2 min-w-0">
<EnvironmentIcon icon={tile.info.icon || 'globe'} envId={tile.info.id} class="w-4 h-4 text-muted-foreground shrink-0" />
<span class="font-medium truncate">{tile.info.name}</span>
</div>
{:else}
<div class="h-4 w-32 bg-muted animate-pulse rounded" />
{/if}
{:else if column.id === 'connection'}
{#if s}
<div class="flex items-center gap-1.5 text-muted-foreground">
{#if s.connectionType === 'hawser-standard'}
<Route class="w-3.5 h-3.5 shrink-0" />
{:else if s.connectionType === 'hawser-edge'}
<UndoDot class="w-3.5 h-3.5 shrink-0" />
{:else if s.connectionType === 'direct'}
<Plug class="w-3.5 h-3.5 shrink-0" />
{:else}
<Icon iconNode={whale} class="w-3.5 h-3.5 shrink-0" />
{/if}
<span class="text-xs">{connectionLabel(s.connectionType)}</span>
</div>
{:else if tile.loading}
<div class="h-4 w-16 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'host'}
{#if s?.host && !isOffline}
<span class="text-xs text-muted-foreground font-mono truncate block" title={s.port ? `${s.host}:${s.port}` : s.host}>
{s.host}{s.port ? `:${s.port}` : ''}
</span>
{:else if s?.socketPath}
<span class="text-xs text-muted-foreground font-mono truncate block" title={s.socketPath}>
{s.socketPath}
</span>
{:else if tile.loading}
<div class="h-4 w-20 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'containers'}
{#if s && !isOffline}
<span class="text-emerald-500 font-medium">{s.containers.running}</span>
<span class="text-muted-foreground">/ {s.containers.total}</span>
{:else if tile.loading}
<div class="h-4 w-12 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'updates'}
{#if s && !isOffline}
{#if s.containers.pendingUpdates > 0}
<div class="flex items-center gap-1">
<CircleArrowUp class="w-3.5 h-3.5 text-amber-500" />
<span class="text-amber-500 font-medium">{s.containers.pendingUpdates}</span>
</div>
{:else}
<span class="text-muted-foreground">0</span>
{/if}
{:else if tile.loading}
<div class="h-4 w-8 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'cpu'}
{#if s?.metrics && !isOffline}
<div class="flex items-center gap-2">
<div class="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all {s.metrics.cpuPercent > 80 ? 'bg-destructive' : s.metrics.cpuPercent > 60 ? 'bg-amber-500' : 'bg-primary'}"
style="width: {Math.min(s.metrics.cpuPercent, 100)}%"
/>
</div>
<span class="text-xs tabular-nums">{formatPercent(s.metrics.cpuPercent)}</span>
</div>
{:else if tile.loading}
<div class="h-4 w-16 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'memory'}
{#if s?.metrics && !isOffline}
<div class="flex items-center gap-2">
<div class="w-12 h-1.5 bg-muted rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all {s.metrics.memoryPercent > 80 ? 'bg-destructive' : s.metrics.memoryPercent > 60 ? 'bg-amber-500' : 'bg-primary'}"
style="width: {Math.min(s.metrics.memoryPercent, 100)}%"
/>
</div>
<span class="text-xs tabular-nums">{formatPercent(s.metrics.memoryPercent)}</span>
</div>
{:else if tile.loading}
<div class="h-4 w-16 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'images'}
{#if s && !isOffline}
{s.images.total}
{:else if tile.loading}
<div class="h-4 w-8 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'volumes'}
{#if s && !isOffline}
{s.volumes.total}
{:else if tile.loading}
<div class="h-4 w-8 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'stacks'}
{#if s && !isOffline}
<span class="text-emerald-500 font-medium">{s.stacks.running}</span>
<span class="text-muted-foreground">/ {s.stacks.total}</span>
{:else if tile.loading}
<div class="h-4 w-12 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'events'}
{#if s && !isOffline}
{s.events.today}
{:else if tile.loading}
<div class="h-4 w-8 bg-muted animate-pulse rounded" />
{:else}
<span class="text-muted-foreground">-</span>
{/if}
{:else if column.id === 'labels'}
{#if s?.labels && s.labels.length > 0}
<div class="flex flex-wrap gap-1">
{#each s.labels as label}
{@const colors = getLabelColors(label)}
<span
class="px-1.5 py-0.5 text-[11px] rounded-sm font-medium leading-tight whitespace-nowrap"
style="background-color: {colors.bgColor}; color: {colors.color}"
>
{label}
</span>
{/each}
</div>
{/if}
{/if}
{/snippet}
</DataGrid>
</div>
+6 -8
View File
@@ -2,7 +2,7 @@
import * as Card from '$lib/components/ui/card';
import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp, Loader2 } from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { goto } from '$app/navigation';
import { canAccess } from '$lib/stores/auth';
import type { EnvironmentStats } from '../api/dashboard/stats/+server';
@@ -31,8 +31,6 @@
let { stats, width = 1, height = 1, oneventsclick, showStacksBreakdown = true }: Props = $props();
const EnvIcon = $derived(getIconComponent(stats.icon));
// Specific tile size conditionals for easy customization
const is1x1 = $derived(width === 1 && height === 1);
const is1x2 = $derived(width === 1 && height === 2);
@@ -64,7 +62,7 @@
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
<EnvironmentIcon icon={stats.icon} envId={stats.id} class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
@@ -160,7 +158,7 @@
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
<EnvironmentIcon icon={stats.icon} envId={stats.id} class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
@@ -263,7 +261,7 @@
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
<EnvironmentIcon icon={stats.icon} envId={stats.id} class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
@@ -364,7 +362,7 @@
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
<EnvironmentIcon icon={stats.icon} envId={stats.id} class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
@@ -468,7 +466,7 @@
<!-- Left: Icons + Name/Host -->
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
<div class="p-1.5 rounded-lg shrink-0 {stats.online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
<EnvironmentIcon icon={stats.icon} envId={stats.id} class="w-4 h-4 {stats.online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if stats.connectionType === 'socket' || !stats.connectionType}
<span title="Unix socket connection" class="shrink-0">
+3 -5
View File
@@ -15,10 +15,9 @@
Loader2
} from 'lucide-svelte';
import { whale } from '@lucide/lab';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { goto } from '$app/navigation';
import { canAccess } from '$lib/stores/auth';
import type { Component } from 'svelte';
type ConnectionType = 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
@@ -71,7 +70,6 @@
(port ? `${host}:${port}` : host || 'Unknown host')
);
const EnvIcon = $derived(getIconComponent(icon)) as Component;
const canEdit = $derived($canAccess('environments', 'edit'));
function openSettings(e: MouseEvent) {
@@ -88,7 +86,7 @@
<!-- Compact header for mini tiles -->
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="p-1.5 rounded-lg {online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {online ? 'text-primary' : 'text-muted-foreground'}" />
<EnvironmentIcon {icon} envId={environmentId} class="w-4 h-4 {online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
@@ -109,7 +107,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="p-1.5 rounded-lg {online ? 'bg-primary/10' : 'bg-muted'}">
<EnvIcon class="w-4 h-4 {online ? 'text-primary' : 'text-muted-foreground'}" />
<EnvironmentIcon {icon} envId={environmentId} class="w-4 h-4 {online ? 'text-primary' : 'text-muted-foreground'}" />
</div>
{#if connectionType === 'socket' || !connectionType}
<span title="Unix socket connection">
+20 -1
View File
@@ -10,7 +10,7 @@
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal } from 'lucide-svelte';
import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import PageHeader from '$lib/components/PageHeader.svelte';
import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
@@ -1290,6 +1290,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
}
// Clear displayed logs
function clearLogs() {
logs = '';
pendingText = '';
mergedLogs = [];
mergedHtml = '';
pendingLogs = [];
}
// Log search functions
function toggleLogSearch() {
logSearchActive = !logSearchActive;
@@ -1960,6 +1969,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
<button onclick={downloadLogs} class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}" title="Download logs">
<Download class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<button onclick={clearLogs} class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}" title="Clear logs">
<Eraser class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
</div>
</div>
<div class="flex-1 overflow-auto p-4 relative" bind:this={logsRef}>
@@ -2137,6 +2149,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
>
<Download class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<button
onclick={clearLogs}
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
title="Clear logs"
>
<Eraser class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<button
onclick={() => fetchLogs()}
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
+17 -2
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type } from 'lucide-svelte';
import { RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, X, Type, Eraser } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
import { AnsiUp } from 'ansi_up';
@@ -15,6 +16,7 @@
autoRefresh?: boolean;
autoScroll?: boolean;
onRefresh?: () => void;
onClear?: () => void;
onAutoRefreshChange?: (value: boolean) => void;
onAutoScrollChange?: (value: boolean) => void;
class?: string;
@@ -27,6 +29,7 @@
autoRefresh = true,
autoScroll = true,
onRefresh,
onClear,
onAutoRefreshChange,
onAutoScrollChange,
class: className = ''
@@ -144,7 +147,11 @@
// Highlighted logs with search matches and ANSI color support
let highlightedLogs = $derived(() => {
const withAnsi = ansiUp.ansi_to_html(logs || '');
let text = logs || '';
if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!logSearchQuery.trim()) return withAnsi;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -279,6 +286,14 @@
>
<Download class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
</button>
<!-- Clear -->
<button
onclick={() => onClear?.()}
class="p-1 rounded hover:bg-zinc-800 transition-colors"
title="Clear logs"
>
<Eraser class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
</button>
<!-- Refresh -->
<button
onclick={() => onRefresh?.()}
+21 -3
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play } from 'lucide-svelte';
import { X, GripHorizontal, RefreshCw, Copy, Download, WrapText, ArrowDownToLine, Search, ChevronUp, ChevronDown, Sun, Moon, Wifi, WifiOff, Pause, Play, Eraser } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import * as Select from '$lib/components/ui/select';
import { appSettings } from '$lib/stores/settings';
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
import { themeStore } from '$lib/stores/theme';
import { getMonospaceFont } from '$lib/themes';
import { AnsiUp } from 'ansi_up';
@@ -469,6 +469,12 @@
}
}
// Clear logs buffer
function clearLogs() {
logs = '';
pendingText = '';
}
// Search functions
function toggleLogSearch() {
logSearchActive = !logSearchActive;
@@ -524,7 +530,11 @@
// Highlighted logs with search matches and ANSI color support
let highlightedLogs = $derived(() => {
const withAnsi = ansiUp.ansi_to_html(logs || '');
let text = logs || '';
if ($appSettings.formatLogTimestamps) {
text = formatLogTimestamps(text);
}
const withAnsi = ansiUp.ansi_to_html(text);
if (!logSearchQuery.trim()) return withAnsi;
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -764,6 +774,14 @@
>
<Download class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Clear -->
<button
onclick={clearLogs}
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-300'}"
title="Clear logs"
>
<Eraser class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
</button>
<!-- Refresh -->
<button
onclick={fetchLogs}
+2 -3
View File
@@ -42,7 +42,7 @@
import type { DataGridRowState } from '$lib/components/data-grid';
import { toast } from 'svelte-sonner';
import { formatDateTime, appSettings } from '$lib/stores/settings';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
@@ -991,9 +991,8 @@
</button>
{/if}
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon)}
<Select.Item value={String(env.id)}>
<EnvIcon class="w-4 h-4 mr-2 inline" />
<EnvironmentIcon icon={env.icon} envId={env.id} class="w-4 h-4 mr-2 inline" />
{env.name}
</Select.Item>
{/each}
@@ -6,7 +6,7 @@
import { Checkbox } from '$lib/components/ui/checkbox';
import { TogglePill } from '$lib/components/ui/toggle-pill';
import { Shield, Pencil, Plus, Check, RefreshCw, Box, Image, HardDrive, Cable, Layers, Globe, Download, Bell, Sliders, Settings, Users, Eye, SquarePlus, Play, Square, RotateCcw, Trash2, Terminal, ScrollText, Search, Upload, Plug, Unplug, Copy, GitBranch, KeyRound, Building2, Container, TriangleAlert, ClipboardList, Activity, Timer } from 'lucide-svelte';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import * as Alert from '$lib/components/ui/alert';
import { focusFirstInput } from '$lib/utils';
@@ -546,13 +546,12 @@
{#if !formAllEnvironments}
<div class="mt-3 grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<label class="flex items-center gap-2 p-2 border rounded-md cursor-pointer hover:bg-muted/50 transition-colors text-xs {formEnvironmentIds.includes(env.id) ? 'border-primary bg-primary/5' : ''}">
<Checkbox
checked={formEnvironmentIds.includes(env.id)}
onCheckedChange={() => toggleEnvironment(env.id)}
/>
<EnvIcon class="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground" />
<span class="truncate">{env.name}</span>
</label>
{/each}
@@ -47,7 +47,7 @@
import { canAccess } from '$lib/stores/auth';
import { licenseStore } from '$lib/stores/license';
import RoleModal from './RoleModal.svelte';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
interface Role {
id: number;
@@ -356,9 +356,8 @@
.map(id => environments.find(e => e.id === id))
.filter(Boolean)}
{#each envs as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs border bg-indigo-500/15 text-indigo-700 dark:text-indigo-400 border-indigo-500/30">
<EnvIcon class="w-3 h-3" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-3 h-3" />
{env.name}
</span>
{/each}
@@ -59,12 +59,16 @@
Tags,
ChevronDown,
ChevronRight,
XCircle
XCircle,
ImageUp
} from 'lucide-svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import * as Alert from '$lib/components/ui/alert';
import * as Popover from '$lib/components/ui/popover';
import IconPicker from '$lib/components/icon-picker.svelte';
import AvatarCropper from '$lib/components/AvatarCropper.svelte';
import { isCustomIcon } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { whale } from '@lucide/lab';
@@ -259,6 +263,11 @@
let formTlsKey = $state('');
let formTlsSkipVerify = $state(false);
let formIcon = $state('globe');
let pendingIconData = $state<string | null>(null);
let iconCropperImageUrl = $state('');
let showIconCropper = $state(false);
let iconFileInput: HTMLInputElement;
let iconCacheBust = $state(Date.now());
let formSocketPath = $state('/var/run/docker.sock');
let formCollectActivity = $state(true);
let formCollectMetrics = $state(true);
@@ -282,6 +291,66 @@
* Clean a PEM certificate - remove leading/trailing whitespace from each line
* This handles copy/paste from formatted output with line numbers
*/
function handleIconFileSelect(e: Event) {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
iconCropperImageUrl = reader.result as string;
showIconCropper = true;
};
reader.readAsDataURL(file);
input.value = '';
}
async function handleIconCropSave(dataUrl: string) {
showIconCropper = false;
if (environment) {
// Edit mode: upload immediately
const res = await fetch(`/api/environments/${environment.id}/icon`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: dataUrl })
});
if (res.ok) {
const result = await res.json();
formIcon = result.icon;
iconCacheBust = Date.now();
pendingIconData = null;
} else {
toast.error('Failed to upload icon');
}
} else {
// Create mode: store for later upload after environment is created
pendingIconData = dataUrl;
formIcon = 'custom:pending';
}
}
async function uploadPendingIcon(envId: number) {
if (!pendingIconData) return;
await fetch(`/api/environments/${envId}/icon`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: pendingIconData })
});
pendingIconData = null;
}
async function removeCustomIcon() {
if (environment) {
const res = await fetch(`/api/environments/${environment.id}/icon`, { method: 'DELETE' });
if (res.ok) {
formIcon = 'globe';
iconCacheBust = Date.now();
}
} else {
pendingIconData = null;
formIcon = 'globe';
}
}
function cleanCertificate(cert: string | undefined): string | undefined {
if (!cert) return undefined;
const cleaned = cert
@@ -367,6 +436,8 @@
let scannerAvailability = $state<{ grype: boolean; trivy: boolean }>({ grype: false, trivy: false });
let scannerVersions = $state<{ grype: string | null; trivy: string | null }>({ grype: null, trivy: null });
let scannerLoading = $state(true);
let scannerGrypeImage = $state('anchore/grype:v0.110.0');
let scannerTrivyImage = $state('aquasec/trivy:0.69.3');
let loadingScannerVersions = $state(false);
let removingGrype = $state(false);
let removingTrivy = $state(false);
@@ -454,6 +525,9 @@
newLabelInput = '';
formPublicIp = environment.publicIp || '';
modalTab = 'general';
// Reset icon state
pendingIconData = null;
iconCacheBust = Date.now();
// Reset token state for this environment (important when switching between envs)
hawserToken = null;
generatedToken = null;
@@ -479,6 +553,7 @@
formTlsKey = '';
formTlsSkipVerify = false;
formIcon = 'globe';
pendingIconData = null;
formSocketPath = '/var/run/docker.sock';
formCollectActivity = true;
formCollectMetrics = true;
@@ -668,7 +743,7 @@
tlsCert: cleanCertificate(formTlsCert),
tlsKey: cleanCertificate(formTlsKey),
tlsSkipVerify: formTlsSkipVerify,
icon: formIcon,
icon: pendingIconData ? 'globe' : formIcon,
socketPath: formConnectionType === 'socket' ? formSocketPath : undefined,
collectActivity: formCollectActivity,
collectMetrics: formCollectMetrics,
@@ -682,6 +757,10 @@
if (response.ok) {
const newEnv = await response.json();
// Upload pending custom icon if set
if (pendingIconData && newEnv?.id) {
await uploadPendingIcon(newEnv.id);
}
// If scanner was enabled, save scanner settings for the new environment
if (formEnableScanner && newEnv?.id) {
scannerEnabled = true;
@@ -876,11 +955,15 @@
async function saveTimezone(envId: number) {
try {
await fetch(`/api/environments/${envId}/timezone`, {
const response = await fetch(`/api/environments/${envId}/timezone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ timezone: formTimezone })
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
console.error('Failed to save timezone:', data.error || response.status);
}
} catch (error) {
console.error('Failed to save timezone:', error);
}
@@ -898,6 +981,8 @@
const savedScanner = settingsData.settings.scanner || 'none';
scannerEnabled = savedScanner !== 'none';
selectedScanner = savedScanner === 'none' ? 'both' : savedScanner;
if (settingsData.settings.grypeImage) scannerGrypeImage = settingsData.settings.grypeImage;
if (settingsData.settings.trivyImage) scannerTrivyImage = settingsData.settings.trivyImage;
}
scannerLoading = false;
loadScannerVersionsAsync(envId);
@@ -1122,7 +1207,7 @@
const response = await fetch(pullUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: 'anchore/grype:latest' })
body: JSON.stringify({ image: scannerGrypeImage })
});
if (!response.ok) {
@@ -1154,7 +1239,7 @@
const response = await fetch(pullUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: 'aquasec/trivy:latest' })
body: JSON.stringify({ image: scannerTrivyImage })
});
if (!response.ok) {
@@ -1382,7 +1467,23 @@
<div class="space-y-2">
<Label for="edit-env-name">Name</Label>
<div class="flex gap-2">
<IconPicker value={formIcon} onchange={(icon) => formIcon = icon} />
{#if isCustomIcon(formIcon) || pendingIconData}
<Button variant="outline" size="sm" class="h-9 w-9 p-0 relative group" type="button" onclick={() => iconFileInput?.click()}>
{#if pendingIconData}
<img src={pendingIconData} alt="" class="w-5 h-5 rounded object-cover" />
{:else if environment}
<EnvironmentIcon icon={formIcon} envId={environment.id} class="w-5 h-5" cacheBust={iconCacheBust} />
{/if}
</Button>
<Button variant="ghost" size="sm" class="h-9 w-9 p-0" type="button" title="Remove custom icon" onclick={removeCustomIcon}>
<X class="w-3.5 h-3.5 text-muted-foreground" />
</Button>
{:else}
<IconPicker value={formIcon} onchange={(icon) => formIcon = icon} />
<Button variant="ghost" size="sm" class="h-9 w-9 p-0" type="button" title="Upload custom icon" onclick={() => iconFileInput?.click()}>
<ImageUp class="w-4 h-4 text-muted-foreground" />
</Button>
{/if}
<Input
id="edit-env-name"
bind:value={formName}
@@ -1395,6 +1496,13 @@
<p class="text-xs text-destructive">{formErrors.name}</p>
{/if}
</div>
<input
type="file"
accept="image/*"
class="hidden"
bind:this={iconFileInput}
onchange={handleIconFileSelect}
/>
<!-- Labels section -->
<div class="space-y-2">
@@ -2192,7 +2300,7 @@
{/if}
{#if !loadingScannerVersions}
{#if !scannerAvailability.grype}
<ImagePullProgressPopover imageName="anchore/grype:latest" envId={environment?.id} onComplete={() => reloadScannerAvailability(environment?.id)}>
<ImagePullProgressPopover imageName={scannerGrypeImage} envId={environment?.id} onComplete={() => reloadScannerAvailability(environment?.id)}>
<button class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
<Download class="w-2.5 h-2.5 mr-0.5" />
Pull
@@ -2269,7 +2377,7 @@
{/if}
{#if !loadingScannerVersions}
{#if !scannerAvailability.trivy}
<ImagePullProgressPopover imageName="aquasec/trivy:latest" envId={environment?.id} onComplete={() => reloadScannerAvailability(environment?.id)}>
<ImagePullProgressPopover imageName={scannerTrivyImage} envId={environment?.id} onComplete={() => reloadScannerAvailability(environment?.id)}>
<button class="inline-flex items-center text-2xs px-1.5 py-0 h-4 rounded-full border bg-muted/50 hover:bg-muted text-muted-foreground hover:text-foreground transition-colors">
<Download class="w-2.5 h-2.5 mr-0.5" />
Pull
@@ -2606,5 +2714,17 @@
{/if}
</div>
</Dialog.Footer>
<AvatarCropper
show={showIconCropper}
imageUrl={iconCropperImageUrl}
cropShape="round"
outputSize={128}
outputFormat="image/webp"
outputQuality={0.85}
title="Crop icon"
saveLabel="Save icon"
onCancel={() => showIconCropper = false}
onSave={handleIconCropSave}
/>
</Dialog.Content>
</Dialog.Root>
@@ -29,7 +29,7 @@
import { broom, whale } from '@lucide/lab';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { canAccess } from '$lib/stores/auth';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { getLabelColors } from '$lib/utils/label-colors';
import EnvironmentModal from './EnvironmentModal.svelte';
import { environments as environmentsStore } from '$lib/stores/environment';
@@ -380,12 +380,11 @@
{@const testResult = testResults[env.id]}
{@const isTesting = testingEnvs.has(env.id)}
{@const hasScannerEnabled = envScannerStatus[env.id]}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Table.Row>
<!-- Name Column -->
<Table.Cell>
<div class="flex items-center gap-2">
<EnvIcon class="w-4 h-4 text-muted-foreground shrink-0" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-4 h-4 text-muted-foreground shrink-0" />
{#if env.connectionType === 'socket' || !env.connectionType}
<span title="Unix socket connection" class="shrink-0">
<Unplug class="w-3.5 h-3.5 text-cyan-500 glow-cyan" />
@@ -55,6 +55,7 @@
{ id: 'container_restarted', label: 'Container restarted', description: 'When a container restarts' },
{ id: 'container_exited', label: 'Container exited', description: 'When a container exits unexpectedly' },
{ id: 'container_unhealthy', label: 'Container unhealthy', description: 'When a container health check fails' },
{ id: 'container_healthy', label: 'Container healthy', description: 'When a container health check recovers' },
{ id: 'container_oom', label: 'Container OOM killed', description: 'When a container is killed due to out of memory' },
{ id: 'container_updated', label: 'Container updated', description: 'When a container image is updated' }
]
@@ -108,6 +109,8 @@
icon: HardDrive,
events: [
{ id: 'image_pulled', label: 'Image pulled', description: 'When a new image is pulled' },
{ id: 'image_prune_success', label: 'Image prune completed', description: 'Scheduled image prune completed successfully' },
{ id: 'image_prune_failed', label: 'Image prune failed', description: 'Scheduled image prune failed' },
{ id: 'environment_offline', label: 'Environment offline', description: 'Environment became unreachable' },
{ id: 'environment_online', label: 'Environment online', description: 'Environment came back online' },
{ id: 'disk_space_warning', label: 'Disk space warning', description: 'Docker disk usage exceeds threshold' }
+102 -37
View File
@@ -8,7 +8,7 @@
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 } from 'lucide-svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info } from 'lucide-svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
@@ -25,6 +25,8 @@
let downloadFormat = $derived($appSettings.downloadFormat);
let defaultGrypeArgs = $derived($appSettings.defaultGrypeArgs);
let defaultTrivyArgs = $derived($appSettings.defaultTrivyArgs);
let defaultGrypeImage = $derived($appSettings.defaultGrypeImage);
let defaultTrivyImage = $derived($appSettings.defaultTrivyImage);
let scheduleRetentionDays = $derived($appSettings.scheduleRetentionDays);
let eventRetentionDays = $derived($appSettings.eventRetentionDays);
let scheduleCleanupCron = $derived($appSettings.scheduleCleanupCron);
@@ -32,6 +34,7 @@
let scheduleCleanupEnabled = $derived($appSettings.scheduleCleanupEnabled);
let eventCleanupEnabled = $derived($appSettings.eventCleanupEnabled);
let logBufferSizeKb = $derived($appSettings.logBufferSizeKb);
let formatLogTimestamps = $derived($appSettings.formatLogTimestamps);
let defaultTimezone = $derived($appSettings.defaultTimezone);
let eventCollectionMode = $derived($appSettings.eventCollectionMode);
let eventPollInterval = $derived($appSettings.eventPollInterval);
@@ -76,6 +79,22 @@
toast.success(eventCleanupEnabled ? 'Event cleanup disabled' : 'Event cleanup enabled');
}
function handleGrypeImageBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value && value !== defaultGrypeImage) {
appSettings.setDefaultGrypeImage(value);
toast.success('Grype image updated');
}
}
function handleTrivyImageBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value && value !== defaultTrivyImage) {
appSettings.setDefaultTrivyImage(value);
toast.success('Trivy image updated');
}
}
function handleGrypeArgsBlur(e: Event) {
const value = (e.target as HTMLInputElement).value.trim();
if (value !== defaultGrypeArgs) {
@@ -313,45 +332,69 @@
Logs & files
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-2">
<Label for="log-buffer-size">Log buffer size (KB)</Label>
<div class="flex items-center gap-2">
<Input
id="log-buffer-size"
type="number"
min="100"
max="5000"
value={logBufferSizeKb}
onchange={handleLogBufferSizeChange}
disabled={!$canAccess('settings', 'edit')}
class="w-24"
/>
<span class="text-sm text-muted-foreground">KB</span>
<Card.Content>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div class="space-y-4">
<div class="space-y-2">
<Label for="log-buffer-size">Log buffer size (KB)</Label>
<div class="flex items-center gap-2">
<Input
id="log-buffer-size"
type="number"
min="100"
max="5000"
value={logBufferSizeKb}
onchange={handleLogBufferSizeChange}
disabled={!$canAccess('settings', 'edit')}
class="w-24"
/>
<span class="text-sm text-muted-foreground">KB</span>
</div>
<p class="text-xs text-muted-foreground">Maximum log buffer per container panel. Older logs are truncated when exceeded.</p>
{#if logBufferSizeKb > 1000}
<div class="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<p class="text-xs text-amber-600 dark:text-amber-400">High values may degrade browser performance with verbose containers. Recommended: 250-1000 KB.</p>
</div>
{/if}
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Download format</Label>
<ToggleSwitch
value={downloadFormat}
leftValue="tar"
rightValue="tar.gz"
onchange={(newFormat) => {
appSettings.setDownloadFormat(newFormat as DownloadFormat);
toast.success(`Download format set to ${newFormat}`);
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Archive format when downloading files from containers</p>
</div>
</div>
<p class="text-xs text-muted-foreground">Maximum log buffer per container panel. Older logs are truncated when exceeded.</p>
{#if logBufferSizeKb > 1000}
<div class="flex items-start gap-2 p-2 rounded-md bg-amber-500/10 border border-amber-500/20">
<AlertTriangle class="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />
<p class="text-xs text-amber-600 dark:text-amber-400">High values may degrade browser performance with verbose containers. Recommended: 250-1000 KB.</p>
<div class="space-y-4">
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Format log timestamps</Label>
<TogglePill
checked={formatLogTimestamps}
onchange={() => {
appSettings.setFormatLogTimestamps(!formatLogTimestamps);
toast.success(formatLogTimestamps ? 'Log timestamp formatting disabled' : 'Log timestamp formatting enabled');
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Convert ISO timestamps in logs to your configured date/time format</p>
<div class="flex items-start gap-1.5 mt-1">
<Info class="w-3.5 h-3.5 text-muted-foreground shrink-0 mt-0.5" />
<p class="text-xs text-muted-foreground">Docker logs use UTC timestamps by default. When enabled, timestamps like <code class="bg-muted px-1 rounded">2026-01-12T07:47:44Z</code> are converted to local time using your date/time settings.</p>
</div>
</div>
{/if}
</div>
<div class="space-y-1">
<div class="flex items-center gap-3">
<Label>Download format</Label>
<ToggleSwitch
value={downloadFormat}
leftValue="tar"
rightValue="tar.gz"
onchange={(newFormat) => {
appSettings.setDownloadFormat(newFormat as DownloadFormat);
toast.success(`Download format set to ${newFormat}`);
}}
disabled={!$canAccess('settings', 'edit')}
/>
</div>
<p class="text-xs text-muted-foreground">Archive format when downloading files from containers</p>
</div>
</Card.Content>
</Card.Root>
@@ -368,6 +411,28 @@
</Card.Title>
</Card.Header>
<Card.Content class="space-y-4">
<div class="space-y-2">
<Label for="grype-image">Grype image</Label>
<Input
id="grype-image"
value={defaultGrypeImage}
onblur={handleGrypeImageBlur}
disabled={!$canAccess('settings', 'edit')}
placeholder={"anchore/grype:v0.110.0"}
/>
<p class="text-xs text-muted-foreground">Docker image for Grype scanner. Pin to a specific version for supply chain security.</p>
</div>
<div class="space-y-2">
<Label for="trivy-image">Trivy image</Label>
<Input
id="trivy-image"
value={defaultTrivyImage}
onblur={handleTrivyImageBlur}
disabled={!$canAccess('settings', 'edit')}
placeholder={"aquasec/trivy:0.69.3"}
/>
<p class="text-xs text-muted-foreground">Docker image for Trivy scanner. Pin to a specific version for supply chain security.</p>
</div>
<div class="space-y-2">
<Label for="grype-args">Default Grype arguments</Label>
<Input
@@ -9,7 +9,7 @@
import * as Tooltip from '$lib/components/ui/tooltip';
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
interface RunningStackInfo {
envId: number;
@@ -121,7 +121,6 @@
const defaultEnv = $derived(environments.find(e => e.id === defaultEnvId));
const defaultEnvName = $derived(defaultEnv?.name || 'Select environment');
const DefaultEnvIcon = $derived(defaultEnv ? getIconComponent(defaultEnv.icon || 'globe') : null);
function isSelected(composePath: string): boolean {
return stackSelections.has(composePath);
@@ -338,17 +337,16 @@
}}
>
<Select.Trigger class="w-[220px]">
{#if DefaultEnvIcon}
<DefaultEnvIcon class="w-4 h-4 mr-2 shrink-0" />
{#if defaultEnv}
<EnvironmentIcon icon={defaultEnv.icon || 'globe'} envId={defaultEnv.id} class="w-4 h-4 mr-2 shrink-0" />
{/if}
<span class="truncate">{defaultEnvName}</span>
</Select.Trigger>
<Select.Content>
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Select.Item value={env.id.toString()}>
<div class="flex items-center gap-2">
<EnvIcon class="w-4 h-4 shrink-0" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-4 h-4 shrink-0" />
<span>{env.name}</span>
</div>
</Select.Item>
@@ -455,17 +453,16 @@
>
<Select.Trigger class="h-8 w-[220px] text-xs">
{#if getStackEnv(stack.composePath)}
{@const StackIcon = getIconComponent(getStackEnv(stack.composePath)?.icon || 'globe')}
<StackIcon class="w-3.5 h-3.5 mr-1.5 shrink-0" />
{@const stackEnv = getStackEnv(stack.composePath)}
<EnvironmentIcon icon={stackEnv?.icon || 'globe'} envId={stackEnv?.id || 0} class="w-3.5 h-3.5 mr-1.5 shrink-0" />
{/if}
<span class="truncate">{getStackEnv(stack.composePath)?.name || 'Select'}</span>
</Select.Trigger>
<Select.Content>
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Select.Item value={env.id.toString()}>
<div class="flex items-center gap-2">
<EnvIcon class="w-3.5 h-3.5 shrink-0" />
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-3.5 h-3.5 shrink-0" />
<span>{env.name}</span>
</div>
</Select.Item>
@@ -490,15 +487,12 @@
<div class="space-y-1.5">
{#each adoptedStacks as adopted}
{@const env = environments.find(e => e.id === adopted.envId)}
{@const EnvIcon = env ? getIconComponent(env.icon || 'globe') : null}
<div class="flex items-center gap-2 p-2 rounded-md bg-green-500/10 border border-green-500/20">
<CheckCircle2 class="w-4 h-4 text-green-600 dark:text-green-500 shrink-0" />
<p class="text-sm font-medium flex-1">{adopted.name}</p>
{#if env}
<div class="flex items-center gap-1.5 text-xs text-muted-foreground shrink-0">
{#if EnvIcon}
<EnvIcon class="w-3.5 h-3.5" />
{/if}
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="w-3.5 h-3.5" />
<span>{env.name}</span>
</div>
{/if}
+100 -1
View File
@@ -2,7 +2,8 @@
import * as Dialog from '$lib/components/ui/dialog';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, FolderOpen, File, FileText, ChevronRight, ArrowUp, AlertCircle, FolderPlus, Search, Import } from 'lucide-svelte';
import { Input } from '$lib/components/ui/input';
import { Loader2, FolderOpen, File, FileText, ChevronRight, ArrowUp, AlertCircle, FolderPlus, Search, Import, Check, X } from 'lucide-svelte';
import type { Component } from 'svelte';
import RecentLocationsPanel from './RecentLocationsPanel.svelte';
@@ -61,6 +62,12 @@
let selectedPath = $state<string | null>(null);
let selectedName = $state<string | null>(null);
// New folder creation
let creatingFolder = $state(false);
let newFolderName = $state('');
let createError = $state<string | null>(null);
let folderInputEl = $state<HTMLInputElement | null>(null);
// Reference to recent locations panel
let recentLocationsPanel = $state<{ addLocation: (path: string) => Promise<void>; getFirstLocation: () => string | null } | null>(null);
@@ -176,6 +183,58 @@
onClose();
}
function startCreatingFolder() {
creatingFolder = true;
newFolderName = '';
createError = null;
// Focus input after it renders
setTimeout(() => folderInputEl?.focus(), 0);
}
function cancelCreatingFolder() {
creatingFolder = false;
newFolderName = '';
createError = null;
}
async function confirmCreateFolder() {
const name = newFolderName.trim();
if (!name || !currentPath) return;
createError = null;
const newPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
try {
const res = await fetch('/api/system/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: newPath })
});
const data = await res.json();
if (!res.ok) {
createError = data.error || 'Failed to create folder';
return;
}
creatingFolder = false;
newFolderName = '';
loadDirectory(newPath);
} catch (e) {
createError = e instanceof Error ? e.message : 'Failed to create folder';
}
}
function handleFolderKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
confirmCreateFolder();
} else if (e.key === 'Escape') {
e.preventDefault();
cancelCreatingFolder();
}
}
function handleScan() {
if (currentPath && onScanDirectory) {
onScanDirectory(currentPath);
@@ -262,6 +321,46 @@
<ArrowUp class="w-4 h-4" />
</button>
<code class="text-xs bg-muted px-2 py-1 rounded truncate flex-1">{currentPath || '/'}</code>
{#if creatingFolder}
<div class="flex items-center gap-1">
<Input
bind:ref={folderInputEl}
bind:value={newFolderName}
placeholder="Folder name"
class="h-7 w-40 max-w-40 text-xs"
onkeydown={handleFolderKeydown}
/>
<button
type="button"
class="p-1 rounded hover:bg-muted text-green-600 disabled:opacity-40 disabled:cursor-not-allowed"
disabled={!newFolderName.trim()}
onclick={confirmCreateFolder}
title="Create folder"
>
<Check class="w-4 h-4" />
</button>
<button
type="button"
class="p-1 rounded hover:bg-muted text-muted-foreground"
onclick={cancelCreatingFolder}
title="Cancel"
>
<X class="w-4 h-4" />
</button>
{#if createError}
<span class="text-xs text-red-500 truncate max-w-48" title={createError}>{createError}</span>
{/if}
</div>
{:else}
<button
type="button"
class="p-1 rounded hover:bg-muted text-muted-foreground"
onclick={startCreatingFolder}
title="New folder"
>
<FolderPlus class="w-4 h-4" />
</button>
{/if}
{#if isAdoptMode}
<Button
variant="default"
+2 -3
View File
@@ -9,7 +9,7 @@
import yaml from 'js-yaml';
import { toast } from 'svelte-sonner';
import { currentEnvironment, environments } from '$lib/stores/environment';
import { getIconComponent } from '$lib/utils/icons';
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
interface DiscoveredStack {
name: string;
@@ -56,7 +56,6 @@
// Look up the icon from the environments list since currentEnvironment doesn't store it
const currentEnvData = $derived($environments.find(e => e.id === envId));
const envIcon = $derived(currentEnvData?.icon || 'globe');
const EnvIconComponent = $derived(getIconComponent(envIcon));
// Reset when modal closes
$effect(() => {
@@ -330,7 +329,7 @@
<Import class="w-5 h-5" />
Select stacks to adopt
<span class="text-muted-foreground">·</span>
<EnvIconComponent class="w-4 h-4 text-muted-foreground" />
<EnvironmentIcon icon={envIcon} envId={envId} class="w-4 h-4 text-muted-foreground" />
<span class="text-muted-foreground font-normal">{envName}</span>
</Dialog.Title>
<Dialog.Description>