feat: add projects groups
This commit is contained in:
@ -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<LogLine[]>([]);
|
||||
let autoScroll = $state(true);
|
||||
let logEl = $state<HTMLElement | null>(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 (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}"
|
||||
>
|
||||
<!-- Modal panel -->
|
||||
<div class="flex flex-col m-4 md:m-8 flex-1 min-h-0 card overflow-hidden">
|
||||
@ -73,7 +111,7 @@
|
||||
<!-- Header -->
|
||||
<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="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>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
@ -109,9 +147,12 @@
|
||||
{#if lines.length === 0}
|
||||
<p class="text-slate-700 italic">En attente des logs…</p>
|
||||
{:else}
|
||||
{#each lines as { stream, line }, i (i)}
|
||||
<div class="whitespace-pre-wrap break-all {stream === 'stderr' ? 'text-signal-red/80' : 'text-slate-300'}">
|
||||
{line}
|
||||
{#each lines as { stream, line, prefix, prefixColor }, i (i)}
|
||||
<div class="whitespace-pre-wrap break-all flex {stream === 'stderr' ? 'text-signal-red/80' : 'text-slate-300'}">
|
||||
{#if prefix}
|
||||
<span class="{prefixColor} mr-1 opacity-80 shrink-0">[{prefix}]</span>
|
||||
{/if}
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@ -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<LogTarget | null>(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<Record<string, boolean>>({});
|
||||
let collapsedVolumes = $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 ─────────────────────────────────
|
||||
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<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 ─────────────────────────────────────────────────────────────
|
||||
onMount(() => {
|
||||
load();
|
||||
@ -488,12 +632,13 @@
|
||||
</svelte:head>
|
||||
|
||||
{#if logTarget}
|
||||
<LogModal
|
||||
agentId={logTarget.agentId}
|
||||
containerId={logTarget.containerId}
|
||||
containerName={logTarget.name}
|
||||
onClose={() => (logTarget = null)}
|
||||
/>
|
||||
{#if logTarget.mode === 'single'}
|
||||
<LogModal agentId={logTarget.agentId} containerId={logTarget.containerId}
|
||||
containerName={logTarget.name} onClose={() => (logTarget = null)} />
|
||||
{:else}
|
||||
<LogModal agentId={logTarget.agentId} projectName={logTarget.projectName}
|
||||
containers={logTarget.containers} onClose={() => (logTarget = null)} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Toast -->
|
||||
@ -665,8 +810,68 @@
|
||||
</button>
|
||||
|
||||
{#if collapsed[agentId] === false}
|
||||
<!-- Desktop table -->
|
||||
<div class="hidden md:block card overflow-hidden">
|
||||
{@const grouped = groupContainersByProject(containers)}
|
||||
<div class="bg-abyss-800/40 rounded-xl border border-white/[0.04] px-4 py-3">
|
||||
|
||||
<!-- Groupes de projets -->
|
||||
{#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)}
|
||||
|
||||
<div class="pl-2 mb-3">
|
||||
<!-- En-tête projet -->
|
||||
<div class="flex items-center gap-2 px-1 mb-1">
|
||||
<button
|
||||
class="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer group/proj"
|
||||
onclick={() => toggleProject(agentId, projectName)}
|
||||
type="button"
|
||||
>
|
||||
<svg class="w-3 h-3 text-slate-600 transition-transform duration-150 shrink-0
|
||||
{isProjectExpanded ? 'rotate-0' : '-rotate-90'}"
|
||||
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(projectContainers)}></span>
|
||||
<span class="text-xs font-semibold text-slate-300 group-hover/proj:text-slate-100 transition-colors font-mono">
|
||||
{projectName}
|
||||
</span>
|
||||
<span class="text-xs text-slate-600">
|
||||
{projectContainers.length} container{projectContainers.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Boutons groupe -->
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{@render ActionBtn({ label: "Logs", variant: "cyan", loading: false,
|
||||
onclick: () => openProjectLogs(agentId, projectName, projectContainers) })}
|
||||
{@render ActionBtn({ label: "Stop All", variant: "ghost", loading: isProjPending,
|
||||
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'stop') })}
|
||||
{@render ActionBtn({ label: "Restart All", variant: "ghost", loading: isProjPending,
|
||||
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'restart') })}
|
||||
<!-- Auto-update groupe -->
|
||||
<button
|
||||
data-group-autoupdate-btn
|
||||
onclick={(e) => { e.stopPropagation(); openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
|
||||
title="Auto-update groupe"
|
||||
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
|
||||
{isGroupAutoUpdateOpen || anyAutoUpdateEnabled
|
||||
? 'bg-violet-500/20 text-violet-300 border-violet-500/40'
|
||||
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]'}"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</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">
|
||||
@ -674,53 +879,42 @@
|
||||
<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">
|
||||
{#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 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">
|
||||
<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,
|
||||
{@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,
|
||||
{@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,
|
||||
{@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,
|
||||
{@render ActionBtn({ label: "Restart", variant: "ghost", loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||
{/if}
|
||||
{@render AutoUpdateBtn(agent_id, container.id)}
|
||||
@ -732,9 +926,9 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile cards -->
|
||||
<div class="md:hidden space-y-2">
|
||||
{#each containers.slice().sort((a, b) => a.container.name.localeCompare(b.container.name)) as { agent_id, container } (container.id)}
|
||||
<!-- 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">
|
||||
@ -755,19 +949,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-2 flex-wrap relative">
|
||||
{@render ActionBtn({ label: "Logs", variant: "cyan",
|
||||
loading: false,
|
||||
{@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,
|
||||
{@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,
|
||||
{@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,
|
||||
{@render ActionBtn({ label: "Restart", variant: "ghost", loading: actionPending === container.id,
|
||||
onclick: () => doAction(agent_id, container.id, "restart") })}
|
||||
{/if}
|
||||
{@render AutoUpdateBtn(agent_id, container.id)}
|
||||
@ -776,6 +966,117 @@
|
||||
{/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>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
{/if}
|
||||
@ -1156,6 +1457,65 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
{#snippet ActionBtn({ label, variant, loading, onclick }: {
|
||||
|
||||
Reference in New Issue
Block a user