feat: add projects groups

This commit is contained in:
2026-05-19 16:39:35 +02:00
parent ba4de62a34
commit d1db386e1d
2 changed files with 535 additions and 134 deletions

View File

@ -4,25 +4,42 @@
let { let {
agentId, agentId,
containerId, containerId = '',
containerName, containerName = '',
containers = [] as Array<{id: string; name: string}>,
projectName = '',
onClose, onClose,
}: { }: {
agentId: string; agentId: string;
containerId: string; containerId?: string;
containerName: string; containerName?: string;
containers?: Array<{id: string; name: string}>;
projectName?: string;
onClose: () => void; onClose: () => void;
} = $props(); } = $props();
interface LogLine { interface LogLine {
stream: string; stream: string;
line: string; line: string;
prefix?: string;
prefixColor?: string;
} }
const PREFIX_COLORS = [
'text-cyan-400',
'text-green-400',
'text-yellow-400',
'text-orange-400',
'text-pink-400',
'text-violet-400',
'text-sky-400',
'text-lime-400',
];
let lines = $state<LogLine[]>([]); let lines = $state<LogLine[]>([]);
let autoScroll = $state(true); let autoScroll = $state(true);
let logEl = $state<HTMLElement | null>(null); let logEl = $state<HTMLElement | null>(null);
let disconnect: (() => void) | null = null; let disconnects: Array<() => void> = [];
function stripAnsi(str: string): string { function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
@ -41,17 +58,38 @@
autoScroll = atBottom; autoScroll = atBottom;
} }
const isMulti = $derived(containers.length > 0);
const title = $derived(isMulti ? projectName : containerName);
onMount(() => { onMount(() => {
disconnect = connectLogs(agentId, containerId, async (msg) => { if (isMulti) {
if (msg.line) { containers.forEach((c, idx) => {
lines.push({ stream: msg.stream, line: stripAnsi(msg.line) }); const prefix = c.name;
if (lines.length > 2000) lines = lines.slice(-2000); const prefixColor = PREFIX_COLORS[idx % PREFIX_COLORS.length];
await scrollToBottom(); const dc = connectLogs(agentId, c.id, async (msg) => {
} if (msg.line) {
}); lines.push({ stream: msg.stream, line: stripAnsi(msg.line), prefix, prefixColor });
if (lines.length > 2000) lines = lines.slice(-2000);
await scrollToBottom();
}
});
disconnects.push(dc);
});
} else {
const dc = connectLogs(agentId, containerId, async (msg) => {
if (msg.line) {
lines.push({ stream: msg.stream, line: stripAnsi(msg.line) });
if (lines.length > 2000) lines = lines.slice(-2000);
await scrollToBottom();
}
});
disconnects.push(dc);
}
}); });
onDestroy(() => disconnect?.()); onDestroy(() => {
for (const dc of disconnects) dc();
});
function handleKey(e: KeyboardEvent) { function handleKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@ -65,7 +103,7 @@
class="fixed inset-0 z-50 flex flex-col bg-black/70 backdrop-blur-sm" class="fixed inset-0 z-50 flex flex-col bg-black/70 backdrop-blur-sm"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Logs — {containerName}" aria-label="Logs — {title}"
> >
<!-- Modal panel --> <!-- Modal panel -->
<div class="flex flex-col m-4 md:m-8 flex-1 min-h-0 card overflow-hidden"> <div class="flex flex-col m-4 md:m-8 flex-1 min-h-0 card overflow-hidden">
@ -73,7 +111,7 @@
<!-- Header --> <!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-white/[0.07] shrink-0"> <div class="flex items-center gap-3 px-4 py-3 border-b border-white/[0.07] shrink-0">
<span class="w-2 h-2 rounded-full bg-signal-green animate-pulse"></span> <span class="w-2 h-2 rounded-full bg-signal-green animate-pulse"></span>
<span class="font-mono text-sm font-semibold text-slate-200">{containerName}</span> <span class="font-mono text-sm font-semibold text-slate-200">{title}</span>
<span class="text-xs text-slate-600 ml-1">logs</span> <span class="text-xs text-slate-600 ml-1">logs</span>
<div class="ml-auto flex items-center gap-3"> <div class="ml-auto flex items-center gap-3">
@ -109,9 +147,12 @@
{#if lines.length === 0} {#if lines.length === 0}
<p class="text-slate-700 italic">En attente des logs…</p> <p class="text-slate-700 italic">En attente des logs…</p>
{:else} {:else}
{#each lines as { stream, line }, i (i)} {#each lines as { stream, line, prefix, prefixColor }, i (i)}
<div class="whitespace-pre-wrap break-all {stream === 'stderr' ? 'text-signal-red/80' : 'text-slate-300'}"> <div class="whitespace-pre-wrap break-all flex {stream === 'stderr' ? 'text-signal-red/80' : 'text-slate-300'}">
{line} {#if prefix}
<span class="{prefixColor} mr-1 opacity-80 shrink-0">[{prefix}]</span>
{/if}
<span>{line}</span>
</div> </div>
{/each} {/each}
{/if} {/if}

View File

@ -27,10 +27,22 @@
import LogModal from "$lib/LogModal.svelte"; import LogModal from "$lib/LogModal.svelte";
// ── Logs modal ──────────────────────────────────────────────────────────── // ── Logs modal ────────────────────────────────────────────────────────────
let logTarget = $state<{ agentId: string; containerId: string; name: string } | null>(null); type LogTarget =
| { mode: 'single'; agentId: string; containerId: string; name: string }
| { mode: 'group'; agentId: string; projectName: string; containers: Array<{id: string; name: string}> };
let logTarget = $state<LogTarget | null>(null);
function openLogs(agentId: string, containerId: string, name: string) { function openLogs(agentId: string, containerId: string, name: string) {
logTarget = { agentId, containerId, name }; logTarget = { mode: 'single', agentId, containerId, name };
}
function openProjectLogs(agentId: string, projectName: string, containers: ContainerEntry[]) {
logTarget = {
mode: 'group',
agentId,
projectName,
containers: containers.map(e => ({ id: e.container.id, name: e.container.name })),
};
} }
function logout() { function logout() {
@ -56,6 +68,20 @@
let collapsedImages = $state<Record<string, boolean>>({}); let collapsedImages = $state<Record<string, boolean>>({});
let collapsedVolumes = $state<Record<string, boolean>>({}); let collapsedVolumes = $state<Record<string, boolean>>({});
let collapsedNetworks= $state<Record<string, boolean>>({}); let collapsedNetworks= $state<Record<string, boolean>>({});
let collapsedProjects = $state<Record<string, boolean>>({});
let projectActionPending = $state<string | null>(null); // `${agentId}/${projectName}`
// Group auto-update
interface GroupAutoUpdateState {
agentId: string;
projectName: string;
containerIds: string[];
policy: AutoUpdatePolicy | null;
loading: boolean;
saving: boolean;
}
let groupAutoUpdate = $state<GroupAutoUpdateState | null>(null);
let groupAutoUpdatePanelPos = $state<{ top: number; right: number } | null>(null);
// ── Derived: containers grouped by agent ───────────────────────────────── // ── Derived: containers grouped by agent ─────────────────────────────────
const byAgent = $derived( const byAgent = $derived(
@ -368,12 +394,16 @@
} }
function closeAutoUpdateOnClickOutside(e: MouseEvent) { function closeAutoUpdateOnClickOutside(e: MouseEvent) {
if (autoUpdateOpen === null) return; if (autoUpdateOpen === null && groupAutoUpdate === null) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (!target.closest("[data-autoupdate-panel]") && !target.closest("[data-autoupdate-btn]")) { if (!target.closest("[data-autoupdate-panel]") && !target.closest("[data-autoupdate-btn]")) {
autoUpdateOpen = null; autoUpdateOpen = null;
autoUpdatePanelPos = null; autoUpdatePanelPos = null;
} }
if (!target.closest('[data-group-autoupdate-panel]') && !target.closest('[data-group-autoupdate-btn]')) {
groupAutoUpdate = null;
groupAutoUpdatePanelPos = null;
}
} }
function formatRelativeTime(iso: string | null): string { function formatRelativeTime(iso: string | null): string {
@ -395,6 +425,120 @@
function toggleVolumes(agentId: string) { collapsedVolumes = { ...collapsedVolumes, [agentId]: !(collapsedVolumes[agentId] ?? true) }; } function toggleVolumes(agentId: string) { collapsedVolumes = { ...collapsedVolumes, [agentId]: !(collapsedVolumes[agentId] ?? true) }; }
function toggleNetworks(agentId: string) { collapsedNetworks = { ...collapsedNetworks, [agentId]: !(collapsedNetworks[agentId] ?? true) }; } function toggleNetworks(agentId: string) { collapsedNetworks = { ...collapsedNetworks, [agentId]: !(collapsedNetworks[agentId] ?? true) }; }
// ── Project grouping ─────────────────────────────────────────────────────
function groupContainersByProject(containers: ContainerEntry[]): {
projects: [string, ContainerEntry[]][];
standalone: ContainerEntry[];
} {
const map = new Map<string, ContainerEntry[]>();
const standalone: ContainerEntry[] = [];
for (const entry of containers) {
const proj = entry.container.compose_project;
if (proj) {
if (!map.has(proj)) map.set(proj, []);
map.get(proj)!.push(entry);
} else {
standalone.push(entry);
}
}
return {
projects: [...map.entries()].sort(([a], [b]) => a.localeCompare(b)),
standalone: standalone.sort((a, b) => a.container.name.localeCompare(b.container.name)),
};
}
function projectKey(agentId: string, projectName: string) {
return `${agentId}/${projectName}`;
}
function toggleProject(agentId: string, projectName: string) {
const k = projectKey(agentId, projectName);
collapsedProjects = { ...collapsedProjects, [k]: collapsedProjects[k] === false };
}
// ── Project actions ───────────────────────────────────────────────────────
async function doProjectAction(agentId: string, projectName: string, containers: ContainerEntry[], action: 'stop' | 'restart') {
const key = projectKey(agentId, projectName);
projectActionPending = key;
try {
await Promise.allSettled(
containers.map(e => containerAction(agentId, e.container.id, action))
);
showToast(`${action} envoyé au projet ${projectName}`, true);
setTimeout(load, 1500);
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
projectActionPending = null;
}
}
// ── Group auto-update ─────────────────────────────────────────────────────
async function openGroupAutoUpdate(e: MouseEvent, agentId: string, projectName: string, containers: ContainerEntry[]) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
if (groupAutoUpdate?.agentId === agentId && groupAutoUpdate?.projectName === projectName) {
groupAutoUpdate = null;
groupAutoUpdatePanelPos = null;
return;
}
groupAutoUpdatePanelPos = { top: rect.bottom + 6, right: window.innerWidth - rect.right };
groupAutoUpdate = {
agentId,
projectName,
containerIds: containers.map(e => e.container.id),
policy: null,
loading: true,
saving: false,
};
try {
const policy = await getAutoUpdatePolicy(agentId, containers[0].container.id);
if (groupAutoUpdate?.projectName === projectName) {
groupAutoUpdate = { ...groupAutoUpdate, policy, loading: false };
}
} catch {
if (groupAutoUpdate?.projectName === projectName) {
groupAutoUpdate = { ...groupAutoUpdate, loading: false };
}
}
}
function toggleGroupAutoUpdateEnabled() {
if (!groupAutoUpdate?.policy) return;
groupAutoUpdate = { ...groupAutoUpdate, policy: { ...groupAutoUpdate.policy, enabled: !groupAutoUpdate.policy.enabled } };
saveGroupAutoUpdate();
}
function changeGroupAutoUpdateInterval(minutes: number) {
if (!groupAutoUpdate?.policy) return;
groupAutoUpdate = { ...groupAutoUpdate, policy: { ...groupAutoUpdate.policy, interval_minutes: minutes } };
saveGroupAutoUpdate();
}
let groupAutoUpdateDebounce: ReturnType<typeof setTimeout> | null = null;
function saveGroupAutoUpdate() {
if (groupAutoUpdateDebounce) clearTimeout(groupAutoUpdateDebounce);
groupAutoUpdateDebounce = setTimeout(async () => {
if (!groupAutoUpdate?.policy) return;
const { agentId, containerIds, policy } = groupAutoUpdate;
groupAutoUpdate = { ...groupAutoUpdate, saving: true };
try {
await Promise.allSettled(
containerIds.map(cid => setAutoUpdatePolicy(agentId, cid, { enabled: policy.enabled, interval_minutes: policy.interval_minutes }))
);
// Sync individual states
const updates: Record<string, { policy: AutoUpdatePolicy; loading: boolean; saving: boolean }> = {};
for (const cid of containerIds) {
updates[autoUpdateKey(agentId, cid)] = { policy, loading: false, saving: false };
}
autoUpdateStates = { ...autoUpdateStates, ...updates };
} catch (e: unknown) {
showToast(e instanceof Error ? e.message : String(e), false);
} finally {
if (groupAutoUpdate) groupAutoUpdate = { ...groupAutoUpdate, saving: false };
}
}, 300);
}
// ── Lifecycle ───────────────────────────────────────────────────────────── // ── Lifecycle ─────────────────────────────────────────────────────────────
onMount(() => { onMount(() => {
load(); load();
@ -488,12 +632,13 @@
</svelte:head> </svelte:head>
{#if logTarget} {#if logTarget}
<LogModal {#if logTarget.mode === 'single'}
agentId={logTarget.agentId} <LogModal agentId={logTarget.agentId} containerId={logTarget.containerId}
containerId={logTarget.containerId} containerName={logTarget.name} onClose={() => (logTarget = null)} />
containerName={logTarget.name} {:else}
onClose={() => (logTarget = null)} <LogModal agentId={logTarget.agentId} projectName={logTarget.projectName}
/> containers={logTarget.containers} onClose={() => (logTarget = null)} />
{/if}
{/if} {/if}
<!-- Toast --> <!-- Toast -->
@ -665,115 +810,271 @@
</button> </button>
{#if collapsed[agentId] === false} {#if collapsed[agentId] === false}
<!-- Desktop table --> {@const grouped = groupContainersByProject(containers)}
<div class="hidden md:block card overflow-hidden"> <div class="bg-abyss-800/40 rounded-xl border border-white/[0.04] px-4 py-3">
<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.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) 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 relative">
{@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}
{@render AutoUpdateBtn(agent_id, container.id)}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile cards --> <!-- Groupes de projets -->
<div class="md:hidden space-y-2"> {#each grouped.projects as [projectName, projectContainers]}
{#each containers.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) as { agent_id, container } (container.id)} {@const projKey = projectKey(agentId, projectName)}
<div class="card p-4"> {@const isProjectExpanded = collapsedProjects[projKey] === false}
<div class="flex items-start justify-between gap-2 mb-2"> {@const isProjPending = projectActionPending === projKey}
<div class="flex items-center gap-2 min-w-0"> {@const isGroupAutoUpdateOpen = groupAutoUpdate?.agentId === agentId && groupAutoUpdate?.projectName === projectName}
<span class={stateDotClass(container.state)}></span> {@const anyAutoUpdateEnabled = projectContainers.some(e => autoUpdateStates[autoUpdateKey(agentId, e.container.id)]?.policy?.enabled)}
<span class="font-mono text-sm font-medium truncate text-slate-200">{container.name}</span>
</div> <div class="pl-2 mb-3">
<span class="text-xs font-medium shrink-0 {stateTextClass(container.state)}">{container.state}</span> <!-- En-tête projet -->
</div> <div class="flex items-center gap-2 px-1 mb-1">
<p class="font-mono text-xs text-slate-600 truncate mb-3">{container.image}</p> <button
{#if uniquePorts(container.ports).length > 0} class="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group/proj"
<div class="flex flex-wrap gap-1 mb-3"> onclick={() => toggleProject(agentId, projectName)}
{#each uniquePorts(container.ports) as port} type="button"
<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"> <svg class="w-3 h-3 text-slate-600 transition-transform duration-150 shrink-0
{port.host_port}:{port.container_port} {isProjectExpanded ? 'rotate-0' : '-rotate-90'}"
</span> fill="none" stroke="currentColor" viewBox="0 0 24 24">
{/each} <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</div> </svg>
{/if} <span class={agentDotClass(projectContainers)}></span>
<div class="flex gap-2 flex-wrap relative"> <span class="text-xs font-semibold text-slate-300 group-hover/proj:text-slate-100 transition-colors font-mono">
{@render ActionBtn({ label: "Logs", variant: "cyan", {projectName}
loading: false, </span>
onclick: () => openLogs(agent_id, container.id, container.name) })} <span class="text-xs text-slate-600">
{#if container.state !== "running"} {projectContainers.length} container{projectContainers.length !== 1 ? 's' : ''}
{@render ActionBtn({ label: "Start", variant: "green", </span>
loading: actionPending === container.id, </button>
onclick: () => doAction(agent_id, container.id, "start") })}
{:else} <!-- Boutons groupe -->
{@render ActionBtn({ label: "Stop", variant: "ghost", <div class="flex items-center gap-1 shrink-0">
loading: actionPending === container.id, {@render ActionBtn({ label: "Logs", variant: "cyan", loading: false,
onclick: () => doAction(agent_id, container.id, "stop") })} onclick: () => openProjectLogs(agentId, projectName, projectContainers) })}
{@render ActionBtn({ label: "Restart", variant: "ghost", {@render ActionBtn({ label: "Stop All", variant: "ghost", loading: isProjPending,
loading: actionPending === container.id, onclick: () => doProjectAction(agentId, projectName, projectContainers, 'stop') })}
onclick: () => doAction(agent_id, container.id, "restart") })} {@render ActionBtn({ label: "Restart All", variant: "ghost", loading: isProjPending,
{/if} onclick: () => doProjectAction(agentId, projectName, projectContainers, 'restart') })}
{@render AutoUpdateBtn(agent_id, container.id)} <!-- Auto-update groupe -->
</div> <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> </div>
{/each} </div>
<!-- Desktop table du projet (collapsible) -->
{#if isProjectExpanded}
<div class="hidden md:block card overflow-hidden mt-1">
<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-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each projectContainers.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) 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">
<div class="flex justify-end gap-1.5 relative">
{@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}
{@render AutoUpdateBtn(agent_id, container.id)}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile cards du projet (collapsible) -->
<div class="md:hidden space-y-2 mt-1">
{#each projectContainers.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) 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 relative">
{@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}
{@render AutoUpdateBtn(agent_id, container.id)}
</div>
</div>
{/each}
</div>
{/if}
</div>
{/each}
<!-- Containers standalone -->
{#if grouped.standalone.length > 0}
<div class="pl-2 {grouped.projects.length > 0 ? 'mt-4' : ''}">
{#if grouped.projects.length > 0}
<div class="flex items-center gap-2 px-1 border-t border-white/[0.04] pt-3 mt-1 mb-1">
<span class="text-xs text-slate-600 uppercase tracking-wider">Standalone</span>
</div>
{/if}
<!-- Desktop table standalone -->
<div class="hidden md:block card overflow-hidden mt-1">
<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-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each grouped.standalone 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">
<div class="flex justify-end gap-1.5 relative">
{@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}
{@render AutoUpdateBtn(agent_id, container.id)}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile cards standalone -->
<div class="md:hidden space-y-2 mt-1">
{#each grouped.standalone 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 relative">
{@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}
{@render AutoUpdateBtn(agent_id, container.id)}
</div>
</div>
{/each}
</div>
</div>
{/if}
</div> </div>
{/if} {/if}
@ -1156,6 +1457,65 @@
{/if} {/if}
</div> </div>
{/if} {/if}
<!-- Group auto-update panel -->
{#if groupAutoUpdate !== null && groupAutoUpdatePanelPos !== null}
<div
data-group-autoupdate-panel
role="presentation"
onclick={(e) => e.stopPropagation()}
style="position: fixed; top: {groupAutoUpdatePanelPos.top}px; right: {groupAutoUpdatePanelPos.right}px;"
class="z-50 w-64 bg-gray-800 border border-gray-700 rounded-lg shadow-xl p-3"
>
<div class="flex items-center gap-1.5 mb-3 pb-2 border-b border-gray-700">
<span class="text-xs font-medium text-slate-400">Projet :</span>
<span class="text-xs font-mono text-slate-300 truncate">{groupAutoUpdate.projectName}</span>
</div>
{#if groupAutoUpdate.loading}
<div class="flex items-center justify-center py-4 text-slate-500 text-xs gap-2">
<div class="w-4 h-4 border border-slate-600 border-t-violet-400 rounded-full animate-spin"></div>
Chargement…
</div>
{:else if !groupAutoUpdate.policy}
<p class="text-xs text-signal-red text-center py-2">Erreur de chargement</p>
{:else}
<!-- Toggle -->
<div class="flex items-center justify-between mb-3">
<span class="text-xs font-medium text-slate-300">Auto-update</span>
<button type="button"
aria-pressed={groupAutoUpdate.policy.enabled}
aria-label="Activer/désactiver l'auto-update du groupe"
onclick={toggleGroupAutoUpdateEnabled}
class="relative w-9 h-5 rounded-full cursor-pointer transition-colors focus:outline-none
{groupAutoUpdate.policy.enabled ? 'bg-violet-500' : 'bg-gray-600'}
{groupAutoUpdate.saving ? 'opacity-60' : ''}">
<span class="absolute top-0.5 left-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform duration-200
{groupAutoUpdate.policy.enabled ? 'translate-x-4' : 'translate-x-0'}"></span>
</button>
</div>
<!-- Interval -->
<div class="mb-2">
<label for="au-group-interval" class="text-xs text-slate-500 block mb-1">Intervalle</label>
<select
id="au-group-interval"
disabled={!groupAutoUpdate.policy.enabled || groupAutoUpdate.saving}
value={groupAutoUpdate.policy.interval_minutes}
onchange={(e) => changeGroupAutoUpdateInterval(Number((e.target as HTMLSelectElement).value))}
class="w-full bg-gray-900 border border-gray-600 text-gray-300 text-xs rounded-md px-2 py-1.5
focus:outline-none focus:border-violet-500 disabled:opacity-40 disabled:cursor-not-allowed"
>
{#each INTERVAL_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<p class="text-xs text-slate-600 text-center">Appliqué aux {groupAutoUpdate.containerIds.length} containers</p>
{#if groupAutoUpdate.saving}
<p class="text-xs text-violet-400 mt-1 text-center">Enregistrement…</p>
{/if}
{/if}
</div>
{/if}
</div> </div>
{#snippet ActionBtn({ label, variant, loading, onclick }: { {#snippet ActionBtn({ label, variant, loading, onclick }: {