mirror of
https://github.com/Finsys/dockhand.git
synced 2026-06-17 19:09:33 +03:00
v1.0.22
This commit is contained in:
@@ -10,10 +10,12 @@ PGID=${PGID:-1001}
|
|||||||
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
|
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
|
||||||
|
|
||||||
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
|
# Default command (--expose-gc allows forced GC from /api/debug/memory?gc=true)
|
||||||
|
# Custom CA: set NODE_EXTRA_CA_CERTS=/path/to/ca.crt (appends to built-in CAs)
|
||||||
|
# Enterprise (system CA store): set NODE_OPTIONS="--use-openssl-ca"
|
||||||
if [ "$MEMORY_MONITOR" = "true" ]; then
|
if [ "$MEMORY_MONITOR" = "true" ]; then
|
||||||
DEFAULT_CMD="node --use-openssl-ca --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
|
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection --expose-gc /app/server.js"
|
||||||
else
|
else
|
||||||
DEFAULT_CMD="node --use-openssl-ca --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js"
|
DEFAULT_CMD="node --dns-result-order=ipv4first --no-network-family-autoselection /app/server.js"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# === Detect if running as root ===
|
# === Detect if running as root ===
|
||||||
@@ -100,14 +102,29 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
|
# === Directory Ownership ===
|
||||||
|
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
|
||||||
|
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
|
||||||
|
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
|
||||||
|
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||||
|
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||||
|
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||||
|
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||||
|
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
if [ "$RUN_USER" = "dockhand" ]; then
|
if [ "$RUN_USER" = "dockhand" ]; then
|
||||||
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||||
|
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||||
|
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||||
|
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -113,14 +113,28 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# === Directory Ownership ===
|
# === Directory Ownership ===
|
||||||
chown -R "$RUN_USER":"$RUN_USER" /app/data 2>/dev/null || true
|
# Only chown Dockhand's own subdirectories, not the entire /app/data tree.
|
||||||
|
# Recursive chown on /app/data breaks stack volumes mounted with relative paths
|
||||||
|
# (e.g. ./postgresql:/var/lib/postgresql) that need different ownership (#719).
|
||||||
|
DATA_DIR="${DATA_DIR:-/app/data}"
|
||||||
|
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||||
|
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||||
|
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||||
|
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
if [ "$RUN_USER" = "dockhand" ]; then
|
if [ "$RUN_USER" = "dockhand" ]; then
|
||||||
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
|
||||||
mkdir -p "$DATA_DIR"
|
mkdir -p "$DATA_DIR"
|
||||||
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
chown "$RUN_USER":"$RUN_USER" "$DATA_DIR" 2>/dev/null || true
|
||||||
|
for subdir in db stacks git-repos tmp icons snapshots scanner-cache; do
|
||||||
|
if [ -d "$DATA_DIR/$subdir" ]; then
|
||||||
|
chown -R "$RUN_USER":"$RUN_USER" "$DATA_DIR/$subdir" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "dockhand",
|
"name": "dockhand",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.21",
|
"version": "1.0.22",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npx vite dev",
|
"dev": "npx vite dev",
|
||||||
|
|||||||
@@ -8,9 +8,26 @@
|
|||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSave: (dataUrl: string) => void;
|
onSave: (dataUrl: string) => void;
|
||||||
|
cropShape?: 'round' | 'rect';
|
||||||
|
outputSize?: number;
|
||||||
|
outputFormat?: 'image/jpeg' | 'image/webp';
|
||||||
|
outputQuality?: number;
|
||||||
|
title?: string;
|
||||||
|
saveLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { show, imageUrl, onCancel, onSave }: Props = $props();
|
let {
|
||||||
|
show,
|
||||||
|
imageUrl,
|
||||||
|
onCancel,
|
||||||
|
onSave,
|
||||||
|
cropShape = 'round',
|
||||||
|
outputSize = 256,
|
||||||
|
outputFormat = 'image/jpeg',
|
||||||
|
outputQuality = 0.9,
|
||||||
|
title = 'Crop avatar',
|
||||||
|
saveLabel = 'Save avatar'
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
// Cropper state
|
// Cropper state
|
||||||
let crop = $state({ x: 0, y: 0 });
|
let crop = $state({ x: 0, y: 0 });
|
||||||
@@ -144,9 +161,9 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set canvas size to output size (256x256 for avatar)
|
// Set canvas size to output size
|
||||||
canvas.width = 256;
|
canvas.width = outputSize;
|
||||||
canvas.height = 256;
|
canvas.height = outputSize;
|
||||||
|
|
||||||
// Ensure we use a square crop area to avoid stretching
|
// Ensure we use a square crop area to avoid stretching
|
||||||
// Center the square within the original crop area
|
// Center the square within the original crop area
|
||||||
@@ -163,12 +180,12 @@
|
|||||||
size,
|
size,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
256,
|
outputSize,
|
||||||
256
|
outputSize
|
||||||
);
|
);
|
||||||
|
|
||||||
// Convert to data URL
|
// Convert to data URL
|
||||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
const dataUrl = canvas.toDataURL(outputFormat, outputQuality);
|
||||||
resolve(dataUrl);
|
resolve(dataUrl);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,16 +221,18 @@
|
|||||||
handleCancel();
|
handleCancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
{#if show && imageUrl}
|
{#if show && imageUrl}
|
||||||
<div class="fixed inset-0 bg-black/80 z-50 flex items-center justify-center p-4">
|
<div class="fixed inset-0 bg-black/80 z-[200] flex items-center justify-center p-4">
|
||||||
<div class="bg-background rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl">
|
<div class="bg-background rounded-lg w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="p-4 border-b">
|
<div class="p-4 border-b">
|
||||||
<h3 class="text-lg font-semibold">Crop avatar</h3>
|
<h3 class="text-lg font-semibold">{title}</h3>
|
||||||
<p class="text-sm text-muted-foreground mt-1">
|
<p class="text-sm text-muted-foreground mt-1">
|
||||||
Drag to reposition. Use the slider to zoom.
|
Drag to reposition. Use the slider to zoom.
|
||||||
</p>
|
</p>
|
||||||
@@ -226,7 +245,8 @@
|
|||||||
bind:crop
|
bind:crop
|
||||||
bind:zoom
|
bind:zoom
|
||||||
aspect={1}
|
aspect={1}
|
||||||
cropShape="round"
|
minZoom={0.5}
|
||||||
|
cropShape={cropShape}
|
||||||
showGrid={false}
|
showGrid={false}
|
||||||
on:cropcomplete={onCropComplete}
|
on:cropcomplete={onCropComplete}
|
||||||
on:mediaLoaded={onMediaLoaded}
|
on:mediaLoaded={onMediaLoaded}
|
||||||
@@ -239,7 +259,7 @@
|
|||||||
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
|
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="1"
|
min="0.5"
|
||||||
max="3"
|
max="3"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
bind:value={zoom}
|
bind:value={zoom}
|
||||||
@@ -266,7 +286,7 @@
|
|||||||
disabled={saving || !imageLoaded}
|
disabled={saving || !imageLoaded}
|
||||||
>
|
>
|
||||||
<Check class="w-4 h-4" />
|
<Check class="w-4 h-4" />
|
||||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
|
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : saveLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getIconComponent, isCustomIcon } from '$lib/utils/icons';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
icon: string;
|
||||||
|
envId: number;
|
||||||
|
class?: string;
|
||||||
|
cacheBust?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { icon, envId, class: className = 'w-4 h-4', cacheBust }: Props = $props();
|
||||||
|
|
||||||
|
const isCustom = $derived(isCustomIcon(icon));
|
||||||
|
const LucideIcon = $derived(!isCustom ? getIconComponent(icon) : null) as Component | null;
|
||||||
|
const imgSrc = $derived(isCustom ? `/api/environments/${envId}/icon${cacheBust ? `?v=${cacheBust}` : ''}` : '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isCustom}
|
||||||
|
<img src={imgSrc} alt="" class="{className} rounded-full object-cover" />
|
||||||
|
{:else if LucideIcon}
|
||||||
|
<LucideIcon class={className} />
|
||||||
|
{/if}
|
||||||
@@ -43,15 +43,17 @@
|
|||||||
let selectedEditorFont = $state('system-mono');
|
let selectedEditorFont = $state('system-mono');
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Load monospace fonts for dropdown previews
|
// Load bundled monospace fonts for dropdown previews
|
||||||
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
|
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
|
||||||
if (fontsToLoad.length > 0) {
|
if (fontsToLoad.length > 0) {
|
||||||
const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&');
|
let loaded = 0;
|
||||||
const link = document.createElement('link');
|
for (const font of fontsToLoad) {
|
||||||
link.rel = 'stylesheet';
|
const link = document.createElement('link');
|
||||||
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
link.rel = 'stylesheet';
|
||||||
link.onload = () => { monoFontsLoaded = true; };
|
link.href = `/fonts/${font.id}/font.css`;
|
||||||
document.head.appendChild(link);
|
link.onload = () => { if (++loaded >= fontsToLoad.length) monoFontsLoaded = true; };
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
monoFontsLoaded = true;
|
monoFontsLoaded = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,22 @@
|
|||||||
'Europe/Kyiv': 'Europe/Kiev',
|
'Europe/Kyiv': 'Europe/Kiev',
|
||||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||||
'America/Nuuk': 'America/Godthab',
|
'America/Nuuk': 'America/Godthab',
|
||||||
'Pacific/Kanton': 'Pacific/Enderbury'
|
'Pacific/Kanton': 'Pacific/Enderbury',
|
||||||
|
'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'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reverse map: canonical → modern alias names (for display hints)
|
// Reverse map: canonical → modern alias names (for display hints)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2 } from 'lucide-svelte';
|
import { Cpu, MemoryStick, Box, Globe, ChevronDown, Check, HardDrive, Clock, Wifi, WifiOff, Route, UndoDot, Icon, AlertCircle, Loader2, Search, X } from 'lucide-svelte';
|
||||||
import { whale } from '@lucide/lab';
|
import { whale } from '@lucide/lab';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
||||||
import { sseConnected } from '$lib/stores/events';
|
import { sseConnected } from '$lib/stores/events';
|
||||||
import { getIconComponent } from '$lib/utils/icons';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { themeStore, type FontSize } from '$lib/stores/theme';
|
import { themeStore, type FontSize } from '$lib/stores/theme';
|
||||||
import { getTimeFormat } from '$lib/stores/settings';
|
import { getTimeFormat } from '$lib/stores/settings';
|
||||||
@@ -77,6 +77,8 @@
|
|||||||
let diskUsageLoading = $state(false);
|
let diskUsageLoading = $state(false);
|
||||||
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
|
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
|
||||||
let showDropdown = $state(false);
|
let showDropdown = $state(false);
|
||||||
|
let searchTerm = $state('');
|
||||||
|
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||||
let currentEnvId = $state<number | null>(null);
|
let currentEnvId = $state<number | null>(null);
|
||||||
let lastUpdated = $state<Date>(new Date());
|
let lastUpdated = $state<Date>(new Date());
|
||||||
let isConnected = $state(false);
|
let isConnected = $state(false);
|
||||||
@@ -94,6 +96,22 @@
|
|||||||
|
|
||||||
// Reactive environment list from store
|
// Reactive environment list from store
|
||||||
let envList = $derived($environments);
|
let envList = $derived($environments);
|
||||||
|
const showSearch = $derived(envList.length > 8);
|
||||||
|
const filteredEnvList = $derived(
|
||||||
|
searchTerm.trim()
|
||||||
|
? envList.filter((e: Environment) => e.name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||||
|
: envList
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear search and focus when dropdown opens/closes
|
||||||
|
$effect(() => {
|
||||||
|
if (showDropdown && showSearch) {
|
||||||
|
// Use tick to wait for DOM render
|
||||||
|
setTimeout(() => searchInputRef?.focus(), 0);
|
||||||
|
} else {
|
||||||
|
searchTerm = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
sseConnected.subscribe(v => isConnected = v);
|
sseConnected.subscribe(v => isConnected = v);
|
||||||
|
|
||||||
@@ -349,14 +367,12 @@
|
|||||||
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
|
class="flex items-center gap-1.5 -ml-1 px-1 py-1 rounded-md hover:bg-muted transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
|
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
|
||||||
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
|
<EnvironmentIcon icon={hostInfo.environment.icon || 'globe'} envId={hostInfo.environment.id} class="{iconSizeLargeClass()} text-primary" />
|
||||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
|
||||||
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
|
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
|
||||||
{:else if currentEnvId && envList.length > 0}
|
{:else if currentEnvId && envList.length > 0}
|
||||||
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
|
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
|
||||||
{#if currentEnv}
|
{#if currentEnv}
|
||||||
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
|
<EnvironmentIcon icon={currentEnv.icon || 'globe'} envId={currentEnv.id} class="{iconSizeLargeClass()} text-primary" />
|
||||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
|
||||||
<span class="font-medium text-foreground">{currentEnv.name}</span>
|
<span class="font-medium text-foreground">{currentEnv.name}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
|
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
|
||||||
@@ -371,9 +387,40 @@
|
|||||||
|
|
||||||
{#if showDropdown && envList.length > 0}
|
{#if showDropdown && envList.length > 0}
|
||||||
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
|
<div class="absolute top-full left-0 mt-1 min-w-56 w-max max-w-80 bg-popover border rounded-md shadow-lg z-50">
|
||||||
<div class="py-1">
|
{#if showSearch}
|
||||||
{#each envList as env (env.id)}
|
<div class="sticky top-0 bg-popover border-b px-2 py-1.5">
|
||||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
<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
|
||||||
|
bind:this={searchInputRef}
|
||||||
|
bind:value={searchTerm}
|
||||||
|
type="text"
|
||||||
|
placeholder="Search environments..."
|
||||||
|
class="w-full pl-7 pr-7 py-1 text-sm bg-transparent border rounded focus:outline-none focus:ring-1 focus:ring-ring"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (searchTerm) {
|
||||||
|
searchTerm = '';
|
||||||
|
} else {
|
||||||
|
showDropdown = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{#if searchTerm}
|
||||||
|
<button
|
||||||
|
class="absolute right-1.5 top-1/2 -translate-y-1/2 p-0.5 rounded hover:bg-muted"
|
||||||
|
onclick={(e) => { e.stopPropagation(); searchTerm = ''; searchInputRef?.focus(); }}
|
||||||
|
>
|
||||||
|
<X class="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="py-1 max-h-[calc(100vh-8rem)] overflow-y-auto">
|
||||||
|
{#each filteredEnvList as env (env.id)}
|
||||||
{@const isOffline = offlineEnvIds.has(env.id)}
|
{@const isOffline = offlineEnvIds.has(env.id)}
|
||||||
{@const isSwitching = switchingEnvId === env.id}
|
{@const isSwitching = switchingEnvId === env.id}
|
||||||
<button
|
<button
|
||||||
@@ -387,7 +434,7 @@
|
|||||||
{:else if isOffline}
|
{:else if isOffline}
|
||||||
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
|
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
|
||||||
{:else}
|
{:else}
|
||||||
<EnvIcon class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
<EnvironmentIcon icon={env.icon || 'globe'} envId={env.id} class="{iconSizeLargeClass()} text-muted-foreground shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
|
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
|
||||||
{#if isOffline && !isSwitching}
|
{#if isOffline && !isSwitching}
|
||||||
@@ -396,6 +443,10 @@
|
|||||||
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
|
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
{:else}
|
||||||
|
<div class="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
No matching environments
|
||||||
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,6 +118,23 @@ export const scheduleColumns: ColumnConfig[] = [
|
|||||||
{ id: 'actions', label: '', fixed: 'end', width: 100, resizable: false }
|
{ id: 'actions', label: '', fixed: 'end', width: 100, resizable: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Environment grid columns (dashboard list view)
|
||||||
|
export const environmentColumns: ColumnConfig[] = [
|
||||||
|
{ id: 'status', label: '', width: 36, resizable: false },
|
||||||
|
{ id: 'name', label: 'Environment', sortable: true, sortField: 'name', width: 180, minWidth: 100, grow: true },
|
||||||
|
{ id: 'connection', label: 'Connection', sortable: true, sortField: 'connection', width: 110, minWidth: 80 },
|
||||||
|
{ id: 'host', label: 'Host', sortable: true, sortField: 'host', width: 150, minWidth: 80 },
|
||||||
|
{ id: 'containers', label: 'Containers', sortable: true, sortField: 'containers', width: 100, minWidth: 70 },
|
||||||
|
{ id: 'updates', label: 'Updates', sortable: true, sortField: 'updates', width: 75, minWidth: 55 },
|
||||||
|
{ id: 'cpu', label: 'CPU', sortable: true, sortField: 'cpu', width: 110, minWidth: 80 },
|
||||||
|
{ id: 'memory', label: 'Memory', sortable: true, sortField: 'memory', width: 110, minWidth: 80 },
|
||||||
|
{ id: 'images', label: 'Images', sortable: true, sortField: 'images', width: 65, minWidth: 50 },
|
||||||
|
{ id: 'volumes', label: 'Volumes', sortable: true, sortField: 'volumes', width: 70, minWidth: 50 },
|
||||||
|
{ id: 'stacks', label: 'Stacks', sortable: true, sortField: 'stacks', width: 85, minWidth: 65 },
|
||||||
|
{ id: 'events', label: 'Events', sortable: true, sortField: 'events', width: 65, minWidth: 50 },
|
||||||
|
{ id: 'labels', label: 'Labels', width: 150, minWidth: 80 }
|
||||||
|
];
|
||||||
|
|
||||||
// Map of grid ID to column definitions
|
// Map of grid ID to column definitions
|
||||||
export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
||||||
containers: containerColumns,
|
containers: containerColumns,
|
||||||
@@ -128,7 +145,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
|||||||
volumes: volumeColumns,
|
volumes: volumeColumns,
|
||||||
activity: activityColumns,
|
activity: activityColumns,
|
||||||
schedules: scheduleColumns,
|
schedules: scheduleColumns,
|
||||||
audit: auditColumns
|
audit: auditColumns,
|
||||||
|
environments: environmentColumns
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get configurable columns (not fixed)
|
// Get configurable columns (not fixed)
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "1.0.22",
|
||||||
|
"comingSoon": true,
|
||||||
|
"changes": [
|
||||||
|
{ "type": "feature", "text": "dashboard list view with inline search and connection filters (#740)" },
|
||||||
|
{ "type": "feature", "text": "custom environment icon (#754)" },
|
||||||
|
{ "type": "feature", "text": "show +N indicator for containers with multiple IP addresses (#644)" },
|
||||||
|
{ "type": "feature", "text": "bundle all fonts locally for privacy and offline use (#734)" },
|
||||||
|
{ "type": "fix", "text": "respect PROXY settings when checking for container updates" },
|
||||||
|
{ "type": "fix", "text": "git stacks force-redeploy after a failed sync (#693)" },
|
||||||
|
{ "type": "fix", "text": "What's New modal shown before login, exposing version info (#717)" },
|
||||||
|
{ "type": "fix", "text": "git repository files not removed from disk on delete (#671)" },
|
||||||
|
{ "type": "fix", "text": "recursive chown at startup breaks stack volumes with different ownership (#719)" },
|
||||||
|
{ "type": "fix", "text": "missing notification event toggles for container healthy, image prune events (#659)" },
|
||||||
|
{ "type": "fix", "text": "container disappears when edit fails (e.g. invalid memory/swap) (#736)" },
|
||||||
|
{ "type": "fix", "text": "regression: network container count always shows 0 (#761)" },
|
||||||
|
{ "type": "fix", "text": "Grype/Trivy scan containers don't inherit proxy env vars (#780)" },
|
||||||
|
{ "type": "fix", "text": "pin vulnerability scanner images to specific versions not :latest" }
|
||||||
|
],
|
||||||
|
"imageTag": "fnsys/dockhand:v1.0.22"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.21",
|
"version": "1.0.21",
|
||||||
"date": "2026-03-13",
|
"date": "2026-03-13",
|
||||||
|
|||||||
@@ -275,6 +275,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": "https://github.com/chalk/ansi-styles"
|
"repository": "https://github.com/chalk/ansi-styles"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "ansi_up",
|
||||||
|
"version": "6.0.6",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "https://github.com/drudru/ansi_up"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "argon2",
|
"name": "argon2",
|
||||||
"version": "0.41.1",
|
"version": "0.41.1",
|
||||||
@@ -289,7 +295,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "aria-query",
|
"name": "aria-query",
|
||||||
"version": "5.3.2",
|
"version": "5.3.1",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": "https://github.com/A11yance/aria-query"
|
"repository": "https://github.com/A11yance/aria-query"
|
||||||
},
|
},
|
||||||
@@ -425,6 +431,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": "https://github.com/sveltejs/devalue"
|
"repository": "https://github.com/sveltejs/devalue"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "devalue",
|
||||||
|
"version": "5.6.4",
|
||||||
|
"license": "MIT",
|
||||||
|
"repository": "https://github.com/sveltejs/devalue"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "dijkstrajs",
|
"name": "dijkstrajs",
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -781,7 +793,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "svelte",
|
"name": "svelte",
|
||||||
"version": "5.53.1",
|
"version": "5.53.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": "https://github.com/sveltejs/svelte"
|
"repository": "https://github.com/sveltejs/svelte"
|
||||||
},
|
},
|
||||||
|
|||||||
+19
-4
@@ -819,6 +819,13 @@ export const ENVIRONMENT_NOTIFICATION_EVENTS = NOTIFICATION_EVENT_TYPES.filter(e
|
|||||||
|
|
||||||
export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id'];
|
export type NotificationEventType = typeof NOTIFICATION_EVENT_TYPES[number]['id'];
|
||||||
|
|
||||||
|
const environmentEventIds = new Set(ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id));
|
||||||
|
|
||||||
|
/** Strip system-scoped events (e.g. license_expiring) from environment notification records */
|
||||||
|
function filterEnvironmentEventTypes(eventTypes: string[]): string[] {
|
||||||
|
return eventTypes.filter(id => environmentEventIds.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
export interface NotificationSettingData {
|
export interface NotificationSettingData {
|
||||||
id: number;
|
id: number;
|
||||||
type: 'smtp' | 'apprise';
|
type: 'smtp' | 'apprise';
|
||||||
@@ -982,7 +989,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis
|
|||||||
|
|
||||||
return rows.map((row: any) => ({
|
return rows.map((row: any) => ({
|
||||||
...row,
|
...row,
|
||||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
|
||||||
})) as EnvironmentNotificationData[];
|
})) as EnvironmentNotificationData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1009,7 +1016,7 @@ export async function getEnvironmentNotification(environmentId: number, notifica
|
|||||||
if (!rows[0]) return null;
|
if (!rows[0]) return null;
|
||||||
return {
|
return {
|
||||||
...rows[0],
|
...rows[0],
|
||||||
eventTypes: rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id)
|
eventTypes: filterEnvironmentEventTypes(rows[0].eventTypes ? JSON.parse(rows[0].eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id))
|
||||||
} as EnvironmentNotificationData;
|
} as EnvironmentNotificationData;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1019,7 +1026,7 @@ export async function createEnvironmentNotification(data: {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
eventTypes?: NotificationEventType[];
|
eventTypes?: NotificationEventType[];
|
||||||
}): Promise<EnvironmentNotificationData> {
|
}): Promise<EnvironmentNotificationData> {
|
||||||
const eventTypes = data.eventTypes || NOTIFICATION_EVENT_TYPES.map(e => e.id);
|
const eventTypes = data.eventTypes || ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id);
|
||||||
await db.insert(environmentNotifications).values({
|
await db.insert(environmentNotifications).values({
|
||||||
environmentId: data.environmentId,
|
environmentId: data.environmentId,
|
||||||
notificationId: data.notificationId,
|
notificationId: data.notificationId,
|
||||||
@@ -1087,7 +1094,7 @@ export async function getEnabledEnvironmentNotifications(
|
|||||||
return rows
|
return rows
|
||||||
.map(row => ({
|
.map(row => ({
|
||||||
...row,
|
...row,
|
||||||
eventTypes: row.eventTypes ? JSON.parse(row.eventTypes) : NOTIFICATION_EVENT_TYPES.map(e => e.id),
|
eventTypes: filterEnvironmentEventTypes(row.eventTypes ? JSON.parse(row.eventTypes) : ENVIRONMENT_NOTIFICATION_EVENTS.map(e => e.id)),
|
||||||
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
|
config: decryptNotificationConfig(row.channelType ?? 'apprise', row.config)
|
||||||
}))
|
}))
|
||||||
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
|
.filter(row => !eventType || row.eventTypes.includes(eventType)) as (EnvironmentNotificationData & { config: any })[];
|
||||||
@@ -2019,6 +2026,14 @@ export async function updateGitRepository(id: number, data: Partial<GitRepositor
|
|||||||
return getGitRepository(id);
|
return getGitRepository(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGitStacksByRepositoryId(repositoryId: number): Promise<Array<{ id: number; stackName: string; environmentId: number | null }>> {
|
||||||
|
return db.select({
|
||||||
|
id: gitStacks.id,
|
||||||
|
stackName: gitStacks.stackName,
|
||||||
|
environmentId: gitStacks.environmentId
|
||||||
|
}).from(gitStacks).where(eq(gitStacks.repositoryId, repositoryId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteGitRepository(id: number): Promise<boolean> {
|
export async function deleteGitRepository(id: number): Promise<boolean> {
|
||||||
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
|
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { setGlobalDispatcher, Agent } from 'undici';
|
import { setGlobalDispatcher, Agent, EnvHttpProxyAgent } from 'undici';
|
||||||
import dns from 'node:dns';
|
import dns from 'node:dns';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
|
|
||||||
@@ -60,32 +60,44 @@ function lookupWithCache(hostname: string): Promise<{ address: string; family: n
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
setGlobalDispatcher(
|
// Shared connect options for DNS lookup
|
||||||
new Agent({
|
const connectOptions = {
|
||||||
connect: {
|
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
|
||||||
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
|
timeout: 30_000,
|
||||||
timeout: 30_000,
|
lookup(hostname: string, opts: any, cb: any) {
|
||||||
lookup(hostname: string, opts: any, cb: any) {
|
if (typeof opts === 'function') {
|
||||||
if (typeof opts === 'function') {
|
cb = opts;
|
||||||
cb = opts;
|
opts = {};
|
||||||
opts = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
// IP addresses / localhost → no DNS needed
|
|
||||||
if (net.isIP(hostname) || hostname === 'localhost') {
|
|
||||||
return origLookup(hostname, opts, cb);
|
|
||||||
}
|
|
||||||
|
|
||||||
lookupWithCache(hostname)
|
|
||||||
.then(({ address, family }) => {
|
|
||||||
if (opts.all) {
|
|
||||||
cb(null, [{ address, family }]);
|
|
||||||
} else {
|
|
||||||
cb(null, address, family);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => cb(err));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
);
|
// IP addresses / localhost → no DNS needed
|
||||||
|
if (net.isIP(hostname) || hostname === 'localhost') {
|
||||||
|
return origLookup(hostname, opts, cb);
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupWithCache(hostname)
|
||||||
|
.then(({ address, family }) => {
|
||||||
|
if (opts.all) {
|
||||||
|
cb(null, [{ address, family }]);
|
||||||
|
} else {
|
||||||
|
cb(null, address, family);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => cb(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use EnvHttpProxyAgent when HTTP(S)_PROXY env vars are set, otherwise plain Agent.
|
||||||
|
// Node.js fetch/undici does NOT respect proxy env vars by default — EnvHttpProxyAgent
|
||||||
|
// reads HTTP_PROXY, HTTPS_PROXY, and NO_PROXY automatically.
|
||||||
|
const hasProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY ||
|
||||||
|
process.env.http_proxy || process.env.https_proxy;
|
||||||
|
|
||||||
|
if (hasProxy) {
|
||||||
|
const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy ||
|
||||||
|
process.env.HTTP_PROXY || process.env.http_proxy;
|
||||||
|
console.log(`[DNS] HTTP proxy detected (${proxyUrl}), using EnvHttpProxyAgent`);
|
||||||
|
setGlobalDispatcher(new EnvHttpProxyAgent({ connect: connectOptions }));
|
||||||
|
} else {
|
||||||
|
setGlobalDispatcher(new Agent({ connect: connectOptions }));
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a value contains path traversal or injection characters.
|
||||||
|
* Rejects: .., /, \, null bytes, % (catches double-encoding).
|
||||||
|
*/
|
||||||
|
function containsPathTraversal(value: string): boolean {
|
||||||
|
return value.includes('..') || value.includes('/') || value.includes('\\') || value.includes('\0') || value.includes('%');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a Docker resource ID/name from URL params.
|
||||||
|
* Returns a 400 Response if invalid, null if valid.
|
||||||
|
*/
|
||||||
|
export function validateDockerIdParam(id: string, resourceType = 'resource'): Response | null {
|
||||||
|
if (!id || containsPathTraversal(id)) {
|
||||||
|
return json({ error: `Invalid ${resourceType} ID` }, { status: 400 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+162
-20
@@ -819,6 +819,11 @@ export async function dockerFetch(
|
|||||||
options: DockerFetchOptions = {},
|
options: DockerFetchOptions = {},
|
||||||
envId?: number | null
|
envId?: number | null
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
// Guard against path traversal — legitimate Docker API paths never contain '..'
|
||||||
|
if (path.includes('..')) {
|
||||||
|
throw new Error('Invalid Docker API path');
|
||||||
|
}
|
||||||
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const config = await getDockerConfig(envId);
|
const config = await getDockerConfig(envId);
|
||||||
const { streaming, ...fetchOptions } = options;
|
const { streaming, ...fetchOptions } = options;
|
||||||
@@ -977,7 +982,7 @@ export async function dockerFetch(
|
|||||||
/**
|
/**
|
||||||
* Make a JSON request to Docker API
|
* Make a JSON request to Docker API
|
||||||
*/
|
*/
|
||||||
async function dockerJsonRequest<T>(
|
export async function dockerJsonRequest<T>(
|
||||||
path: string,
|
path: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
envId?: number | null
|
envId?: number | null
|
||||||
@@ -1789,6 +1794,40 @@ export async function recreateContainerFromInspect(
|
|||||||
HostConfig: hostConfig
|
HostConfig: hostConfig
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 4a. Update image-embedded labels to match the new image.
|
||||||
|
// Docker's create API uses exactly the labels you pass, ignoring the new image's
|
||||||
|
// embedded labels. We inspect both old and new images to distinguish image-origin
|
||||||
|
// labels from user-set labels, then merge accordingly.
|
||||||
|
try {
|
||||||
|
const [oldImageInspect, newImageInspect] = await Promise.all([
|
||||||
|
inspectImage(config.Image, envId),
|
||||||
|
inspectImage(newImage, envId)
|
||||||
|
]);
|
||||||
|
const oldImageLabels: Record<string, string> = (oldImageInspect as any)?.Config?.Labels || {};
|
||||||
|
const newImageLabels: Record<string, string> = (newImageInspect as any)?.Config?.Labels || {};
|
||||||
|
const containerLabels: Record<string, string> = createConfig.Labels || {};
|
||||||
|
|
||||||
|
const mergedLabels: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Keep user-set labels (not present in old image)
|
||||||
|
for (const [k, v] of Object.entries(containerLabels)) {
|
||||||
|
if (!(k in oldImageLabels)) {
|
||||||
|
mergedLabels[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add all new image labels (overrides old image labels)
|
||||||
|
for (const [k, v] of Object.entries(newImageLabels)) {
|
||||||
|
mergedLabels[k] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
createConfig.Labels = mergedLabels;
|
||||||
|
log?.(`Updated image labels: ${Object.keys(newImageLabels).length} from new image, ${Object.keys(mergedLabels).length} total`);
|
||||||
|
} catch (e) {
|
||||||
|
log?.(`Warning: could not update image labels: ${e}`);
|
||||||
|
// Fall through with old labels — non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
|
// Strip default MemorySwappiness — Podman + cgroupv2 rejects it.
|
||||||
// Docker returns -1, Podman returns 0 when unset.
|
// Docker returns -1, Podman returns 0 when unset.
|
||||||
const swappiness = createConfig.HostConfig?.MemorySwappiness;
|
const swappiness = createConfig.HostConfig?.MemorySwappiness;
|
||||||
@@ -2269,6 +2308,14 @@ export function extractContainerOptions(inspectData: any): CreateContainerOption
|
|||||||
export async function updateContainer(id: string, options: Partial<CreateContainerOptions>, startAfterUpdate = false, envId?: number | null) {
|
export async function updateContainer(id: string, options: Partial<CreateContainerOptions>, startAfterUpdate = false, envId?: number | null) {
|
||||||
const oldContainerInfo = await inspectContainer(id, envId);
|
const oldContainerInfo = await inspectContainer(id, envId);
|
||||||
const wasRunning = oldContainerInfo.State.Running;
|
const wasRunning = oldContainerInfo.State.Running;
|
||||||
|
const name = oldContainerInfo.Name?.replace(/^\//, '') || '';
|
||||||
|
const oldContainerId = oldContainerInfo.Id;
|
||||||
|
const networks: Record<string, any> = oldContainerInfo.NetworkSettings?.Networks || {};
|
||||||
|
const hostConfig = oldContainerInfo.HostConfig || {};
|
||||||
|
const networkMode = hostConfig.NetworkMode || '';
|
||||||
|
const isSharedNetwork = networkMode.startsWith('container:') ||
|
||||||
|
networkMode.startsWith('service:') ||
|
||||||
|
networkMode === 'host' || networkMode === 'none';
|
||||||
|
|
||||||
// Extract ALL existing container options
|
// Extract ALL existing container options
|
||||||
const existingOptions = extractContainerOptions(oldContainerInfo);
|
const existingOptions = extractContainerOptions(oldContainerInfo);
|
||||||
@@ -2285,18 +2332,81 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 1. Stop old container
|
||||||
if (wasRunning) {
|
if (wasRunning) {
|
||||||
await stopContainer(id, envId);
|
await stopContainer(id, envId);
|
||||||
}
|
}
|
||||||
|
|
||||||
await removeContainer(id, true, envId);
|
// 2. Rename old container to free the name (instead of removing — allows rollback)
|
||||||
|
await dockerFetch(
|
||||||
|
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name + '-old')}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
envId
|
||||||
|
).then(async r => { if (!r.ok) throw new Error('Failed to rename old container'); await drainResponse(r); });
|
||||||
|
|
||||||
const newContainer = await createContainer(mergedOptions, envId);
|
// 3. Disconnect networks from old container to free static IPs
|
||||||
|
if (!isSharedNetwork) {
|
||||||
if (startAfterUpdate || wasRunning) {
|
for (const [, netConfig] of Object.entries(networks)) {
|
||||||
await newContainer.start();
|
const nc = netConfig as any;
|
||||||
|
if (nc.NetworkID) {
|
||||||
|
await disconnectContainerFromNetwork(nc.NetworkID, oldContainerId, true, envId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rollback helper: restore old container on failure
|
||||||
|
const rollback = async () => {
|
||||||
|
try {
|
||||||
|
// Rename back
|
||||||
|
await dockerFetch(
|
||||||
|
`/containers/${oldContainerId}/rename?name=${encodeURIComponent(name)}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
envId
|
||||||
|
).then(r => drainResponse(r)).catch(() => {});
|
||||||
|
|
||||||
|
// Reconnect networks
|
||||||
|
if (!isSharedNetwork) {
|
||||||
|
for (const [, netConfig] of Object.entries(networks)) {
|
||||||
|
const nc = netConfig as any;
|
||||||
|
if (nc.NetworkID) {
|
||||||
|
await connectContainerToNetworkRaw(nc.NetworkID, oldContainerId, nc, envId).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart if it was running
|
||||||
|
if (wasRunning) {
|
||||||
|
await startContainer(oldContainerId, envId).catch(() => {});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Rollback is best-effort
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Create new container
|
||||||
|
let newContainer;
|
||||||
|
try {
|
||||||
|
newContainer = await createContainer(mergedOptions, envId);
|
||||||
|
} catch (createError) {
|
||||||
|
await rollback();
|
||||||
|
throw createError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Start if needed
|
||||||
|
if (startAfterUpdate || wasRunning) {
|
||||||
|
try {
|
||||||
|
await newContainer.start();
|
||||||
|
} catch (startError) {
|
||||||
|
// Remove failed new container and restore old one
|
||||||
|
await removeContainer(newContainer.id, true, envId).catch(() => {});
|
||||||
|
await rollback();
|
||||||
|
throw startError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Remove old container (success path only)
|
||||||
|
await removeContainer(oldContainerId, true, envId).catch(() => {});
|
||||||
|
|
||||||
return newContainer;
|
return newContainer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3134,6 +3244,22 @@ export async function getDockerVersion(envId?: number | null) {
|
|||||||
return dockerJsonRequest('/version', {}, envId);
|
return dockerJsonRequest('/version', {}, envId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Docker daemon's API version string for a given environment.
|
||||||
|
* Used to pin DOCKER_API_VERSION when spawning sidecar containers (scanner, updater)
|
||||||
|
* whose bundled Docker CLI may be newer than the host daemon supports.
|
||||||
|
* Returns null if the version cannot be determined.
|
||||||
|
* Requires an envId — local Docker (no environment) must query /version directly.
|
||||||
|
*/
|
||||||
|
export async function getNegotiatedApiVersion(envId: number): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const versionInfo = await getDockerVersion(envId);
|
||||||
|
return versionInfo?.ApiVersion || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lightweight ping check for Docker daemon availability.
|
* Lightweight ping check for Docker daemon availability.
|
||||||
* Uses /_ping endpoint which returns "OK" as plain text with minimal overhead.
|
* Uses /_ping endpoint which returns "OK" as plain text with minimal overhead.
|
||||||
@@ -3317,7 +3443,29 @@ export interface NetworkInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]> {
|
export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]> {
|
||||||
const networks = await dockerJsonRequest<any[]>('/networks', {}, envId);
|
const [networks, containers] = await Promise.all([
|
||||||
|
dockerJsonRequest<any[]>('/networks', {}, envId),
|
||||||
|
dockerJsonRequest<any[]>('/containers/json?all=true', {}, envId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Build map of networkId -> container info from container network settings
|
||||||
|
const networkContainers = new Map<string, Record<string, { name: string; ipv4Address: string }>>();
|
||||||
|
for (const container of containers) {
|
||||||
|
const nets = container.NetworkSettings?.Networks;
|
||||||
|
if (!nets) continue;
|
||||||
|
const containerName = (container.Names?.[0] || '').replace(/^\//, '');
|
||||||
|
for (const [, netInfo] of Object.entries(nets as Record<string, any>)) {
|
||||||
|
const netId = netInfo.NetworkID;
|
||||||
|
if (!netId) continue;
|
||||||
|
if (!networkContainers.has(netId)) {
|
||||||
|
networkContainers.set(netId, {});
|
||||||
|
}
|
||||||
|
networkContainers.get(netId)![container.Id] = {
|
||||||
|
name: containerName,
|
||||||
|
ipv4Address: netInfo.IPAddress || ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return networks.map((network: any) => ({
|
return networks.map((network: any) => ({
|
||||||
id: network.Id,
|
id: network.Id,
|
||||||
@@ -3335,13 +3483,7 @@ export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]
|
|||||||
auxAddress: cfg.AuxAddress || cfg.auxAddress
|
auxAddress: cfg.AuxAddress || cfg.auxAddress
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
containers: Object.entries(network.Containers || {}).reduce((acc: any, [id, data]: [string, any]) => {
|
containers: networkContainers.get(network.Id) || {}
|
||||||
acc[id] = {
|
|
||||||
name: data.Name,
|
|
||||||
ipv4Address: data.IPv4Address
|
|
||||||
};
|
|
||||||
return acc;
|
|
||||||
}, {})
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3970,8 +4112,9 @@ export async function runContainerWithStreaming(options: {
|
|||||||
// Container has exited. Now fetch stdout reliably (no race condition).
|
// Container has exited. Now fetch stdout reliably (no race condition).
|
||||||
const stdout = await fetchContainerStdout(containerId, config, options.envId);
|
const stdout = await fetchContainerStdout(containerId, config, options.envId);
|
||||||
|
|
||||||
// If stdout is empty and exit code is non-zero, fetch stderr for debugging
|
// If stdout is empty and exit code is non-zero, fetch stderr and throw
|
||||||
if (stdout.length === 0 && exitCode !== 0) {
|
if (stdout.length === 0 && exitCode !== 0) {
|
||||||
|
let stderrText = '';
|
||||||
try {
|
try {
|
||||||
const stderrResponse = await dockerFetch(
|
const stderrResponse = await dockerFetch(
|
||||||
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`,
|
`/containers/${containerId}/logs?stdout=false&stderr=true&follow=false`,
|
||||||
@@ -3980,13 +4123,12 @@ export async function runContainerWithStreaming(options: {
|
|||||||
);
|
);
|
||||||
const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer());
|
const stderrBuffer = Buffer.from(await stderrResponse.arrayBuffer());
|
||||||
const stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true });
|
const stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true });
|
||||||
const stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
|
stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
|
||||||
if (stderrText) {
|
|
||||||
console.error(`[runContainerWithStreaming] Container stderr: ${stderrText.substring(0, 1000)}`);
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore stderr fetch errors
|
// Ignore stderr fetch errors
|
||||||
}
|
}
|
||||||
|
const detail = stderrText ? stderrText.substring(0, 1000) : 'no stderr output';
|
||||||
|
throw new Error(`Container exited with code ${exitCode}: ${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return stdout;
|
return stdout;
|
||||||
@@ -4761,7 +4903,7 @@ function getVolumeCacheKey(volumeName: string, envId?: number | null): string {
|
|||||||
/**
|
/**
|
||||||
* Ensure the volume helper image (busybox) is available, pulling if necessary
|
* Ensure the volume helper image (busybox) is available, pulling if necessary
|
||||||
*/
|
*/
|
||||||
async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
|
export async function ensureVolumeHelperImage(envId?: number | null): Promise<void> {
|
||||||
// Check if image exists
|
// Check if image exists
|
||||||
const response = await dockerFetch(`/images/${encodeURIComponent(VOLUME_HELPER_IMAGE)}/json`, {}, envId);
|
const response = await dockerFetch(`/images/${encodeURIComponent(VOLUME_HELPER_IMAGE)}/json`, {}, envId);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { resolve } from 'path';
|
||||||
|
import { existsSync, mkdirSync, writeFileSync, unlinkSync, readFileSync } from 'fs';
|
||||||
|
|
||||||
|
function getIconsDir(): string {
|
||||||
|
const dataDir = process.env.DATA_DIR || './data';
|
||||||
|
const dir = resolve(dataDir, 'icons');
|
||||||
|
if (!existsSync(dir)) {
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveEnvironmentIcon(envId: number, base64Data: string): void {
|
||||||
|
const dir = getIconsDir();
|
||||||
|
// Strip data URL prefix if present
|
||||||
|
const base64 = base64Data.replace(/^data:image\/\w+;base64,/, '');
|
||||||
|
const buffer = Buffer.from(base64, 'base64');
|
||||||
|
writeFileSync(resolve(dir, `env-${envId}.webp`), buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteEnvironmentIcon(envId: number): void {
|
||||||
|
const dir = getIconsDir();
|
||||||
|
const path = resolve(dir, `env-${envId}.webp`);
|
||||||
|
if (existsSync(path)) {
|
||||||
|
unlinkSync(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEnvironmentIconBuffer(envId: number): Buffer | null {
|
||||||
|
const dir = getIconsDir();
|
||||||
|
const path = resolve(dir, `env-${envId}.webp`);
|
||||||
|
if (!existsSync(path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return readFileSync(path);
|
||||||
|
}
|
||||||
+10
-6
@@ -88,7 +88,7 @@ let _nssWrapperNeeded = false;
|
|||||||
async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||||
if (_nssWrapperChecked) {
|
if (_nssWrapperChecked) {
|
||||||
if (_nssWrapperNeeded) {
|
if (_nssWrapperNeeded) {
|
||||||
env.LD_PRELOAD = NSS_WRAPPER_LIB;
|
env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
|
||||||
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
|
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
|
||||||
env.NSS_WRAPPER_GROUP = TMP_GROUP;
|
env.NSS_WRAPPER_GROUP = TMP_GROUP;
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_nssWrapperNeeded = true;
|
_nssWrapperNeeded = true;
|
||||||
env.LD_PRELOAD = NSS_WRAPPER_LIB;
|
env.LD_PRELOAD = env.LD_PRELOAD ? `${env.LD_PRELOAD}:${NSS_WRAPPER_LIB}` : NSS_WRAPPER_LIB;
|
||||||
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
|
env.NSS_WRAPPER_PASSWD = TMP_PASSWD;
|
||||||
env.NSS_WRAPPER_GROUP = TMP_GROUP;
|
env.NSS_WRAPPER_GROUP = TMP_GROUP;
|
||||||
console.log(`[git] Created temp passwd for UID ${uid} with libnss_wrapper`);
|
console.log(`[git] Created temp passwd for UID ${uid} with libnss_wrapper`);
|
||||||
@@ -733,7 +733,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
|||||||
|
|
||||||
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
|
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
|
||||||
// Blobless clones fetch all commits (for git diff) but download blobs on-demand
|
// Blobless clones fetch all commits (for git diff) but download blobs on-demand
|
||||||
const previousCommit = await getPreviousCommit(repoPath, env);
|
// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
|
||||||
|
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
|
||||||
if (existsSync(repoPath)) {
|
if (existsSync(repoPath)) {
|
||||||
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
|
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
|
||||||
rmSync(repoPath, { recursive: true, force: true });
|
rmSync(repoPath, { recursive: true, force: true });
|
||||||
@@ -762,7 +763,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
|||||||
// Check if commit changed
|
// Check if commit changed
|
||||||
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||||
const newCommit = newCommitResult.stdout.trim();
|
const newCommit = newCommitResult.stdout.trim();
|
||||||
const commitChanged = previousCommit !== newCommit;
|
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
|
||||||
|
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
|
||||||
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`);
|
console.log(`${logPrefix} Previous commit: ${previousCommit || '(none)'}, new commit: ${newCommit.substring(0, 7)}, commit changed: ${commitChanged}`);
|
||||||
|
|
||||||
// Check if any files in the compose file's directory have changed
|
// Check if any files in the compose file's directory have changed
|
||||||
@@ -1101,7 +1103,8 @@ export async function deployGitStackWithProgress(
|
|||||||
|
|
||||||
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
|
// Always re-clone to ensure clean state (handles branch/URL/credential changes, force pushes, etc.)
|
||||||
// Shallow clones are fast so this is acceptable
|
// Shallow clones are fast so this is acceptable
|
||||||
const previousCommit = await getPreviousCommit(repoPath, env);
|
// Fall back to DB lastCommit when repo dir was deleted by a previous failed sync (#693)
|
||||||
|
const previousCommit = await getPreviousCommit(repoPath, env) ?? gitStack.lastCommit ?? null;
|
||||||
|
|
||||||
// Step 2: Cloning
|
// Step 2: Cloning
|
||||||
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
|
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
|
||||||
@@ -1130,7 +1133,8 @@ export async function deployGitStackWithProgress(
|
|||||||
// Check if commit changed
|
// Check if commit changed
|
||||||
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||||
const newCommit = newCommitResult.stdout.trim();
|
const newCommit = newCommitResult.stdout.trim();
|
||||||
const commitChanged = previousCommit !== newCommit;
|
// Normalize to 7-char short hash for comparison (DB stores 7-char, git returns 40-char)
|
||||||
|
const commitChanged = previousCommit?.substring(0, 7) !== newCommit.substring(0, 7);
|
||||||
|
|
||||||
// Check if any files in the compose file's directory have changed
|
// Check if any files in the compose file's directory have changed
|
||||||
// (for consistency with syncGitStack, though this function always deploys)
|
// (for consistency with syncGitStack, though this function always deploys)
|
||||||
|
|||||||
@@ -42,47 +42,21 @@ export interface NotificationResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SMTP transporter cache — reuses connections instead of creating a new TLS pool per notification.
|
|
||||||
const transporterCache = new Map<string, { transporter: ReturnType<typeof nodemailer.createTransport>; lastUsed: number }>();
|
|
||||||
|
|
||||||
function getOrCreateTransporter(config: SmtpConfig): ReturnType<typeof nodemailer.createTransport> {
|
|
||||||
const key = `${config.host}:${config.port}:${config.secure}:${config.username || ''}`;
|
|
||||||
const cached = transporterCache.get(key);
|
|
||||||
if (cached) {
|
|
||||||
cached.lastUsed = Date.now();
|
|
||||||
return cached.transporter;
|
|
||||||
}
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: config.host,
|
|
||||||
port: config.port,
|
|
||||||
secure: config.secure,
|
|
||||||
auth: config.username ? {
|
|
||||||
user: config.username,
|
|
||||||
pass: config.password
|
|
||||||
} : undefined,
|
|
||||||
tls: config.skipTlsVerify ? {
|
|
||||||
rejectUnauthorized: false
|
|
||||||
} : undefined
|
|
||||||
});
|
|
||||||
transporterCache.set(key, { transporter, lastUsed: Date.now() });
|
|
||||||
return transporter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up idle transporters every 10 minutes
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, entry] of transporterCache) {
|
|
||||||
if (now - entry.lastUsed > 10 * 60 * 1000) {
|
|
||||||
entry.transporter.close();
|
|
||||||
transporterCache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 10 * 60 * 1000);
|
|
||||||
|
|
||||||
// Send notification via SMTP
|
// Send notification via SMTP
|
||||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||||
try {
|
try {
|
||||||
const transporter = getOrCreateTransporter(config);
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
secure: config.secure,
|
||||||
|
auth: config.username ? {
|
||||||
|
user: config.username,
|
||||||
|
pass: config.password
|
||||||
|
} : undefined,
|
||||||
|
tls: config.skipTlsVerify ? {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
} : undefined
|
||||||
|
});
|
||||||
|
|
||||||
const envBadge = payload.environmentName
|
const envBadge = payload.environmentName
|
||||||
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
? `<span style="display: inline-block; background: #3b82f6; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px;">${payload.environmentName}</span>`
|
||||||
|
|||||||
+65
-26
@@ -11,7 +11,8 @@ import {
|
|||||||
runContainer,
|
runContainer,
|
||||||
runContainerWithStreaming,
|
runContainerWithStreaming,
|
||||||
inspectImage,
|
inspectImage,
|
||||||
checkImageUpdateAvailable
|
checkImageUpdateAvailable,
|
||||||
|
getNegotiatedApiVersion
|
||||||
} from './docker';
|
} from './docker';
|
||||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||||
import { sendEventNotification } from './notifications';
|
import { sendEventNotification } from './notifications';
|
||||||
@@ -108,6 +109,10 @@ const inProgressScans = new Map<string, Promise<string>>();
|
|||||||
export const DEFAULT_GRYPE_ARGS = '-o json -v {image}';
|
export const DEFAULT_GRYPE_ARGS = '-o json -v {image}';
|
||||||
export const DEFAULT_TRIVY_ARGS = 'image --format json {image}';
|
export const DEFAULT_TRIVY_ARGS = 'image --format json {image}';
|
||||||
|
|
||||||
|
// Pinned scanner images — avoid :latest after the March 2026 Trivy supply chain attack
|
||||||
|
export const DEFAULT_GRYPE_IMAGE = 'anchore/grype:v0.110.0';
|
||||||
|
export const DEFAULT_TRIVY_IMAGE = 'aquasec/trivy:0.69.3';
|
||||||
|
|
||||||
export interface VulnerabilitySeverity {
|
export interface VulnerabilitySeverity {
|
||||||
critical: number;
|
critical: number;
|
||||||
high: number;
|
high: number;
|
||||||
@@ -150,28 +155,36 @@ export interface ScanProgress {
|
|||||||
output?: string; // Line of scanner output
|
output?: string; // Line of scanner output
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get global default scanner CLI args from general settings (or fallback to hardcoded defaults)
|
// Get global default scanner CLI args and images from general settings (or fallback to hardcoded defaults)
|
||||||
export async function getGlobalScannerDefaults(): Promise<{
|
export async function getGlobalScannerDefaults(): Promise<{
|
||||||
grypeArgs: string;
|
grypeArgs: string;
|
||||||
trivyArgs: string;
|
trivyArgs: string;
|
||||||
|
grypeImage: string;
|
||||||
|
trivyImage: string;
|
||||||
}> {
|
}> {
|
||||||
const [grypeArgs, trivyArgs] = await Promise.all([
|
const [grypeArgs, trivyArgs, grypeImage, trivyImage] = await Promise.all([
|
||||||
getSetting('default_grype_args'),
|
getSetting('default_grype_args'),
|
||||||
getSetting('default_trivy_args')
|
getSetting('default_trivy_args'),
|
||||||
|
getSetting('default_grype_image'),
|
||||||
|
getSetting('default_trivy_image')
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
|
grypeArgs: grypeArgs ?? DEFAULT_GRYPE_ARGS,
|
||||||
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS
|
trivyArgs: trivyArgs ?? DEFAULT_TRIVY_ARGS,
|
||||||
|
grypeImage: grypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||||
|
trivyImage: trivyImage ?? DEFAULT_TRIVY_IMAGE
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get scanner settings (scanner type is per-environment, CLI args are global)
|
// Get scanner settings (scanner type is per-environment, CLI args and images are global)
|
||||||
export async function getScannerSettings(envId?: number): Promise<{
|
export async function getScannerSettings(envId?: number): Promise<{
|
||||||
scanner: ScannerType;
|
scanner: ScannerType;
|
||||||
grypeArgs: string;
|
grypeArgs: string;
|
||||||
trivyArgs: string;
|
trivyArgs: string;
|
||||||
|
grypeImage: string;
|
||||||
|
trivyImage: string;
|
||||||
}> {
|
}> {
|
||||||
// CLI args are always global - no need for per-env settings
|
// CLI args and images are always global - no need for per-env settings
|
||||||
const [globalDefaults, scanner] = await Promise.all([
|
const [globalDefaults, scanner] = await Promise.all([
|
||||||
getGlobalScannerDefaults(),
|
getGlobalScannerDefaults(),
|
||||||
getEnvSetting('vulnerability_scanner', envId)
|
getEnvSetting('vulnerability_scanner', envId)
|
||||||
@@ -180,25 +193,31 @@ export async function getScannerSettings(envId?: number): Promise<{
|
|||||||
return {
|
return {
|
||||||
scanner: scanner || 'none',
|
scanner: scanner || 'none',
|
||||||
grypeArgs: globalDefaults.grypeArgs,
|
grypeArgs: globalDefaults.grypeArgs,
|
||||||
trivyArgs: globalDefaults.trivyArgs
|
trivyArgs: globalDefaults.trivyArgs,
|
||||||
|
grypeImage: globalDefaults.grypeImage,
|
||||||
|
trivyImage: globalDefaults.trivyImage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimized version that accepts pre-cached global defaults (avoids redundant DB calls)
|
// Optimized version that accepts pre-cached global defaults (avoids redundant DB calls)
|
||||||
// Only looks up scanner type per-environment since CLI args are global
|
// Only looks up scanner type per-environment since CLI args and images are global
|
||||||
export async function getScannerSettingsWithDefaults(
|
export async function getScannerSettingsWithDefaults(
|
||||||
envId: number | undefined,
|
envId: number | undefined,
|
||||||
globalDefaults: { grypeArgs: string; trivyArgs: string }
|
globalDefaults: { grypeArgs: string; trivyArgs: string; grypeImage: string; trivyImage: string }
|
||||||
): Promise<{
|
): Promise<{
|
||||||
scanner: ScannerType;
|
scanner: ScannerType;
|
||||||
grypeArgs: string;
|
grypeArgs: string;
|
||||||
trivyArgs: string;
|
trivyArgs: string;
|
||||||
|
grypeImage: string;
|
||||||
|
trivyImage: string;
|
||||||
}> {
|
}> {
|
||||||
const scanner = await getEnvSetting('vulnerability_scanner', envId) || 'none';
|
const scanner = await getEnvSetting('vulnerability_scanner', envId) || 'none';
|
||||||
return {
|
return {
|
||||||
scanner,
|
scanner,
|
||||||
grypeArgs: globalDefaults.grypeArgs,
|
grypeArgs: globalDefaults.grypeArgs,
|
||||||
trivyArgs: globalDefaults.trivyArgs
|
trivyArgs: globalDefaults.trivyArgs,
|
||||||
|
grypeImage: globalDefaults.grypeImage,
|
||||||
|
trivyImage: globalDefaults.trivyImage
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,6 +687,28 @@ async function runScannerContainerCore(
|
|||||||
? [`GRYPE_DB_CACHE_DIR=${dbPath}`]
|
? [`GRYPE_DB_CACHE_DIR=${dbPath}`]
|
||||||
: [`TRIVY_CACHE_DIR=${dbPath}`];
|
: [`TRIVY_CACHE_DIR=${dbPath}`];
|
||||||
|
|
||||||
|
// Pin Docker API version so scanner's bundled Docker client doesn't request
|
||||||
|
// a version newer than the host daemon supports (e.g. grype ships client v1.53
|
||||||
|
// but the host may only support up to v1.43).
|
||||||
|
const apiVersion = await getNegotiatedApiVersion(envId);
|
||||||
|
if (apiVersion) {
|
||||||
|
envVars.push(`DOCKER_API_VERSION=${apiVersion}`);
|
||||||
|
console.log(`[Scanner] Using negotiated Docker API version: ${apiVersion}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Propagate proxy env vars so scanners can reach the internet in proxied environments
|
||||||
|
const proxyVarNames = [
|
||||||
|
'HTTP_PROXY', 'http_proxy',
|
||||||
|
'HTTPS_PROXY', 'https_proxy',
|
||||||
|
'NO_PROXY', 'no_proxy',
|
||||||
|
'ALL_PROXY', 'all_proxy',
|
||||||
|
];
|
||||||
|
for (const name of proxyVarNames) {
|
||||||
|
if (process.env[name]) {
|
||||||
|
envVars.push(`${name}=${process.env[name]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// In TCP mode, pass DOCKER_HOST so scanner connects to Docker via TCP
|
// In TCP mode, pass DOCKER_HOST so scanner connects to Docker via TCP
|
||||||
if (scannerDockerHost) {
|
if (scannerDockerHost) {
|
||||||
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
|
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
|
||||||
@@ -697,11 +738,7 @@ async function runScannerContainerCore(
|
|||||||
});
|
});
|
||||||
|
|
||||||
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
|
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
|
||||||
if (output.length === 0) {
|
if (output.length < 100) {
|
||||||
console.error(`[Scanner] WARNING: Empty output from ${scannerType} container`);
|
|
||||||
console.error(`[Scanner] This may indicate the scanner couldn't access Docker`);
|
|
||||||
console.error(`[Scanner] Docker access: ${scannerDockerHost ? `TCP ${scannerDockerHost}` : `socket ${hostSocketPath}`}`);
|
|
||||||
} else if (output.length < 100) {
|
|
||||||
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
|
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -715,8 +752,7 @@ export async function scanWithGrype(
|
|||||||
onProgress?: (progress: ScanProgress) => void
|
onProgress?: (progress: ScanProgress) => void
|
||||||
): Promise<ScanResult> {
|
): Promise<ScanResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const scannerImage = 'anchore/grype:latest';
|
const { grypeArgs, grypeImage: scannerImage } = await getScannerSettings(envId);
|
||||||
const { grypeArgs } = await getScannerSettings(envId);
|
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
stage: 'checking',
|
stage: 'checking',
|
||||||
@@ -813,8 +849,7 @@ export async function scanWithTrivy(
|
|||||||
onProgress?: (progress: ScanProgress) => void
|
onProgress?: (progress: ScanProgress) => void
|
||||||
): Promise<ScanResult> {
|
): Promise<ScanResult> {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
const scannerImage = 'aquasec/trivy:latest';
|
const { trivyArgs, trivyImage: scannerImage } = await getScannerSettings(envId);
|
||||||
const { trivyArgs } = await getScannerSettings(envId);
|
|
||||||
|
|
||||||
onProgress?.({
|
onProgress?.({
|
||||||
stage: 'checking',
|
stage: 'checking',
|
||||||
@@ -978,9 +1013,10 @@ export async function checkScannerAvailability(envId?: number): Promise<{
|
|||||||
grype: boolean;
|
grype: boolean;
|
||||||
trivy: boolean;
|
trivy: boolean;
|
||||||
}> {
|
}> {
|
||||||
|
const defaults = await getGlobalScannerDefaults();
|
||||||
const [grypeAvailable, trivyAvailable] = await Promise.all([
|
const [grypeAvailable, trivyAvailable] = await Promise.all([
|
||||||
isScannerImageAvailable('anchore/grype', envId),
|
isScannerImageAvailable(defaults.grypeImage, envId),
|
||||||
isScannerImageAvailable('aquasec/trivy', envId)
|
isScannerImageAvailable(defaults.trivyImage, envId)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -995,12 +1031,14 @@ async function getScannerVersion(
|
|||||||
envId?: number
|
envId?: number
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const scannerImage = scannerType === 'grype' ? 'anchore/grype:latest' : 'aquasec/trivy:latest';
|
const defaults = await getGlobalScannerDefaults();
|
||||||
|
const scannerImage = scannerType === 'grype' ? defaults.grypeImage : defaults.trivyImage;
|
||||||
|
|
||||||
// Check if image exists first
|
// Check if image exists first — match by repo name prefix, not exact tag
|
||||||
|
const imageRepo = scannerType === 'grype' ? 'anchore/grype' : 'aquasec/trivy';
|
||||||
const images = await listImages(envId);
|
const images = await listImages(envId);
|
||||||
const hasImage = images.some((img) =>
|
const hasImage = images.some((img) =>
|
||||||
img.tags?.some((tag: string) => tag === scannerImage)
|
img.tags?.some((tag: string) => tag.startsWith(imageRepo + ':'))
|
||||||
);
|
);
|
||||||
if (!hasImage) return null;
|
if (!hasImage) return null;
|
||||||
|
|
||||||
@@ -1062,10 +1100,11 @@ export async function checkScannerUpdates(envId?: number): Promise<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const defaults = await getGlobalScannerDefaults();
|
||||||
const images = await listImages(envId);
|
const images = await listImages(envId);
|
||||||
|
|
||||||
// Check both scanners
|
// Check both scanners
|
||||||
for (const [scanner, imageName] of [['grype', 'anchore/grype:latest'], ['trivy', 'aquasec/trivy:latest']] as const) {
|
for (const [scanner, imageName] of [['grype', defaults.grypeImage], ['trivy', defaults.trivyImage]] as const) {
|
||||||
try {
|
try {
|
||||||
// Find local image
|
// Find local image
|
||||||
const localImage = images.find((img) =>
|
const localImage = images.find((img) =>
|
||||||
|
|||||||
@@ -13,10 +13,14 @@ export interface GridItem {
|
|||||||
|
|
||||||
export interface DashboardPreferences {
|
export interface DashboardPreferences {
|
||||||
gridLayout: GridItem[];
|
gridLayout: GridItem[];
|
||||||
|
locked: boolean;
|
||||||
|
viewMode: 'grid' | 'list';
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultPreferences: DashboardPreferences = {
|
const defaultPreferences: DashboardPreferences = {
|
||||||
gridLayout: []
|
gridLayout: [],
|
||||||
|
locked: false,
|
||||||
|
viewMode: 'grid'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Environment info from API
|
// Environment info from API
|
||||||
@@ -147,16 +151,20 @@ function createDashboardStore() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
// Handle migration from old format
|
// Handle migration from old format
|
||||||
if (data.gridLayout && Array.isArray(data.gridLayout)) {
|
if (data.gridLayout && Array.isArray(data.gridLayout)) {
|
||||||
set({ gridLayout: data.gridLayout });
|
set({
|
||||||
|
gridLayout: data.gridLayout,
|
||||||
|
locked: data.locked ?? false,
|
||||||
|
viewMode: data.viewMode ?? 'grid'
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
set({ gridLayout: [] });
|
set(defaultPreferences);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
set({ gridLayout: [] });
|
set(defaultPreferences);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard preferences:', error);
|
console.error('Failed to load dashboard preferences:', error);
|
||||||
set({ gridLayout: [] });
|
set(defaultPreferences);
|
||||||
} finally {
|
} finally {
|
||||||
// Always mark as initialized so saves can proceed
|
// Always mark as initialized so saves can proceed
|
||||||
initialized = true;
|
initialized = true;
|
||||||
@@ -206,6 +214,24 @@ function createDashboardStore() {
|
|||||||
return newPrefs;
|
return newPrefs;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setLocked: (locked: boolean) => {
|
||||||
|
update(prefs => {
|
||||||
|
const newPrefs = { ...prefs, locked };
|
||||||
|
if (initialized) {
|
||||||
|
scheduleSave(newPrefs);
|
||||||
|
}
|
||||||
|
return newPrefs;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setViewMode: (viewMode: 'grid' | 'list') => {
|
||||||
|
update(prefs => {
|
||||||
|
const newPrefs = { ...prefs, viewMode };
|
||||||
|
if (initialized) {
|
||||||
|
scheduleSave(newPrefs);
|
||||||
|
}
|
||||||
|
return newPrefs;
|
||||||
|
});
|
||||||
|
},
|
||||||
reset: () => {
|
reset: () => {
|
||||||
initialized = false;
|
initialized = false;
|
||||||
set(defaultPreferences);
|
set(defaultPreferences);
|
||||||
|
|||||||
@@ -27,8 +27,11 @@ export interface AppSettings {
|
|||||||
eventPollInterval: number;
|
eventPollInterval: number;
|
||||||
metricsCollectionInterval: number;
|
metricsCollectionInterval: number;
|
||||||
compactPorts: boolean;
|
compactPorts: boolean;
|
||||||
|
formatLogTimestamps: boolean;
|
||||||
externalStackPaths: string[];
|
externalStackPaths: string[];
|
||||||
primaryStackLocation: string | null;
|
primaryStackLocation: string | null;
|
||||||
|
defaultGrypeImage: string;
|
||||||
|
defaultTrivyImage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: AppSettings = {
|
const DEFAULT_SETTINGS: AppSettings = {
|
||||||
@@ -52,8 +55,11 @@ const DEFAULT_SETTINGS: AppSettings = {
|
|||||||
eventPollInterval: 60000,
|
eventPollInterval: 60000,
|
||||||
metricsCollectionInterval: 30000,
|
metricsCollectionInterval: 30000,
|
||||||
compactPorts: false,
|
compactPorts: false,
|
||||||
|
formatLogTimestamps: false,
|
||||||
externalStackPaths: [],
|
externalStackPaths: [],
|
||||||
primaryStackLocation: null
|
primaryStackLocation: null,
|
||||||
|
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
||||||
|
defaultTrivyImage: 'aquasec/trivy:0.69.3'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a writable store for app settings
|
// Create a writable store for app settings
|
||||||
@@ -91,8 +97,11 @@ function createSettingsStore() {
|
|||||||
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||||
metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||||
compactPorts: settings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
compactPorts: settings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
||||||
|
formatLogTimestamps: settings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||||
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
externalStackPaths: settings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||||
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
|
primaryStackLocation: settings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
||||||
|
defaultGrypeImage: settings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||||
|
defaultTrivyImage: settings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -133,8 +142,11 @@ function createSettingsStore() {
|
|||||||
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||||
metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||||
compactPorts: updatedSettings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
compactPorts: updatedSettings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
||||||
|
formatLogTimestamps: updatedSettings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||||
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
externalStackPaths: updatedSettings.externalStackPaths ?? DEFAULT_SETTINGS.externalStackPaths,
|
||||||
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation
|
primaryStackLocation: updatedSettings.primaryStackLocation ?? DEFAULT_SETTINGS.primaryStackLocation,
|
||||||
|
defaultGrypeImage: updatedSettings.defaultGrypeImage ?? DEFAULT_SETTINGS.defaultGrypeImage,
|
||||||
|
defaultTrivyImage: updatedSettings.defaultTrivyImage ?? DEFAULT_SETTINGS.defaultTrivyImage
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -301,6 +313,13 @@ function createSettingsStore() {
|
|||||||
return newSettings;
|
return newSettings;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setFormatLogTimestamps: (value: boolean) => {
|
||||||
|
update((current) => {
|
||||||
|
const newSettings = { ...current, formatLogTimestamps: value };
|
||||||
|
saveSettings({ formatLogTimestamps: value });
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
setExternalStackPaths: (value: string[]) => {
|
setExternalStackPaths: (value: string[]) => {
|
||||||
update((current) => {
|
update((current) => {
|
||||||
const newSettings = { ...current, externalStackPaths: value };
|
const newSettings = { ...current, externalStackPaths: value };
|
||||||
@@ -315,6 +334,20 @@ function createSettingsStore() {
|
|||||||
return newSettings;
|
return newSettings;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
setDefaultGrypeImage: (value: string) => {
|
||||||
|
update((current) => {
|
||||||
|
const newSettings = { ...current, defaultGrypeImage: value };
|
||||||
|
saveSettings({ defaultGrypeImage: value });
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
setDefaultTrivyImage: (value: string) => {
|
||||||
|
update((current) => {
|
||||||
|
const newSettings = { ...current, defaultTrivyImage: value };
|
||||||
|
saveSettings({ defaultTrivyImage: value });
|
||||||
|
return newSettings;
|
||||||
|
});
|
||||||
|
},
|
||||||
// Manual refresh from database
|
// Manual refresh from database
|
||||||
refresh: loadSettings
|
refresh: loadSettings
|
||||||
};
|
};
|
||||||
@@ -430,3 +463,19 @@ export function getTimeFormat(): TimeFormat {
|
|||||||
export function getDateFormat(): DateFormat {
|
export function getDateFormat(): DateFormat {
|
||||||
return cachedDateFormat;
|
return cachedDateFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regex matching ISO 8601 timestamps at the start of log lines (after optional container prefix)
|
||||||
|
// Matches: 2026-01-12T07:47:44.449821093Z or 2026-01-12T07:47:44Z
|
||||||
|
const ISO_TIMESTAMP_RE = /(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?Z/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace ISO 8601 timestamps in log text with formatted local timestamps.
|
||||||
|
* Uses the user's configured date/time format settings.
|
||||||
|
*/
|
||||||
|
export function formatLogTimestamps(text: string): string {
|
||||||
|
return text.replace(ISO_TIMESTAMP_RE, (_match, dateTimePart) => {
|
||||||
|
const d = new Date(_match);
|
||||||
|
if (isNaN(d.getTime())) return _match;
|
||||||
|
return `${formatDatePart(d)} ${formatTimePart(d, true)}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ function applyEditorFont(fontId: string) {
|
|||||||
document.documentElement.style.setProperty('--font-editor', fontMeta.family);
|
document.documentElement.style.setProperty('--font-editor', fontMeta.family);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load Google Font dynamically
|
// Load bundled font CSS (fonts are bundled in static/fonts/)
|
||||||
function loadGoogleFont(font: FontMeta) {
|
function loadGoogleFont(font: FontMeta) {
|
||||||
if (!font.googleFont) return;
|
if (!font.googleFont) return;
|
||||||
|
|
||||||
@@ -274,7 +274,7 @@ function loadGoogleFont(font: FontMeta) {
|
|||||||
const link = document.createElement('link');
|
const link = document.createElement('link');
|
||||||
link.id = linkId;
|
link.id = linkId;
|
||||||
link.rel = 'stylesheet';
|
link.rel = 'stylesheet';
|
||||||
link.href = `https://fonts.googleapis.com/css2?family=${font.googleFont}&display=swap`;
|
link.href = `/fonts/${font.id}/font.css`;
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export const monospaceFonts: FontMeta[] = [
|
|||||||
{ id: 'ubuntu-mono', name: 'Ubuntu Mono', family: "'Ubuntu Mono', monospace", googleFont: 'Ubuntu+Mono:wght@400;700' },
|
{ id: 'ubuntu-mono', name: 'Ubuntu Mono', family: "'Ubuntu Mono', monospace", googleFont: 'Ubuntu+Mono:wght@400;700' },
|
||||||
{ id: 'space-mono', name: 'Space Mono', family: "'Space Mono', monospace", googleFont: 'Space+Mono:wght@400;700' },
|
{ id: 'space-mono', name: 'Space Mono', family: "'Space Mono', monospace", googleFont: 'Space+Mono:wght@400;700' },
|
||||||
{ id: 'inconsolata', name: 'Inconsolata', family: "'Inconsolata', monospace", googleFont: 'Inconsolata:wght@400;500;600;700' },
|
{ id: 'inconsolata', name: 'Inconsolata', family: "'Inconsolata', monospace", googleFont: 'Inconsolata:wght@400;500;600;700' },
|
||||||
{ id: 'hack', name: 'Hack', family: "'Hack', monospace", googleFont: 'Hack:wght@400;700' },
|
|
||||||
{ id: 'anonymous-pro', name: 'Anonymous Pro', family: "'Anonymous Pro', monospace", googleFont: 'Anonymous+Pro:wght@400;700' },
|
{ id: 'anonymous-pro', name: 'Anonymous Pro', family: "'Anonymous Pro', monospace", googleFont: 'Anonymous+Pro:wght@400;700' },
|
||||||
{ id: 'dm-mono', name: 'DM Mono', family: "'DM Mono', monospace", googleFont: 'DM+Mono:wght@400;500' },
|
{ id: 'dm-mono', name: 'DM Mono', family: "'DM Mono', monospace", googleFont: 'DM+Mono:wght@400;500' },
|
||||||
{ id: 'red-hat-mono', name: 'Red Hat Mono', family: "'Red Hat Mono', monospace", googleFont: 'Red+Hat+Mono:wght@400;500;600;700' },
|
{ id: 'red-hat-mono', name: 'Red Hat Mono', family: "'Red Hat Mono', monospace", googleFont: 'Red+Hat+Mono:wght@400;500;600;700' },
|
||||||
|
|||||||
+2
-2
@@ -28,7 +28,7 @@ export interface ContainerInfo {
|
|||||||
rw: boolean;
|
rw: boolean;
|
||||||
}>;
|
}>;
|
||||||
networkMode: string;
|
networkMode: string;
|
||||||
networks: string[];
|
networks: Record<string, { ipAddress: string }>;
|
||||||
/**
|
/**
|
||||||
* Identifies system containers (Dockhand, Hawser) that cannot be updated from within Dockhand.
|
* Identifies system containers (Dockhand, Hawser) that cannot be updated from within Dockhand.
|
||||||
* - 'dockhand': The Dockhand container itself
|
* - 'dockhand': The Dockhand container itself
|
||||||
@@ -164,7 +164,7 @@ export interface GitRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Grid column configuration types
|
// Grid column configuration types
|
||||||
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit';
|
export type GridId = 'containers' | 'images' | 'imageTags' | 'networks' | 'stacks' | 'volumes' | 'activity' | 'schedules' | 'audit' | 'environments';
|
||||||
|
|
||||||
export interface ColumnConfig {
|
export interface ColumnConfig {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
|||||||
document.body.appendChild(textarea);
|
document.body.appendChild(textarea);
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.select();
|
textarea.select();
|
||||||
|
textarea.setSelectionRange(0, textarea.value.length);
|
||||||
const ok = document.execCommand('copy');
|
const ok = document.execCommand('copy');
|
||||||
document.body.removeChild(textarea);
|
document.body.removeChild(textarea);
|
||||||
if (ok) return true;
|
if (ok) return true;
|
||||||
|
|||||||
@@ -43,4 +43,8 @@ export function getIconComponent(iconName: string): ComponentType {
|
|||||||
return iconMap[iconName] || Globe;
|
return iconMap[iconName] || Globe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isCustomIcon(icon: string | null | undefined): boolean {
|
||||||
|
return !!icon && icon.startsWith('custom:');
|
||||||
|
}
|
||||||
|
|
||||||
export { iconMap };
|
export { iconMap };
|
||||||
|
|||||||
@@ -82,9 +82,6 @@
|
|||||||
// Check auth status
|
// Check auth status
|
||||||
authStore.check();
|
authStore.check();
|
||||||
|
|
||||||
// Check What's New popup
|
|
||||||
checkWhatsNew();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disconnectSSE();
|
disconnectSSE();
|
||||||
};
|
};
|
||||||
@@ -113,6 +110,15 @@
|
|||||||
localStorage.setItem('dockhand-whats-new-version', currentVersion);
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|||||||
+131
-4
@@ -5,7 +5,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { browser } from '$app/environment';
|
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 { toast } from 'svelte-sonner';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
@@ -14,11 +14,14 @@
|
|||||||
import EnvironmentTile from './dashboard/EnvironmentTile.svelte';
|
import EnvironmentTile from './dashboard/EnvironmentTile.svelte';
|
||||||
import EnvironmentTileSkeleton from './dashboard/EnvironmentTileSkeleton.svelte';
|
import EnvironmentTileSkeleton from './dashboard/EnvironmentTileSkeleton.svelte';
|
||||||
import DraggableGrid, { type GridItemLayout } from './dashboard/DraggableGrid.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 { dashboardPreferences, dashboardData, GRID_COLS, GRID_ROW_HEIGHT, type TileItem } from '$lib/stores/dashboard';
|
||||||
import { currentEnvironment, environments } from '$lib/stores/environment';
|
import { currentEnvironment, environments } from '$lib/stores/environment';
|
||||||
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
import { IsMobile } from '$lib/hooks/is-mobile.svelte';
|
||||||
import type { EnvironmentStats } from './api/dashboard/stats/+server';
|
import type { EnvironmentStats } from './api/dashboard/stats/+server';
|
||||||
import { getLabelColor, getLabelBgColor } from '$lib/utils/label-colors';
|
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';
|
const LABEL_FILTER_STORAGE_KEY = 'dockhand-dashboard-label-filter';
|
||||||
|
|
||||||
@@ -52,6 +55,42 @@
|
|||||||
const mobileWatcher = new IsMobile();
|
const mobileWatcher = new IsMobile();
|
||||||
const isMobile = $derived.by(() => mobileWatcher.current);
|
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
|
// Subscribe to environments store's loaded flag for quick "loaded" detection
|
||||||
// When loaded, immediately create skeleton tiles so the UI shows something useful
|
// When loaded, immediately create skeleton tiles so the UI shows something useful
|
||||||
// The SSE stream will then update these tiles with real stats
|
// 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
|
// Filter grid items based on selected labels
|
||||||
const filteredGridItems = $derived.by(() => {
|
const filteredGridItems = $derived.by(() => {
|
||||||
if (filterLabels.length === 0) {
|
if (filterLabels.length === 0) {
|
||||||
@@ -217,6 +267,8 @@
|
|||||||
|
|
||||||
// Subscribe to preferences store to load saved layout
|
// Subscribe to preferences store to load saved layout
|
||||||
const unsubscribePrefs = dashboardPreferences.subscribe(prefs => {
|
const unsubscribePrefs = dashboardPreferences.subscribe(prefs => {
|
||||||
|
locked = prefs.locked;
|
||||||
|
viewMode = prefs.viewMode;
|
||||||
if (prefs.gridLayout.length > 0 && tiles.length > 0 && !prefsLoaded) {
|
if (prefs.gridLayout.length > 0 && tiles.length > 0 && !prefsLoaded) {
|
||||||
// Apply saved layout
|
// Apply saved layout
|
||||||
gridItems = prefs.gridLayout.map(item => ({
|
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
|
// Apply autolayout - arrange all tiles with specified dimensions
|
||||||
function applyAutoLayout(width: number, height: number) {
|
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 tileIds = tiles.map(t => t.id);
|
||||||
const newGridItems: GridItemLayout[] = [];
|
const newGridItems: GridItemLayout[] = [];
|
||||||
|
|
||||||
@@ -928,7 +999,7 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
|
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3 min-h-8">
|
||||||
<div class="flex items-center gap-4">
|
<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) -->
|
<!-- Label filter toggles (only show if there are labels) -->
|
||||||
{#if allLabels.length > 0}
|
{#if allLabels.length > 0}
|
||||||
@@ -960,6 +1031,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-1">
|
<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 -->
|
<!-- Add environment button -->
|
||||||
<button
|
<button
|
||||||
onclick={() => goto('/settings?tab=environments&new=true')}
|
onclick={() => goto('/settings?tab=environments&new=true')}
|
||||||
@@ -969,6 +1067,21 @@
|
|||||||
<Plus class="w-4 h-4" />
|
<Plus class="w-4 h-4" />
|
||||||
</button>
|
</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 -->
|
<!-- Autolayout dropdown -->
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger>
|
<DropdownMenu.Trigger>
|
||||||
@@ -976,7 +1089,7 @@
|
|||||||
<button
|
<button
|
||||||
{...props}
|
{...props}
|
||||||
class="p-1.5 rounded hover:bg-muted transition-colors"
|
class="p-1.5 rounded hover:bg-muted transition-colors"
|
||||||
title="Auto-layout tiles"
|
title="Layout options"
|
||||||
>
|
>
|
||||||
<LayoutTemplate class="w-4 h-4" />
|
<LayoutTemplate class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -999,6 +1112,11 @@
|
|||||||
<Maximize2 class="w-4 h-4" />
|
<Maximize2 class="w-4 h-4" />
|
||||||
<span>Full</span>
|
<span>Full</span>
|
||||||
</DropdownMenu.Item>
|
</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.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
|
||||||
@@ -1032,8 +1150,16 @@
|
|||||||
Go to Settings
|
Go to Settings
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if viewMode === 'list'}
|
||||||
|
<!-- List view -->
|
||||||
|
<EnvironmentListView
|
||||||
|
tiles={filteredTiles}
|
||||||
|
searchQuery={listSearchQuery}
|
||||||
|
connectionFilter={listConnectionFilter}
|
||||||
|
onrowclick={handleTileClick}
|
||||||
|
/>
|
||||||
{:else if filteredGridItems.length === 0}
|
{: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="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">
|
<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" />
|
<Tags class="w-8 h-8 opacity-40" />
|
||||||
@@ -1086,6 +1212,7 @@
|
|||||||
maxW={2}
|
maxW={2}
|
||||||
minH={1}
|
minH={1}
|
||||||
maxH={4}
|
maxH={4}
|
||||||
|
{locked}
|
||||||
onchange={handleGridChange}
|
onchange={handleGridChange}
|
||||||
onitemclick={handleTileClick}
|
onitemclick={handleTileClick}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
|
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 { canAccess } from '$lib/stores/auth';
|
||||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -683,14 +683,17 @@
|
|||||||
<!-- Environment filter -->
|
<!-- Environment filter -->
|
||||||
{#if environments.length > 0}
|
{#if environments.length > 0}
|
||||||
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
|
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
|
||||||
{@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server}
|
|
||||||
<Select.Root
|
<Select.Root
|
||||||
type="single"
|
type="single"
|
||||||
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
|
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
|
||||||
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
|
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
|
||||||
>
|
>
|
||||||
<Select.Trigger size="sm" class="w-44 text-sm">
|
<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">
|
<span class="truncate">
|
||||||
{#if filterEnvironmentId === null}
|
{#if filterEnvironmentId === null}
|
||||||
Environment
|
Environment
|
||||||
@@ -705,9 +708,8 @@
|
|||||||
All environments
|
All environments
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{#each environments as env}
|
{#each environments as env}
|
||||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
|
||||||
<Select.Item value={String(env.id)}>
|
<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}
|
{env.name}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -814,9 +816,8 @@
|
|||||||
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(event.timestamp)}</span>
|
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(event.timestamp)}</span>
|
||||||
{:else if column.id === 'environment'}
|
{:else if column.id === 'environment'}
|
||||||
{#if event.environmentName}
|
{#if event.environmentName}
|
||||||
{@const EventEnvIcon = getIconComponent(event.environmentIcon || 'globe')}
|
|
||||||
<div class="flex items-center gap-1 text-xs">
|
<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>
|
<span class="truncate">{event.environmentName}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getOidcConfig } from '$lib/server/db';
|
|||||||
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
|
// GET /api/auth/oidc/[id]/initiate - Start OIDC authentication flow
|
||||||
export const GET: RequestHandler = async ({ params, url }) => {
|
export const GET: RequestHandler = async ({ params, url }) => {
|
||||||
// Check if auth is enabled
|
// Check if auth is enabled
|
||||||
if (!isAuthEnabled()) {
|
if (!await isAuthEnabled()) {
|
||||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
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
|
// POST /api/auth/oidc/[id]/initiate - Get authorization URL without redirect
|
||||||
export const POST: RequestHandler = async ({ params, request }) => {
|
export const POST: RequestHandler = async ({ params, request }) => {
|
||||||
// Check if auth is enabled
|
// Check if auth is enabled
|
||||||
if (!isAuthEnabled()) {
|
if (!await isAuthEnabled()) {
|
||||||
return json({ error: 'Authentication is not enabled' }, { status: 400 });
|
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 }) => {
|
export const POST: RequestHandler = async ({ params, cookies }) => {
|
||||||
// When auth is disabled, allow access (for initial setup)
|
// When auth is disabled, allow access (for initial setup)
|
||||||
// When auth is enabled, require admin
|
// When auth is enabled, require admin
|
||||||
if (isAuthEnabled()) {
|
if (await isAuthEnabled()) {
|
||||||
const user = await validateSession(cookies);
|
const user = await validateSession(cookies);
|
||||||
if (!user || !user.isAdmin) {
|
if (!user || !user.isAdmin) {
|
||||||
return json({ error: 'Admin access required' }, { status: 403 });
|
return json({ error: 'Admin access required' }, { status: 403 });
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { auditAuth } from '$lib/server/audit';
|
|||||||
export const GET: RequestHandler = async (event) => {
|
export const GET: RequestHandler = async (event) => {
|
||||||
const { url, cookies } = event;
|
const { url, cookies } = event;
|
||||||
// Check if auth is enabled
|
// Check if auth is enabled
|
||||||
if (!isAuthEnabled()) {
|
if (!await isAuthEnabled()) {
|
||||||
throw redirect(302, '/login?error=auth_disabled');
|
throw redirect(302, '/login?error=auth_disabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ import { deleteAutoUpdateSchedule, getAutoUpdateSetting, removePendingContainerU
|
|||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
@@ -41,6 +45,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const force = url.searchParams.get('force') === 'true';
|
const force = url.searchParams.get('force') === 'true';
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
|
import { createExec, getDockerConnectionInfo } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
|
export const POST: RequestHandler = async ({ params, request, cookies, url }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
if (auth.authEnabled && !auth.isAuthenticated) {
|
if (auth.authEnabled && !auth.isAuthenticated) {
|
||||||
return json({ error: 'Unauthorized' }, { status: 401 });
|
return json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { listContainerDirectory } from '$lib/server/docker';
|
import { listContainerDirectory } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const path = url.searchParams.get('path') || '/';
|
const path = url.searchParams.get('path') || '/';
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { chmodContainerPath } from '$lib/server/docker';
|
import { chmodContainerPath } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
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 auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
|
import { readContainerFile, writeContainerFile } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
// Max file size for reading (1MB)
|
// Max file size for reading (1MB)
|
||||||
const MAX_FILE_SIZE = 1024 * 1024;
|
const MAX_FILE_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const path = url.searchParams.get('path');
|
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 }) => {
|
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 auth = await authorize(cookies);
|
||||||
|
|
||||||
const path = url.searchParams.get('path');
|
const path = url.searchParams.get('path');
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
|
import { createContainerFile, createContainerDirectory } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
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 auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { deleteContainerPath } from '$lib/server/docker';
|
import { deleteContainerPath } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
export const DELETE: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const path = url.searchParams.get('path');
|
const path = url.searchParams.get('path');
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { gzipSync } from 'node:zlib';
|
import { gzipSync } from 'node:zlib';
|
||||||
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
import { getContainerArchive, statContainerPath } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const path = url.searchParams.get('path');
|
const path = url.searchParams.get('path');
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { renameContainerPath } from '$lib/server/docker';
|
import { renameContainerPath } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, url, cookies, request }) => {
|
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 auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { putContainerArchive } from '$lib/server/docker';
|
import { putContainerArchive } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
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 }) => {
|
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 auth = await authorize(cookies);
|
||||||
|
|
||||||
const path = url.searchParams.get('path');
|
const path = url.searchParams.get('path');
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { inspectContainer } from '$lib/server/docker';
|
import { inspectContainer } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { getContainerLogs } from '$lib/server/docker';
|
import { getContainerLogs } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const tail = parseInt(url.searchParams.get('tail') || '100');
|
const tail = parseInt(url.searchParams.get('tail') || '100');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { getEnvironment } from '$lib/server/db';
|
import { getEnvironment } from '$lib/server/db';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
|
import { unixSocketRequest, unixSocketStreamRequest, httpsAgentRequest } from '$lib/server/docker';
|
||||||
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
|
import type { DockerClientConfig as BaseDockerClientConfig } from '$lib/server/docker';
|
||||||
import { sendEdgeRequest, sendEdgeStreamRequest, isEdgeConnected } from '$lib/server/hawser';
|
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 }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const containerId = params.id;
|
const containerId = params.id;
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
|
|||||||
import { pauseContainer, inspectContainer } from '$lib/server/docker';
|
import { pauseContainer, inspectContainer } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import { renameContainer, inspectContainer } from '$lib/server/docker';
|
|||||||
import { renameAutoUpdateSchedule } from '$lib/server/db';
|
import { renameAutoUpdateSchedule } from '$lib/server/db';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, request, url, cookies } = event;
|
const { params, request, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
|
|||||||
import { restartContainer, inspectContainer } from '$lib/server/docker';
|
import { restartContainer, inspectContainer } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { execInContainer } from '$lib/server/docker';
|
import { execInContainer } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
// Shell paths to check
|
// Shell paths to check
|
||||||
@@ -12,6 +13,9 @@ const SHELLS_TO_CHECK = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
|
|||||||
import { startContainer, inspectContainer } from '$lib/server/docker';
|
import { startContainer, inspectContainer } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
import { getContainerStats, EnvironmentNotFoundError } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { hasEnvironments } from '$lib/server/db';
|
import { hasEnvironments } from '$lib/server/db';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
function calculateCpuPercent(stats: any): number {
|
function calculateCpuPercent(stats: any): number {
|
||||||
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage;
|
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 }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
|
|||||||
import { stopContainer, inspectContainer } from '$lib/server/docker';
|
import { stopContainer, inspectContainer } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { execInContainer, getContainerTop } from '$lib/server/docker';
|
import { execInContainer, getContainerTop } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
|
function parsePsOutput(output: string): { Titles: string[]; Processes: string[][] } | null {
|
||||||
const lines = output.trim().split('\n').filter(line => line.trim());
|
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 }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
|
|||||||
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
|
import { unpauseContainer, inspectContainer } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
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 { authorize } from '$lib/server/authorize';
|
||||||
import { auditContainer } from '$lib/server/audit';
|
import { auditContainer } from '$lib/server/audit';
|
||||||
import { removePendingContainerUpdate } from '$lib/server/db';
|
import { removePendingContainerUpdate } from '$lib/server/db';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, request, url, cookies } = event;
|
const { params, request, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'container');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -273,7 +273,6 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
|
|
||||||
let scanBlocked = false;
|
let scanBlocked = false;
|
||||||
let blockReason = '';
|
let blockReason = '';
|
||||||
let finalScanResult: ScanResult | undefined;
|
|
||||||
let individualScannerResults: ScannerResult[] = [];
|
let individualScannerResults: ScannerResult[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -290,17 +289,7 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (scanResults.length > 0) {
|
if (scanResults.length > 0) {
|
||||||
const scanSummary = combineScanSummaries(scanResults);
|
// Build individual scanner results (used by frontend)
|
||||||
finalScanResult = {
|
|
||||||
critical: scanSummary.critical,
|
|
||||||
high: scanSummary.high,
|
|
||||||
medium: scanSummary.medium,
|
|
||||||
low: scanSummary.low,
|
|
||||||
negligible: scanSummary.negligible,
|
|
||||||
unknown: scanSummary.unknown
|
|
||||||
};
|
|
||||||
|
|
||||||
// Build individual scanner results
|
|
||||||
individualScannerResults = scanResults.map(result => ({
|
individualScannerResults = scanResults.map(result => ({
|
||||||
scanner: result.scanner as 'grype' | 'trivy',
|
scanner: result.scanner as 'grype' | 'trivy',
|
||||||
critical: result.summary.critical,
|
critical: result.summary.critical,
|
||||||
@@ -333,8 +322,9 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
} catch { /* ignore save errors */ }
|
} catch { /* ignore save errors */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if blocked
|
// Check if blocked (combineScanSummaries uses Math.max for security check)
|
||||||
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, scanSummary, undefined);
|
const combinedForBlockCheck = combineScanSummaries(scanResults);
|
||||||
|
const { blocked, reason } = shouldBlockUpdate(vulnerabilityCriteria, combinedForBlockCheck, undefined);
|
||||||
if (blocked) {
|
if (blocked) {
|
||||||
scanBlocked = true;
|
scanBlocked = true;
|
||||||
blockReason = reason;
|
blockReason = reason;
|
||||||
@@ -355,15 +345,21 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
scanner: v.scanner
|
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({
|
sendData({
|
||||||
type: 'scan_complete',
|
type: 'scan_complete',
|
||||||
containerId,
|
containerId,
|
||||||
containerName,
|
containerName,
|
||||||
scanResult: finalScanResult,
|
|
||||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||||
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
|
vulnerabilities: vulnerabilities.length > 0 ? vulnerabilities : undefined,
|
||||||
message: finalScanResult
|
message: hasVulns
|
||||||
? `Scan complete: ${finalScanResult.critical} critical, ${finalScanResult.high} high, ${finalScanResult.medium} medium, ${finalScanResult.low} low`
|
? `Scan complete: ${totalCritical} critical, ${totalHigh} high, ${totalMedium} medium, ${totalLow} low`
|
||||||
: 'Scan complete: no vulnerabilities found'
|
: 'Scan complete: no vulnerabilities found'
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -398,7 +394,6 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
current: i + 1,
|
current: i + 1,
|
||||||
total: containerIds.length,
|
total: containerIds.length,
|
||||||
success: false,
|
success: false,
|
||||||
scanResult: finalScanResult,
|
|
||||||
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
scannerResults: individualScannerResults.length > 0 ? individualScannerResults : undefined,
|
||||||
blockReason,
|
blockReason,
|
||||||
message: `Update blocked: ${blockReason}`
|
message: `Update blocked: ${blockReason}`
|
||||||
|
|||||||
@@ -1,26 +1,59 @@
|
|||||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
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';
|
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 }) => {
|
export const GET: RequestHandler = async ({ cookies }) => {
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get user-specific preferences, or fall back to global preferences
|
|
||||||
const userId = auth.user?.id ?? null;
|
const userId = auth.user?.id ?? null;
|
||||||
const prefs = await getDashboardPreferences(userId);
|
const prefs = await getPrefs(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()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return json(prefs);
|
return json(prefs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to get dashboard preferences:', error);
|
console.error('Failed to get dashboard preferences:', error);
|
||||||
@@ -33,19 +66,23 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { gridLayout } = body;
|
const userId = auth.user?.id ?? null;
|
||||||
|
|
||||||
if (!gridLayout || !Array.isArray(gridLayout)) {
|
// Load current prefs and merge changes
|
||||||
return json({ error: 'gridLayout is required and must be an array' }, { status: 400 });
|
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;
|
await savePrefs(userId, current);
|
||||||
const prefs = await saveDashboardPreferences({
|
return json(current);
|
||||||
userId,
|
|
||||||
gridLayout
|
|
||||||
});
|
|
||||||
|
|
||||||
return json(prefs);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save dashboard preferences:', error);
|
console.error('Failed to save dashboard preferences:', error);
|
||||||
return json({ error: 'Failed to save dashboard preferences' }, { status: 500 });
|
return json({ error: 'Failed to save dashboard preferences' }, { status: 500 });
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
import { json, type RequestHandler } from '@sveltejs/kit';
|
||||||
import dependencies from '$lib/data/dependencies.json';
|
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)
|
// External tools used by Dockhand (Docker images)
|
||||||
const externalTools = [
|
const externalTools = [
|
||||||
{
|
{
|
||||||
name: 'anchore/grype',
|
name: 'anchore/grype',
|
||||||
version: 'latest',
|
version: imageTag(DEFAULT_GRYPE_IMAGE),
|
||||||
license: 'Apache-2.0',
|
license: 'Apache-2.0',
|
||||||
repository: 'https://github.com/anchore/grype'
|
repository: 'https://github.com/anchore/grype'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'aquasec/trivy',
|
name: 'aquasec/trivy',
|
||||||
version: 'latest',
|
version: imageTag(DEFAULT_TRIVY_IMAGE),
|
||||||
license: 'Apache-2.0',
|
license: 'Apache-2.0',
|
||||||
repository: 'https://github.com/aquasecurity/trivy'
|
repository: 'https://github.com/aquasecurity/trivy'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { cleanPem } from '$lib/utils/pem';
|
|||||||
import { unregisterSchedule } from '$lib/server/scheduler';
|
import { unregisterSchedule } from '$lib/server/scheduler';
|
||||||
import { closeEdgeConnection } from '$lib/server/hawser';
|
import { closeEdgeConnection } from '$lib/server/hawser';
|
||||||
import { computeAuditDiff } from '$lib/utils/diff';
|
import { computeAuditDiff } from '$lib/utils/diff';
|
||||||
|
import { deleteEnvironmentIcon } from '$lib/server/env-icons';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, cookies }) => {
|
export const GET: RequestHandler = async ({ params, cookies }) => {
|
||||||
const auth = await authorize(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 });
|
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
|
// Clean up public IP entry for this environment
|
||||||
await deleteEnvironmentPublicIp(id);
|
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',
|
'Europe/Kyiv': 'Europe/Kiev',
|
||||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||||
'America/Nuuk': 'America/Godthab',
|
'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 {
|
function normalizeTimezone(tz: string): string {
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import {
|
|||||||
getGitRepository,
|
getGitRepository,
|
||||||
updateGitRepository,
|
updateGitRepository,
|
||||||
deleteGitRepository,
|
deleteGitRepository,
|
||||||
getGitCredentials
|
getGitCredentials,
|
||||||
|
getGitStacksByRepositoryId
|
||||||
} from '$lib/server/db';
|
} from '$lib/server/db';
|
||||||
import { deleteRepositoryFiles } from '$lib/server/git';
|
import { deleteRepositoryFiles, deleteGitStackFiles } from '$lib/server/git';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditGitRepository } from '$lib/server/audit';
|
import { auditGitRepository } from '$lib/server/audit';
|
||||||
import { computeAuditDiff } from '$lib/utils/diff';
|
import { computeAuditDiff } from '$lib/utils/diff';
|
||||||
@@ -112,7 +113,13 @@ export const DELETE: RequestHandler = async (event) => {
|
|||||||
return json({ error: 'Repository not found' }, { status: 404 });
|
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);
|
deleteRepositoryFiles(id);
|
||||||
|
|
||||||
const deleted = await deleteGitRepository(id);
|
const deleted = await deleteGitRepository(id);
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
|
|||||||
.createHmac('sha256', secret)
|
.createHmac('sha256', secret)
|
||||||
.update(payload)
|
.update(payload)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
return crypto.timingSafeEqual(
|
const sigBuf = Buffer.from(signature);
|
||||||
Buffer.from(signature),
|
const expectedBuf = Buffer.from(expectedSignature);
|
||||||
Buffer.from(expectedSignature)
|
if (sigBuf.length !== expectedBuf.length) return false;
|
||||||
);
|
return crypto.timingSafeEqual(sigBuf, expectedBuf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitLab uses X-Gitlab-Token which should match exactly
|
// GitLab uses X-Gitlab-Token which should match exactly
|
||||||
|
|||||||
@@ -17,10 +17,10 @@ function verifySignature(payload: string, signature: string | null, secret: stri
|
|||||||
.createHmac('sha256', secret)
|
.createHmac('sha256', secret)
|
||||||
.update(payload)
|
.update(payload)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
return crypto.timingSafeEqual(
|
const sigBuf = Buffer.from(signature);
|
||||||
Buffer.from(signature),
|
const expectedBuf = Buffer.from(expectedSignature);
|
||||||
Buffer.from(expectedSignature)
|
if (sigBuf.length !== expectedBuf.length) return false;
|
||||||
);
|
return crypto.timingSafeEqual(sigBuf, expectedBuf);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GitLab uses X-Gitlab-Token which should match exactly
|
// GitLab uses X-Gitlab-Token which should match exactly
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { json } from '@sveltejs/kit';
|
|||||||
import { removeImage, inspectImage } from '$lib/server/docker';
|
import { removeImage, inspectImage } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditImage } from '$lib/server/audit';
|
import { auditImage } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'image');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const force = url.searchParams.get('force') === 'true';
|
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 { authorize } from '$lib/server/authorize';
|
||||||
import { createGzip } from 'zlib';
|
import { createGzip } from 'zlib';
|
||||||
import { Readable } from 'stream';
|
import { Readable } from 'stream';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'image');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getImageHistory } from '$lib/server/docker';
|
import { getImageHistory } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'image');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { tagImage } from '$lib/server/docker';
|
import { tagImage } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
export const POST: RequestHandler = async ({ params, request, url, cookies }) => {
|
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 auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
|
|
||||||
// Return as plain text if requested
|
// Return as plain text if requested
|
||||||
if (url.searchParams.get('format') === 'text') {
|
if (url.searchParams.get('format') === 'text') {
|
||||||
return text(content);
|
return text(content, {
|
||||||
|
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ content });
|
return json({ content });
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
|
|
||||||
// Return as plain text if requested
|
// Return as plain text if requested
|
||||||
if (url.searchParams.get('format') === 'text') {
|
if (url.searchParams.get('format') === 'text') {
|
||||||
return text(content);
|
return text(content, {
|
||||||
|
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({ content });
|
return json({ content });
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
|
|||||||
import { removeNetwork, inspectNetwork } from '$lib/server/docker';
|
import { removeNetwork, inspectNetwork } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditNetwork } from '$lib/server/audit';
|
import { auditNetwork } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'network');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
@@ -32,6 +36,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'network');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
|
|||||||
import { connectContainerToNetwork, inspectNetwork } from '$lib/server/docker';
|
import { connectContainerToNetwork, inspectNetwork } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditNetwork } from '$lib/server/audit';
|
import { auditNetwork } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, url, request, cookies } = event;
|
const { params, url, request, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'network');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
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 });
|
return json({ error: 'Container ID is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invalidContainer = validateDockerIdParam(containerId, 'container');
|
||||||
|
if (invalidContainer) return invalidContainer;
|
||||||
|
|
||||||
// Get network name for audit
|
// Get network name for audit
|
||||||
let networkName = params.id;
|
let networkName = params.id;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import type { RequestHandler } from './$types';
|
|||||||
import { disconnectContainerFromNetwork, inspectNetwork } from '$lib/server/docker';
|
import { disconnectContainerFromNetwork, inspectNetwork } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditNetwork } from '$lib/server/audit';
|
import { auditNetwork } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, url, request, cookies } = event;
|
const { params, url, request, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'network');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
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 });
|
return json({ error: 'Container ID is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const invalidContainer = validateDockerIdParam(containerId, 'container');
|
||||||
|
if (invalidContainer) return invalidContainer;
|
||||||
|
|
||||||
// Get network name for audit
|
// Get network name for audit
|
||||||
let networkName = params.id;
|
let networkName = params.id;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { inspectNetwork } from '$lib/server/docker';
|
import { inspectNetwork } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.id, 'network');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -371,19 +371,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
...networkEnvVars
|
...networkEnvVars
|
||||||
];
|
];
|
||||||
|
|
||||||
// Pass Docker API version so the updater CLI speaks a compatible version.
|
// Pin Docker API version so the updater's bundled Docker CLI
|
||||||
// Without this, newer CLI versions (e.g. API 1.53) fail against older
|
// doesn't request a version newer than the host daemon supports
|
||||||
// daemons (e.g. Synology DSM shipping API 1.43).
|
// (e.g. Synology DSM with Docker 24.x / API 1.43)
|
||||||
const dockerApiVersion = process.env.DOCKER_API_VERSION;
|
if (process.env.DOCKER_API_VERSION) {
|
||||||
if (dockerApiVersion) {
|
updaterEnv.push(`DOCKER_API_VERSION=${process.env.DOCKER_API_VERSION}`);
|
||||||
updaterEnv.push(`DOCKER_API_VERSION=${dockerApiVersion}`);
|
console.log(`[SelfUpdate] Forwarding explicit DOCKER_API_VERSION: ${process.env.DOCKER_API_VERSION}`);
|
||||||
} else {
|
} else {
|
||||||
const versionRes = await localDockerFetch('/version');
|
try {
|
||||||
if (versionRes.ok) {
|
const versionResp = await localDockerFetch('/version');
|
||||||
const vInfo = await versionRes.json() as { ApiVersion?: string };
|
if (versionResp.ok) {
|
||||||
if (vInfo.ApiVersion) {
|
const versionInfo = await versionResp.json() as { ApiVersion?: string };
|
||||||
updaterEnv.push(`DOCKER_API_VERSION=${vInfo.ApiVersion}`);
|
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();
|
const containerId = getOwnContainerId();
|
||||||
if (!containerId) {
|
if (!containerId) {
|
||||||
|
console.log('[SelfUpdate] Not running in Docker, skipping update check');
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
error: 'Not running in Docker'
|
error: 'Not running in Docker'
|
||||||
@@ -45,6 +46,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
// Inspect own container to get current image info
|
// Inspect own container to get current image info
|
||||||
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
const inspectResponse = await localDockerFetch(`/containers/${containerId}/json`);
|
||||||
if (!inspectResponse.ok) {
|
if (!inspectResponse.ok) {
|
||||||
|
console.log(`[SelfUpdate] Failed to inspect container ${containerId.substring(0, 12)}: ${inspectResponse.status}`);
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
error: 'Failed to inspect own container'
|
error: 'Failed to inspect own container'
|
||||||
@@ -61,7 +63,10 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
const currentImageId = inspectData.Image || '';
|
const currentImageId = inspectData.Image || '';
|
||||||
const containerName = inspectData.Name?.replace(/^\//, '') || '';
|
const containerName = inspectData.Name?.replace(/^\//, '') || '';
|
||||||
|
|
||||||
|
console.log(`[SelfUpdate] Container: ${containerId.substring(0, 12)}, image: ${currentImage}, tag: ${currentImage.split(':').pop() || 'latest'}`);
|
||||||
|
|
||||||
if (!currentImage) {
|
if (!currentImage) {
|
||||||
|
console.log('[SelfUpdate] Could not determine current image from inspect data');
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
error: 'Could not determine current image'
|
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
|
// Digest-based images (e.g. image@sha256:...) can't be checked for updates
|
||||||
if (currentImage.includes('@sha256:')) {
|
if (currentImage.includes('@sha256:')) {
|
||||||
|
console.log('[SelfUpdate] Image pinned by digest, cannot check for updates');
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -94,6 +100,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
// Version-based check: compare against latest released version from changelog
|
// Version-based check: compare against latest released version from changelog
|
||||||
const currentTagVersion = versionMatch[1];
|
const currentTagVersion = versionMatch[1];
|
||||||
const suffix = versionMatch[2] || ''; // '-baseline' or ''
|
const suffix = versionMatch[2] || ''; // '-baseline' or ''
|
||||||
|
console.log(`[SelfUpdate] Version-based check: current=${currentTagVersion}${suffix}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const changelogResponse = await fetch(
|
const changelogResponse = await fetch(
|
||||||
@@ -102,6 +109,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!changelogResponse.ok) {
|
if (!changelogResponse.ok) {
|
||||||
|
console.log(`[SelfUpdate] Failed to fetch changelog from GitHub: ${changelogResponse.status}`);
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -122,6 +130,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
const latestRelease = changelog.find(entry => !entry.comingSoon);
|
const latestRelease = changelog.find(entry => !entry.comingSoon);
|
||||||
|
|
||||||
if (!latestRelease) {
|
if (!latestRelease) {
|
||||||
|
console.log('[SelfUpdate] No released version found in changelog');
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -133,12 +142,14 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
|
|
||||||
const latestVersion = latestRelease.version;
|
const latestVersion = latestRelease.version;
|
||||||
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
|
const hasNewer = compareVersions(latestVersion, currentTagVersion) > 0;
|
||||||
|
console.log(`[SelfUpdate] Latest changelog version: ${latestVersion}, current: ${currentTagVersion}, hasNewer: ${hasNewer}`);
|
||||||
|
|
||||||
if (hasNewer) {
|
if (hasNewer) {
|
||||||
// Build new image tag preserving registry prefix and suffix
|
// Build new image tag preserving registry prefix and suffix
|
||||||
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
|
const newTag = `v${latestVersion.replace(/^v/, '')}${suffix}`;
|
||||||
const newImage = `${imageWithoutTag}:${newTag}`;
|
const newImage = `${imageWithoutTag}:${newTag}`;
|
||||||
|
|
||||||
|
console.log(`[SelfUpdate] Update available: ${currentImage} → ${newImage}`);
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: true,
|
updateAvailable: true,
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -149,6 +160,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[SelfUpdate] Up to date (version ${currentTagVersion})`);
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -156,6 +168,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
isComposeManaged
|
isComposeManaged
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log(`[SelfUpdate] Version check failed: ${err}`);
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -167,10 +180,12 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Digest-based check for mutable tags (:latest, :baseline, etc.)
|
// 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
|
// Inspect image via local Docker socket to get RepoDigests
|
||||||
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
|
const imageResponse = await localDockerFetch(`/images/${encodeURIComponent(currentImageId)}/json`);
|
||||||
if (!imageResponse.ok) {
|
if (!imageResponse.ok) {
|
||||||
|
console.log(`[SelfUpdate] Failed to inspect image ${currentImageId}: ${imageResponse.status}`);
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -192,6 +207,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
.filter(Boolean) as string[];
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
if (localDigests.length === 0) {
|
if (localDigests.length === 0) {
|
||||||
|
console.log('[SelfUpdate] No RepoDigests found — local/untagged image, cannot check registry');
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
currentImage,
|
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
|
// Query registry for latest digest
|
||||||
const registryDigest = await getRegistryManifestDigest(currentImage);
|
const registryDigest = await getRegistryManifestDigest(currentImage);
|
||||||
if (!registryDigest) {
|
if (!registryDigest) {
|
||||||
|
console.log(`[SelfUpdate] Could not query registry for ${currentImage}`);
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
currentImage,
|
currentImage,
|
||||||
@@ -216,6 +235,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasUpdate = !localDigests.includes(registryDigest);
|
const hasUpdate = !localDigests.includes(registryDigest);
|
||||||
|
console.log(`[SelfUpdate] Registry digest: ${registryDigest.substring(0, 19)}, match: ${!hasUpdate}, updateAvailable: ${hasUpdate}`);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: hasUpdate,
|
updateAvailable: hasUpdate,
|
||||||
@@ -227,6 +247,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
isComposeManaged
|
isComposeManaged
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.log(`[SelfUpdate] Check failed with error: ${err}`);
|
||||||
return json({
|
return json({
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
error: 'Check failed: ' + String(err)
|
error: 'Check failed: ' + String(err)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { refreshSystemJobs } from '$lib/server/scheduler';
|
import { refreshSystemJobs } from '$lib/server/scheduler';
|
||||||
import { sendToEventSubprocess, sendToMetricsSubprocess } from '$lib/server/subprocess-manager';
|
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 TimeFormat = '12h' | '24h';
|
||||||
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
export type DateFormat = 'MM/DD/YYYY' | 'DD/MM/YYYY' | 'YYYY-MM-DD' | 'DD.MM.YYYY';
|
||||||
@@ -67,10 +68,15 @@ export interface GeneralSettings {
|
|||||||
editorFont: string;
|
editorFont: string;
|
||||||
// Compact ports
|
// Compact ports
|
||||||
compactPorts: boolean;
|
compactPorts: boolean;
|
||||||
|
// Log timestamp formatting
|
||||||
|
formatLogTimestamps: boolean;
|
||||||
// External stack paths
|
// External stack paths
|
||||||
externalStackPaths: string[];
|
externalStackPaths: string[];
|
||||||
// Primary stack location
|
// Primary stack location
|
||||||
primaryStackLocation: string | null;
|
primaryStackLocation: string | null;
|
||||||
|
// Scanner images
|
||||||
|
defaultGrypeImage: string;
|
||||||
|
defaultTrivyImage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
|
const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRetentionDays' | 'scheduleCleanupCron' | 'eventCleanupCron' | 'scheduleCleanupEnabled' | 'eventCleanupEnabled'> = {
|
||||||
@@ -88,13 +94,18 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
|
|||||||
eventPollInterval: 60000,
|
eventPollInterval: 60000,
|
||||||
metricsCollectionInterval: 30000,
|
metricsCollectionInterval: 30000,
|
||||||
compactPorts: false,
|
compactPorts: false,
|
||||||
|
formatLogTimestamps: false,
|
||||||
lightTheme: 'default',
|
lightTheme: 'default',
|
||||||
darkTheme: 'default',
|
darkTheme: 'default',
|
||||||
font: 'system',
|
font: 'system',
|
||||||
fontSize: 'normal',
|
fontSize: 'normal',
|
||||||
gridFontSize: 'normal',
|
gridFontSize: 'normal',
|
||||||
terminalFont: 'system-mono',
|
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'];
|
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,
|
terminalFont,
|
||||||
editorFont,
|
editorFont,
|
||||||
compactPorts,
|
compactPorts,
|
||||||
|
formatLogTimestamps,
|
||||||
externalStackPaths,
|
externalStackPaths,
|
||||||
primaryStackLocation
|
primaryStackLocation,
|
||||||
|
defaultGrypeImage,
|
||||||
|
defaultTrivyImage
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getSetting('confirm_destructive'),
|
getSetting('confirm_destructive'),
|
||||||
getSetting('show_stopped_containers'),
|
getSetting('show_stopped_containers'),
|
||||||
@@ -174,8 +188,11 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
getSetting('theme_terminal_font'),
|
getSetting('theme_terminal_font'),
|
||||||
getSetting('theme_editor_font'),
|
getSetting('theme_editor_font'),
|
||||||
getSetting('compact_ports'),
|
getSetting('compact_ports'),
|
||||||
|
getSetting('format_log_timestamps'),
|
||||||
getExternalStackPaths(),
|
getExternalStackPaths(),
|
||||||
getPrimaryStackLocation()
|
getPrimaryStackLocation(),
|
||||||
|
getSetting('default_grype_image'),
|
||||||
|
getSetting('default_trivy_image')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const settings: GeneralSettings = {
|
const settings: GeneralSettings = {
|
||||||
@@ -206,8 +223,11 @@ export const GET: RequestHandler = async ({ cookies }) => {
|
|||||||
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
|
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
|
||||||
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
|
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
|
||||||
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
||||||
|
formatLogTimestamps: formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||||
externalStackPaths,
|
externalStackPaths,
|
||||||
primaryStackLocation
|
primaryStackLocation,
|
||||||
|
defaultGrypeImage: defaultGrypeImage ?? DEFAULT_GRYPE_IMAGE,
|
||||||
|
defaultTrivyImage: defaultTrivyImage ?? DEFAULT_TRIVY_IMAGE
|
||||||
};
|
};
|
||||||
|
|
||||||
return json(settings);
|
return json(settings);
|
||||||
@@ -225,7 +245,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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) {
|
if (confirmDestructive !== undefined) {
|
||||||
await setSetting('confirm_destructive', confirmDestructive);
|
await setSetting('confirm_destructive', confirmDestructive);
|
||||||
@@ -321,6 +341,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
if (compactPorts !== undefined) {
|
if (compactPorts !== undefined) {
|
||||||
await setSetting('compact_ports', compactPorts);
|
await setSetting('compact_ports', compactPorts);
|
||||||
}
|
}
|
||||||
|
if (formatLogTimestamps !== undefined) {
|
||||||
|
await setSetting('format_log_timestamps', formatLogTimestamps);
|
||||||
|
}
|
||||||
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
|
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
|
||||||
// Filter to valid non-empty strings
|
// Filter to valid non-empty strings
|
||||||
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
|
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);
|
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
|
// Fetch all settings in parallel for the response
|
||||||
const [
|
const [
|
||||||
@@ -365,8 +394,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
terminalFontVal,
|
terminalFontVal,
|
||||||
editorFontVal,
|
editorFontVal,
|
||||||
compactPortsVal,
|
compactPortsVal,
|
||||||
|
formatLogTimestampsVal,
|
||||||
externalStackPathsVal,
|
externalStackPathsVal,
|
||||||
primaryStackLocationVal
|
primaryStackLocationVal,
|
||||||
|
defaultGrypeImageVal,
|
||||||
|
defaultTrivyImageVal
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
getSetting('confirm_destructive'),
|
getSetting('confirm_destructive'),
|
||||||
getSetting('show_stopped_containers'),
|
getSetting('show_stopped_containers'),
|
||||||
@@ -395,8 +427,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
getSetting('theme_terminal_font'),
|
getSetting('theme_terminal_font'),
|
||||||
getSetting('theme_editor_font'),
|
getSetting('theme_editor_font'),
|
||||||
getSetting('compact_ports'),
|
getSetting('compact_ports'),
|
||||||
|
getSetting('format_log_timestamps'),
|
||||||
getExternalStackPaths(),
|
getExternalStackPaths(),
|
||||||
getPrimaryStackLocation()
|
getPrimaryStackLocation(),
|
||||||
|
getSetting('default_grype_image'),
|
||||||
|
getSetting('default_trivy_image')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const settings: GeneralSettings = {
|
const settings: GeneralSettings = {
|
||||||
@@ -427,8 +462,11 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
|
|||||||
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
|
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
|
||||||
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
|
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
|
||||||
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
|
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
|
||||||
|
formatLogTimestamps: formatLogTimestampsVal ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||||
externalStackPaths: externalStackPathsVal,
|
externalStackPaths: externalStackPathsVal,
|
||||||
primaryStackLocation: primaryStackLocationVal
|
primaryStackLocation: primaryStackLocationVal,
|
||||||
|
defaultGrypeImage: defaultGrypeImageVal ?? DEFAULT_GRYPE_IMAGE,
|
||||||
|
defaultTrivyImage: defaultTrivyImageVal ?? DEFAULT_TRIVY_IMAGE
|
||||||
};
|
};
|
||||||
|
|
||||||
return json(settings);
|
return json(settings);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { json, type RequestHandler } from '@sveltejs/kit';
|
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 {
|
import {
|
||||||
checkScannerAvailability,
|
checkScannerAvailability,
|
||||||
getScannerVersions,
|
getScannerVersions,
|
||||||
@@ -15,6 +15,8 @@ export interface ScannerSettings {
|
|||||||
scanner: ScannerType;
|
scanner: ScannerType;
|
||||||
grypeArgs: string;
|
grypeArgs: string;
|
||||||
trivyArgs: string;
|
trivyArgs: string;
|
||||||
|
grypeImage: string;
|
||||||
|
trivyImage: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, cookies }) => {
|
export const GET: RequestHandler = async ({ url, cookies }) => {
|
||||||
@@ -39,7 +41,9 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
|
|||||||
const settings: ScannerSettings = {
|
const settings: ScannerSettings = {
|
||||||
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
|
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
|
||||||
grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs,
|
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
|
// Fast path: return just settings without Docker checks
|
||||||
@@ -80,7 +84,7 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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;
|
const parsedEnvId = envId ? parseInt(envId) : undefined;
|
||||||
|
|
||||||
// Permission check with environment context
|
// Permission check with environment context
|
||||||
@@ -104,6 +108,12 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
|||||||
if (trivyArgs !== undefined) {
|
if (trivyArgs !== undefined) {
|
||||||
await setEnvSetting('trivy_cli_args', trivyArgs, parsedEnvId);
|
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
|
// Get global defaults for fallback
|
||||||
const globalDefaults = await getGlobalScannerDefaults();
|
const globalDefaults = await getGlobalScannerDefaults();
|
||||||
@@ -113,7 +123,9 @@ export const POST: RequestHandler = async ({ request, url, cookies }) => {
|
|||||||
settings: {
|
settings: {
|
||||||
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
|
scanner: await getEnvSetting('vulnerability_scanner', parsedEnvId) || 'none',
|
||||||
grypeArgs: await getEnvSetting('grype_cli_args', parsedEnvId) || globalDefaults.grypeArgs,
|
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) {
|
} catch (error) {
|
||||||
@@ -154,6 +166,9 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
|||||||
const removed: string[] = [];
|
const removed: string[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Get configured scanner images
|
||||||
|
const globalDefaults = await getGlobalScannerDefaults();
|
||||||
|
|
||||||
// Determine which images to remove
|
// Determine which images to remove
|
||||||
const scannersToRemove: ('grype' | 'trivy')[] =
|
const scannersToRemove: ('grype' | 'trivy')[] =
|
||||||
scanner === 'grype' ? ['grype'] :
|
scanner === 'grype' ? ['grype'] :
|
||||||
@@ -161,7 +176,7 @@ export const DELETE: RequestHandler = async ({ url, cookies }) => {
|
|||||||
['grype', 'trivy'];
|
['grype', 'trivy'];
|
||||||
|
|
||||||
for (const scannerType of scannersToRemove) {
|
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
|
// Find the image
|
||||||
const image = images.find((img) =>
|
const image = images.find((img) =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { readdirSync, statSync, existsSync } from 'node:fs';
|
import { readdirSync, statSync, existsSync, mkdirSync } from 'node:fs';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename, isAbsolute } from 'node:path';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
@@ -13,6 +13,49 @@ export interface FileEntry {
|
|||||||
mode: string;
|
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
|
* GET /api/system/files
|
||||||
* Browse Dockhand's local filesystem (for mount browsing)
|
* Browse Dockhand's local filesystem (for mount browsing)
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import type { RequestHandler } from './$types';
|
|||||||
import { removeVolume, inspectVolume } from '$lib/server/docker';
|
import { removeVolume, inspectVolume } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
import { auditVolume } from '$lib/server/audit';
|
import { auditVolume } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
@@ -33,6 +37,9 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
|||||||
|
|
||||||
export const DELETE: RequestHandler = async (event) => {
|
export const DELETE: RequestHandler = async (event) => {
|
||||||
const { params, url, cookies } = event;
|
const { params, url, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const force = url.searchParams.get('force') === 'true';
|
const force = url.searchParams.get('force') === 'true';
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { listVolumeDirectory, getVolumeUsage } from '$lib/server/docker';
|
import { listVolumeDirectory, getVolumeUsage } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { readVolumeFile } from '$lib/server/docker';
|
import { readVolumeFile } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
// Max file size for reading (1MB)
|
// Max file size for reading (1MB)
|
||||||
const MAX_FILE_SIZE = 1024 * 1024;
|
const MAX_FILE_SIZE = 1024 * 1024;
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const path = url.searchParams.get('path');
|
const path = url.searchParams.get('path');
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { releaseVolumeHelperContainer } from '$lib/server/docker';
|
import { releaseVolumeHelperContainer } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release the cached volume helper container when done browsing.
|
* Release the cached volume helper container when done browsing.
|
||||||
* This is called when the volume browser modal is closed.
|
* This is called when the volume browser modal is closed.
|
||||||
*/
|
*/
|
||||||
export const POST: RequestHandler = async ({ params, url, cookies }) => {
|
export const POST: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import type { RequestHandler } from './$types';
|
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 { authorize } from '$lib/server/authorize';
|
||||||
import { auditVolume } from '$lib/server/audit';
|
import { auditVolume } from '$lib/server/audit';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const POST: RequestHandler = async (event) => {
|
export const POST: RequestHandler = async (event) => {
|
||||||
const { params, url, request, cookies } = event;
|
const { params, url, request, cookies } = event;
|
||||||
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
@@ -38,6 +42,49 @@ export const POST: RequestHandler = async (event) => {
|
|||||||
|
|
||||||
const newVolume = await createVolume(options, envIdNum);
|
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
|
// Audit log
|
||||||
await auditVolume(event, 'clone', newVolume.Name, `${params.name} → ${newName}`, envIdNum, {
|
await auditVolume(event, 'clone', newVolume.Name, `${params.name} → ${newName}`, envIdNum, {
|
||||||
source: params.name,
|
source: params.name,
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { getVolumeArchive } from '$lib/server/docker';
|
import { getVolumeArchive } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { json } from '@sveltejs/kit';
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { inspectVolume } from '$lib/server/docker';
|
import { inspectVolume } from '$lib/server/docker';
|
||||||
import { authorize } from '$lib/server/authorize';
|
import { authorize } from '$lib/server/authorize';
|
||||||
|
import { validateDockerIdParam } from '$lib/server/docker-validation';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
export const GET: RequestHandler = async ({ params, url, cookies }) => {
|
||||||
|
const invalid = validateDockerIdParam(params.name, 'volume');
|
||||||
|
if (invalid) return invalid;
|
||||||
|
|
||||||
const auth = await authorize(cookies);
|
const auth = await authorize(cookies);
|
||||||
|
|
||||||
const envId = url.searchParams.get('env');
|
const envId = url.searchParams.get('env');
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
FileX
|
FileX
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { licenseStore } from '$lib/stores/license';
|
import { licenseStore } from '$lib/stores/license';
|
||||||
import { getIconComponent } from '$lib/utils/icons';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
import {
|
import {
|
||||||
auditSseConnected,
|
auditSseConnected,
|
||||||
connectAuditSSE,
|
connectAuditSSE,
|
||||||
@@ -701,14 +701,17 @@
|
|||||||
<!-- Environment filter -->
|
<!-- Environment filter -->
|
||||||
{#if environments.length > 0}
|
{#if environments.length > 0}
|
||||||
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
|
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
|
||||||
{@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server}
|
|
||||||
<Select.Root
|
<Select.Root
|
||||||
type="single"
|
type="single"
|
||||||
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
|
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
|
||||||
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
|
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
|
||||||
>
|
>
|
||||||
<Select.Trigger size="sm" class="w-40 text-sm">
|
<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">
|
<span class="truncate">
|
||||||
{#if filterEnvironmentId === null}
|
{#if filterEnvironmentId === null}
|
||||||
Environment
|
Environment
|
||||||
@@ -723,9 +726,8 @@
|
|||||||
All environments
|
All environments
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{#each environments as env}
|
{#each environments as env}
|
||||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
|
||||||
<Select.Item value={String(env.id)}>
|
<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}
|
{env.name}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -870,9 +872,8 @@
|
|||||||
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(log.createdAt)}</span>
|
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(log.createdAt)}</span>
|
||||||
{:else if column.id === 'environment'}
|
{:else if column.id === 'environment'}
|
||||||
{#if log.environmentName}
|
{#if log.environmentName}
|
||||||
{@const LogEnvIcon = getIconComponent(log.environmentIcon || 'globe')}
|
|
||||||
<div class="flex items-center gap-1 text-xs">
|
<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>
|
<span class="truncate">{log.environmentName}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -265,6 +265,7 @@
|
|||||||
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
|
let updateCheckStatus = $state<'idle' | 'checking' | 'found' | 'none' | 'error'>('idle');
|
||||||
let updateCheckProgress = $state({ checked: 0, total: 0 });
|
let updateCheckProgress = $state({ checked: 0, total: 0 });
|
||||||
let updateCheckBtnEl = $state<HTMLButtonElement | null>(null);
|
let updateCheckBtnEl = $state<HTMLButtonElement | null>(null);
|
||||||
|
let failedUpdateChecks = $state<Array<{ containerName: string; imageName: string; error: string }>>([]);
|
||||||
let showBatchUpdateModal = $state(false);
|
let showBatchUpdateModal = $state(false);
|
||||||
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
|
const batchUpdateContainerIds = $derived($containerStore.pendingUpdateIds);
|
||||||
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
|
const batchUpdateContainerNames = $derived($containerStore.pendingUpdateNames);
|
||||||
@@ -424,9 +425,24 @@
|
|||||||
}, 3000));
|
}, 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() {
|
async function checkForUpdates() {
|
||||||
updateCheckStatus = 'checking';
|
updateCheckStatus = 'checking';
|
||||||
updateCheckProgress = { checked: 0, total: 0 };
|
updateCheckProgress = { checked: 0, total: 0 };
|
||||||
|
failedUpdateChecks = [];
|
||||||
|
|
||||||
// Lock button width to prevent layout shift
|
// Lock button width to prevent layout shift
|
||||||
if (updateCheckBtnEl) {
|
if (updateCheckBtnEl) {
|
||||||
@@ -455,18 +471,24 @@
|
|||||||
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
|
if (updateCheckBtnEl) updateCheckBtnEl.style.minWidth = '';
|
||||||
|
|
||||||
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
|
const containersWithUpdates = data.results.filter((r: any) => r.hasUpdate);
|
||||||
const failedChecks = data.results.filter((r: any) => r.error && !r.hasUpdate).length;
|
const failed = data.results.filter((r: any) => r.error && !r.hasUpdate);
|
||||||
const failedSuffix = failedChecks > 0 ? ` (${failedChecks} failed to check)` : '';
|
failedUpdateChecks = failed.map((r: any) => ({
|
||||||
|
containerName: r.containerName,
|
||||||
|
imageName: r.imageName,
|
||||||
|
error: r.error
|
||||||
|
}));
|
||||||
|
|
||||||
if (containersWithUpdates.length === 0) {
|
if (containersWithUpdates.length === 0) {
|
||||||
updateCheckStatus = 'none';
|
|
||||||
containerStore.setPendingUpdates([], new Map());
|
containerStore.setPendingUpdates([], new Map());
|
||||||
if (failedChecks > 0) {
|
if (failed.length > 0) {
|
||||||
toast.warning(`All containers are up to date${failedSuffix}`);
|
updateCheckStatus = 'none';
|
||||||
|
showFailedChecksToast(failedUpdateChecks, 'All containers are up to date');
|
||||||
|
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
||||||
} else {
|
} else {
|
||||||
|
updateCheckStatus = 'none';
|
||||||
toast.success('All containers are up to date');
|
toast.success('All containers are up to date');
|
||||||
|
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
||||||
}
|
}
|
||||||
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,7 +498,11 @@
|
|||||||
new Map(containersWithUpdates.map((r: any) => [r.containerId, r.containerName]))
|
new Map(containersWithUpdates.map((r: any) => [r.containerId, r.containerName]))
|
||||||
);
|
);
|
||||||
updateCheckStatus = 'found';
|
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) {
|
} catch (error) {
|
||||||
updateCheckStatus = 'error';
|
updateCheckStatus = 'error';
|
||||||
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
pendingTimeouts.push(setTimeout(() => { updateCheckStatus = 'idle'; }, 3000));
|
||||||
@@ -1823,7 +1849,25 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if column.id === 'ip'}
|
{: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'}
|
{:else if column.id === 'ports'}
|
||||||
{#if ports.length > 0}
|
{#if ports.length > 0}
|
||||||
{@const compactPorts = $appSettings.compactPorts}
|
{@const compactPorts = $appSettings.compactPorts}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { Progress } from '$lib/components/ui/progress';
|
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 { CircleArrowUp, Loader2, AlertCircle, CheckCircle2, XCircle, ChevronDown, ChevronRight, ExternalLink } from 'lucide-svelte';
|
||||||
import { appendEnvParam } from '$lib/stores/environment';
|
import { appendEnvParam } from '$lib/stores/environment';
|
||||||
import type { VulnerabilityCriteria } from '$lib/server/db';
|
import type { VulnerabilityCriteria } from '$lib/server/db';
|
||||||
@@ -68,7 +68,6 @@
|
|||||||
error?: string;
|
error?: string;
|
||||||
pullLogs: PullLogEntry[];
|
pullLogs: PullLogEntry[];
|
||||||
scanLogs: ScanLogEntry[];
|
scanLogs: ScanLogEntry[];
|
||||||
scanResult?: ScanResult;
|
|
||||||
scannerResults?: ScannerResult[];
|
scannerResults?: ScannerResult[];
|
||||||
vulnerabilities?: VulnerabilityEntry[];
|
vulnerabilities?: VulnerabilityEntry[];
|
||||||
blockReason?: string;
|
blockReason?: string;
|
||||||
@@ -223,7 +222,6 @@
|
|||||||
if (data.message && data.scannerResults && data.scannerResults.length > 1) {
|
if (data.message && data.scannerResults && data.scannerResults.length > 1) {
|
||||||
containerProgress.scanLogs.push({ message: data.message });
|
containerProgress.scanLogs.push({ message: data.message });
|
||||||
}
|
}
|
||||||
containerProgress.scanResult = data.scanResult;
|
|
||||||
containerProgress.scannerResults = data.scannerResults;
|
containerProgress.scannerResults = data.scannerResults;
|
||||||
containerProgress.vulnerabilities = data.vulnerabilities;
|
containerProgress.vulnerabilities = data.vulnerabilities;
|
||||||
progress = [...progress];
|
progress = [...progress];
|
||||||
@@ -234,7 +232,6 @@
|
|||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
progress[existingIndex].step = 'blocked';
|
progress[existingIndex].step = 'blocked';
|
||||||
progress[existingIndex].success = false;
|
progress[existingIndex].success = false;
|
||||||
progress[existingIndex].scanResult = data.scanResult;
|
|
||||||
progress[existingIndex].scannerResults = data.scannerResults;
|
progress[existingIndex].scannerResults = data.scannerResults;
|
||||||
progress[existingIndex].blockReason = data.blockReason;
|
progress[existingIndex].blockReason = data.blockReason;
|
||||||
progress = [...progress];
|
progress = [...progress];
|
||||||
@@ -465,52 +462,9 @@ const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2,
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scan result badges - show per scanner when available -->
|
<!-- Scan result badges per scanner -->
|
||||||
{#if item.scannerResults && item.scannerResults.length > 0}
|
{#if item.scannerResults && item.scannerResults.length > 0}
|
||||||
<ScannerSeverityPills results={item.scannerResults} />
|
<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}
|
||||||
|
|
||||||
{#if item.success === true}
|
{#if item.success === true}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
maxW?: number;
|
maxW?: number;
|
||||||
minH?: number;
|
minH?: number;
|
||||||
maxH?: number;
|
maxH?: number;
|
||||||
|
locked?: boolean;
|
||||||
onchange?: (items: GridItemLayout[]) => void;
|
onchange?: (items: GridItemLayout[]) => void;
|
||||||
onitemclick?: (id: number) => void;
|
onitemclick?: (id: number) => void;
|
||||||
children: import('svelte').Snippet<[{ item: GridItemLayout; width: number; height: number }]>;
|
children: import('svelte').Snippet<[{ item: GridItemLayout; width: number; height: number }]>;
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
maxW = 2,
|
maxW = 2,
|
||||||
minH = 1,
|
minH = 1,
|
||||||
maxH = 4,
|
maxH = 4,
|
||||||
|
locked = false,
|
||||||
onchange,
|
onchange,
|
||||||
onitemclick,
|
onitemclick,
|
||||||
children
|
children
|
||||||
@@ -202,6 +204,11 @@
|
|||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (locked) {
|
||||||
|
onitemclick?.(item.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
dragItem = item;
|
dragItem = item;
|
||||||
dragStartX = e.clientX;
|
dragStartX = e.clientX;
|
||||||
dragStartY = e.clientY;
|
dragStartY = e.clientY;
|
||||||
@@ -409,6 +416,11 @@
|
|||||||
class:resizing={isResizing}
|
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);` : ''}"
|
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) => {
|
onpointerdown={(e) => {
|
||||||
|
if (locked) {
|
||||||
|
e.preventDefault();
|
||||||
|
onitemclick?.(item.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Check if clicking on resize handle area (bottom-right 28x28 corner)
|
// Check if clicking on resize handle area (bottom-right 28x28 corner)
|
||||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
const x = e.clientX - rect.left;
|
const x = e.clientX - rect.left;
|
||||||
@@ -428,11 +440,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resize handle visual indicator -->
|
<!-- Resize handle visual indicator -->
|
||||||
<div class="tile-resize-handle">
|
{#if !locked}
|
||||||
<svg viewBox="0 0 10 10" fill="currentColor">
|
<div class="tile-resize-handle">
|
||||||
<path d="M9 1v8H1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
<svg viewBox="0 0 10 10" fill="currentColor">
|
||||||
</svg>
|
<path d="M9 1v8H1" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
||||||
</div>
|
</svg>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
import * as Card from '$lib/components/ui/card';
|
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 { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp, Loader2 } from 'lucide-svelte';
|
||||||
import { whale } from '@lucide/lab';
|
import { whale } from '@lucide/lab';
|
||||||
import { getIconComponent } from '$lib/utils/icons';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { canAccess } from '$lib/stores/auth';
|
import { canAccess } from '$lib/stores/auth';
|
||||||
import type { EnvironmentStats } from '../api/dashboard/stats/+server';
|
import type { EnvironmentStats } from '../api/dashboard/stats/+server';
|
||||||
@@ -31,8 +31,6 @@
|
|||||||
|
|
||||||
let { stats, width = 1, height = 1, oneventsclick, showStacksBreakdown = true }: Props = $props();
|
let { stats, width = 1, height = 1, oneventsclick, showStacksBreakdown = true }: Props = $props();
|
||||||
|
|
||||||
const EnvIcon = $derived(getIconComponent(stats.icon));
|
|
||||||
|
|
||||||
// Specific tile size conditionals for easy customization
|
// Specific tile size conditionals for easy customization
|
||||||
const is1x1 = $derived(width === 1 && height === 1);
|
const is1x1 = $derived(width === 1 && height === 1);
|
||||||
const is1x2 = $derived(width === 1 && height === 2);
|
const is1x2 = $derived(width === 1 && height === 2);
|
||||||
@@ -64,7 +62,7 @@
|
|||||||
<!-- Left: Icons + Name/Host -->
|
<!-- Left: Icons + Name/Host -->
|
||||||
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
|
<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'}">
|
<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>
|
</div>
|
||||||
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
||||||
<span title="Unix socket connection" class="shrink-0">
|
<span title="Unix socket connection" class="shrink-0">
|
||||||
@@ -160,7 +158,7 @@
|
|||||||
<!-- Left: Icons + Name/Host -->
|
<!-- Left: Icons + Name/Host -->
|
||||||
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
|
<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'}">
|
<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>
|
</div>
|
||||||
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
||||||
<span title="Unix socket connection" class="shrink-0">
|
<span title="Unix socket connection" class="shrink-0">
|
||||||
@@ -263,7 +261,7 @@
|
|||||||
<!-- Left: Icons + Name/Host -->
|
<!-- Left: Icons + Name/Host -->
|
||||||
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
|
<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'}">
|
<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>
|
</div>
|
||||||
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
||||||
<span title="Unix socket connection" class="shrink-0">
|
<span title="Unix socket connection" class="shrink-0">
|
||||||
@@ -364,7 +362,7 @@
|
|||||||
<!-- Left: Icons + Name/Host -->
|
<!-- Left: Icons + Name/Host -->
|
||||||
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
|
<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'}">
|
<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>
|
</div>
|
||||||
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
||||||
<span title="Unix socket connection" class="shrink-0">
|
<span title="Unix socket connection" class="shrink-0">
|
||||||
@@ -468,7 +466,7 @@
|
|||||||
<!-- Left: Icons + Name/Host -->
|
<!-- Left: Icons + Name/Host -->
|
||||||
<div class="flex items-center gap-2 min-w-0 overflow-hidden flex-1">
|
<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'}">
|
<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>
|
</div>
|
||||||
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
{#if stats.connectionType === 'socket' || !stats.connectionType}
|
||||||
<span title="Unix socket connection" class="shrink-0">
|
<span title="Unix socket connection" class="shrink-0">
|
||||||
|
|||||||
@@ -15,10 +15,9 @@
|
|||||||
Loader2
|
Loader2
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { whale } from '@lucide/lab';
|
import { whale } from '@lucide/lab';
|
||||||
import { getIconComponent } from '$lib/utils/icons';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { canAccess } from '$lib/stores/auth';
|
import { canAccess } from '$lib/stores/auth';
|
||||||
import type { Component } from 'svelte';
|
|
||||||
|
|
||||||
type ConnectionType = 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
type ConnectionType = 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
|
||||||
|
|
||||||
@@ -71,7 +70,6 @@
|
|||||||
(port ? `${host}:${port}` : host || 'Unknown host')
|
(port ? `${host}:${port}` : host || 'Unknown host')
|
||||||
);
|
);
|
||||||
|
|
||||||
const EnvIcon = $derived(getIconComponent(icon)) as Component;
|
|
||||||
const canEdit = $derived($canAccess('environments', 'edit'));
|
const canEdit = $derived($canAccess('environments', 'edit'));
|
||||||
|
|
||||||
function openSettings(e: MouseEvent) {
|
function openSettings(e: MouseEvent) {
|
||||||
@@ -88,7 +86,7 @@
|
|||||||
<!-- Compact header for mini tiles -->
|
<!-- Compact header for mini tiles -->
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<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'}">
|
<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>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
@@ -109,7 +107,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<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'}">
|
<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>
|
||||||
{#if connectionType === 'socket' || !connectionType}
|
{#if connectionType === 'socket' || !connectionType}
|
||||||
<span title="Unix socket connection">
|
<span title="Unix socket connection">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
|
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 { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
|
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
|
// Log search functions
|
||||||
function toggleLogSearch() {
|
function toggleLogSearch() {
|
||||||
logSearchActive = !logSearchActive;
|
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">
|
<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'}" />
|
<Download class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<div class="flex-1 overflow-auto p-4 relative" bind:this={logsRef}>
|
<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'}" />
|
<Download class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
</button>
|
</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
|
<button
|
||||||
onclick={() => fetchLogs()}
|
onclick={() => fetchLogs()}
|
||||||
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
|
class="p-1 rounded transition-colors {darkMode ? 'hover:bg-zinc-800' : 'hover:bg-gray-200'}"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<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 { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
import * as Select from '$lib/components/ui/select';
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import { appSettings, formatLogTimestamps } from '$lib/stores/settings';
|
||||||
import { themeStore } from '$lib/stores/theme';
|
import { themeStore } from '$lib/stores/theme';
|
||||||
import { getMonospaceFont } from '$lib/themes';
|
import { getMonospaceFont } from '$lib/themes';
|
||||||
import { AnsiUp } from 'ansi_up';
|
import { AnsiUp } from 'ansi_up';
|
||||||
@@ -15,6 +16,7 @@
|
|||||||
autoRefresh?: boolean;
|
autoRefresh?: boolean;
|
||||||
autoScroll?: boolean;
|
autoScroll?: boolean;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
|
onClear?: () => void;
|
||||||
onAutoRefreshChange?: (value: boolean) => void;
|
onAutoRefreshChange?: (value: boolean) => void;
|
||||||
onAutoScrollChange?: (value: boolean) => void;
|
onAutoScrollChange?: (value: boolean) => void;
|
||||||
class?: string;
|
class?: string;
|
||||||
@@ -27,6 +29,7 @@
|
|||||||
autoRefresh = true,
|
autoRefresh = true,
|
||||||
autoScroll = true,
|
autoScroll = true,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onClear,
|
||||||
onAutoRefreshChange,
|
onAutoRefreshChange,
|
||||||
onAutoScrollChange,
|
onAutoScrollChange,
|
||||||
class: className = ''
|
class: className = ''
|
||||||
@@ -144,7 +147,11 @@
|
|||||||
|
|
||||||
// Highlighted logs with search matches and ANSI color support
|
// Highlighted logs with search matches and ANSI color support
|
||||||
let highlightedLogs = $derived(() => {
|
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;
|
if (!logSearchQuery.trim()) return withAnsi;
|
||||||
|
|
||||||
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
@@ -279,6 +286,14 @@
|
|||||||
>
|
>
|
||||||
<Download class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
<Download class="w-3 h-3 text-zinc-500 hover:text-zinc-300" />
|
||||||
</button>
|
</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 -->
|
<!-- Refresh -->
|
||||||
<button
|
<button
|
||||||
onclick={() => onRefresh?.()}
|
onclick={() => onRefresh?.()}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy, tick } from 'svelte';
|
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 { copyToClipboard } from '$lib/utils/clipboard';
|
||||||
import * as Select from '$lib/components/ui/select';
|
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 { themeStore } from '$lib/stores/theme';
|
||||||
import { getMonospaceFont } from '$lib/themes';
|
import { getMonospaceFont } from '$lib/themes';
|
||||||
import { AnsiUp } from 'ansi_up';
|
import { AnsiUp } from 'ansi_up';
|
||||||
@@ -469,6 +469,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear logs buffer
|
||||||
|
function clearLogs() {
|
||||||
|
logs = '';
|
||||||
|
pendingText = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Search functions
|
// Search functions
|
||||||
function toggleLogSearch() {
|
function toggleLogSearch() {
|
||||||
logSearchActive = !logSearchActive;
|
logSearchActive = !logSearchActive;
|
||||||
@@ -524,7 +530,11 @@
|
|||||||
|
|
||||||
// Highlighted logs with search matches and ANSI color support
|
// Highlighted logs with search matches and ANSI color support
|
||||||
let highlightedLogs = $derived(() => {
|
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;
|
if (!logSearchQuery.trim()) return withAnsi;
|
||||||
|
|
||||||
const query = logSearchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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'}" />
|
<Download class="w-3 h-3 {darkMode ? 'text-zinc-500 hover:text-zinc-300' : 'text-gray-500 hover:text-gray-700'}" />
|
||||||
</button>
|
</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 -->
|
<!-- Refresh -->
|
||||||
<button
|
<button
|
||||||
onclick={fetchLogs}
|
onclick={fetchLogs}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@
|
|||||||
import type { DataGridRowState } from '$lib/components/data-grid';
|
import type { DataGridRowState } from '$lib/components/data-grid';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { formatDateTime, appSettings } from '$lib/stores/settings';
|
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 ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||||
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
|
import ScannerSeverityPills from '$lib/components/ScannerSeverityPills.svelte';
|
||||||
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
|
import VulnerabilityCriteriaBadge from '$lib/components/VulnerabilityCriteriaBadge.svelte';
|
||||||
@@ -991,9 +991,8 @@
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#each environments as env}
|
{#each environments as env}
|
||||||
{@const EnvIcon = getIconComponent(env.icon)}
|
|
||||||
<Select.Item value={String(env.id)}>
|
<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}
|
{env.name}
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||||
import { TogglePill } from '$lib/components/ui/toggle-pill';
|
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 { 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 * as Alert from '$lib/components/ui/alert';
|
||||||
import { focusFirstInput } from '$lib/utils';
|
import { focusFirstInput } from '$lib/utils';
|
||||||
|
|
||||||
@@ -546,13 +546,12 @@
|
|||||||
{#if !formAllEnvironments}
|
{#if !formAllEnvironments}
|
||||||
<div class="mt-3 grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
|
<div class="mt-3 grid grid-cols-3 sm:grid-cols-4 lg:grid-cols-6 gap-2">
|
||||||
{#each environments as env}
|
{#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' : ''}">
|
<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
|
<Checkbox
|
||||||
checked={formEnvironmentIds.includes(env.id)}
|
checked={formEnvironmentIds.includes(env.id)}
|
||||||
onCheckedChange={() => toggleEnvironment(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>
|
<span class="truncate">{env.name}</span>
|
||||||
</label>
|
</label>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
import { canAccess } from '$lib/stores/auth';
|
import { canAccess } from '$lib/stores/auth';
|
||||||
import { licenseStore } from '$lib/stores/license';
|
import { licenseStore } from '$lib/stores/license';
|
||||||
import RoleModal from './RoleModal.svelte';
|
import RoleModal from './RoleModal.svelte';
|
||||||
import { getIconComponent } from '$lib/utils/icons';
|
import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
|
||||||
|
|
||||||
interface Role {
|
interface Role {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -356,9 +356,8 @@
|
|||||||
.map(id => environments.find(e => e.id === id))
|
.map(id => environments.find(e => e.id === id))
|
||||||
.filter(Boolean)}
|
.filter(Boolean)}
|
||||||
{#each envs as env}
|
{#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">
|
<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}
|
{env.name}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user