v1.0.22
This commit is contained in:
1 parent
aefa5e7925
commit
b55e1e5aad
107 files changed
+2193
-443
No files matched your search
@@ -8,9 +8,26 @@
|
||||
imageUrl: string;
|
||||
onCancel: () => 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
|
||||
let crop = $state({ x: 0, y: 0 });
|
||||
@@ -144,9 +161,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Set canvas size to output size (256x256 for avatar)
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
// Set canvas size to output size
|
||||
canvas.width = outputSize;
|
||||
canvas.height = outputSize;
|
||||
|
||||
// Ensure we use a square crop area to avoid stretching
|
||||
// Center the square within the original crop area
|
||||
@@ -163,12 +180,12 @@
|
||||
size,
|
||||
0,
|
||||
0,
|
||||
256,
|
||||
256
|
||||
outputSize,
|
||||
outputSize
|
||||
);
|
||||
|
||||
// Convert to data URL
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
|
||||
const dataUrl = canvas.toDataURL(outputFormat, outputQuality);
|
||||
resolve(dataUrl);
|
||||
};
|
||||
|
||||
@@ -204,16 +221,18 @@
|
||||
handleCancel();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#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">
|
||||
<!-- Header -->
|
||||
<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">
|
||||
Drag to reposition. Use the slider to zoom.
|
||||
</p>
|
||||
@@ -226,7 +245,8 @@
|
||||
bind:crop
|
||||
bind:zoom
|
||||
aspect={1}
|
||||
cropShape="round"
|
||||
minZoom={0.5}
|
||||
cropShape={cropShape}
|
||||
showGrid={false}
|
||||
on:cropcomplete={onCropComplete}
|
||||
on:mediaLoaded={onMediaLoaded}
|
||||
@@ -239,7 +259,7 @@
|
||||
<ZoomOut class="w-5 h-5 text-muted-foreground shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
bind:value={zoom}
|
||||
@@ -266,7 +286,7 @@
|
||||
disabled={saving || !imageLoaded}
|
||||
>
|
||||
<Check class="w-4 h-4" />
|
||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
|
||||
{saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : saveLabel}
|
||||
</Button>
|
||||
</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');
|
||||
|
||||
onMount(async () => {
|
||||
// Load monospace fonts for dropdown previews
|
||||
// Load bundled monospace fonts for dropdown previews
|
||||
const fontsToLoad = monospaceFonts.filter(f => f.googleFont);
|
||||
if (fontsToLoad.length > 0) {
|
||||
const families = fontsToLoad.map(f => `family=${f.googleFont}`).join('&');
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||
link.onload = () => { monoFontsLoaded = true; };
|
||||
document.head.appendChild(link);
|
||||
let loaded = 0;
|
||||
for (const font of fontsToLoad) {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = `/fonts/${font.id}/font.css`;
|
||||
link.onload = () => { if (++loaded >= fontsToLoad.length) monoFontsLoaded = true; };
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
} else {
|
||||
monoFontsLoaded = true;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,22 @@
|
||||
'Europe/Kyiv': 'Europe/Kiev',
|
||||
'Asia/Ho_Chi_Minh': 'Asia/Saigon',
|
||||
'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)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
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 { Button } from '$lib/components/ui/button';
|
||||
import { currentEnvironment, environments, type Environment } from '$lib/stores/environment';
|
||||
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 { themeStore, type FontSize } from '$lib/stores/theme';
|
||||
import { getTimeFormat } from '$lib/stores/settings';
|
||||
@@ -77,6 +77,8 @@
|
||||
let diskUsageLoading = $state(false);
|
||||
let envAbortController: AbortController | null = null; // Aborts ALL requests when switching envs
|
||||
let showDropdown = $state(false);
|
||||
let searchTerm = $state('');
|
||||
let searchInputRef = $state<HTMLInputElement | null>(null);
|
||||
let currentEnvId = $state<number | null>(null);
|
||||
let lastUpdated = $state<Date>(new Date());
|
||||
let isConnected = $state(false);
|
||||
@@ -94,6 +96,22 @@
|
||||
|
||||
// Reactive environment list from store
|
||||
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);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
{#if hostInfo?.environment && Number(hostInfo.environment.id) === Number(currentEnvId)}
|
||||
{@const EnvIcon = getIconComponent(hostInfo.environment.icon || 'globe')}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
||||
<EnvironmentIcon icon={hostInfo.environment.icon || 'globe'} envId={hostInfo.environment.id} class="{iconSizeLargeClass()} text-primary" />
|
||||
<span class="font-medium text-foreground">{hostInfo.environment.name}</span>
|
||||
{:else if currentEnvId && envList.length > 0}
|
||||
{@const currentEnv = envList.find(e => Number(e.id) === Number(currentEnvId))}
|
||||
{#if currentEnv}
|
||||
{@const EnvIcon = getIconComponent(currentEnv.icon || 'globe')}
|
||||
<EnvIcon class="{iconSizeLargeClass()} text-primary" />
|
||||
<EnvironmentIcon icon={currentEnv.icon || 'globe'} envId={currentEnv.id} class="{iconSizeLargeClass()} text-primary" />
|
||||
<span class="font-medium text-foreground">{currentEnv.name}</span>
|
||||
{:else}
|
||||
<Globe class="{iconSizeLargeClass()} text-muted-foreground" />
|
||||
@@ -371,9 +387,40 @@
|
||||
|
||||
{#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="py-1">
|
||||
{#each envList as env (env.id)}
|
||||
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
|
||||
{#if showSearch}
|
||||
<div class="sticky top-0 bg-popover border-b px-2 py-1.5">
|
||||
<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 isSwitching = switchingEnvId === env.id}
|
||||
<button
|
||||
@@ -387,7 +434,7 @@
|
||||
{:else if isOffline}
|
||||
<WifiOff class="{iconSizeLargeClass()} text-destructive shrink-0" />
|
||||
{: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}
|
||||
<span class="flex-1 whitespace-nowrap" class:text-muted-foreground={isOffline}>{env.name}</span>
|
||||
{#if isOffline && !isSwitching}
|
||||
@@ -396,6 +443,10 @@
|
||||
<Check class="{iconSizeLargeClass()} text-primary shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-sm text-muted-foreground">
|
||||
No matching environments
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -118,6 +118,23 @@ export const scheduleColumns: ColumnConfig[] = [
|
||||
{ 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
|
||||
export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
||||
containers: containerColumns,
|
||||
@@ -128,7 +145,8 @@ export const gridColumnConfigs: Record<GridId, ColumnConfig[]> = {
|
||||
volumes: volumeColumns,
|
||||
activity: activityColumns,
|
||||
schedules: scheduleColumns,
|
||||
audit: auditColumns
|
||||
audit: auditColumns,
|
||||
environments: environmentColumns
|
||||
};
|
||||
|
||||
// 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",
|
||||
"date": "2026-03-13",
|
||||
|
||||
@@ -275,6 +275,12 @@
|
||||
"license": "MIT",
|
||||
"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",
|
||||
"version": "0.41.1",
|
||||
@@ -289,7 +295,7 @@
|
||||
},
|
||||
{
|
||||
"name": "aria-query",
|
||||
"version": "5.3.2",
|
||||
"version": "5.3.1",
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/A11yance/aria-query"
|
||||
},
|
||||
@@ -425,6 +431,12 @@
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/devalue"
|
||||
},
|
||||
{
|
||||
"name": "devalue",
|
||||
"version": "5.6.4",
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/sveltejs/devalue"
|
||||
},
|
||||
{
|
||||
"name": "dijkstrajs",
|
||||
"version": "1.0.3",
|
||||
@@ -781,7 +793,7 @@
|
||||
},
|
||||
{
|
||||
"name": "svelte",
|
||||
"version": "5.53.1",
|
||||
"version": "5.53.5",
|
||||
"license": "MIT",
|
||||
"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'];
|
||||
|
||||
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 {
|
||||
id: number;
|
||||
type: 'smtp' | 'apprise';
|
||||
@@ -982,7 +989,7 @@ export async function getEnvironmentNotifications(environmentId: number): Promis
|
||||
|
||||
return rows.map((row: any) => ({
|
||||
...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[];
|
||||
}
|
||||
|
||||
@@ -1009,7 +1016,7 @@ export async function getEnvironmentNotification(environmentId: number, notifica
|
||||
if (!rows[0]) return null;
|
||||
return {
|
||||
...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;
|
||||
}
|
||||
|
||||
@@ -1019,7 +1026,7 @@ export async function createEnvironmentNotification(data: {
|
||||
enabled?: boolean;
|
||||
eventTypes?: NotificationEventType[];
|
||||
}): 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({
|
||||
environmentId: data.environmentId,
|
||||
notificationId: data.notificationId,
|
||||
@@ -1087,7 +1094,7 @@ export async function getEnabledEnvironmentNotifications(
|
||||
return rows
|
||||
.map(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)
|
||||
}))
|
||||
.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);
|
||||
}
|
||||
|
||||
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> {
|
||||
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
|
||||
return true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { setGlobalDispatcher, Agent } from 'undici';
|
||||
import { setGlobalDispatcher, Agent, EnvHttpProxyAgent } from 'undici';
|
||||
import dns from 'node:dns';
|
||||
import net from 'node:net';
|
||||
|
||||
@@ -60,32 +60,44 @@ function lookupWithCache(hostname: string): Promise<{ address: string; family: n
|
||||
return promise;
|
||||
}
|
||||
|
||||
setGlobalDispatcher(
|
||||
new Agent({
|
||||
connect: {
|
||||
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
|
||||
timeout: 30_000,
|
||||
lookup(hostname: string, opts: any, cb: any) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = 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));
|
||||
}
|
||||
// Shared connect options for DNS lookup
|
||||
const connectOptions = {
|
||||
// Undici default is 10s. Increase to 30s for NAS environments with slow NAT/firewalls (#676).
|
||||
timeout: 30_000,
|
||||
lookup(hostname: string, opts: any, cb: any) {
|
||||
if (typeof opts === 'function') {
|
||||
cb = 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));
|
||||
}
|
||||
};
|
||||
|
||||
// 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 = {},
|
||||
envId?: number | null
|
||||
): 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 config = await getDockerConfig(envId);
|
||||
const { streaming, ...fetchOptions } = options;
|
||||
@@ -977,7 +982,7 @@ export async function dockerFetch(
|
||||
/**
|
||||
* Make a JSON request to Docker API
|
||||
*/
|
||||
async function dockerJsonRequest<T>(
|
||||
export async function dockerJsonRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
envId?: number | null
|
||||
@@ -1789,6 +1794,40 @@ export async function recreateContainerFromInspect(
|
||||
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.
|
||||
// Docker returns -1, Podman returns 0 when unset.
|
||||
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) {
|
||||
const oldContainerInfo = await inspectContainer(id, envId);
|
||||
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
|
||||
const existingOptions = extractContainerOptions(oldContainerInfo);
|
||||
@@ -2285,18 +2332,81 @@ export async function updateContainer(id: string, options: Partial<CreateContain
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Stop old container
|
||||
if (wasRunning) {
|
||||
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);
|
||||
|
||||
if (startAfterUpdate || wasRunning) {
|
||||
await newContainer.start();
|
||||
// 3. Disconnect networks from old container to free static IPs
|
||||
if (!isSharedNetwork) {
|
||||
for (const [, netConfig] of Object.entries(networks)) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -3134,6 +3244,22 @@ export async function getDockerVersion(envId?: number | null) {
|
||||
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.
|
||||
* 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[]> {
|
||||
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) => ({
|
||||
id: network.Id,
|
||||
@@ -3335,13 +3483,7 @@ export async function listNetworks(envId?: number | null): Promise<NetworkInfo[]
|
||||
auxAddress: cfg.AuxAddress || cfg.auxAddress
|
||||
}))
|
||||
},
|
||||
containers: Object.entries(network.Containers || {}).reduce((acc: any, [id, data]: [string, any]) => {
|
||||
acc[id] = {
|
||||
name: data.Name,
|
||||
ipv4Address: data.IPv4Address
|
||||
};
|
||||
return acc;
|
||||
}, {})
|
||||
containers: networkContainers.get(network.Id) || {}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3970,8 +4112,9 @@ export async function runContainerWithStreaming(options: {
|
||||
// Container has exited. Now fetch stdout reliably (no race condition).
|
||||
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) {
|
||||
let stderrText = '';
|
||||
try {
|
||||
const stderrResponse = await dockerFetch(
|
||||
`/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 stderrOutput = demuxDockerStream(stderrBuffer, { separateStreams: true });
|
||||
const stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
|
||||
if (stderrText) {
|
||||
console.error(`[runContainerWithStreaming] Container stderr: ${stderrText.substring(0, 1000)}`);
|
||||
}
|
||||
stderrText = typeof stderrOutput === 'string' ? stderrOutput : stderrOutput.stderr;
|
||||
} catch {
|
||||
// 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;
|
||||
@@ -4761,7 +4903,7 @@ function getVolumeCacheKey(volumeName: string, envId?: number | null): string {
|
||||
/**
|
||||
* 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
|
||||
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> {
|
||||
if (_nssWrapperChecked) {
|
||||
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_GROUP = TMP_GROUP;
|
||||
}
|
||||
@@ -136,7 +136,7 @@ async function ensurePasswdEntry(env: GitEnv): Promise<void> {
|
||||
}
|
||||
|
||||
_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_GROUP = TMP_GROUP;
|
||||
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.)
|
||||
// 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)) {
|
||||
console.log(`${logPrefix} Removing existing clone for fresh sync...`);
|
||||
rmSync(repoPath, { recursive: true, force: true });
|
||||
@@ -762,7 +763,8 @@ export async function syncGitStack(stackId: number): Promise<SyncResult> {
|
||||
// Check if commit changed
|
||||
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||
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}`);
|
||||
|
||||
// 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.)
|
||||
// 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
|
||||
onProgress({ status: 'cloning', message: 'Cloning repository...', step: 2, totalSteps });
|
||||
@@ -1130,7 +1133,8 @@ export async function deployGitStackWithProgress(
|
||||
// Check if commit changed
|
||||
const newCommitResult = await execGit(['rev-parse', 'HEAD'], repoPath, env);
|
||||
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
|
||||
// (for consistency with syncGitStack, though this function always deploys)
|
||||
|
||||
@@ -42,47 +42,21 @@ export interface NotificationResult {
|
||||
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
|
||||
async function sendSmtpNotification(config: SmtpConfig, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
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
|
||||
? `<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,
|
||||
runContainerWithStreaming,
|
||||
inspectImage,
|
||||
checkImageUpdateAvailable
|
||||
checkImageUpdateAvailable,
|
||||
getNegotiatedApiVersion
|
||||
} from './docker';
|
||||
import { getEnvironment, getEnvSetting, getSetting } from './db';
|
||||
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_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 {
|
||||
critical: number;
|
||||
high: number;
|
||||
@@ -150,28 +155,36 @@ export interface ScanProgress {
|
||||
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<{
|
||||
grypeArgs: 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_trivy_args')
|
||||
getSetting('default_trivy_args'),
|
||||
getSetting('default_grype_image'),
|
||||
getSetting('default_trivy_image')
|
||||
]);
|
||||
return {
|
||||
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<{
|
||||
scanner: ScannerType;
|
||||
grypeArgs: 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([
|
||||
getGlobalScannerDefaults(),
|
||||
getEnvSetting('vulnerability_scanner', envId)
|
||||
@@ -180,25 +193,31 @@ export async function getScannerSettings(envId?: number): Promise<{
|
||||
return {
|
||||
scanner: scanner || 'none',
|
||||
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)
|
||||
// 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(
|
||||
envId: number | undefined,
|
||||
globalDefaults: { grypeArgs: string; trivyArgs: string }
|
||||
globalDefaults: { grypeArgs: string; trivyArgs: string; grypeImage: string; trivyImage: string }
|
||||
): Promise<{
|
||||
scanner: ScannerType;
|
||||
grypeArgs: string;
|
||||
trivyArgs: string;
|
||||
grypeImage: string;
|
||||
trivyImage: string;
|
||||
}> {
|
||||
const scanner = await getEnvSetting('vulnerability_scanner', envId) || 'none';
|
||||
return {
|
||||
scanner,
|
||||
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}`]
|
||||
: [`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
|
||||
if (scannerDockerHost) {
|
||||
envVars.push(`DOCKER_HOST=${scannerDockerHost}`);
|
||||
@@ -697,11 +738,7 @@ async function runScannerContainerCore(
|
||||
});
|
||||
|
||||
console.log(`[Scanner] ${scannerType} container completed, output length: ${output.length}`);
|
||||
if (output.length === 0) {
|
||||
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) {
|
||||
if (output.length < 100) {
|
||||
console.log(`[Scanner] ${scannerType} output preview: ${output}`);
|
||||
}
|
||||
|
||||
@@ -715,8 +752,7 @@ export async function scanWithGrype(
|
||||
onProgress?: (progress: ScanProgress) => void
|
||||
): Promise<ScanResult> {
|
||||
const startTime = Date.now();
|
||||
const scannerImage = 'anchore/grype:latest';
|
||||
const { grypeArgs } = await getScannerSettings(envId);
|
||||
const { grypeArgs, grypeImage: scannerImage } = await getScannerSettings(envId);
|
||||
|
||||
onProgress?.({
|
||||
stage: 'checking',
|
||||
@@ -813,8 +849,7 @@ export async function scanWithTrivy(
|
||||
onProgress?: (progress: ScanProgress) => void
|
||||
): Promise<ScanResult> {
|
||||
const startTime = Date.now();
|
||||
const scannerImage = 'aquasec/trivy:latest';
|
||||
const { trivyArgs } = await getScannerSettings(envId);
|
||||
const { trivyArgs, trivyImage: scannerImage } = await getScannerSettings(envId);
|
||||
|
||||
onProgress?.({
|
||||
stage: 'checking',
|
||||
@@ -978,9 +1013,10 @@ export async function checkScannerAvailability(envId?: number): Promise<{
|
||||
grype: boolean;
|
||||
trivy: boolean;
|
||||
}> {
|
||||
const defaults = await getGlobalScannerDefaults();
|
||||
const [grypeAvailable, trivyAvailable] = await Promise.all([
|
||||
isScannerImageAvailable('anchore/grype', envId),
|
||||
isScannerImageAvailable('aquasec/trivy', envId)
|
||||
isScannerImageAvailable(defaults.grypeImage, envId),
|
||||
isScannerImageAvailable(defaults.trivyImage, envId)
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -995,12 +1031,14 @@ async function getScannerVersion(
|
||||
envId?: number
|
||||
): Promise<string | null> {
|
||||
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 hasImage = images.some((img) =>
|
||||
img.tags?.some((tag: string) => tag === scannerImage)
|
||||
img.tags?.some((tag: string) => tag.startsWith(imageRepo + ':'))
|
||||
);
|
||||
if (!hasImage) return null;
|
||||
|
||||
@@ -1062,10 +1100,11 @@ export async function checkScannerUpdates(envId?: number): Promise<{
|
||||
};
|
||||
|
||||
try {
|
||||
const defaults = await getGlobalScannerDefaults();
|
||||
const images = await listImages(envId);
|
||||
|
||||
// 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 {
|
||||
// Find local image
|
||||
const localImage = images.find((img) =>
|
||||
|
||||
@@ -13,10 +13,14 @@ export interface GridItem {
|
||||
|
||||
export interface DashboardPreferences {
|
||||
gridLayout: GridItem[];
|
||||
locked: boolean;
|
||||
viewMode: 'grid' | 'list';
|
||||
}
|
||||
|
||||
const defaultPreferences: DashboardPreferences = {
|
||||
gridLayout: []
|
||||
gridLayout: [],
|
||||
locked: false,
|
||||
viewMode: 'grid'
|
||||
};
|
||||
|
||||
// Environment info from API
|
||||
@@ -147,16 +151,20 @@ function createDashboardStore() {
|
||||
const data = await response.json();
|
||||
// Handle migration from old format
|
||||
if (data.gridLayout && Array.isArray(data.gridLayout)) {
|
||||
set({ gridLayout: data.gridLayout });
|
||||
set({
|
||||
gridLayout: data.gridLayout,
|
||||
locked: data.locked ?? false,
|
||||
viewMode: data.viewMode ?? 'grid'
|
||||
});
|
||||
} else {
|
||||
set({ gridLayout: [] });
|
||||
set(defaultPreferences);
|
||||
}
|
||||
} else {
|
||||
set({ gridLayout: [] });
|
||||
set(defaultPreferences);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard preferences:', error);
|
||||
set({ gridLayout: [] });
|
||||
set(defaultPreferences);
|
||||
} finally {
|
||||
// Always mark as initialized so saves can proceed
|
||||
initialized = true;
|
||||
@@ -206,6 +214,24 @@ function createDashboardStore() {
|
||||
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: () => {
|
||||
initialized = false;
|
||||
set(defaultPreferences);
|
||||
|
||||
@@ -27,8 +27,11 @@ export interface AppSettings {
|
||||
eventPollInterval: number;
|
||||
metricsCollectionInterval: number;
|
||||
compactPorts: boolean;
|
||||
formatLogTimestamps: boolean;
|
||||
externalStackPaths: string[];
|
||||
primaryStackLocation: string | null;
|
||||
defaultGrypeImage: string;
|
||||
defaultTrivyImage: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: AppSettings = {
|
||||
@@ -52,8 +55,11 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
eventPollInterval: 60000,
|
||||
metricsCollectionInterval: 30000,
|
||||
compactPorts: false,
|
||||
formatLogTimestamps: false,
|
||||
externalStackPaths: [],
|
||||
primaryStackLocation: null
|
||||
primaryStackLocation: null,
|
||||
defaultGrypeImage: 'anchore/grype:v0.110.0',
|
||||
defaultTrivyImage: 'aquasec/trivy:0.69.3'
|
||||
};
|
||||
|
||||
// Create a writable store for app settings
|
||||
@@ -91,8 +97,11 @@ function createSettingsStore() {
|
||||
eventPollInterval: settings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: settings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
compactPorts: settings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
||||
formatLogTimestamps: settings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||
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 {
|
||||
@@ -133,8 +142,11 @@ function createSettingsStore() {
|
||||
eventPollInterval: updatedSettings.eventPollInterval ?? DEFAULT_SETTINGS.eventPollInterval,
|
||||
metricsCollectionInterval: updatedSettings.metricsCollectionInterval ?? DEFAULT_SETTINGS.metricsCollectionInterval,
|
||||
compactPorts: updatedSettings.compactPorts ?? DEFAULT_SETTINGS.compactPorts,
|
||||
formatLogTimestamps: updatedSettings.formatLogTimestamps ?? DEFAULT_SETTINGS.formatLogTimestamps,
|
||||
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) {
|
||||
@@ -301,6 +313,13 @@ function createSettingsStore() {
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setFormatLogTimestamps: (value: boolean) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, formatLogTimestamps: value };
|
||||
saveSettings({ formatLogTimestamps: value });
|
||||
return newSettings;
|
||||
});
|
||||
},
|
||||
setExternalStackPaths: (value: string[]) => {
|
||||
update((current) => {
|
||||
const newSettings = { ...current, externalStackPaths: value };
|
||||
@@ -315,6 +334,20 @@ function createSettingsStore() {
|
||||
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
|
||||
refresh: loadSettings
|
||||
};
|
||||
@@ -430,3 +463,19 @@ export function getTimeFormat(): TimeFormat {
|
||||
export function getDateFormat(): DateFormat {
|
||||
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);
|
||||
}
|
||||
|
||||
// Load Google Font dynamically
|
||||
// Load bundled font CSS (fonts are bundled in static/fonts/)
|
||||
function loadGoogleFont(font: FontMeta) {
|
||||
if (!font.googleFont) return;
|
||||
|
||||
@@ -274,7 +274,7 @@ function loadGoogleFont(font: FontMeta) {
|
||||
const link = document.createElement('link');
|
||||
link.id = linkId;
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '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: '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: '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' },
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ export interface ContainerInfo {
|
||||
rw: boolean;
|
||||
}>;
|
||||
networkMode: string;
|
||||
networks: string[];
|
||||
networks: Record<string, { ipAddress: string }>;
|
||||
/**
|
||||
* Identifies system containers (Dockhand, Hawser) that cannot be updated from within Dockhand.
|
||||
* - 'dockhand': The Dockhand container itself
|
||||
@@ -164,7 +164,7 @@ export interface GitRepository {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
id: string;
|
||||
|
||||
@@ -23,6 +23,7 @@ export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (ok) return true;
|
||||
|
||||
@@ -43,4 +43,8 @@ export function getIconComponent(iconName: string): ComponentType {
|
||||
return iconMap[iconName] || Globe;
|
||||
}
|
||||
|
||||
export function isCustomIcon(icon: string | null | undefined): boolean {
|
||||
return !!icon && icon.startsWith('custom:');
|
||||
}
|
||||
|
||||
export { iconMap };
|
||||
Reference in new issue
Block a user