fix: fix mobile view + add orphean deletions
This commit is contained in:
@ -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,
|
||||
|
||||
@ -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"><none></span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="text-xs text-slate-600 italic"><none></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"><none></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}
|
||||
|
||||
Reference in New Issue
Block a user