This commit is contained in:
jarek committed 2026-06-17 08:21:38 +02:00
1 parent aa45be6844
commit efb634701c
56 files changed
+9101 -117

No files matched your search

+104 -8
View File
@@ -29,6 +29,7 @@
Plus,
FileText,
Pencil,
NotepadText,
RefreshCw,
CircleArrowUp,
X,
@@ -88,6 +89,9 @@
import { ipToNumber } from '$lib/utils/ip';
import { formatHostPortUrl } from '$lib/utils/url';
import { parseCustomUrl } from '$lib/utils/custom-url';
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
import { extractPangolinUrls } from '$lib/utils/pangolin-urls';
import { resolveChangelogUrl } from '$lib/utils/changelog-url';
import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellDetectionResult } from '$lib/utils/shell-detection';
import { DataGrid } from '$lib/components/data-grid';
import type { ColumnConfig } from '$lib/types';
@@ -119,8 +123,10 @@
let sortField = $state<SortField>('name');
let sortDirection = $state<SortDirection>('asc');
// Status filter state
// Status filter state — also carries the synthetic 'update-available'
// pseudo-status (#1063), which ANDs with actual Docker states.
const STATUS_FILTER_STORAGE_KEY = 'dockhand-containers-status-filter';
const UPDATE_AVAILABLE_FILTER_VALUE = 'update-available';
let statusFilter = $state<string[]>([]);
// Status types with icons for filter and table
@@ -305,6 +311,35 @@
// Set of container IDs with updates available (for O(1) lookup)
const containersWithUpdatesSet = $derived(new Set(batchUpdateContainerIds));
// Filter dropdown entries: real statuses plus the synthetic
// "update-available" entry, only offered once we know about a pending
// update — picking it on an empty set would just empty the list (#1063).
const filterOptions = $derived(
containersWithUpdatesSet.size > 0
? [
...statusTypes,
{
value: UPDATE_AVAILABLE_FILTER_VALUE,
label: 'Update available',
icon: CircleArrowUp,
color: 'text-amber-500'
}
]
: statusTypes
);
// Drop the 'update-available' filter when no pending updates remain —
// otherwise the user has no way to deselect it (dropdown hides the
// entry) and the list stays empty (#1063).
$effect(() => {
if (
statusFilter.includes(UPDATE_AVAILABLE_FILTER_VALUE) &&
containersWithUpdatesSet.size === 0
) {
statusFilter = statusFilter.filter((v) => v !== UPDATE_AVAILABLE_FILTER_VALUE);
}
});
// Count of updatable containers (excluding system containers like Dockhand/Hawser)
const updatableContainersCount = $derived(
batchUpdateContainerIds.filter(id => {
@@ -746,9 +781,16 @@
result = result.filter(c => c.state.toLowerCase() !== 'exited');
}
// Filter by status if any are selected
if (statusFilter.length > 0) {
result = result.filter(c => statusFilter.includes(c.state.toLowerCase()));
// Filter by status. The synthetic 'update-available' value (#1063)
// is split off so it ANDs with real-state selections instead of
// being treated like another Docker state.
const stateValues = statusFilter.filter((v) => v !== UPDATE_AVAILABLE_FILTER_VALUE);
const updatesOnly = statusFilter.includes(UPDATE_AVAILABLE_FILTER_VALUE);
if (stateValues.length > 0) {
result = result.filter((c) => stateValues.includes(c.state.toLowerCase()));
}
if (updatesOnly) {
result = result.filter((c) => containersWithUpdatesSet.has(c.id));
}
// Filter by search query
@@ -1397,12 +1439,14 @@
class="pl-8 h-8 w-48 text-sm"
/>
</div>
<!-- Status filter (multi-select) -->
<!-- Status filter (multi-select). The synthetic 'update-available'
entry appears once at least one container has a pending update,
and ANDs with selected real states (#1063). -->
<MultiSelectFilter
bind:value={statusFilter}
options={statusTypes}
options={filterOptions}
placeholder="All statuses"
pluralLabel="statuses"
pluralLabel="filters"
width="w-44"
defaultIcon={Box}
/>
@@ -1790,6 +1834,21 @@
<span title="Update available">
<CircleArrowUp class="w-3 h-3 text-amber-500 {$appSettings.highlightUpdates ? 'glow-amber' : ''} shrink-0" />
</span>
{#if $appSettings.showImageChangelogLinks}
{@const changelogUrl = resolveChangelogUrl(container.image, container.labels)}
{#if changelogUrl}
<a
href={changelogUrl}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
title="View changelog"
class="shrink-0 text-amber-500 hover:text-amber-400 transition-colors"
>
<NotepadText class="w-3 h-3" />
</a>
{/if}
{/if}
{/if}
<span class="text-xs text-muted-foreground truncate" title={container.image}>{container.image}</span>
</div>
@@ -1897,7 +1956,9 @@
{:else if column.id === 'ports'}
{@const exposedPorts = $appSettings.showExposedPorts ? formatExposedPorts(container.ports) : []}
{@const parsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])}
{#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl}
{@const traefikUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractTraefikUrls(container.labels)}
{@const pangolinUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractPangolinUrls(container.labels)}
{#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl || traefikUrls.length > 0 || pangolinUrls.length > 0}
{@const compactPorts = $appSettings.compactPorts}
{@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports}
{@const remainingCount = ports.length - 1}
@@ -1916,6 +1977,36 @@
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/if}
<!-- Traefik fallback URLs (#2). dockhand.url suppresses these. -->
{#each traefikUrls as t}
<a
href={t.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0"
title="Traefik router {t.router} → {t.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{t.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
<!-- Pangolin fallback URLs (#2 follow-up). dockhand.url suppresses these. -->
{#each pangolinUrls as p}
<a
href={p.url}
target="_blank"
rel="noopener noreferrer"
onclick={(e) => e.stopPropagation()}
class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0"
title="Pangolin resource {p.resource} → {p.url}"
>
<Globe class="w-2.5 h-2.5" />
<span class="max-w-[120px] truncate">{p.displayName ?? p.url.replace(/^https?:\/\//, '')}</span>
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
</a>
{/each}
{#each displayPorts as port}
{@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])}
{@const portUrl = portParsed?.url || null}
@@ -2381,6 +2472,11 @@
// Refresh the container list
fetchContainers();
}}
onStart={$canAccess('containers', 'start') ? (id) => startContainer(id) : undefined}
onStop={$canAccess('containers', 'stop') ? (id) => stopContainer(id) : undefined}
onRestart={$canAccess('containers', 'restart') ? (id) => restartContainer(id) : undefined}
onRemove={$canAccess('containers', 'remove') ? (id) => removeContainer(id) : undefined}
onEdit={$canAccess('containers', 'edit') ? (id) => editContainer(id) : undefined}
/>
<FileBrowserModal
+16 -4
View File
@@ -76,6 +76,10 @@
}
let status = $state<'idle' | 'updating' | 'complete' | 'error'>('idle');
// Incremented on each close. The startUpdate() poll loop captures the
// run id at start and stops mutating state once it no longer matches —
// prevents a stale background poll from clobbering the next run (#1094).
let runId = 0;
let progress = $state<ContainerProgress[]>([]);
let progressListEl = $state<HTMLDivElement | null>(null);
let scrollTick = $state(0);
@@ -139,6 +143,7 @@
async function startUpdate() {
if (containerIds.length === 0) return;
const myRunId = ++runId;
status = 'updating';
progress = [];
currentIndex = 0;
@@ -164,6 +169,8 @@
const blockedIds: string[] = [];
await watchJob(jobId, (line) => {
// If the user closed the modal (or started another run), drop the line.
if (myRunId !== runId) return;
try {
const data = line.data as any;
scrollTick++;
@@ -282,6 +289,7 @@
});
} catch (error: any) {
console.error('Failed to update containers:', error);
if (myRunId !== runId) return;
status = 'error';
errorMessage = error.message || 'Failed to update';
}
@@ -290,6 +298,9 @@
function handleClose() {
open = false;
onClose();
// Invalidate any in-flight poll so its onLine callbacks stop mutating
// state for this modal instance (#1094).
runId++;
// Reset state
status = 'idle';
progress = [];
@@ -300,10 +311,11 @@
}
function handleOpenChange(isOpen: boolean) {
if (!isOpen && status === 'updating') {
// Don't allow closing while updating
return;
}
// The X button (DialogPrimitive.Close) bypasses controlled close, so
// returning early here without resetting state strands `status` at
// 'updating' and the next open of this modal never re-triggers
// startUpdate() (#1094). Always reset; the in-flight poll loop
// finishes against the server but the UI is freed.
if (!isOpen) {
handleClose();
}
@@ -5,7 +5,8 @@
import * as Tabs from '$lib/components/ui/tabs';
import { Button } from '$lib/components/ui/button';
import { Badge } from '$lib/components/ui/badge';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink } from 'lucide-svelte';
import { Loader2, Box, Info, Layers, Cpu, MemoryStick, HardDrive, Network, Shield, Settings2, Code, Copy, Check, XCircle, Activity, Wifi, Pencil, RefreshCw, X, FolderOpen, Moon, Tags, ExternalLink, Gpu, Globe, Link, Unlink, Play, Square as SquareIcon, RotateCw, Trash2 } from 'lucide-svelte';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import * as Select from '$lib/components/ui/select';
import { toast } from 'svelte-sonner';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -26,9 +27,73 @@
containerId: string;
containerName?: string;
onRename?: (newName: string) => void;
// Lifecycle handlers from the parent (#461). Non-destructive actions
// return a promise so the modal can refresh inspect data afterwards;
// onRemove and onEdit close the modal themselves.
onStart?: (id: string) => Promise<void> | void;
onStop?: (id: string) => Promise<void> | void;
onRestart?: (id: string) => Promise<void> | void;
onRemove?: (id: string) => Promise<void> | void;
onEdit?: (id: string) => void;
}
let { open = $bindable(), containerId, containerName, onRename }: Props = $props();
let { open = $bindable(), containerId, containerName, onRename, onStart, onStop, onRestart, onRemove, onEdit }: Props = $props();
// Confirmation-popover open state for the destructive actions in the header.
let confirmStopOpen = $state(false);
let confirmRestartOpen = $state(false);
let confirmRemoveOpen = $state(false);
// Per-action in-flight flags so the corresponding icon can spin/pulse.
let starting = $state(false);
let stopping = $state(false);
let restarting = $state(false);
async function doStart() {
if (!onStart) return;
starting = true;
try {
await onStart(containerId);
await fetchContainerInspect();
} finally {
starting = false;
}
}
async function doStop() {
if (!onStop) return;
stopping = true;
try {
await onStop(containerId);
await fetchContainerInspect();
} finally {
stopping = false;
}
}
async function doRestart() {
if (!onRestart) return;
restarting = true;
try {
await onRestart(containerId);
await fetchContainerInspect();
} finally {
restarting = false;
}
}
async function doRemove() {
if (!onRemove) return;
try {
await onRemove(containerId);
} finally {
// Always close: the container is either gone or the parent toasted an error.
open = false;
}
}
function doEdit() {
if (!onEdit) return;
// Close the inspect modal first so EditContainerModal isn't stacked on top.
open = false;
onEdit(containerId);
}
// Rename state
let isEditing = $state(false);
@@ -652,16 +717,91 @@
</span>
{/if}
{#if containerData && !loading}
<Button
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw inspect data"
class="ml-auto mr-6"
>
<Code class="w-4 h-4 mr-1.5" />
Inspect
</Button>
<div class="ml-auto mr-6 flex items-center gap-1">
<!-- Lifecycle actions (#461). Mirrors the per-row action set on the containers page;
non-destructive actions refresh the inspect data in place, Delete closes the modal. -->
{#if containerData.State?.Running}
{#if onStop}
<ConfirmPopover
open={confirmStopOpen}
action="Stop"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Stop"
onConfirm={doStop}
onOpenChange={(o) => confirmStopOpen = o}
>
{#snippet children({ open })}
<SquareIcon class="w-4 h-4 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'} {stopping ? 'animate-pulse text-destructive' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{#if onRestart}
<ConfirmPopover
open={confirmRestartOpen}
action="Restart"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Restart"
variant="secondary"
onConfirm={doRestart}
onOpenChange={(o) => confirmRestartOpen = o}
>
{#snippet children({ open })}
<RotateCw class="w-4 h-4 {open ? 'text-foreground' : 'text-muted-foreground hover:text-foreground'} {restarting ? 'animate-spin text-foreground' : ''}" />
{/snippet}
</ConfirmPopover>
{/if}
{:else}
{#if onStart}
<button
type="button"
onclick={doStart}
title="Start"
disabled={starting}
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer disabled:opacity-50"
>
<Play class="w-4 h-4 {starting ? 'animate-pulse text-emerald-500' : 'text-muted-foreground hover:text-emerald-500'}" />
</button>
{/if}
{/if}
{#if onEdit}
<button
type="button"
onclick={doEdit}
title="Edit"
class="p-1 rounded hover:bg-muted transition-colors cursor-pointer"
>
<Pencil class="w-4 h-4 text-muted-foreground hover:text-foreground" />
</button>
{/if}
{#if onRemove}
<ConfirmPopover
open={confirmRemoveOpen}
action="Delete"
itemType="container"
itemName={displayName || containerId.slice(0, 12)}
title="Delete"
variant="destructive"
onConfirm={doRemove}
onOpenChange={(o) => confirmRemoveOpen = o}
>
{#snippet children({ open })}
<Trash2 class="w-4 h-4 {open ? 'text-destructive' : 'text-muted-foreground hover:text-destructive'}" />
{/snippet}
</ConfirmPopover>
{/if}
<Button
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw inspect data"
class="ml-1"
>
<Code class="w-4 h-4 mr-1.5" />
Inspect
</Button>
</div>
{/if}
</Dialog.Title>
</Dialog.Header>
@@ -605,7 +605,12 @@
<Dialog.Root bind:open onOpenChange={(isOpen) => isOpen && focusFirstInput()}>
<Dialog.Content class="max-w-4xl w-full h-[85vh] p-0 flex flex-col overflow-hidden !zoom-in-0 !zoom-out-0" showCloseButton={false}>
<Dialog.Header class="px-5 py-4 border-b bg-muted/30 shrink-0 sticky top-0 z-10">
<Dialog.Title class="text-base font-semibold">Create new container</Dialog.Title>
<Dialog.Title class="text-base font-semibold">
Create new container
{#if $currentEnvironment}
<span class="font-medium">on <span class="text-amber-600 dark:text-amber-400">{$currentEnvironment.name}</span></span>
{/if}
</Dialog.Title>
<button
type="button"
onclick={handleClose}