1.0.32
This commit is contained in:
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 & 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 & 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"
|
||||
|
||||
Reference in new issue
Block a user