feat: add first page with auth and containers list and agents
This commit is contained in:
351
web/src/routes/+page.svelte
Normal file
351
web/src/routes/+page.svelte
Normal file
@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
fetchContainers,
|
||||
containerAction,
|
||||
connectEvents,
|
||||
type ContainerEntry,
|
||||
type ContainerPort,
|
||||
} from "$lib/api";
|
||||
import { clearToken } from "$lib/auth";
|
||||
import LogModal from "$lib/LogModal.svelte";
|
||||
|
||||
let logTarget = $state<{ agentId: string; containerId: string; name: string } | null>(null);
|
||||
|
||||
function openLogs(agentId: string, containerId: string, name: string) {
|
||||
logTarget = { agentId, containerId, name };
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearToken();
|
||||
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);
|
||||
|
||||
const byAgent = $derived(
|
||||
(entries ?? []).reduce<Record<string, ContainerEntry[]>>((acc, e) => {
|
||||
(acc[e.agent_id] ??= []).push(e);
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
||||
let disconnect: (() => void) | null = null;
|
||||
|
||||
async function load() {
|
||||
loadError = null;
|
||||
try {
|
||||
entries = await fetchContainers() ?? [];
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
entries = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function doAction(
|
||||
agentId: string,
|
||||
containerId: string,
|
||||
action: "start" | "stop" | "restart" | "remove"
|
||||
) {
|
||||
actionPending = containerId;
|
||||
try {
|
||||
await containerAction(agentId, containerId, action);
|
||||
showToast(`${action} envoyé`, true);
|
||||
setTimeout(load, 1500);
|
||||
} catch (e: any) {
|
||||
showToast(e.message, false);
|
||||
} finally {
|
||||
actionPending = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg: string, ok: boolean) {
|
||||
toast = { msg, ok };
|
||||
setTimeout(() => (toast = null), 3000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
disconnect = connectEvents((evt) => {
|
||||
if (evt.type === "containers.updated") load();
|
||||
if (evt.type === "agent.connected" || evt.type === "agent.disconnected") load();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => disconnect?.());
|
||||
|
||||
function uniquePorts(ports: ContainerPort[] | null) {
|
||||
const seen = new Set<string>();
|
||||
return (ports ?? []).filter(p => {
|
||||
if (p.host_port <= 0) return false;
|
||||
const key = `${p.host_port}:${p.container_port}:${p.protocol}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function stateDotClass(state: string) {
|
||||
if (state === "running") return "dot-running";
|
||||
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";
|
||||
return "text-signal-yellow";
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Containarr</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if logTarget}
|
||||
<LogModal
|
||||
agentId={logTarget.agentId}
|
||||
containerId={logTarget.containerId}
|
||||
containerName={logTarget.name}
|
||||
onClose={() => (logTarget = null)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Toast -->
|
||||
{#if toast}
|
||||
<div class="fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm
|
||||
font-medium shadow-2xl border transition-all
|
||||
{toast.ok
|
||||
? 'bg-abyss-800 border-signal-green/30 text-signal-green'
|
||||
: 'bg-abyss-800 border-signal-red/30 text-signal-red'}">
|
||||
<span class="w-1.5 h-1.5 rounded-full {toast.ok ? 'bg-signal-green' : 'bg-signal-red'}"></span>
|
||||
{toast.msg}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img src="/icon-192.png" alt="Containarr" class="w-6 h-6 rounded-md" />
|
||||
<span class="font-semibold text-slate-100 tracking-tight">Containarr</span>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
{#if entries !== null}
|
||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||
{entries.length} containers · {Object.keys(byAgent).length} hosts
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<a href="/admin" class="nav-btn" title="Administration">
|
||||
<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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<button onclick={load} 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" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button onclick={logout} class="nav-btn" title="Déconnexion">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
|
||||
{: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}
|
||||
{#each Object.entries(byAgent) as [_agentId, containers]}
|
||||
{#if containers.length > 0}
|
||||
{@const first = containers[0]}
|
||||
<section class="mb-8">
|
||||
|
||||
<!-- Host header -->
|
||||
<div class="flex items-center gap-2.5 mb-3 px-1">
|
||||
<span class="dot-online"></span>
|
||||
<h2 class="font-semibold text-slate-200 text-sm">
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
</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") })}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{#snippet ActionBtn({ label, variant, loading, onclick }: {
|
||||
label: string;
|
||||
variant: "green" | "ghost" | "cyan";
|
||||
loading: boolean;
|
||||
onclick: () => void;
|
||||
})}
|
||||
<button
|
||||
{onclick}
|
||||
disabled={loading}
|
||||
class="px-2.5 py-1 rounded-lg text-xs font-medium transition-all disabled:opacity-40
|
||||
{variant === 'green'
|
||||
? 'bg-signal-green/10 hover:bg-signal-green/20 text-signal-green border border-signal-green/25'
|
||||
: variant === 'cyan'
|
||||
? 'bg-signal-cyan/10 hover:bg-signal-cyan/20 text-signal-cyan border border-signal-cyan/25'
|
||||
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-400 border border-white/[0.08]'}"
|
||||
>
|
||||
{loading ? "…" : label}
|
||||
</button>
|
||||
{/snippet}
|
||||
Reference in New Issue
Block a user