feat: add first page with auth and containers list and agents
This commit is contained in:
8613
web/package-lock.json
generated
Normal file
8613
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
web/package.json
Normal file
33
web/package.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "containarr-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/svelte": "^5.3.1",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.1",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.1.4",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^4.1.6",
|
||||
"workbox-window": "^7.3.0"
|
||||
}
|
||||
}
|
||||
6
web/postcss.config.js
Normal file
6
web/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
86
web/src/app.css
Normal file
86
web/src/app.css
Normal file
@ -0,0 +1,86 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body { background-color: #050f0c; }
|
||||
|
||||
code, .font-mono {
|
||||
font-family: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
|
||||
|
||||
::selection { background: rgba(59,130,246,0.3); color: #e8edf8; }
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.dot-running {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 7px rgba(52,211,153,0.6);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-exited {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #f87171;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-other {
|
||||
width: 7px; height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #fbbf24;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-online {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #34d399;
|
||||
box-shadow: 0 0 8px rgba(52,211,153,0.55);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-offline {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #1e2f48;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(5,15,12,0.85);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
border-bottom: 1px solid rgba(16,185,129,0.08);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #071410;
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(52,211,153,0.04);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.375rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #3d6b5a;
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
}
|
||||
.nav-btn:hover {
|
||||
color: #a7f3d0;
|
||||
background: rgba(16,185,129,0.08);
|
||||
}
|
||||
}
|
||||
15
web/src/app.html
Normal file
15
web/src/app.html
Normal file
@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="bg-slate-900 text-slate-100">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
121
web/src/lib/LogModal.svelte
Normal file
121
web/src/lib/LogModal.svelte
Normal file
@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy, tick } from "svelte";
|
||||
import { connectLogs } from "./api";
|
||||
|
||||
let {
|
||||
agentId,
|
||||
containerId,
|
||||
containerName,
|
||||
onClose,
|
||||
}: {
|
||||
agentId: string;
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
interface LogLine {
|
||||
stream: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
let lines = $state<LogLine[]>([]);
|
||||
let autoScroll = $state(true);
|
||||
let logEl = $state<HTMLElement | null>(null);
|
||||
let disconnect: (() => void) | null = null;
|
||||
|
||||
function stripAnsi(str: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, "");
|
||||
}
|
||||
|
||||
async function scrollToBottom() {
|
||||
if (!autoScroll || !logEl) return;
|
||||
await tick();
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
|
||||
function onScroll() {
|
||||
if (!logEl) return;
|
||||
const atBottom = logEl.scrollHeight - logEl.scrollTop - logEl.clientHeight < 40;
|
||||
autoScroll = atBottom;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => disconnect?.());
|
||||
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKey} />
|
||||
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex flex-col bg-black/70 backdrop-blur-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Logs — {containerName}"
|
||||
>
|
||||
<!-- Modal panel -->
|
||||
<div class="flex flex-col m-4 md:m-8 flex-1 min-h-0 card overflow-hidden">
|
||||
|
||||
<!-- 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="text-xs text-slate-600 ml-1">logs</span>
|
||||
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 text-xs text-slate-500 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={autoScroll}
|
||||
class="accent-emerald w-3 h-3"
|
||||
/>
|
||||
Auto-scroll
|
||||
</label>
|
||||
<button
|
||||
onclick={() => { lines = []; }}
|
||||
class="nav-btn text-xs px-2"
|
||||
title="Effacer"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
<button onclick={onClose} class="nav-btn" title="Fermer (Échap)">
|
||||
<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="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log output -->
|
||||
<div
|
||||
bind:this={logEl}
|
||||
onscroll={onScroll}
|
||||
class="flex-1 overflow-y-auto font-mono text-xs leading-5 p-4 space-y-px bg-abyss-900"
|
||||
>
|
||||
{#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}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
138
web/src/lib/api.ts
Normal file
138
web/src/lib/api.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import { getToken, clearToken } from "./auth";
|
||||
|
||||
export interface ContainerPort {
|
||||
host_port: number;
|
||||
container_port: number;
|
||||
protocol: string;
|
||||
host_ip: string;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state: string;
|
||||
ports: ContainerPort[];
|
||||
created_at: number;
|
||||
labels: Record<string, string>;
|
||||
compose_project: string;
|
||||
}
|
||||
|
||||
export interface ContainerEntry {
|
||||
agent_id: string;
|
||||
hostname: string;
|
||||
alias: string;
|
||||
ip_address: string;
|
||||
container: ContainerInfo;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
hostname: string;
|
||||
alias: string;
|
||||
ip_address: string;
|
||||
arch: string;
|
||||
os: string;
|
||||
online: boolean;
|
||||
last_seen_at: string;
|
||||
}
|
||||
|
||||
const BASE = "/api/v1";
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = getToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function apiFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const r = await fetch(input, {
|
||||
...init,
|
||||
headers: { ...authHeaders(), ...(init.headers as Record<string, string> ?? {}) },
|
||||
});
|
||||
if (r.status === 401) {
|
||||
clearToken();
|
||||
location.href = "/login";
|
||||
throw new Error("Session expirée");
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
export async function fetchAgents(): Promise<Agent[]> {
|
||||
const r = await apiFetch(`${BASE}/agents`);
|
||||
if (!r.ok) throw new Error(`agents: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function updateAgent(id: string, alias: string): Promise<Agent> {
|
||||
const r = await apiFetch(`${BASE}/agents/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ alias }),
|
||||
});
|
||||
if (!r.ok) throw new Error(`update agent: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function fetchContainers(): Promise<ContainerEntry[]> {
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), 8000);
|
||||
try {
|
||||
const r = await apiFetch(`${BASE}/containers`, { signal: ac.signal });
|
||||
if (!r.ok) throw new Error(`containers: ${r.status}`);
|
||||
return r.json();
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
export async function containerAction(
|
||||
agentId: string,
|
||||
containerId: string,
|
||||
action: "start" | "stop" | "restart" | "remove"
|
||||
): Promise<{ command_id: string }> {
|
||||
const r = await apiFetch(
|
||||
`${BASE}/agents/${agentId}/containers/${containerId}/action`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action }),
|
||||
}
|
||||
);
|
||||
if (!r.ok) throw new Error(`action failed: ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export function connectLogs(
|
||||
agentId: string,
|
||||
containerId: string,
|
||||
onLine: (line: { stream: string; line: string }) => void,
|
||||
tail = 200,
|
||||
follow = true,
|
||||
): () => void {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const token = getToken() ?? "";
|
||||
const ws = new WebSocket(
|
||||
`${proto}://${location.host}/api/v1/agents/${agentId}/containers/${containerId}/logs?token=${encodeURIComponent(token)}&tail=${tail}&follow=${follow}`
|
||||
);
|
||||
ws.onmessage = (e) => {
|
||||
try { onLine(JSON.parse(e.data)); } catch {}
|
||||
};
|
||||
return () => ws.close();
|
||||
}
|
||||
|
||||
export function connectEvents(
|
||||
onEvent: (evt: { type: string; agent_id?: string; payload: unknown }) => void
|
||||
): () => void {
|
||||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||||
const token = getToken() ?? "";
|
||||
const ws = new WebSocket(
|
||||
`${proto}://${location.host}/api/v1/events?token=${encodeURIComponent(token)}`
|
||||
);
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
onEvent(JSON.parse(e.data));
|
||||
} catch {}
|
||||
};
|
||||
return () => ws.close();
|
||||
}
|
||||
35
web/src/lib/auth.ts
Normal file
35
web/src/lib/auth.ts
Normal file
@ -0,0 +1,35 @@
|
||||
const KEY = "containarr_token";
|
||||
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string) {
|
||||
localStorage.setItem(KEY, token);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(KEY);
|
||||
}
|
||||
|
||||
export function isLoggedIn(): boolean {
|
||||
const t = getToken();
|
||||
if (!t) return false;
|
||||
try {
|
||||
const payload = JSON.parse(atob(t.split(".")[1]));
|
||||
return payload.exp * 1000 > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<void> {
|
||||
const r = await fetch("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (!r.ok) throw new Error("Identifiants invalides");
|
||||
const { token } = await r.json();
|
||||
setToken(token);
|
||||
}
|
||||
19
web/src/routes/+layout.svelte
Normal file
19
web/src/routes/+layout.svelte
Normal file
@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import "../app.css";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import { goto } from "$app/navigation";
|
||||
import { isLoggedIn } from "$lib/auth";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
onMount(() => {
|
||||
const publicRoutes = ["/login"];
|
||||
if (!publicRoutes.includes($page.url.pathname) && !isLoggedIn()) {
|
||||
goto("/login");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
2
web/src/routes/+layout.ts
Normal file
2
web/src/routes/+layout.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const ssr = false;
|
||||
export const prerender = false;
|
||||
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}
|
||||
206
web/src/routes/admin/+page.svelte
Normal file
206
web/src/routes/admin/+page.svelte
Normal file
@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { fetchAgents, updateAgent, type Agent } from "$lib/api";
|
||||
import { getToken } from "$lib/auth";
|
||||
|
||||
let agents = $state<Agent[]>([]);
|
||||
let loadError = $state<string | null>(null);
|
||||
let saving = $state<string | null>(null);
|
||||
let toast = $state<{ msg: string; ok: boolean } | null>(null);
|
||||
let editing = $state<Record<string, string>>({});
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
agents = await fetchAgents();
|
||||
for (const a of agents) editing[a.id] = a.alias;
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
}
|
||||
});
|
||||
|
||||
async function saveAlias(agent: Agent) {
|
||||
saving = agent.id;
|
||||
try {
|
||||
const updated = await updateAgent(agent.id, editing[agent.id] ?? "");
|
||||
agents = agents.map(a => a.id === updated.id ? { ...a, alias: updated.alias } : a);
|
||||
showToast("Alias sauvegardé", true);
|
||||
} catch (e: any) {
|
||||
showToast(e.message, false);
|
||||
} finally {
|
||||
saving = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(msg: string, ok: boolean) {
|
||||
toast = { msg, ok };
|
||||
setTimeout(() => (toast = null), 3000);
|
||||
}
|
||||
|
||||
// ── Password change ──────────────────────────────────────────────────────
|
||||
let pwCurrent = $state("");
|
||||
let pwNew = $state("");
|
||||
let pwConfirm = $state("");
|
||||
let pwSaving = $state(false);
|
||||
let pwError = $state<string | null>(null);
|
||||
|
||||
async function changePassword(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
pwError = null;
|
||||
if (pwNew !== pwConfirm) { pwError = "Les mots de passe ne correspondent pas."; return; }
|
||||
if (pwNew.length < 8) { pwError = "Minimum 8 caractères requis."; return; }
|
||||
pwSaving = true;
|
||||
try {
|
||||
const r = await fetch("/api/v1/auth/change-password", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${getToken()}` },
|
||||
body: JSON.stringify({ current_password: pwCurrent, new_password: pwNew }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.text()).trim() || `Erreur ${r.status}`);
|
||||
pwCurrent = pwNew = pwConfirm = "";
|
||||
showToast("Mot de passe modifié", true);
|
||||
} catch (err: any) {
|
||||
pwError = err.message;
|
||||
} finally {
|
||||
pwSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Admin — Containarr</title>
|
||||
</svelte:head>
|
||||
|
||||
{#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
|
||||
{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>
|
||||
<span class="text-slate-700">/</span>
|
||||
<span class="text-sm text-slate-400">Administration</span>
|
||||
</div>
|
||||
<a href="/" class="ml-auto nav-btn flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-200 px-3">
|
||||
<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="1.75" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Retour
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<main class="p-4 md:p-6 max-w-4xl mx-auto space-y-10">
|
||||
|
||||
<!-- Password section -->
|
||||
<section>
|
||||
<h2 class="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-4">Sécurité</h2>
|
||||
<div class="card p-5 max-w-sm">
|
||||
<h3 class="text-sm font-semibold text-slate-200 mb-4">Changer le mot de passe</h3>
|
||||
<form onsubmit={changePassword} class="space-y-3">
|
||||
{#if pwError}
|
||||
<p class="text-xs text-signal-red bg-signal-red/10 border border-signal-red/20
|
||||
rounded-lg px-3 py-2">{pwError}</p>
|
||||
{/if}
|
||||
{#each [
|
||||
{ id: "pw-c", label: "Mot de passe actuel", bind: "pwCurrent", val: pwCurrent, ac: "current-password" },
|
||||
{ id: "pw-n", label: "Nouveau mot de passe", bind: "pwNew", val: pwNew, ac: "new-password" },
|
||||
{ id: "pw-cf", label: "Confirmer", bind: "pwConfirm", val: pwConfirm, ac: "new-password" },
|
||||
] as field}
|
||||
<div>
|
||||
<label class="block text-xs text-slate-500 mb-1" for={field.id}>{field.label}</label>
|
||||
<input id={field.id} type="password" required
|
||||
value={field.id === "pw-c" ? pwCurrent : field.id === "pw-n" ? pwNew : pwConfirm}
|
||||
oninput={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value;
|
||||
if (field.id === "pw-c") pwCurrent = v;
|
||||
else if (field.id === "pw-n") pwNew = v;
|
||||
else pwConfirm = v;
|
||||
}}
|
||||
class="w-full bg-abyss-700 border border-white/[0.08] rounded-lg px-3 py-2 text-sm
|
||||
text-slate-100 outline-none focus:border-emerald/60 focus:ring-1 focus:ring-emerald/30
|
||||
transition-all" />
|
||||
</div>
|
||||
{/each}
|
||||
<button type="submit" disabled={pwSaving}
|
||||
class="w-full mt-1 bg-emerald hover:bg-emerald-bright disabled:opacity-50 text-white
|
||||
text-sm font-medium py-2 rounded-lg transition-colors">
|
||||
{pwSaving ? "Sauvegarde…" : "Mettre à jour"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Agents section -->
|
||||
<section>
|
||||
<h2 class="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-4">Agents</h2>
|
||||
|
||||
{#if loadError}
|
||||
<p class="text-signal-red text-sm">{loadError}</p>
|
||||
{:else if agents.length === 0}
|
||||
<p class="text-slate-600 text-sm">Aucun agent enregistré.</p>
|
||||
{:else}
|
||||
<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">Statut</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Hostname</th>
|
||||
<th class="px-4 py-3 text-left font-medium">IP</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Arch / OS</th>
|
||||
<th class="px-4 py-3 text-left font-medium">Alias</th>
|
||||
<th class="px-4 py-3 text-right font-medium"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each agents as agent (agent.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">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={agent.online ? "dot-online" : "dot-offline"}></span>
|
||||
<span class="text-xs {agent.online ? 'text-signal-green' : 'text-slate-600'}">
|
||||
{agent.online ? "En ligne" : "Hors ligne"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-400">{agent.hostname}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{agent.ip_address || "—"}</td>
|
||||
<td class="px-4 py-3 text-xs text-slate-600 font-mono">{agent.arch || "—"} / {agent.os || "—"}</td>
|
||||
<td class="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={editing[agent.id]}
|
||||
placeholder={agent.hostname}
|
||||
class="bg-abyss-700 border border-white/[0.08] rounded-lg px-2.5 py-1.5
|
||||
text-xs text-slate-100 placeholder-slate-700 outline-none w-36
|
||||
focus:border-emerald/60 focus:ring-1 focus:ring-emerald/30 transition-all"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
onclick={() => saveAlias(agent)}
|
||||
disabled={saving === agent.id || editing[agent.id] === agent.alias}
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-all disabled:opacity-30
|
||||
bg-emerald/10 hover:bg-emerald/20 text-emerald border border-emerald/20">
|
||||
{saving === agent.id ? "…" : "Sauvegarder"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
94
web/src/routes/login/+page.svelte
Normal file
94
web/src/routes/login/+page.svelte
Normal file
@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { login } from "$lib/auth";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let username = $state("");
|
||||
let password = $state("");
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
try {
|
||||
await login(username, password);
|
||||
goto("/");
|
||||
} catch (err: any) {
|
||||
error = err.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Connexion — Containarr</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid flex items-center justify-center p-4">
|
||||
<div class="w-full max-w-[360px]">
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="flex flex-col items-center gap-3 mb-8">
|
||||
<img src="/icon-192.png" alt="Containarr" class="w-14 h-14 rounded-2xl shadow-glow-emerald" />
|
||||
<div class="text-center">
|
||||
<h1 class="text-lg font-semibold text-slate-100 tracking-tight">Containarr</h1>
|
||||
<p class="text-xs text-slate-600 mt-0.5">Gestionnaire de containers Docker</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card -->
|
||||
<form onsubmit={submit} class="card p-6 space-y-4">
|
||||
|
||||
{#if error}
|
||||
<div class="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-signal-red/10
|
||||
border border-signal-red/20 text-signal-red text-xs">
|
||||
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-xs font-medium text-slate-500" for="username">Utilisateur</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
bind:value={username}
|
||||
autocomplete="username"
|
||||
required
|
||||
class="w-full bg-abyss-700 border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
||||
text-slate-100 placeholder-slate-700 outline-none
|
||||
focus:border-emerald/60 focus:ring-1 focus:ring-emerald/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label class="block text-xs font-medium text-slate-500" for="password">Mot de passe</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="w-full bg-abyss-700 border border-white/[0.08] rounded-lg px-3 py-2.5 text-sm
|
||||
text-slate-100 placeholder-slate-700 outline-none
|
||||
focus:border-emerald/60 focus:ring-1 focus:ring-emerald/30 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="w-full mt-2 bg-emerald hover:bg-emerald-bright disabled:opacity-50
|
||||
text-white text-sm font-medium py-2.5 rounded-lg transition-colors
|
||||
shadow-glow-emerald"
|
||||
>
|
||||
{loading ? "Connexion…" : "Se connecter"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
147
web/src/tests/LogModal.test.ts
Normal file
147
web/src/tests/LogModal.test.ts
Normal file
@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Tests for LogModal.svelte
|
||||
*
|
||||
* Strategy:
|
||||
* - Mount the component with @testing-library/svelte
|
||||
* - Use a MockWebSocket to simulate incoming log messages
|
||||
* - Verify DOM output and interaction (close button, Escape key, clear button)
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/svelte";
|
||||
import LogModal from "../lib/LogModal.svelte";
|
||||
|
||||
// ── WebSocket mock ────────────────────────────────────────────────────────────
|
||||
|
||||
class MockWebSocket {
|
||||
static last: MockWebSocket | null = null;
|
||||
url: string;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.last = this;
|
||||
}
|
||||
|
||||
close() { this.closed = true; }
|
||||
|
||||
/** Helper: push a log line */
|
||||
emit(data: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
MockWebSocket.last = null;
|
||||
localStorage.setItem("containarr_token", "test-token");
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
value: { protocol: "http:", host: "localhost" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
// ── Default props ─────────────────────────────────────────────────────────────
|
||||
|
||||
const defaultProps = {
|
||||
agentId: "agent1",
|
||||
containerId: "ctn1",
|
||||
containerName: "my-container",
|
||||
onClose: vi.fn(),
|
||||
};
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("LogModal.svelte", () => {
|
||||
it("renders the container name in the header", () => {
|
||||
render(LogModal, { props: defaultProps });
|
||||
expect(screen.getByText("my-container")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the empty-state placeholder before any log arrives", () => {
|
||||
render(LogModal, { props: defaultProps });
|
||||
expect(screen.getByText(/en attente des logs/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a WebSocket with the correct URL", () => {
|
||||
render(LogModal, { props: defaultProps });
|
||||
expect(MockWebSocket.last).not.toBeNull();
|
||||
expect(MockWebSocket.last!.url).toContain("/api/v1/agents/agent1/containers/ctn1/logs");
|
||||
expect(MockWebSocket.last!.url).toContain("token=test-token");
|
||||
});
|
||||
|
||||
it("displays incoming log lines", async () => {
|
||||
render(LogModal, { props: defaultProps });
|
||||
MockWebSocket.last!.emit({ stream: "stdout", line: "Server started" });
|
||||
|
||||
// Give Svelte a tick to update the DOM
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Server started")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("strips ANSI escape codes from log lines", async () => {
|
||||
render(LogModal, { props: defaultProps });
|
||||
MockWebSocket.last!.emit({ stream: "stdout", line: "\x1b[32mGreen text\x1b[0m" });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByText("Green text")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("clears lines when the clear button is clicked", async () => {
|
||||
render(LogModal, { props: defaultProps });
|
||||
MockWebSocket.last!.emit({ stream: "stdout", line: "some log" });
|
||||
|
||||
await vi.waitFor(() => screen.getByText("some log"));
|
||||
|
||||
await fireEvent.click(screen.getByTitle("Effacer"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByText("some log")).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/en attente des logs/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onClose when the close button is clicked", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(LogModal, { props: { ...defaultProps, onClose } });
|
||||
|
||||
await fireEvent.click(screen.getByTitle(/fermer/i));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("calls onClose when the Escape key is pressed", async () => {
|
||||
const onClose = vi.fn();
|
||||
render(LogModal, { props: { ...defaultProps, onClose } });
|
||||
|
||||
await fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("closes the WebSocket when the component is unmounted", async () => {
|
||||
const { unmount } = render(LogModal, { props: defaultProps });
|
||||
const ws = MockWebSocket.last!;
|
||||
expect(ws.closed).toBe(false);
|
||||
unmount();
|
||||
expect(ws.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores messages with an empty line field", async () => {
|
||||
render(LogModal, { props: defaultProps });
|
||||
MockWebSocket.last!.emit({ stream: "stdout", line: "" });
|
||||
|
||||
// The placeholder should still be visible (no lines added)
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(screen.getByText(/en attente des logs/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
296
web/src/tests/api.test.ts
Normal file
296
web/src/tests/api.test.ts
Normal file
@ -0,0 +1,296 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
fetchAgents,
|
||||
fetchContainers,
|
||||
containerAction,
|
||||
updateAgent,
|
||||
connectLogs,
|
||||
connectEvents,
|
||||
} from "../lib/api";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function mockFetch(status: number, body: unknown, ok = status >= 200 && status < 300) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
}
|
||||
|
||||
/** Minimal WebSocket mock */
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
url: string;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
closed = false;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
/** Helper: simulate incoming message */
|
||||
emit(data: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup / teardown ─────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
MockWebSocket.instances = [];
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
|
||||
// Default location stubs (jsdom provides location but let's make sure)
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
value: {
|
||||
protocol: "http:",
|
||||
host: "localhost:5173",
|
||||
href: "",
|
||||
},
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ── fetchAgents ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("fetchAgents", () => {
|
||||
it("returns agents array on success", async () => {
|
||||
const agents = [{ id: "a1", hostname: "host1", alias: "", ip_address: "1.2.3.4", arch: "amd64", os: "linux", online: true, last_seen_at: "2024-01-01" }];
|
||||
vi.stubGlobal("fetch", mockFetch(200, agents));
|
||||
|
||||
const result = await fetchAgents();
|
||||
expect(result).toEqual(agents);
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(500, {}, false));
|
||||
await expect(fetchAgents()).rejects.toThrow("agents: 500");
|
||||
});
|
||||
|
||||
it("includes Authorization header when token is present", async () => {
|
||||
localStorage.setItem("containarr_token", "my-token");
|
||||
const fetchMock = mockFetch(200, []);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await fetchAgents();
|
||||
|
||||
const [, opts] = fetchMock.mock.calls[0];
|
||||
expect(opts.headers?.Authorization).toBe("Bearer my-token");
|
||||
});
|
||||
|
||||
it("redirects to /login and clears token on 401", async () => {
|
||||
localStorage.setItem("containarr_token", "expired");
|
||||
vi.stubGlobal("fetch", mockFetch(401, {}, false));
|
||||
|
||||
await expect(fetchAgents()).rejects.toThrow("Session expirée");
|
||||
expect(localStorage.getItem("containarr_token")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateAgent ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("updateAgent", () => {
|
||||
it("sends PATCH with alias and returns updated agent", async () => {
|
||||
const updated = { id: "a1", hostname: "host1", alias: "new-alias", ip_address: "1.2.3.4", arch: "amd64", os: "linux", online: true, last_seen_at: "2024-01-01" };
|
||||
const fetchMock = mockFetch(200, updated);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await updateAgent("a1", "new-alias");
|
||||
|
||||
expect(result).toEqual(updated);
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/agents/a1");
|
||||
expect(opts.method).toBe("PATCH");
|
||||
expect(JSON.parse(opts.body)).toEqual({ alias: "new-alias" });
|
||||
});
|
||||
|
||||
it("throws on error", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(404, {}, false));
|
||||
await expect(updateAgent("missing", "alias")).rejects.toThrow("update agent: 404");
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchContainers ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("fetchContainers", () => {
|
||||
it("returns container entries on success", async () => {
|
||||
const entries = [
|
||||
{
|
||||
agent_id: "a1",
|
||||
hostname: "host1",
|
||||
alias: "",
|
||||
ip_address: "1.2.3.4",
|
||||
container: {
|
||||
id: "c1",
|
||||
name: "my-app",
|
||||
image: "nginx:latest",
|
||||
status: "running",
|
||||
state: "running",
|
||||
ports: [],
|
||||
created_at: 0,
|
||||
labels: {},
|
||||
compose_project: "",
|
||||
},
|
||||
},
|
||||
];
|
||||
vi.stubGlobal("fetch", mockFetch(200, entries));
|
||||
|
||||
const result = await fetchContainers();
|
||||
expect(result).toEqual(entries);
|
||||
});
|
||||
|
||||
it("throws on non-ok status", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(503, {}, false));
|
||||
await expect(fetchContainers()).rejects.toThrow("containers: 503");
|
||||
});
|
||||
|
||||
it("passes an AbortSignal to fetch", async () => {
|
||||
const fetchMock = mockFetch(200, []);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await fetchContainers();
|
||||
|
||||
const [, opts] = fetchMock.mock.calls[0];
|
||||
expect(opts.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
// ── containerAction ──────────────────────────────────────────────────────────
|
||||
|
||||
describe("containerAction", () => {
|
||||
it.each(["start", "stop", "restart", "remove"] as const)(
|
||||
"sends %s action and returns command_id",
|
||||
async (action) => {
|
||||
const fetchMock = mockFetch(200, { command_id: "cmd-42" });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await containerAction("agent1", "container1", action);
|
||||
expect(result).toEqual({ command_id: "cmd-42" });
|
||||
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/agents/agent1/containers/container1/action");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(JSON.parse(opts.body)).toEqual({ action });
|
||||
}
|
||||
);
|
||||
|
||||
it("throws on error", async () => {
|
||||
vi.stubGlobal("fetch", mockFetch(500, {}, false));
|
||||
await expect(containerAction("a", "c", "start")).rejects.toThrow("action failed: 500");
|
||||
});
|
||||
});
|
||||
|
||||
// ── connectLogs ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe("connectLogs", () => {
|
||||
it("creates a WebSocket with the correct URL", () => {
|
||||
localStorage.setItem("containarr_token", "tok123");
|
||||
connectLogs("agent1", "container1", () => {});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
const ws = MockWebSocket.instances[0];
|
||||
expect(ws.url).toContain("/api/v1/agents/agent1/containers/container1/logs");
|
||||
expect(ws.url).toContain("token=tok123");
|
||||
expect(ws.url).toContain("tail=200");
|
||||
expect(ws.url).toContain("follow=true");
|
||||
});
|
||||
|
||||
it("calls onLine with parsed message", () => {
|
||||
const onLine = vi.fn();
|
||||
connectLogs("a", "c", onLine);
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
ws.emit({ stream: "stdout", line: "hello world" });
|
||||
|
||||
expect(onLine).toHaveBeenCalledOnce();
|
||||
expect(onLine).toHaveBeenCalledWith({ stream: "stdout", line: "hello world" });
|
||||
});
|
||||
|
||||
it("returns a close function that closes the WebSocket", () => {
|
||||
const close = connectLogs("a", "c", () => {});
|
||||
const ws = MockWebSocket.instances[0];
|
||||
expect(ws.closed).toBe(false);
|
||||
close();
|
||||
expect(ws.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("silently ignores invalid JSON messages", () => {
|
||||
const onLine = vi.fn();
|
||||
connectLogs("a", "c", onLine);
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
ws.onmessage?.({ data: "not-json{{{{" });
|
||||
|
||||
expect(onLine).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses wss when protocol is https", () => {
|
||||
Object.defineProperty(globalThis, "location", {
|
||||
value: { protocol: "https:", host: "myapp.io" },
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
connectLogs("a", "c", () => {});
|
||||
expect(MockWebSocket.instances[0].url).toMatch(/^wss:\/\//);
|
||||
});
|
||||
|
||||
it("respects custom tail and follow parameters", () => {
|
||||
connectLogs("a", "c", () => {}, 50, false);
|
||||
const url = MockWebSocket.instances[0].url;
|
||||
expect(url).toContain("tail=50");
|
||||
expect(url).toContain("follow=false");
|
||||
});
|
||||
});
|
||||
|
||||
// ── connectEvents ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("connectEvents", () => {
|
||||
it("creates a WebSocket pointing to /api/v1/events", () => {
|
||||
localStorage.setItem("containarr_token", "evtTok");
|
||||
connectEvents(() => {});
|
||||
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
const ws = MockWebSocket.instances[0];
|
||||
expect(ws.url).toContain("/api/v1/events");
|
||||
expect(ws.url).toContain("token=evtTok");
|
||||
});
|
||||
|
||||
it("calls onEvent with parsed message", () => {
|
||||
const onEvent = vi.fn();
|
||||
connectEvents(onEvent);
|
||||
|
||||
const ws = MockWebSocket.instances[0];
|
||||
ws.emit({ type: "containers.updated", payload: { count: 3 } });
|
||||
|
||||
expect(onEvent).toHaveBeenCalledOnce();
|
||||
expect(onEvent).toHaveBeenCalledWith({ type: "containers.updated", payload: { count: 3 } });
|
||||
});
|
||||
|
||||
it("returns a close function that closes the WebSocket", () => {
|
||||
const close = connectEvents(() => {});
|
||||
const ws = MockWebSocket.instances[0];
|
||||
close();
|
||||
expect(ws.closed).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores malformed JSON", () => {
|
||||
const onEvent = vi.fn();
|
||||
connectEvents(onEvent);
|
||||
MockWebSocket.instances[0].onmessage?.({ data: "{{bad}}" });
|
||||
expect(onEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
120
web/src/tests/auth.test.ts
Normal file
120
web/src/tests/auth.test.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { getToken, setToken, clearToken, isLoggedIn, login } from "../lib/auth";
|
||||
|
||||
// localStorage is available via jsdom
|
||||
|
||||
describe("auth.ts", () => {
|
||||
const KEY = "containarr_token";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// --- getToken ---
|
||||
describe("getToken", () => {
|
||||
it("returns null when no token is stored", () => {
|
||||
expect(getToken()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the stored token", () => {
|
||||
localStorage.setItem(KEY, "my-token");
|
||||
expect(getToken()).toBe("my-token");
|
||||
});
|
||||
});
|
||||
|
||||
// --- setToken ---
|
||||
describe("setToken", () => {
|
||||
it("stores the token in localStorage", () => {
|
||||
setToken("abc123");
|
||||
expect(localStorage.getItem(KEY)).toBe("abc123");
|
||||
});
|
||||
});
|
||||
|
||||
// --- clearToken ---
|
||||
describe("clearToken", () => {
|
||||
it("removes the token from localStorage", () => {
|
||||
localStorage.setItem(KEY, "to-remove");
|
||||
clearToken();
|
||||
expect(localStorage.getItem(KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing if no token is stored", () => {
|
||||
expect(() => clearToken()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// --- isLoggedIn ---
|
||||
describe("isLoggedIn", () => {
|
||||
it("returns false when no token is stored", () => {
|
||||
expect(isLoggedIn()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for a malformed token", () => {
|
||||
localStorage.setItem(KEY, "not.a.jwt");
|
||||
expect(isLoggedIn()).toBe(false);
|
||||
});
|
||||
|
||||
function makeJWT(payload: Record<string, unknown>): string {
|
||||
const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const body = btoa(JSON.stringify(payload));
|
||||
return `${header}.${body}.signature`;
|
||||
}
|
||||
|
||||
it("returns true for a non-expired token", () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 3600; // +1h
|
||||
const token = makeJWT({ sub: "user", exp });
|
||||
localStorage.setItem(KEY, token);
|
||||
expect(isLoggedIn()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an expired token", () => {
|
||||
const exp = Math.floor(Date.now() / 1000) - 3600; // -1h
|
||||
const token = makeJWT({ sub: "user", exp });
|
||||
localStorage.setItem(KEY, token);
|
||||
expect(isLoggedIn()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// --- login ---
|
||||
describe("login", () => {
|
||||
it("stores the token on success", async () => {
|
||||
const fakeToken = "fresh-jwt-token";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ token: fakeToken }),
|
||||
})
|
||||
);
|
||||
|
||||
await login("admin", "password");
|
||||
expect(localStorage.getItem(KEY)).toBe(fakeToken);
|
||||
});
|
||||
|
||||
it("throws on non-ok response", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({ ok: false })
|
||||
);
|
||||
|
||||
await expect(login("admin", "wrong")).rejects.toThrow("Identifiants invalides");
|
||||
});
|
||||
|
||||
it("calls the correct endpoint with correct body", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ token: "tok" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await login("myuser", "mypass");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
const [url, opts] = fetchMock.mock.calls[0];
|
||||
expect(url).toBe("/api/v1/auth/login");
|
||||
expect(opts.method).toBe("POST");
|
||||
expect(JSON.parse(opts.body)).toEqual({ username: "myuser", password: "mypass" });
|
||||
});
|
||||
});
|
||||
});
|
||||
1
web/src/tests/setup.ts
Normal file
1
web/src/tests/setup.ts
Normal file
@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
54
web/src/tests/stripAnsi.test.ts
Normal file
54
web/src/tests/stripAnsi.test.ts
Normal file
@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Tests for the stripAnsi logic used in LogModal.svelte.
|
||||
*
|
||||
* The function is defined inline in the component; we replicate it here
|
||||
* identically so it can be unit-tested without mounting the full component.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
// Replicated directly from LogModal.svelte (src/lib/LogModal.svelte)
|
||||
function stripAnsi(str: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, "");
|
||||
}
|
||||
|
||||
describe("stripAnsi", () => {
|
||||
it("passes through plain text unchanged", () => {
|
||||
expect(stripAnsi("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("strips a simple SGR reset sequence (\\x1b[0m)", () => {
|
||||
expect(stripAnsi("\x1b[0mhello")).toBe("hello");
|
||||
});
|
||||
|
||||
it("strips bold / colour sequences", () => {
|
||||
expect(stripAnsi("\x1b[1;32mGreen bold\x1b[0m")).toBe("Green bold");
|
||||
});
|
||||
|
||||
it("strips cursor-move sequences (G, K, H, F)", () => {
|
||||
expect(stripAnsi("\x1b[2K\x1b[1Gsome line")).toBe("some line");
|
||||
});
|
||||
|
||||
it("strips multiple sequences in a single string", () => {
|
||||
const raw = "\x1b[31mERROR\x1b[0m: \x1b[1mfoo\x1b[0m";
|
||||
expect(stripAnsi(raw)).toBe("ERROR: foo");
|
||||
});
|
||||
|
||||
it("handles multi-param sequences like \\x1b[38;5;200m", () => {
|
||||
expect(stripAnsi("\x1b[38;5;200mtext\x1b[0m")).toBe("text");
|
||||
});
|
||||
|
||||
it("does not strip incomplete escape sequences (no letter terminator)", () => {
|
||||
// "\x1b[123" is incomplete — no terminating letter, should stay
|
||||
const partial = "\x1b[123";
|
||||
expect(stripAnsi(partial)).toBe(partial);
|
||||
});
|
||||
|
||||
it("returns empty string unchanged", () => {
|
||||
expect(stripAnsi("")).toBe("");
|
||||
});
|
||||
|
||||
it("preserves newlines", () => {
|
||||
expect(stripAnsi("\x1b[32mline1\x1b[0m\nline2")).toBe("line1\nline2");
|
||||
});
|
||||
});
|
||||
BIN
web/static/icon-192.png
Normal file
BIN
web/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
BIN
web/static/icon-512.png
Normal file
BIN
web/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
14
web/svelte.config.js
Normal file
14
web/svelte.config.js
Normal file
@ -0,0 +1,14 @@
|
||||
import adapter from "@sveltejs/adapter-static";
|
||||
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
fallback: "index.html", // SPA mode for PWA
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
46
web/tailwind.config.js
Normal file
46
web/tailwind.config.js
Normal file
@ -0,0 +1,46 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./src/**/*.{html,js,svelte,ts}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
abyss: {
|
||||
950: "#03070f",
|
||||
900: "#060c18",
|
||||
800: "#0a1220",
|
||||
700: "#0f1b2e",
|
||||
600: "#162238",
|
||||
500: "#1e2f48",
|
||||
},
|
||||
emerald: {
|
||||
DEFAULT: "#10b981",
|
||||
bright: "#34d399",
|
||||
dim: "#0a3d26",
|
||||
},
|
||||
signal: {
|
||||
green: "#34d399",
|
||||
red: "#f87171",
|
||||
yellow: "#fbbf24",
|
||||
cyan: "#2dd4bf",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ["JetBrains Mono", "Fira Code", "ui-monospace", "monospace"],
|
||||
sans: ["Inter", "system-ui", "-apple-system", "sans-serif"],
|
||||
},
|
||||
boxShadow: {
|
||||
"glow-green": "0 0 10px rgba(52,211,153,0.45)",
|
||||
"glow-emerald": "0 0 14px rgba(16,185,129,0.4)",
|
||||
card: "0 4px 24px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.04)",
|
||||
},
|
||||
backgroundImage: {
|
||||
"grid-faint":
|
||||
"linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px)",
|
||||
},
|
||||
backgroundSize: {
|
||||
grid: "32px 32px",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
13
web/tsconfig.json
Normal file
13
web/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
47
web/vite.config.ts
Normal file
47
web/vite.config.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite";
|
||||
import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
||||
import { svelteTesting } from "@testing-library/svelte/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: ["./src/tests/setup.ts"],
|
||||
include: ["src/**/*.{test,spec}.{js,ts}"],
|
||||
},
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
svelteTesting(),
|
||||
SvelteKitPWA({
|
||||
registerType: "autoUpdate",
|
||||
manifest: {
|
||||
name: "Containarr",
|
||||
short_name: "Containarr",
|
||||
description: "Multi-VM Docker container manager",
|
||||
theme_color: "#0f172a",
|
||||
background_color: "#0f172a",
|
||||
display: "standalone",
|
||||
start_url: "/",
|
||||
icons: [
|
||||
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2}"],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8080",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user