diff --git a/VERSION b/VERSION
index 4b296b2..90c4f8c 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-v1.0.21
+v1.0.22
diff --git a/docker-entrypoint-node.sh b/docker-entrypoint-node.sh
index 16e65ca..fe01600 100644
--- a/docker-entrypoint-node.sh
+++ b/docker-entrypoint-node.sh
@@ -10,10 +10,12 @@ PGID=${PGID:-1001}
export BODY_SIZE_LIMIT=${BODY_SIZE_LIMIT:-2G}
# 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
- 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
- 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
# === Detect if running as root ===
@@ -100,14 +102,29 @@ else
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
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
fi
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
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
diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh
index 8de5670..c224dbf 100644
--- a/docker-entrypoint.sh
+++ b/docker-entrypoint.sh
@@ -113,14 +113,28 @@ else
fi
# === 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
chown -R dockhand:dockhand /home/dockhand 2>/dev/null || true
fi
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/app/data" ] && [ "$DATA_DIR" != "./data" ]; then
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
diff --git a/package.json b/package.json
index f129cbc..af8de12 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "dockhand",
"private": true,
- "version": "1.0.21",
+ "version": "1.0.22",
"type": "module",
"scripts": {
"dev": "npx vite dev",
diff --git a/src/lib/components/AvatarCropper.svelte b/src/lib/components/AvatarCropper.svelte
index 96576de..71b8d9c 100644
--- a/src/lib/components/AvatarCropper.svelte
+++ b/src/lib/components/AvatarCropper.svelte
@@ -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();
}
}
+
+
{#if show && imageUrl}
-
+
-
Crop avatar
+
{title}
Drag to reposition. Use the slider to zoom.
@@ -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 @@
- {saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : 'Save avatar'}
+ {saving ? 'Uploading...' : !imageLoaded ? 'Loading...' : saveLabel}
diff --git a/src/lib/components/EnvironmentIcon.svelte b/src/lib/components/EnvironmentIcon.svelte
new file mode 100644
index 0000000..669aedf
--- /dev/null
+++ b/src/lib/components/EnvironmentIcon.svelte
@@ -0,0 +1,23 @@
+
+
+{#if isCustom}
+

+{:else if LucideIcon}
+
+{/if}
diff --git a/src/lib/components/ThemeSelector.svelte b/src/lib/components/ThemeSelector.svelte
index 9a65a60..f186a74 100644
--- a/src/lib/components/ThemeSelector.svelte
+++ b/src/lib/components/ThemeSelector.svelte
@@ -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;
}
diff --git a/src/lib/components/TimezoneSelector.svelte b/src/lib/components/TimezoneSelector.svelte
index 72e9547..2596c17 100644
--- a/src/lib/components/TimezoneSelector.svelte
+++ b/src/lib/components/TimezoneSelector.svelte
@@ -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)
diff --git a/src/lib/components/host-info.svelte b/src/lib/components/host-info.svelte
index ef3a4bf..746e243 100644
--- a/src/lib/components/host-info.svelte
+++ b/src/lib/components/host-info.svelte
@@ -1,11 +1,11 @@
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 50a80f0..682502a 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -5,7 +5,7 @@
+
+
+
+
{ 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}
+
+ {:else if isOnline}
+
+ {:else if isOffline}
+
+ {:else}
+
+ {/if}
+
+ {:else if column.id === 'name'}
+ {#if s}
+
+
+ {s.name}
+
+ {:else if tile.info}
+
+
+ {tile.info.name}
+
+ {:else}
+
+ {/if}
+
+ {:else if column.id === 'connection'}
+ {#if s}
+
+ {#if s.connectionType === 'hawser-standard'}
+
+ {:else if s.connectionType === 'hawser-edge'}
+
+ {:else if s.connectionType === 'direct'}
+
+ {:else}
+
+ {/if}
+
{connectionLabel(s.connectionType)}
+
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'host'}
+ {#if s?.host && !isOffline}
+
+ {s.host}{s.port ? `:${s.port}` : ''}
+
+ {:else if s?.socketPath}
+
+ {s.socketPath}
+
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'containers'}
+ {#if s && !isOffline}
+ {s.containers.running}
+ / {s.containers.total}
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'updates'}
+ {#if s && !isOffline}
+ {#if s.containers.pendingUpdates > 0}
+
+
+ {s.containers.pendingUpdates}
+
+ {:else}
+ 0
+ {/if}
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'cpu'}
+ {#if s?.metrics && !isOffline}
+
+
+
{formatPercent(s.metrics.cpuPercent)}
+
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'memory'}
+ {#if s?.metrics && !isOffline}
+
+
+
{formatPercent(s.metrics.memoryPercent)}
+
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'images'}
+ {#if s && !isOffline}
+ {s.images.total}
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'volumes'}
+ {#if s && !isOffline}
+ {s.volumes.total}
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'stacks'}
+ {#if s && !isOffline}
+ {s.stacks.running}
+ / {s.stacks.total}
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'events'}
+ {#if s && !isOffline}
+ {s.events.today}
+ {:else if tile.loading}
+
+ {:else}
+ -
+ {/if}
+
+ {:else if column.id === 'labels'}
+ {#if s?.labels && s.labels.length > 0}
+
+ {#each s.labels as label}
+ {@const colors = getLabelColors(label)}
+
+ {label}
+
+ {/each}
+
+ {/if}
+ {/if}
+ {/snippet}
+
+
diff --git a/src/routes/dashboard/EnvironmentTile.svelte b/src/routes/dashboard/EnvironmentTile.svelte
index 1f170f3..0a80a15 100644
--- a/src/routes/dashboard/EnvironmentTile.svelte
+++ b/src/routes/dashboard/EnvironmentTile.svelte
@@ -2,7 +2,7 @@
import * as Card from '$lib/components/ui/card';
import { Wifi, WifiOff, ShieldCheck, Activity, Cpu, Settings, Unplug, Icon, Route, UndoDot, CircleArrowUp, CircleFadingArrowUp, Loader2 } from 'lucide-svelte';
import { whale } from '@lucide/lab';
- import { getIconComponent } from '$lib/utils/icons';
+ import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { goto } from '$app/navigation';
import { canAccess } from '$lib/stores/auth';
import type { EnvironmentStats } from '../api/dashboard/stats/+server';
@@ -31,8 +31,6 @@
let { stats, width = 1, height = 1, oneventsclick, showStacksBreakdown = true }: Props = $props();
- const EnvIcon = $derived(getIconComponent(stats.icon));
-
// Specific tile size conditionals for easy customization
const is1x1 = $derived(width === 1 && height === 1);
const is1x2 = $derived(width === 1 && height === 2);
@@ -64,7 +62,7 @@
-
+
{#if stats.connectionType === 'socket' || !stats.connectionType}
@@ -160,7 +158,7 @@
-
+
{#if stats.connectionType === 'socket' || !stats.connectionType}
@@ -263,7 +261,7 @@
-
+
{#if stats.connectionType === 'socket' || !stats.connectionType}
@@ -364,7 +362,7 @@
-
+
{#if stats.connectionType === 'socket' || !stats.connectionType}
@@ -468,7 +466,7 @@
-
+
{#if stats.connectionType === 'socket' || !stats.connectionType}
diff --git a/src/routes/dashboard/dashboard-header.svelte b/src/routes/dashboard/dashboard-header.svelte
index 2bc35eb..95eeb2f 100644
--- a/src/routes/dashboard/dashboard-header.svelte
+++ b/src/routes/dashboard/dashboard-header.svelte
@@ -15,10 +15,9 @@
Loader2
} from 'lucide-svelte';
import { whale } from '@lucide/lab';
- import { getIconComponent } from '$lib/utils/icons';
+ import EnvironmentIcon from '$lib/components/EnvironmentIcon.svelte';
import { goto } from '$app/navigation';
import { canAccess } from '$lib/stores/auth';
- import type { Component } from 'svelte';
type ConnectionType = 'socket' | 'direct' | 'hawser-standard' | 'hawser-edge';
@@ -71,7 +70,6 @@
(port ? `${host}:${port}` : host || 'Unknown host')
);
- const EnvIcon = $derived(getIconComponent(icon)) as Component;
const canEdit = $derived($canAccess('environments', 'edit'));
function openSettings(e: MouseEvent) {
@@ -88,7 +86,7 @@
-
+
@@ -109,7 +107,7 @@
-
+
{#if connectionType === 'socket' || !connectionType}
diff --git a/src/routes/logs/+page.svelte b/src/routes/logs/+page.svelte
index 9a97833..87b0362 100644
--- a/src/routes/logs/+page.svelte
+++ b/src/routes/logs/+page.svelte
@@ -10,7 +10,7 @@
import * as Select from '$lib/components/ui/select';
import { Checkbox } from '$lib/components/ui/checkbox';
import { ToggleGroup } from '$lib/components/ui/toggle-pill';
- import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal } from 'lucide-svelte';
+ import { RefreshCw, Search, ChevronDown, ChevronUp, Unplug, Copy, Download, WrapText, ArrowDownToLine, X, Sun, Moon, LayoutList, Square, Box, Wifi, WifiOff, Pause, Play, ScrollText, Star, GripVertical, Layers, Check, FolderHeart, Save, Trash2, MoreHorizontal, Eraser } from 'lucide-svelte';
import { copyToClipboard } from '$lib/utils/clipboard';
import PageHeader from '$lib/components/PageHeader.svelte';
import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
@@ -1290,6 +1290,15 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
}
}
+ // Clear displayed logs
+ function clearLogs() {
+ logs = '';
+ pendingText = '';
+ mergedLogs = [];
+ mergedHtml = '';
+ pendingLogs = [];
+ }
+
// Log search functions
function toggleLogSearch() {
logSearchActive = !logSearchActive;
@@ -1960,6 +1969,9 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
+
@@ -2137,6 +2149,13 @@ import type { FavoriteGroup } from '../api/preferences/favorite-groups/+server';
>
+