feat: add volume, images and networks
This commit is contained in:
@ -118,6 +118,71 @@ export async function containerAction(
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export interface ImageEntry {
|
||||
agent_id: string;
|
||||
hostname: string;
|
||||
alias: string;
|
||||
id: string;
|
||||
tags: string[];
|
||||
size: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface VolumeEntry {
|
||||
agent_id: string;
|
||||
hostname: string;
|
||||
alias: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
mountpoint: string;
|
||||
}
|
||||
|
||||
export interface NetworkEntry {
|
||||
agent_id: string;
|
||||
hostname: string;
|
||||
alias: string;
|
||||
id: string;
|
||||
name: string;
|
||||
driver: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export async function fetchImages(): Promise<ImageEntry[]> {
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), 8000);
|
||||
try {
|
||||
const r = await apiFetch(`${BASE}/images`, { signal: ac.signal });
|
||||
if (!r.ok) throw new Error(`images: ${r.status}`);
|
||||
return r.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchVolumes(): Promise<VolumeEntry[]> {
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), 8000);
|
||||
try {
|
||||
const r = await apiFetch(`${BASE}/volumes`, { signal: ac.signal });
|
||||
if (!r.ok) throw new Error(`volumes: ${r.status}`);
|
||||
return r.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchNetworks(): Promise<NetworkEntry[]> {
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), 8000);
|
||||
try {
|
||||
const r = await apiFetch(`${BASE}/networks`, { signal: ac.signal });
|
||||
if (!r.ok) throw new Error(`networks: ${r.status}`);
|
||||
return r.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export function connectLogs(
|
||||
agentId: string,
|
||||
containerId: string,
|
||||
|
||||
@ -8,14 +8,21 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
fetchContainers,
|
||||
fetchImages,
|
||||
fetchVolumes,
|
||||
fetchNetworks,
|
||||
containerAction,
|
||||
connectEvents,
|
||||
type ContainerEntry,
|
||||
type ContainerPort,
|
||||
type ImageEntry,
|
||||
type VolumeEntry,
|
||||
type NetworkEntry,
|
||||
} from "$lib/api";
|
||||
import { clearToken } from "$lib/auth";
|
||||
import LogModal from "$lib/LogModal.svelte";
|
||||
|
||||
// ── Logs modal ────────────────────────────────────────────────────────────
|
||||
let logTarget = $state<{ agentId: string; containerId: string; name: string } | null>(null);
|
||||
|
||||
function openLogs(agentId: string, containerId: string, name: string) {
|
||||
@ -27,11 +34,26 @@
|
||||
goto("/login");
|
||||
}
|
||||
|
||||
let entries = $state<ContainerEntry[] | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let actionPending = $state<string | null>(null);
|
||||
let toast = $state<{ msg: string; ok: boolean } | null>(null);
|
||||
// ── Tab state ─────────────────────────────────────────────────────────────
|
||||
type Tab = "containers" | "images" | "volumes" | "networks";
|
||||
let activeTab = $state<Tab>("containers");
|
||||
|
||||
// ── Data ──────────────────────────────────────────────────────────────────
|
||||
let entries = $state<ContainerEntry[] | null>(null);
|
||||
let images = $state<ImageEntry[] | null>(null);
|
||||
let volumes = $state<VolumeEntry[] | null>(null);
|
||||
let networks = $state<NetworkEntry[] | null>(null);
|
||||
let loadError = $state<string | null>(null);
|
||||
let actionPending = $state<string | null>(null);
|
||||
let toast = $state<{ msg: string; ok: boolean } | null>(null);
|
||||
|
||||
// ── Collapsed states (independent per view) ───────────────────────────────
|
||||
let collapsed = $state<Record<string, boolean>>({});
|
||||
let collapsedImages = $state<Record<string, boolean>>({});
|
||||
let collapsedVolumes = $state<Record<string, boolean>>({});
|
||||
let collapsedNetworks= $state<Record<string, boolean>>({});
|
||||
|
||||
// ── Derived: containers grouped by agent ─────────────────────────────────
|
||||
const byAgent = $derived(
|
||||
(entries ?? []).reduce<Record<string, ContainerEntry[]>>((acc, e) => {
|
||||
(acc[e.agent_id] ??= []).push(e);
|
||||
@ -39,7 +61,6 @@
|
||||
}, {})
|
||||
);
|
||||
|
||||
// Tri alphabétique des agents : alias (si défini) sinon hostname, insensible à la casse
|
||||
const sortedAgents = $derived(
|
||||
Object.entries(byAgent).sort(([, a], [, b]) => {
|
||||
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
||||
@ -48,23 +69,55 @@
|
||||
})
|
||||
);
|
||||
|
||||
// État replié/déplié par agent (déplié par défaut)
|
||||
let collapsed = $state<Record<string, boolean>>({});
|
||||
// ── Derived: images grouped by agent ─────────────────────────────────────
|
||||
const byAgentImages = $derived(
|
||||
(images ?? []).reduce<Record<string, ImageEntry[]>>((acc, e) => {
|
||||
(acc[e.agent_id] ??= []).push(e);
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
function toggleSection(agentId: string) {
|
||||
collapsed[agentId] = !collapsed[agentId];
|
||||
}
|
||||
const sortedAgentImages = $derived(
|
||||
Object.entries(byAgentImages).sort(([, a], [, b]) => {
|
||||
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
||||
const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase();
|
||||
return labelA.localeCompare(labelB);
|
||||
})
|
||||
);
|
||||
|
||||
// Pastille de statut pour un agent selon l'état de ses containers
|
||||
function agentDotClass(containers: ContainerEntry[]): string {
|
||||
if (containers.length === 0) return "dot-offline";
|
||||
const running = containers.filter(c => c.container.state === "running").length;
|
||||
if (running === containers.length) return "dot-running";
|
||||
if (running === 0) return "dot-exited";
|
||||
return "dot-other";
|
||||
}
|
||||
// ── Derived: volumes grouped by agent ────────────────────────────────────
|
||||
const byAgentVolumes = $derived(
|
||||
(volumes ?? []).reduce<Record<string, VolumeEntry[]>>((acc, e) => {
|
||||
(acc[e.agent_id] ??= []).push(e);
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
// PWA install prompt
|
||||
const sortedAgentVolumes = $derived(
|
||||
Object.entries(byAgentVolumes).sort(([, a], [, b]) => {
|
||||
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
||||
const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase();
|
||||
return labelA.localeCompare(labelB);
|
||||
})
|
||||
);
|
||||
|
||||
// ── Derived: networks grouped by agent ───────────────────────────────────
|
||||
const byAgentNetworks = $derived(
|
||||
(networks ?? []).reduce<Record<string, NetworkEntry[]>>((acc, e) => {
|
||||
(acc[e.agent_id] ??= []).push(e);
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const sortedAgentNetworks = $derived(
|
||||
Object.entries(byAgentNetworks).sort(([, a], [, b]) => {
|
||||
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
||||
const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase();
|
||||
return labelA.localeCompare(labelB);
|
||||
})
|
||||
);
|
||||
|
||||
// ── PWA install prompt ────────────────────────────────────────────────────
|
||||
let installPrompt = $state<BeforeInstallPromptEvent | null>(null);
|
||||
|
||||
function onBeforeInstallPrompt(e: Event) {
|
||||
@ -83,18 +136,61 @@
|
||||
installPrompt = null;
|
||||
}
|
||||
|
||||
// ── WebSocket ─────────────────────────────────────────────────────────────
|
||||
let disconnect: (() => void) | null = null;
|
||||
|
||||
// ── Load functions ────────────────────────────────────────────────────────
|
||||
async function load() {
|
||||
loadError = null;
|
||||
try {
|
||||
entries = await fetchContainers() ?? [];
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} catch (e: unknown) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadImages() {
|
||||
try {
|
||||
images = await fetchImages() ?? [];
|
||||
} catch (e: unknown) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
images = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVolumes() {
|
||||
try {
|
||||
volumes = await fetchVolumes() ?? [];
|
||||
} catch (e: unknown) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
volumes = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNetworks() {
|
||||
try {
|
||||
networks = await fetchNetworks() ?? [];
|
||||
} catch (e: unknown) {
|
||||
loadError = e instanceof Error ? e.message : String(e);
|
||||
networks = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveTab() {
|
||||
if (activeTab === "containers") await load();
|
||||
else if (activeTab === "images") await loadImages();
|
||||
else if (activeTab === "volumes") await loadVolumes();
|
||||
else if (activeTab === "networks") await loadNetworks();
|
||||
}
|
||||
|
||||
async function switchTab(tab: Tab) {
|
||||
activeTab = tab;
|
||||
loadError = null;
|
||||
await loadActiveTab();
|
||||
}
|
||||
|
||||
// ── Container actions ─────────────────────────────────────────────────────
|
||||
async function doAction(
|
||||
agentId: string,
|
||||
containerId: string,
|
||||
@ -105,8 +201,8 @@
|
||||
await containerAction(agentId, containerId, action);
|
||||
showToast(`${action} envoyé`, true);
|
||||
setTimeout(load, 1500);
|
||||
} catch (e: any) {
|
||||
showToast(e.message, false);
|
||||
} catch (e: unknown) {
|
||||
showToast(e instanceof Error ? e.message : String(e), false);
|
||||
} finally {
|
||||
actionPending = null;
|
||||
}
|
||||
@ -117,13 +213,25 @@
|
||||
setTimeout(() => (toast = null), 3000);
|
||||
}
|
||||
|
||||
// ── Toggle helpers ────────────────────────────────────────────────────────
|
||||
function toggleSection(agentId: string) { collapsed[agentId] = !(collapsed[agentId] ?? true); }
|
||||
function toggleImages(agentId: string) { collapsedImages[agentId] = !(collapsedImages[agentId] ?? true); }
|
||||
function toggleVolumes(agentId: string) { collapsedVolumes[agentId] = !(collapsedVolumes[agentId] ?? true); }
|
||||
function toggleNetworks(agentId: string) { collapsedNetworks[agentId] = !(collapsedNetworks[agentId] ?? true); }
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
load();
|
||||
disconnect = connectEvents((evt) => {
|
||||
if (evt.type === "containers.updated") load();
|
||||
if (evt.type === "agent.connected" || evt.type === "agent.disconnected") load();
|
||||
if (evt.type === "containers.updated" || evt.type === "agent.connected" || evt.type === "agent.disconnected") {
|
||||
if (activeTab === "containers") load();
|
||||
}
|
||||
if (evt.type === "resources.updated") {
|
||||
if (activeTab === "images") loadImages();
|
||||
if (activeTab === "volumes") loadVolumes();
|
||||
if (activeTab === "networks") loadNetworks();
|
||||
}
|
||||
});
|
||||
// Récupère le prompt capturé tôt dans app.html avant que onMount soit prêt
|
||||
if ((window as any).__installPrompt) {
|
||||
installPrompt = (window as any).__installPrompt;
|
||||
(window as any).__installPrompt = null;
|
||||
@ -138,6 +246,7 @@
|
||||
window.removeEventListener("appinstalled", onAppInstalled);
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function uniquePorts(ports: ContainerPort[] | null) {
|
||||
const seen = new Set<string>();
|
||||
return (ports ?? []).filter(p => {
|
||||
@ -151,15 +260,39 @@
|
||||
|
||||
function stateDotClass(state: string) {
|
||||
if (state === "running") return "dot-running";
|
||||
if (state === "exited") return "dot-exited";
|
||||
if (state === "exited") return "dot-exited";
|
||||
return "dot-other";
|
||||
}
|
||||
|
||||
function stateTextClass(state: string) {
|
||||
if (state === "running") return "text-signal-green";
|
||||
if (state === "exited") return "text-signal-red";
|
||||
if (state === "exited") return "text-signal-red";
|
||||
return "text-signal-yellow";
|
||||
}
|
||||
|
||||
function agentDotClass(containers: ContainerEntry[]): string {
|
||||
if (containers.length === 0) return "dot-offline";
|
||||
const running = containers.filter(c => c.container.state === "running").length;
|
||||
if (running === containers.length) return "dot-running";
|
||||
if (running === 0) return "dot-exited";
|
||||
return "dot-other";
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||
}
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString("fr-FR");
|
||||
}
|
||||
|
||||
function shortId(id: string): string {
|
||||
// sha256:abc... → take 12 chars after the colon (or from start)
|
||||
const bare = id.startsWith("sha256:") ? id.slice(7) : id;
|
||||
return bare.slice(0, 12);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@ -197,10 +330,22 @@
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
{#if entries !== null}
|
||||
{#if activeTab === "containers" && entries !== null}
|
||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||
{entries.length} containers · {Object.keys(byAgent).length} hosts
|
||||
</span>
|
||||
{:else if activeTab === "images" && images !== null}
|
||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||
{images.length} images · {Object.keys(byAgentImages).length} hosts
|
||||
</span>
|
||||
{:else if activeTab === "volumes" && volumes !== null}
|
||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||
{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts
|
||||
</span>
|
||||
{:else if activeTab === "networks" && networks !== null}
|
||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||
{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if installPrompt}
|
||||
@ -220,7 +365,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<button onclick={load} class="nav-btn" title="Actualiser">
|
||||
<button onclick={loadActiveTab} class="nav-btn" title="Actualiser">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
|
||||
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" />
|
||||
@ -236,182 +381,479 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 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">
|
||||
{#each ([
|
||||
{ id: "containers", label: "Containers" },
|
||||
{ id: "images", label: "Images" },
|
||||
{ id: "volumes", label: "Volumes" },
|
||||
{ id: "networks", label: "Networks" },
|
||||
] 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
|
||||
{activeTab === tab.id
|
||||
? 'border-cyan-400 text-cyan-400'
|
||||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-600'}"
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<main class="p-4 md:p-6 max-w-7xl mx-auto">
|
||||
|
||||
{#if entries === null}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||
<div class="w-8 h-8 border-2 border-emerald/30 border-t-emerald-bright rounded-full animate-spin"></div>
|
||||
<span class="text-sm">Chargement…</span>
|
||||
</div>
|
||||
{#key activeTab}
|
||||
<!-- ═══════════════════════════════════════════════════
|
||||
CONTAINERS TAB
|
||||
════════════════════════════════════════════════════ -->
|
||||
{#if activeTab === "containers"}
|
||||
{#if entries === null}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||
<div class="w-8 h-8 border-2 border-emerald/30 border-t-emerald-bright rounded-full animate-spin"></div>
|
||||
<span class="text-sm">Chargement…</span>
|
||||
</div>
|
||||
|
||||
{:else if loadError}
|
||||
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||
<span class="text-signal-red text-xl">⚠</span>
|
||||
<p class="text-signal-red text-sm">{loadError}</p>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||
<span class="text-signal-red text-xl">⚠</span>
|
||||
<p class="text-signal-red text-sm">{loadError}</p>
|
||||
</div>
|
||||
|
||||
{:else if Object.keys(byAgent).length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<span class="text-sm">Aucun agent connecté</span>
|
||||
</div>
|
||||
{:else if Object.keys(byAgent).length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
<span class="text-sm">Aucun agent connecté</span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#each sortedAgents as [agentId, containers]}
|
||||
{#if containers.length > 0}
|
||||
{@const first = containers[0]}
|
||||
{@const isCollapsed = collapsed[agentId] ?? false}
|
||||
<section class="mb-8">
|
||||
{:else}
|
||||
{#each sortedAgents as [agentId, containers]}
|
||||
{#if containers.length > 0}
|
||||
{@const first = containers[0]}
|
||||
{@const isCollapsed = collapsed[agentId] ?? true}
|
||||
<section class="mb-8">
|
||||
|
||||
<!-- Host header (cliquable pour replier/déplier) -->
|
||||
<button
|
||||
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||
onclick={() => toggleSection(agentId)}
|
||||
type="button"
|
||||
>
|
||||
<!-- Chevron -->
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
<button
|
||||
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||
onclick={() => toggleSection(agentId)}
|
||||
type="button"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
<!-- Pastille de statut dynamique -->
|
||||
<span class={agentDotClass(containers)}></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>
|
||||
{/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">
|
||||
{containers.length} container{containers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
<svg
|
||||
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||
{isCollapsed ? '-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={agentDotClass(containers)}></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>
|
||||
{/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">
|
||||
{containers.length} container{containers.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if !isCollapsed}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden md:block card overflow-hidden">
|
||||
<table class="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">Image</th>
|
||||
<th class="px-4 py-3 text-left font-medium">État</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Ports</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Projet</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each containers as { agent_id, container } (container.id)}
|
||||
<tr class="border-b border-white/[0.04] last:border-0
|
||||
hover:bg-white/[0.025] transition-colors group">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class={stateDotClass(container.state)}></span>
|
||||
<span class="font-mono text-slate-200 text-xs font-medium">{container.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500 max-w-[220px] truncate"
|
||||
title={container.image}>{container.image}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-medium {stateTextClass(container.state)}">{container.state}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each uniquePorts(container.ports) as port}
|
||||
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
||||
bg-signal-cyan/10 text-signal-cyan border border-signal-cyan/20">
|
||||
{port.host_port}:{port.container_port}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600 font-mono">
|
||||
{container.compose_project || "—"}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-end gap-1.5">
|
||||
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||
loading: false,
|
||||
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
||||
{#if container.state !== "running"}
|
||||
{@render ActionBtn({ label: "Start", variant: "green",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "start") })}
|
||||
{:else}
|
||||
{@render ActionBtn({ label: "Stop", variant: "ghost",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "stop") })}
|
||||
{@render ActionBtn({ label: "Restart", variant: "ghost",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{#if !isCollapsed}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden md:block card overflow-hidden">
|
||||
<table class="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">Image</th>
|
||||
<th class="px-4 py-3 text-left font-medium">État</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Ports</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Projet</th>
|
||||
<th class="px-4 py-3 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each containers as { agent_id, container } (container.id)}
|
||||
<tr class="border-b border-white/[0.04] last:border-0
|
||||
hover:bg-white/[0.025] transition-colors group">
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class={stateDotClass(container.state)}></span>
|
||||
<span class="font-mono text-slate-200 text-xs font-medium">{container.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500 max-w-[220px] truncate"
|
||||
title={container.image}>{container.image}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-xs font-medium {stateTextClass(container.state)}">{container.state}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each uniquePorts(container.ports) as port}
|
||||
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
||||
bg-signal-cyan/10 text-signal-cyan border border-signal-cyan/20">
|
||||
{port.host_port}:{port.container_port}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600 font-mono">
|
||||
{container.compose_project || "—"}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex justify-end gap-1.5">
|
||||
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||
loading: false,
|
||||
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
||||
{#if container.state !== "running"}
|
||||
{@render ActionBtn({ label: "Start", variant: "green",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "start") })}
|
||||
{:else}
|
||||
{@render ActionBtn({ label: "Stop", variant: "ghost",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "stop") })}
|
||||
{@render ActionBtn({ label: "Restart", variant: "ghost",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="md:hidden space-y-2">
|
||||
{#each containers as { agent_id, container } (container.id)}
|
||||
<div class="card p-4">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class={stateDotClass(container.state)}></span>
|
||||
<span class="font-mono text-sm font-medium truncate text-slate-200">{container.name}</span>
|
||||
<!-- Mobile cards -->
|
||||
<div class="md:hidden space-y-2">
|
||||
{#each containers as { agent_id, container } (container.id)}
|
||||
<div class="card p-4">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class={stateDotClass(container.state)}></span>
|
||||
<span class="font-mono text-sm font-medium truncate text-slate-200">{container.name}</span>
|
||||
</div>
|
||||
<span class="text-xs font-medium shrink-0 {stateTextClass(container.state)}">{container.state}</span>
|
||||
</div>
|
||||
<span class="text-xs font-medium shrink-0 {stateTextClass(container.state)}">{container.state}</span>
|
||||
</div>
|
||||
<p class="font-mono text-xs text-slate-600 truncate mb-3">{container.image}</p>
|
||||
{#if uniquePorts(container.ports).length > 0}
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
{#each uniquePorts(container.ports) as port}
|
||||
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
||||
bg-signal-cyan/10 text-signal-cyan border border-signal-cyan/20">
|
||||
{port.host_port}:{port.container_port}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||
loading: false,
|
||||
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
||||
{#if container.state !== "running"}
|
||||
{@render ActionBtn({ label: "Start", variant: "green",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "start") })}
|
||||
{:else}
|
||||
{@render ActionBtn({ label: "Stop", variant: "ghost",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "stop") })}
|
||||
{@render ActionBtn({ label: "Restart", variant: "ghost",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||
<p class="font-mono text-xs text-slate-600 truncate mb-3">{container.image}</p>
|
||||
{#if uniquePorts(container.ports).length > 0}
|
||||
<div class="flex flex-wrap gap-1 mb-3">
|
||||
{#each uniquePorts(container.ports) as port}
|
||||
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
||||
bg-signal-cyan/10 text-signal-cyan border border-signal-cyan/20">
|
||||
{port.host_port}:{port.container_port}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||
loading: false,
|
||||
onclick: () => openLogs(agent_id, container.id, container.name) })}
|
||||
{#if container.state !== "running"}
|
||||
{@render ActionBtn({ label: "Start", variant: "green",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "start") })}
|
||||
{:else}
|
||||
{@render ActionBtn({ label: "Stop", variant: "ghost",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "stop") })}
|
||||
{@render ActionBtn({ label: "Restart", variant: "ghost",
|
||||
loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════
|
||||
IMAGES TAB
|
||||
════════════════════════════════════════════════════ -->
|
||||
{:else if activeTab === "images"}
|
||||
{#if images === null}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||
<div class="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin"></div>
|
||||
<span class="text-sm">Chargement…</span>
|
||||
</div>
|
||||
|
||||
{:else if loadError}
|
||||
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||
<span class="text-signal-red text-xl">⚠</span>
|
||||
<p class="text-signal-red text-sm">{loadError}</p>
|
||||
</div>
|
||||
|
||||
{:else if sortedAgentImages.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="text-sm">Aucune image disponible</span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#each sortedAgentImages as [agentId, agentImages]}
|
||||
{@const first = agentImages[0]}
|
||||
{@const isCollapsed = collapsedImages[agentId] ?? true}
|
||||
<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
|
||||
{isCollapsed ? '-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="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||
{/if}
|
||||
<span class="text-xs text-slate-600 ml-auto">
|
||||
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if !isCollapsed}
|
||||
<div class="card overflow-hidden">
|
||||
<table class="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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each agentImages as img (img.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">
|
||||
{#if img.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#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}
|
||||
</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>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════
|
||||
VOLUMES TAB
|
||||
════════════════════════════════════════════════════ -->
|
||||
{:else if activeTab === "volumes"}
|
||||
{#if volumes === null}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||
<div class="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin"></div>
|
||||
<span class="text-sm">Chargement…</span>
|
||||
</div>
|
||||
|
||||
{:else if loadError}
|
||||
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||
<span class="text-signal-red text-xl">⚠</span>
|
||||
<p class="text-signal-red text-sm">{loadError}</p>
|
||||
</div>
|
||||
|
||||
{:else if sortedAgentVolumes.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<span class="text-sm">Aucun volume disponible</span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#each sortedAgentVolumes as [agentId, agentVolumes]}
|
||||
{@const first = agentVolumes[0]}
|
||||
{@const isCollapsed = collapsedVolumes[agentId] ?? true}
|
||||
<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
|
||||
{isCollapsed ? '-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="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||
{/if}
|
||||
<span class="text-xs text-slate-600 ml-auto">
|
||||
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if !isCollapsed}
|
||||
<div class="card overflow-hidden">
|
||||
<table class="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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each agentVolumes 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>
|
||||
<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>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════
|
||||
NETWORKS TAB
|
||||
════════════════════════════════════════════════════ -->
|
||||
{:else if activeTab === "networks"}
|
||||
{#if networks === null}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||
<div class="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin"></div>
|
||||
<span class="text-sm">Chargement…</span>
|
||||
</div>
|
||||
|
||||
{:else if loadError}
|
||||
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||
<span class="text-signal-red text-xl">⚠</span>
|
||||
<p class="text-signal-red text-sm">{loadError}</p>
|
||||
</div>
|
||||
|
||||
{:else if sortedAgentNetworks.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18" />
|
||||
</svg>
|
||||
<span class="text-sm">Aucun réseau disponible</span>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{#each sortedAgentNetworks as [agentId, agentNetworks]}
|
||||
{@const first = agentNetworks[0]}
|
||||
{@const isCollapsed = collapsedNetworks[agentId] ?? true}
|
||||
<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
|
||||
{isCollapsed ? '-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="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||
{/if}
|
||||
<span class="text-xs text-slate-600 ml-auto">
|
||||
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{#if !isCollapsed}
|
||||
<div class="card overflow-hidden">
|
||||
<table class="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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each agentNetworks 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>
|
||||
<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
|
||||
{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>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-600">{shortId(net.id)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user