1.0.34
This commit is contained in:
56 files changed
+9101
-117
No files matched your search
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user