From d1db386e1d36ed6fba6cd077e2e5875f8173af1e Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 19 May 2026 16:39:35 +0200 Subject: [PATCH] feat: add projects groups --- web/src/lib/LogModal.svelte | 77 +++-- web/src/routes/+page.svelte | 592 +++++++++++++++++++++++++++++------- 2 files changed, 535 insertions(+), 134 deletions(-) diff --git a/web/src/lib/LogModal.svelte b/web/src/lib/LogModal.svelte index 6749ecd..a357556 100644 --- a/web/src/lib/LogModal.svelte +++ b/web/src/lib/LogModal.svelte @@ -4,25 +4,42 @@ let { agentId, - containerId, - containerName, + containerId = '', + containerName = '', + containers = [] as Array<{id: string; name: string}>, + projectName = '', onClose, }: { agentId: string; - containerId: string; - containerName: string; + containerId?: string; + containerName?: string; + containers?: Array<{id: string; name: string}>; + projectName?: string; onClose: () => void; } = $props(); interface LogLine { stream: 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([]); let autoScroll = $state(true); let logEl = $state(null); - let disconnect: (() => void) | null = null; + let disconnects: Array<() => void> = []; function stripAnsi(str: string): string { // eslint-disable-next-line no-control-regex @@ -41,17 +58,38 @@ autoScroll = atBottom; } + const isMulti = $derived(containers.length > 0); + const title = $derived(isMulti ? projectName : containerName); + onMount(() => { - disconnect = 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(); - } - }); + if (isMulti) { + containers.forEach((c, idx) => { + const prefix = c.name; + const prefixColor = PREFIX_COLORS[idx % PREFIX_COLORS.length]; + 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) { if (e.key === "Escape") onClose(); @@ -65,7 +103,7 @@ class="fixed inset-0 z-50 flex flex-col bg-black/70 backdrop-blur-sm" role="dialog" aria-modal="true" - aria-label="Logs — {containerName}" + aria-label="Logs — {title}" >
@@ -73,7 +111,7 @@
- {containerName} + {title} logs
@@ -109,9 +147,12 @@ {#if lines.length === 0}

En attente des logs…

{:else} - {#each lines as { stream, line }, i (i)} -
- {line} + {#each lines as { stream, line, prefix, prefixColor }, i (i)} +
+ {#if prefix} + [{prefix}] + {/if} + {line}
{/each} {/if} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 464d1eb..3ce7612 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -27,10 +27,22 @@ import LogModal from "$lib/LogModal.svelte"; // ── 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(null); 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() { @@ -56,6 +68,20 @@ let collapsedImages = $state>({}); let collapsedVolumes = $state>({}); let collapsedNetworks= $state>({}); + let collapsedProjects = $state>({}); + let projectActionPending = $state(null); // `${agentId}/${projectName}` + + // Group auto-update + interface GroupAutoUpdateState { + agentId: string; + projectName: string; + containerIds: string[]; + policy: AutoUpdatePolicy | null; + loading: boolean; + saving: boolean; + } + let groupAutoUpdate = $state(null); + let groupAutoUpdatePanelPos = $state<{ top: number; right: number } | null>(null); // ── Derived: containers grouped by agent ───────────────────────────────── const byAgent = $derived( @@ -368,12 +394,16 @@ } function closeAutoUpdateOnClickOutside(e: MouseEvent) { - if (autoUpdateOpen === null) return; + if (autoUpdateOpen === null && groupAutoUpdate === null) return; const target = e.target as HTMLElement; if (!target.closest("[data-autoupdate-panel]") && !target.closest("[data-autoupdate-btn]")) { autoUpdateOpen = 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 { @@ -395,6 +425,120 @@ function toggleVolumes(agentId: string) { collapsedVolumes = { ...collapsedVolumes, [agentId]: !(collapsedVolumes[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(); + 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 | 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 = {}; + 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 ───────────────────────────────────────────────────────────── onMount(() => { load(); @@ -488,12 +632,13 @@ {#if logTarget} - (logTarget = null)} - /> + {#if logTarget.mode === 'single'} + (logTarget = null)} /> + {:else} + (logTarget = null)} /> + {/if} {/if} @@ -665,115 +810,271 @@ {#if collapsed[agentId] === false} - - + {@const grouped = groupContainersByProject(containers)} +
- -
- {#each containers.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) as { agent_id, container } (container.id)} -
-
-
- - {container.name} -
- {container.state} -
-

{container.image}

- {#if uniquePorts(container.ports).length > 0} -
- {#each uniquePorts(container.ports) as port} - - {port.host_port}:{port.container_port} - - {/each} -
- {/if} -
- {@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)} -
+ + {#each grouped.projects as [projectName, projectContainers]} + {@const projKey = projectKey(agentId, projectName)} + {@const isProjectExpanded = collapsedProjects[projKey] === false} + {@const isProjPending = projectActionPending === projKey} + {@const isGroupAutoUpdateOpen = groupAutoUpdate?.agentId === agentId && groupAutoUpdate?.projectName === projectName} + {@const anyAutoUpdateEnabled = projectContainers.some(e => autoUpdateStates[autoUpdateKey(agentId, e.container.id)]?.policy?.enabled)} + +
+ +
+ + + +
+ {@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') })} + +
- {/each} +
+ + + {#if isProjectExpanded} + + + +
+ {#each projectContainers.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) as { agent_id, container } (container.id)} +
+
+
+ + {container.name} +
+ {container.state} +
+

{container.image}

+ {#if uniquePorts(container.ports).length > 0} +
+ {#each uniquePorts(container.ports) as port} + + {port.host_port}:{port.container_port} + + {/each} +
+ {/if} +
+ {@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)} +
+
+ {/each} +
+ {/if} +
+ {/each} + + + {#if grouped.standalone.length > 0} +
+ {#if grouped.projects.length > 0} +
+ Standalone +
+ {/if} + + + + +
+ {#each grouped.standalone as { agent_id, container } (container.id)} +
+
+
+ + {container.name} +
+ {container.state} +
+

{container.image}

+ {#if uniquePorts(container.ports).length > 0} +
+ {#each uniquePorts(container.ports) as port} + + {port.host_port}:{port.container_port} + + {/each} +
+ {/if} +
+ {@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)} +
+
+ {/each} +
+
+ {/if} +
{/if} @@ -1156,6 +1457,65 @@ {/if}
{/if} + + + {#if groupAutoUpdate !== null && groupAutoUpdatePanelPos !== null} +
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" + > +
+ Projet : + {groupAutoUpdate.projectName} +
+ {#if groupAutoUpdate.loading} +
+
+ Chargement… +
+ {:else if !groupAutoUpdate.policy} +

Erreur de chargement

+ {:else} + +
+ Auto-update + +
+ +
+ + +
+

Appliqué aux {groupAutoUpdate.containerIds.length} containers

+ {#if groupAutoUpdate.saving} +

Enregistrement…

+ {/if} + {/if} +
+ {/if}
{#snippet ActionBtn({ label, variant, loading, onclick }: {