This commit is contained in:
jarek committed 2026-06-06 16:18:05 +02:00
1 parent 00bd09df55
commit 3cbcfa3cdb
26 files changed
+1367 -765

No files matched your search

@@ -38,6 +38,23 @@
let loading = $state(true);
let error = $state('');
let containerData = $state<any>(null);
// Peer containers in the current env — used to resolve "container:<sha>" mode to a friendly name
let peerContainers = $state<Array<{ id: string; name: string }>>([]);
const networkModeLabel = $derived.by(() => {
const raw = containerData?.HostConfig?.NetworkMode || 'default';
if (!raw.startsWith('container:')) return raw;
const ref = raw.slice('container:'.length);
const match = peerContainers.find(c => c.id === ref || c.id.startsWith(ref));
return match ? `container:${match.name}` : raw;
});
// Docker rejects attaching extra networks when the container shares another
// namespace (host / none / container:X / service:X). Hide join controls then.
const isSharedNetworkMode = $derived.by(() => {
const mode = containerData?.HostConfig?.NetworkMode || '';
return mode === 'host' || mode === 'none' || mode.startsWith('container:') || mode.startsWith('service:');
});
// Active tab state for layers visibility
let activeTab = $state('overview');
@@ -52,6 +69,8 @@
// Label copy state
let copiedLabel = $state<string | null>(null);
let copyLabelFailed = $state(false);
let labelFilter = $state('');
let copiedAllLabels = $state(false);
async function copyLabel(key: string, value: string) {
const ok = await copyToClipboard(`${key}=${value}`);
@@ -64,6 +83,19 @@
}
}
async function copyAllLabels(entries: [string, string][]) {
if (entries.length === 0) return;
const text = entries.map(([k, v]) => `${k}=${v}`).join('\n');
const ok = await copyToClipboard(text);
if (ok) {
copiedAllLabels = true;
setTimeout(() => copiedAllLabels = false, 2000);
} else {
copyLabelFailed = true;
setTimeout(() => copyLabelFailed = false, 2000);
}
}
// Processes state
interface ProcessesData {
Titles: string[];
@@ -281,6 +313,9 @@
$effect(() => {
if (open && containerData?.State?.Running) {
startStatsCollection();
// One-shot fetch so the Overview's process count tile renders
// immediately, even if the user never opens the Processes tab.
if (!processesData) fetchProcesses();
} else {
stopStatsCollection();
}
@@ -317,6 +352,7 @@
lastFetchedId = '';
isLiveConnected = false;
lastStatsUpdate = 0;
labelFilter = '';
displayName = '';
isEditing = false;
editName = '';
@@ -335,6 +371,19 @@
throw new Error('Failed to fetch container details');
}
containerData = await response.json();
// Fetch peers only when this container shares another container's namespace —
// keeps the dialog snappy when the network mode is bridge/host/none/custom.
if (containerData?.HostConfig?.NetworkMode?.startsWith('container:')) {
try {
const peersRes = await fetch(appendEnvParam('/api/containers', envId));
if (peersRes.ok) {
const list = await peersRes.json();
peerContainers = list.map((c: any) => ({ id: c.id, name: c.name }));
}
} catch {
// Non-fatal — falls back to the raw SHA
}
}
} catch (err: any) {
error = err.message || 'Failed to load container details';
console.error('Failed to fetch container inspect:', err);
@@ -583,11 +632,11 @@
variant="outline"
size="sm"
onclick={() => showRawJson = true}
title="View raw JSON"
title="View raw inspect data"
class="ml-auto mr-6"
>
<Code class="w-4 h-4 mr-1.5" />
JSON
Inspect
</Button>
{/if}
</Dialog.Title>
@@ -623,7 +672,7 @@
<Tabs.Content value="overview" class="space-y-4 overflow-auto">
<!-- Real-time Stats (only for running containers) -->
{#if containerData.State?.Running}
<div class="grid grid-cols-2 lg:grid-cols-4 gap-3">
<div class="grid grid-cols-2 lg:grid-cols-5 gap-3">
<!-- CPU -->
<div class="p-3 border border-border rounded-lg">
<div class="flex items-center gap-2 mb-2">
@@ -709,6 +758,30 @@
</div>
</div>
</div>
<!-- Processes -->
<div class="p-3 border border-border rounded-lg">
<div class="flex items-center gap-2 mb-2">
<Activity class="w-4 h-4 text-pink-500" />
<span class="text-xs font-medium">Processes</span>
<button
type="button"
class="ml-auto text-sm font-bold hover:text-foreground/80 transition-colors"
onclick={() => activeTab = 'processes'}
title="View process list"
>
{processesData?.Processes?.length ?? '—'}
</button>
</div>
<div class="h-8 flex items-center justify-center text-2xs text-muted-foreground">
{#if processesData?.Processes?.length}
running in container
{:else if processesLoading}
<Loader2 class="w-3 h-3 animate-spin" />
{:else}
—
{/if}
</div>
</div>
</div>
{/if}
@@ -865,7 +938,7 @@
<!-- Network Mode -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Network mode</h3>
<Badge variant="outline">{containerData.HostConfig?.NetworkMode || 'default'}</Badge>
<Badge variant="outline">{networkModeLabel}</Badge>
</div>
<!-- DNS Settings -->
@@ -918,7 +991,11 @@
<!-- Networks -->
<div class="space-y-2">
<h3 class="text-sm font-semibold">Connected networks</h3>
{#if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
{#if isSharedNetworkMode}
<p class="text-xs text-muted-foreground">
Network namespace is shared via <code class="px-1 py-0.5 rounded bg-muted">{containerData.HostConfig?.NetworkMode}</code> — additional networks cannot be attached.
</p>
{:else if containerData.NetworkSettings?.Networks && Object.keys(containerData.NetworkSettings.Networks).length > 0}
<div class="space-y-2">
{#each Object.entries(containerData.NetworkSettings.Networks) as [networkName, networkData]}
{@const netData = networkData as any}
@@ -985,7 +1062,7 @@
{/if}
<!-- Join network dropdown -->
{#if containerData.State?.Running}
{#if containerData.State?.Running && !isSharedNetworkMode}
<div class="flex items-center gap-2 pt-1">
<Select.Root type="single" bind:value={selectedNetwork}>
<Select.Trigger class="flex-1 h-8 text-xs">
@@ -1152,12 +1229,23 @@
<!-- Environment Tab -->
<Tabs.Content value="env" class="space-y-4 overflow-auto">
{#if containerData.divergence?.env?.length > 0}
<div class="flex items-start gap-2 text-xs p-2.5 rounded border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300">
<Info class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div class="min-w-0">
{containerData.divergence.env.length} env var{containerData.divergence.env.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.env.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
</div>
</div>
{/if}
{#if containerData.Config?.Env && containerData.Config.Env.length > 0}
<div class="space-y-1">
{#each [...containerData.Config.Env].sort((a, b) => a.split('=')[0].localeCompare(b.split('=')[0])) as envVar}
{@const [key, ...valueParts] = envVar.split('=')}
{@const value = valueParts.join('=')}
<div class="text-xs p-2 bg-muted rounded">
{@const diverges = containerData.divergence?.env?.includes(key)}
<div class="text-xs p-2 rounded {diverges ? 'bg-amber-500/10 border border-amber-500/30' : 'bg-muted'}">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
@@ -1170,31 +1258,79 @@
</Tabs.Content>
<!-- Labels Tab -->
<Tabs.Content value="labels" class="space-y-4 overflow-auto">
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
<div class="space-y-1">
{#each Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0])) as [key, value]}
<div class="text-xs p-2 bg-muted rounded flex items-start gap-2 group">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
<Tabs.Content value="labels" class="space-y-3 overflow-auto">
{#if containerData.divergence?.labels?.length > 0}
<div class="flex items-start gap-2 text-xs p-2.5 rounded border border-amber-500/30 bg-amber-500/5 text-amber-700 dark:text-amber-300">
<Info class="w-3.5 h-3.5 shrink-0 mt-0.5" />
<div class="min-w-0">
{containerData.divergence.labels.length} label{containerData.divergence.labels.length === 1 ? '' : 's'} differ from the image:
<span class="font-mono">{containerData.divergence.labels.join(', ')}</span>.
Values set by you at create time will stay. To reset to the image's current values, Remove &amp; Deploy.
</div>
</div>
{/if}
{#if containerData.Config?.Labels && Object.keys(containerData.Config.Labels).length > 0}
{@const allLabels = Object.entries(containerData.Config.Labels).sort((a, b) => a[0].localeCompare(b[0]))}
{@const filter = labelFilter.trim().toLowerCase()}
{@const visibleLabels = filter
? allLabels.filter(([k, v]) => k.toLowerCase().includes(filter) || String(v).toLowerCase().includes(filter))
: allLabels}
<div class="flex items-center gap-2">
<Input
type="search"
placeholder="Filter labels..."
bind:value={labelFilter}
class="h-8 text-xs flex-1"
/>
<span class="text-xs text-muted-foreground shrink-0">
{visibleLabels.length === allLabels.length
? `${allLabels.length} label${allLabels.length === 1 ? '' : 's'}`
: `${visibleLabels.length} of ${allLabels.length}`}
</span>
<Button
variant="outline"
size="sm"
onclick={() => copyAllLabels(visibleLabels)}
disabled={visibleLabels.length === 0}
title={copiedAllLabels ? 'Copied!' : 'Copy visible labels as key=value lines'}
>
{#if copiedAllLabels}
<Check class="w-3 h-3 mr-1.5 text-green-500" />
Copied
{:else}
<Copy class="w-3 h-3 mr-1.5" />
Copy all
{/if}
</Button>
</div>
{#if visibleLabels.length > 0}
<div class="space-y-1">
{#each visibleLabels as [key, value]}
{@const diverges = containerData.divergence?.labels?.includes(key)}
<div class="text-xs p-2 rounded flex items-start gap-2 group {diverges ? 'bg-amber-500/10 border border-amber-500/30' : 'bg-muted'}">
<div class="flex-1 min-w-0">
<code class="text-muted-foreground font-medium">{key}</code>
<code class="text-muted-foreground">=</code>
<code class="break-all">{value}</code>
</div>
<button
type="button"
onclick={() => copyLabel(key, value)}
class="shrink-0 p-1 rounded hover:bg-background/50 transition-colors opacity-0 group-hover:opacity-100 {copiedLabel === key ? '!opacity-100' : ''}"
title={copiedLabel === key ? 'Copied!' : 'Copy label'}
>
{#if copiedLabel === key}
<Check class="w-3 h-3 text-green-500" />
{:else}
<Copy class="w-3 h-3 text-muted-foreground" />
{/if}
</button>
</div>
{/each}
</div>
{:else}
<p class="text-sm text-muted-foreground">No labels match "{labelFilter}"</p>
{/if}
{:else}
<p class="text-sm text-muted-foreground">No labels</p>
{/if}
@@ -1532,13 +1668,13 @@
</Dialog.Content>
</Dialog.Root>
<!-- Raw JSON Modal -->
<!-- Inspect (raw) modal -->
<Dialog.Root bind:open={showRawJson}>
<Dialog.Content class="max-w-4xl max-h-[90vh] sm:max-h-[80vh] flex flex-col">
<Dialog.Header class="shrink-0">
<Dialog.Title class="flex items-center gap-2">
<Code class="w-5 h-5" />
Raw JSON
Inspect
<Button
variant="outline"
size="sm"