fix: fix mobile view + add orphean deletions

This commit is contained in:
2026-05-20 08:48:14 +02:00
parent b3176c4dfa
commit 35643b2ea9
8 changed files with 1185 additions and 149 deletions

View File

@ -127,6 +127,7 @@ export interface ImageEntry {
tags: string[];
size: number;
created_at: number;
is_orphan: boolean;
}
export interface VolumeEntry {
@ -137,6 +138,7 @@ export interface VolumeEntry {
name: string;
driver: string;
mountpoint: string;
is_orphan: boolean;
}
export interface NetworkEntry {
@ -148,6 +150,7 @@ export interface NetworkEntry {
name: string;
driver: string;
scope: string;
is_orphan: boolean;
}
export async function fetchImages(): Promise<ImageEntry[]> {
@ -186,6 +189,27 @@ export async function fetchNetworks(): Promise<NetworkEntry[]> {
}
}
export async function deleteImage(agentId: string, imageId: string, force = true): Promise<void> {
const r = await apiFetch(`${BASE}/agents/${agentId}/images/${encodeURIComponent(imageId)}?force=${force}`, {
method: "DELETE",
});
if (!r.ok) throw new Error(`deleteImage: ${r.status}`);
}
export async function deleteVolume(agentId: string, volumeName: string): Promise<void> {
const r = await apiFetch(`${BASE}/agents/${agentId}/volumes/${encodeURIComponent(volumeName)}`, {
method: "DELETE",
});
if (!r.ok) throw new Error(`deleteVolume: ${r.status}`);
}
export async function deleteNetwork(agentId: string, networkId: string): Promise<void> {
const r = await apiFetch(`${BASE}/agents/${agentId}/networks/${encodeURIComponent(networkId)}`, {
method: "DELETE",
});
if (!r.ok) throw new Error(`deleteNetwork: ${r.status}`);
}
export function connectLogs(
agentId: string,
containerId: string,

View File

@ -16,6 +16,9 @@
getAutoUpdatePolicy,
setAutoUpdatePolicy,
updateNow,
deleteImage,
deleteVolume,
deleteNetwork,
type ContainerEntry,
type ContainerPort,
type ImageEntry,
@ -70,6 +73,106 @@
let collapsedNetworks= $state<Record<string, boolean>>({});
let collapsedProjects = $state<Record<string, boolean>>({});
let projectActionPending = $state<string | null>(null); // `${agentId}/${projectName}`
let openKebab = $state<string | null>(null);
// ── Orphan / delete state ─────────────────────────────────────────────────
let deletePending = $state<string | null>(null);
let showOrphansOnlyImages = $state(false);
let showOrphansOnlyVolumes = $state(false);
let showOrphansOnlyNetworks = $state(false);
async function doDeleteImage(agentId: string, imageId: string) {
if (!confirm("Supprimer cette image ?")) return;
deletePending = imageId;
try {
await deleteImage(agentId, imageId, true);
showToast("Image supprimée", true);
await loadImages();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
deletePending = null;
}
}
async function doDeleteVolume(agentId: string, volumeName: string) {
if (!confirm(`Supprimer le volume "${volumeName}" ?`)) return;
deletePending = volumeName;
try {
await deleteVolume(agentId, volumeName);
showToast("Volume supprimé", true);
await loadVolumes();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
deletePending = null;
}
}
async function doDeleteNetwork(agentId: string, networkId: string) {
if (!confirm("Supprimer ce réseau ?")) return;
deletePending = networkId;
try {
await deleteNetwork(agentId, networkId);
showToast("Réseau supprimé", true);
await loadNetworks();
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
deletePending = null;
}
}
async function pruneAllImages(agentId: string, agentImages: ImageEntry[]) {
const orphans = agentImages.filter(i => i.is_orphan);
if (orphans.length === 0) return;
if (!confirm(`Supprimer ${orphans.length} image(s) orpheline(s) ?`)) return;
for (const img of orphans) {
deletePending = img.id;
try {
await deleteImage(agentId, img.id, true);
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
}
}
deletePending = null;
showToast("Images orphelines supprimées", true);
await loadImages();
}
async function pruneAllVolumes(agentId: string, agentVolumes: VolumeEntry[]) {
const orphans = agentVolumes.filter(v => v.is_orphan);
if (orphans.length === 0) return;
if (!confirm(`Supprimer ${orphans.length} volume(s) orphelin(s) ?`)) return;
for (const vol of orphans) {
deletePending = vol.name;
try {
await deleteVolume(agentId, vol.name);
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
}
}
deletePending = null;
showToast("Volumes orphelins supprimés", true);
await loadVolumes();
}
async function pruneAllNetworks(agentId: string, agentNetworks: NetworkEntry[]) {
const orphans = agentNetworks.filter(n => n.is_orphan);
if (orphans.length === 0) return;
if (!confirm(`Supprimer ${orphans.length} réseau(x) orphelin(s) ?`)) return;
for (const net of orphans) {
deletePending = net.id;
try {
await deleteNetwork(agentId, net.id);
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
}
}
deletePending = null;
showToast("Réseaux orphelins supprimés", true);
await loadNetworks();
}
// Group auto-update
interface GroupAutoUpdateState {
@ -664,24 +767,24 @@
<div class="ml-auto flex items-center gap-1">
{#if activeTab === "containers" && entries !== null}
<span class="text-xs text-slate-500 mr-3 tabular-nums">
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{entries.length} containers · {Object.keys(byAgent).length} hosts
</span>
{:else if activeTab === "images"}
{#if images !== null}
<span class="text-xs text-slate-500 mr-3 tabular-nums">
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{images.length} images · {Object.keys(byAgentImages).length} hosts
</span>
{/if}
{:else if activeTab === "volumes"}
{#if volumes !== null}
<span class="text-xs text-slate-500 mr-3 tabular-nums">
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts
</span>
{/if}
{:else if activeTab === "networks"}
{#if networks !== null}
<span class="text-xs text-slate-500 mr-3 tabular-nums">
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts
</span>
{/if}
@ -729,7 +832,7 @@
<!-- Tab bar -->
<div class="border-b border-white/[0.06] bg-abyss-900/80 px-4 md:px-6">
<nav class="flex max-w-7xl mx-auto -mb-px">
<nav class="flex max-w-7xl mx-auto -mb-px overflow-x-auto">
{#each ([
{ id: "containers", label: "Containers" },
{ id: "images", label: "Images" },
@ -738,7 +841,7 @@
] as { id: Tab; label: string }[]) as tab}
<button
onclick={() => switchTab(tab.id)}
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors
class="px-3 sm:px-4 py-3 text-sm font-medium border-b-2 shrink-0 transition-colors
{activeTab === tab.id
? 'border-cyan-400 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-600'}"
@ -798,10 +901,10 @@
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
<span class="hidden sm:inline font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
<span class="hidden sm:inline font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
@ -838,7 +941,7 @@
<span class="text-xs font-semibold text-slate-300 group-hover/proj:text-slate-100 transition-colors font-mono">
{projectName}
</span>
<span class="text-xs text-slate-600">
<span class="hidden sm:inline text-xs text-slate-600">
{projectContainers.length} container{projectContainers.length !== 1 ? 's' : ''}
</span>
</button>
@ -847,25 +950,68 @@
<div class="flex items-center gap-1 shrink-0">
{@render ActionBtn({ label: "Logs", variant: "cyan", loading: false,
onclick: () => openProjectLogs(agentId, projectName, projectContainers) })}
{@render ActionBtn({ label: "Stop All", variant: "ghost", loading: isProjPending,
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'stop') })}
{@render ActionBtn({ label: "Restart All", variant: "ghost", loading: isProjPending,
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'restart') })}
<!-- Auto-update groupe -->
<button
data-group-autoupdate-btn
onclick={(e) => { e.stopPropagation(); openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
title="Auto-update groupe"
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
{isGroupAutoUpdateOpen || anyAutoUpdateEnabled
? 'bg-violet-500/20 text-violet-300 border-violet-500/40'
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]'}"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<!-- Desktop : boutons complets -->
<div class="hidden sm:flex items-center gap-1">
{@render ActionBtn({ label: "Stop All", variant: "ghost", loading: isProjPending,
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'stop') })}
{@render ActionBtn({ label: "Restart All", variant: "ghost", loading: isProjPending,
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'restart') })}
<button
data-group-autoupdate-btn
onclick={(e) => { e.stopPropagation(); openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
title="Auto-update groupe"
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
{isGroupAutoUpdateOpen || anyAutoUpdateEnabled
? 'bg-violet-500/20 text-violet-300 border-violet-500/40'
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]'}"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<!-- Mobile : kebab menu -->
<div class="relative sm:hidden">
<button
onclick={(e) => { e.stopPropagation(); openKebab = openKebab === projKey ? null : projKey; }}
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]"
title="Plus d'actions"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 5v.01M12 12v.01M12 19v.01" />
</svg>
</button>
{#if openKebab === projKey}
<!-- Overlay pour fermer -->
<button
class="fixed inset-0 z-40"
onclick={() => openKebab = null}
aria-label="Fermer le menu"
></button>
<!-- Dropdown -->
<div class="absolute right-0 top-full mt-1 z-50 min-w-[140px] bg-abyss-800 border border-white/[0.08] rounded-lg shadow-xl overflow-hidden">
<button
class="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-white/[0.06] transition-colors"
onclick={(e) => { e.stopPropagation(); doProjectAction(agentId, projectName, projectContainers, 'stop'); openKebab = null; }}
>Stop All</button>
<button
class="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-white/[0.06] transition-colors"
onclick={(e) => { e.stopPropagation(); doProjectAction(agentId, projectName, projectContainers, 'restart'); openKebab = null; }}
>Restart All</button>
<button
data-group-autoupdate-btn
class="w-full text-left px-3 py-2 text-xs transition-colors border-t border-white/[0.04]
{anyAutoUpdateEnabled ? 'text-violet-300 hover:bg-violet-500/10' : 'text-slate-300 hover:bg-white/[0.06]'}"
onclick={(e) => { e.stopPropagation(); openKebab = null; openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
>Auto-update</button>
</div>
{/if}
</div>
</div>
</div>
@ -1127,77 +1273,156 @@
</div>
{:else}
<!-- Orphans filter toggle -->
<div class="flex items-center justify-end mb-4">
<button
onclick={() => showOrphansOnlyImages = !showOrphansOnlyImages}
class="text-xs px-2.5 py-1 rounded-lg border transition-colors
{showOrphansOnlyImages
? 'bg-red-500/15 text-red-400 border-red-500/30'
: 'bg-white/[0.05] text-slate-400 border-white/[0.08] hover:bg-white/[0.09]'}"
>
{showOrphansOnlyImages ? "Toutes les images" : "Orphelins seulement"}
</button>
</div>
{#each sortedAgentImages as [agentId, agentImages]}
{@const displayedImages = showOrphansOnlyImages ? agentImages.filter(i => i.is_orphan) : agentImages}
{#if displayedImages.length > 0}
{@const first = agentImages[0]}
<section class="mb-8">
<button
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
onclick={() => toggleImages(agentId)}
type="button"
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedImages[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="flex items-center gap-2.5 mb-3 px-1">
<button
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
onclick={() => toggleImages(agentId)}
type="button"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedImages[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="hidden sm:inline font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.ip_address}
<span class="hidden sm:inline font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
</span>
</button>
{#if agentImages.some(i => i.is_orphan)}
<button
onclick={() => pruneAllImages(agentId, agentImages)}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
Prune orphans
</button>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{#if collapsedImages[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<!-- Desktop table -->
<table class="hidden md:table w-full text-sm">
<thead>
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
<th class="px-4 py-3 text-left font-medium">Tags</th>
<th class="px-4 py-3 text-left font-medium">ID</th>
<th class="px-4 py-3 text-left font-medium">Taille</th>
<th class="px-4 py-3 text-left font-medium">Date</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each agentImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
{#each displayedImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors
{img.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<td class="px-4 py-3">
{#if img.tags?.length > 0}
<div class="flex flex-wrap gap-1">
<div class="flex flex-wrap items-center gap-1">
{#if img.tags?.length > 0}
{#each img.tags as tag}
<span class="font-mono text-xs px-1.5 py-0.5 rounded
bg-cyan-400/10 text-cyan-400 border border-cyan-400/20">
{tag}
</span>
{/each}
</div>
{:else}
<span class="text-xs text-slate-600 italic">&lt;none&gt;</span>
{/if}
{:else}
<span class="text-xs text-slate-600 italic">&lt;none&gt;</span>
{/if}
{#if img.is_orphan}
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500">{shortId(img.id)}</td>
<td class="px-4 py-3 text-xs text-slate-400 tabular-nums">{formatSize(img.size)}</td>
<td class="px-4 py-3 text-xs text-slate-500">{formatDate(img.created_at)}</td>
<td class="px-4 py-3 text-right">
{#if img.is_orphan}
<button
onclick={() => doDeleteImage(agentId, img.id)}
disabled={deletePending === img.id}
class="text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === img.id ? "…" : "Delete"}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile cards -->
<div class="md:hidden divide-y divide-white/[0.04]">
{#each displayedImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)}
<div class="p-4 {img.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex flex-wrap items-center gap-1 min-w-0">
{#if img.tags?.length > 0}
{#each img.tags as tag}
<span class="font-mono text-xs px-1.5 py-0.5 rounded bg-cyan-400/10 text-cyan-400 border border-cyan-400/20">{tag}</span>
{/each}
{:else}
<span class="text-xs text-slate-600 italic">&lt;none&gt;</span>
{/if}
{#if img.is_orphan}
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
{#if img.is_orphan}
<button
onclick={() => doDeleteImage(agentId, img.id)}
disabled={deletePending === img.id}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === img.id ? "…" : "Delete"}
</button>
{/if}
</div>
<div class="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span class="font-mono">{shortId(img.id)}</span>
<span class="tabular-nums text-slate-400">{formatSize(img.size)}</span>
<span>{formatDate(img.created_at)}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
{/each}
{/if}
@ -1227,63 +1452,139 @@
</div>
{:else}
<!-- Orphans filter toggle -->
<div class="flex items-center justify-end mb-4">
<button
onclick={() => showOrphansOnlyVolumes = !showOrphansOnlyVolumes}
class="text-xs px-2.5 py-1 rounded-lg border transition-colors
{showOrphansOnlyVolumes
? 'bg-red-500/15 text-red-400 border-red-500/30'
: 'bg-white/[0.05] text-slate-400 border-white/[0.08] hover:bg-white/[0.09]'}"
>
{showOrphansOnlyVolumes ? "Tous les volumes" : "Orphelins seulement"}
</button>
</div>
{#each sortedAgentVolumes as [agentId, agentVolumes]}
{@const displayedVolumes = showOrphansOnlyVolumes ? agentVolumes.filter(v => v.is_orphan) : agentVolumes}
{#if displayedVolumes.length > 0}
{@const first = agentVolumes[0]}
<section class="mb-8">
<button
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
onclick={() => toggleVolumes(agentId)}
type="button"
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedVolumes[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="flex items-center gap-2.5 mb-3 px-1">
<button
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
onclick={() => toggleVolumes(agentId)}
type="button"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedVolumes[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="hidden sm:inline font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.ip_address}
<span class="hidden sm:inline font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
</span>
</button>
{#if agentVolumes.some(v => v.is_orphan)}
<button
onclick={() => pruneAllVolumes(agentId, agentVolumes)}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
Prune orphans
</button>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{#if collapsedVolumes[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<!-- Desktop table -->
<table class="hidden md:table w-full text-sm">
<thead>
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
<th class="px-4 py-3 text-left font-medium">Nom</th>
<th class="px-4 py-3 text-left font-medium">Driver</th>
<th class="px-4 py-3 text-left font-medium">Mountpoint</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each agentVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
<td class="px-4 py-3 font-mono text-xs text-slate-200 font-medium">{vol.name}</td>
{#each displayedVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors
{vol.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-slate-200 font-medium">{vol.name}</span>
{#if vol.is_orphan}
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
</td>
<td class="px-4 py-3 text-xs text-slate-400">{vol.driver}</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500 max-w-xs truncate"
title={vol.mountpoint}>{vol.mountpoint}</td>
<td class="px-4 py-3 text-right">
{#if vol.is_orphan}
<button
onclick={() => doDeleteVolume(agentId, vol.name)}
disabled={deletePending === vol.name}
class="text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === vol.name ? "…" : "Delete"}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile cards -->
<div class="md:hidden divide-y divide-white/[0.04]">
{#each displayedVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)}
<div class="p-4 {vol.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex items-center gap-2 min-w-0">
<span class="font-mono text-xs font-medium text-slate-200 truncate">{vol.name}</span>
{#if vol.is_orphan}
<span class="shrink-0 text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
{#if vol.is_orphan}
<button
onclick={() => doDeleteVolume(agentId, vol.name)}
disabled={deletePending === vol.name}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === vol.name ? "…" : "Delete"}
</button>
{/if}
</div>
<div class="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span>{vol.driver}</span>
<span class="font-mono truncate">{vol.mountpoint}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
{/each}
{/if}
@ -1313,53 +1614,88 @@
</div>
{:else}
<!-- Orphans filter toggle -->
<div class="flex items-center justify-end mb-4">
<button
onclick={() => showOrphansOnlyNetworks = !showOrphansOnlyNetworks}
class="text-xs px-2.5 py-1 rounded-lg border transition-colors
{showOrphansOnlyNetworks
? 'bg-red-500/15 text-red-400 border-red-500/30'
: 'bg-white/[0.05] text-slate-400 border-white/[0.08] hover:bg-white/[0.09]'}"
>
{showOrphansOnlyNetworks ? "Tous les réseaux" : "Orphelins seulement"}
</button>
</div>
{#each sortedAgentNetworks as [agentId, agentNetworks]}
{@const displayedNetworks = showOrphansOnlyNetworks ? agentNetworks.filter(n => n.is_orphan) : agentNetworks}
{#if displayedNetworks.length > 0}
{@const first = agentNetworks[0]}
<section class="mb-8">
<button
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
onclick={() => toggleNetworks(agentId)}
type="button"
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedNetworks[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="flex items-center gap-2.5 mb-3 px-1">
<button
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
onclick={() => toggleNetworks(agentId)}
type="button"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedNetworks[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="hidden sm:inline font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.ip_address}
<span class="hidden sm:inline font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
</span>
</button>
{#if agentNetworks.some(n => n.is_orphan)}
<button
onclick={() => pruneAllNetworks(agentId, agentNetworks)}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
Prune orphans
</button>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
</span>
</button>
</div>
{#if collapsedNetworks[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<!-- Desktop table -->
<table class="hidden md:table w-full text-sm">
<thead>
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
<th class="px-4 py-3 text-left font-medium">Nom</th>
<th class="px-4 py-3 text-left font-medium">Driver</th>
<th class="px-4 py-3 text-left font-medium">Scope</th>
<th class="px-4 py-3 text-left font-medium">ID</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each agentNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
<td class="px-4 py-3 font-mono text-xs text-slate-200 font-medium">{net.name}</td>
{#each displayedNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors
{net.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-slate-200 font-medium">{net.name}</span>
{#if net.is_orphan}
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
</td>
<td class="px-4 py-3 text-xs text-slate-400">{net.driver}</td>
<td class="px-4 py-3 text-xs">
<span class="px-1.5 py-0.5 rounded text-xs
@ -1370,14 +1706,59 @@
</span>
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-600">{shortId(net.id)}</td>
<td class="px-4 py-3 text-right">
{#if net.is_orphan}
<button
onclick={() => doDeleteNetwork(agentId, net.id)}
disabled={deletePending === net.id}
class="text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === net.id ? "…" : "Delete"}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile cards -->
<div class="md:hidden divide-y divide-white/[0.04]">
{#each displayedNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)}
<div class="p-4 {net.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex items-center gap-2 min-w-0">
<span class="font-mono text-xs font-medium text-slate-200 truncate">{net.name}</span>
{#if net.is_orphan}
<span class="shrink-0 text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
{#if net.is_orphan}
<button
onclick={() => doDeleteNetwork(agentId, net.id)}
disabled={deletePending === net.id}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === net.id ? "…" : "Delete"}
</button>
{/if}
</div>
<div class="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span>{net.driver}</span>
<span class="px-1.5 py-0.5 rounded
{net.scope === 'local'
? 'bg-slate-700/60 text-slate-400'
: 'bg-cyan-400/10 text-cyan-400 border border-cyan-400/20'}">{net.scope}</span>
<span class="font-mono">{shortId(net.id)}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
{/each}
{/if}
{/if}