server-up/server-up/templates/index.html
Ramon cb04c4a7ad
Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 25s
v0.5.45-beta - wizard-knop toegevoegd in Instellingen
De wizard was alleen te zien bij een verse installatie: zodra hij een keer was
afgerond, was er geen enkele manier meer om hem te openen. De vertaalsleutels
wizard_open en wizard_reset bestonden al, en /api/wizard/reset werkte ook, maar
de knop is nooit gebouwd - ook niet in v0.4.60.

- Blok "Setup Wizard" onder Instellingen met "Wizard openen" en "Opnieuw
  uitvoeren". Dat laatste zet WIZARD_DONE terug en opent hem meteen.
- openWizard() staat los van checkWizard(), zodat handmatig openen niet meer
  afhangt van of de wizard al gedraaid heeft. Stap wordt op 0 gezet.
- Sluitknop in de wizard zelf; die kon je eerder alleen verlaten door hem
  helemaal af te ronden.
- De staptitels lopen nu via t() in plaats van harde Nederlandse teksten.

De wizard-stappen zelf zijn end-to-end nagelopen tegen een draaiende instantie
(paths, git, sync, complete, reset): alle endpoints geven 200.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C7oLCRYzY5ixJ5Sv8Y8EFb
2026-07-27 00:39:35 +02:00

2184 lines
133 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="nl" x-data="app()" x-init="init()" :class="theme==='dark' ? 'dark' : ''">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Server Up</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
<link rel="stylesheet" href="/static/fonts/mdi.min.css">
<!-- Lokaal meegeleverd (zie Dockerfile): geen CDN op runtime, werkt offline
en houdt de Content-Security-Policy op 'self'. -->
<script src="/static/vendor/tailwind.js"></script>
<script defer src="/static/vendor/alpine.min.js"></script>
<script src="/static/vendor/htmx.min.js"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: {
colors: {
bg: { DEFAULT:'#f6f8fb', dark:'#0a0e14' },
s1: { DEFAULT:'#ffffff', dark:'#111720' },
s2: { DEFAULT:'#f1f5f9', dark:'#161d27' },
s3: { DEFAULT:'#e8edf3', dark:'#1d2531' },
bd: { DEFAULT:'#e7ecf2', dark:'#222c38' },
bd2: { DEFAULT:'#d3dbe5', dark:'#303c4c' },
tx: { DEFAULT:'#28323e', dark:'#c7d1dc' },
t2: { DEFAULT:'#64748b', dark:'#8995a6' },
dm: { DEFAULT:'#94a3b8', dark:'#586577' },
wh: { DEFAULT:'#0f172a', dark:'#f2f6fb' },
ac: { DEFAULT:'#6366f1', dark:'#818cf8' },
ac2: { DEFAULT:'#4f46e5', dark:'#a5b4fc' },
ok: '#22c55e',
wr: '#f59e0b',
er: '#ef4444',
bl: '#3b82f6',
pu: '#8b5cf6',
},
fontFamily: {
sans: ['Inter','system-ui','sans-serif'],
mono: ['"JetBrains Mono"','ui-monospace','monospace'],
},
boxShadow: {
soft: '0 1px 2px rgba(15,23,42,.04), 0 2px 8px rgba(15,23,42,.05)',
soft2: '0 4px 16px rgba(15,23,42,.08), 0 1px 3px rgba(15,23,42,.05)',
pop: '0 12px 40px rgba(15,23,42,.16)',
},
}}
}
</script>
<!-- Geen Google Fonts: dat is een externe request bij elke paginalading. Inter
en JetBrains Mono worden gebruikt als ze lokaal geïnstalleerd zijn, anders
valt de stack terug op de systeemfonts uit tailwind.config. -->
<style>
body { font-family: 'Inter', system-ui, sans-serif; -webkit-font-smoothing: antialiased; }
[x-cloak] { display: none !important; }
.mdi-spin { animation: mdis 1s linear infinite; display:inline-block; }
@keyframes mdis { to { transform: rotate(360deg); } }
::-webkit-scrollbar { width:8px; height:8px; }
::-webkit-scrollbar-thumb { background: rgb(148 163 184 / .35); border-radius: 9px; }
::-webkit-scrollbar-thumb:hover { background: rgb(148 163 184 / .55); }
::-webkit-scrollbar-track { background: transparent; }
.dark ::-webkit-scrollbar-thumb { background: rgb(48 60 76 / .8); }
@keyframes fadeup { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform:none; } }
.anim, .modal { animation: fadeup .28s ease both; }
.log-ok{color:#4ade80}.log-error{color:#f87171}.log-warn{color:#fbbf24}.log-info{color:#60a5fa}.log-dim{color:#7c8aa0}.log-section{color:#818cf8;font-weight:600}
</style>
</head>
<body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-[15px] h-screen overflow-hidden antialiased">
<!-- ░░ Login / eerste account ░░ -->
<!-- Ligt bovenop alles (z-[90]) tot er een geldige sessie is. De API weigert
ondertussen sowieso elk verzoek met 401, dit is de nette voorkant. -->
<div x-show="auth.ready && !auth.authenticated" x-cloak
class="fixed inset-0 bg-bg dark:bg-bg-dark z-[90] grid place-items-center p-4">
<div class="w-full max-w-[380px] anim">
<div class="flex items-center gap-3 mb-6 justify-center">
<div class="w-11 h-11 rounded-xl bg-gradient-to-br from-ac to-ac2 grid place-items-center shadow-soft text-white text-xl">🚀</div>
<div class="font-extrabold text-xl text-wh dark:text-wh-dark tracking-tight">Server<span class="text-ac dark:text-ac-dark">Up</span></div>
</div>
<div class="card card-pad">
<template x-if="auth.needsSetup">
<div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-1" x-text="t('setup_title')">Eerste account aanmaken</div>
<p class="text-xs text-t2 dark:text-t2-dark mb-4" x-text="t('setup_hint')"></p>
<form @submit.prevent="doSetup()" class="grid gap-3">
<div><label class="form-label" x-text="t('username')">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label" x-text="t('password_min')">Wachtwoord</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="new-password" required></div>
<div><label class="form-label" x-text="t('password_repeat')">Wachtwoord herhalen</label>
<input type="password" class="form-input" x-model="loginForm.password2" autocomplete="new-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-account-plus'"></span><span x-text="t('create_account')">Account aanmaken</span>
</button>
</form>
</div>
</template>
<template x-if="!auth.needsSetup">
<div>
<div class="text-base font-extrabold text-wh dark:text-wh-dark mb-4" x-text="t('login')">Inloggen</div>
<form @submit.prevent="doLogin()" class="grid gap-3">
<div><label class="form-label" x-text="t('username')">Gebruikersnaam</label>
<input class="form-input" x-model="loginForm.username" autocomplete="username" required></div>
<div><label class="form-label" x-text="t('password')">Wachtwoord</label>
<input type="password" class="form-input" x-model="loginForm.password" autocomplete="current-password" required></div>
<div x-show="loginForm.error" class="text-xs text-er font-semibold" x-text="loginForm.error"></div>
<button type="submit" class="btn btn-primary w-full" :disabled="loginForm.busy">
<span class="mdi" :class="loginForm.busy ? 'mdi-loading mdi-spin' : 'mdi-login'"></span><span x-text="t('login')">Inloggen</span>
</button>
</form>
</div>
</template>
</div>
</div>
</div>
<!-- ░░ Sidebar ░░ -->
<aside class="fixed top-0 bottom-0 left-0 w-[264px] max-w-[86vw] bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark z-40 flex flex-col transition-transform duration-200 ease-out"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
<!-- brand -->
<div class="h-16 px-5 flex items-center gap-3 border-b border-bd dark:border-bd-dark shrink-0">
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-ac to-ac2 grid place-items-center shadow-soft text-white text-lg">🚀</div>
<div class="leading-tight">
<div class="font-extrabold text-[15px] text-wh dark:text-wh-dark tracking-tight">Server<span class="text-ac dark:text-ac-dark">Up</span></div>
<div class="text-[10px] font-mono text-dm dark:text-dm-dark" x-text="'v'+version"></div>
</div>
<button @click="toggleSidebar()" class="ml-auto md:hidden w-9 h-9 grid place-items-center rounded-lg hover:bg-s2 dark:hover:bg-s2-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-close text-xl"></span></button>
</div>
<nav class="flex-1 overflow-y-auto p-3">
<div class="px-3 pb-1.5 text-[10px] font-bold tracking-[.12em] uppercase text-dm dark:text-dm-dark" x-text="t('overview')">Overzicht</div>
<template x-for="item in navItems" :key="item.id">
<a href="#" @click.prevent="goto(item.id)" class="nav-link" :class="page === item.id ? 'nav-active' : ''">
<span class="mdi text-xl w-6 text-center shrink-0" :class="item.icon"></span>
<span class="flex-1" x-text="t(item.label)"></span>
<span x-show="page === item.id" class="w-1.5 h-1.5 rounded-full bg-ac dark:bg-ac-dark"></span>
</a>
</template>
<template x-if="modules.length > 0">
<div class="px-3 pt-5 pb-1.5 text-[10px] font-bold tracking-[.12em] uppercase text-dm dark:text-dm-dark" x-text="t('modules')">Modules</div>
</template>
<template x-for="m in modules" :key="m.id">
<a href="#" @click.prevent="goto('mod:'+m.id)" class="nav-link">
<span class="text-xl w-6 text-center shrink-0" x-text="m.icon || '🧩'"></span>
<span class="flex-1 truncate" x-text="m.name"></span>
</a>
</template>
</nav>
<!-- footer status -->
<div class="p-3 border-t border-bd dark:border-bd-dark shrink-0">
<div class="rounded-xl bg-s2 dark:bg-s2-dark p-3 flex items-center gap-3">
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="docker.running>0 ? 'bg-ok shadow-[0_0_8px_#22c55e]' : 'bg-dm dark:bg-dm-dark'"></span>
<div class="text-[11px] leading-tight">
<div class="font-semibold text-wh dark:text-wh-dark"><span x-text="docker.running ?? ''"></span> <span x-text="t('running')">actief</span></div>
<div class="text-dm dark:text-dm-dark"><span x-text="docker.images ?? ''"></span> images</div>
</div>
<button @click="termOpen = !termOpen" :title="t('log')" class="ml-auto w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-console text-lg"></span></button>
<button @click="logout()" :title="t('logout') + ' (' + auth.user + ')'" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s1 dark:hover:bg-s1-dark text-t2 dark:text-t2-dark"><span class="mdi mdi-logout text-lg"></span></button>
</div>
</div>
</aside>
<div x-show="sidebarOpen && isMobile" @click="sidebarOpen=false" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-30" x-cloak></div>
<!-- ░░ Main column ░░ -->
<div class="h-screen flex flex-col transition-[padding] duration-200 ease-out" :class="(sidebarOpen && !isMobile) ? 'md:pl-[264px]' : 'pl-0'">
<!-- Topbar -->
<header class="h-16 px-4 sm:px-6 flex items-center gap-3 bg-bg/80 dark:bg-bg-dark/80 backdrop-blur border-b border-bd dark:border-bd-dark shrink-0 sticky top-0 z-20">
<button @click="toggleSidebar()" :title="sidebarOpen ? t('hide_menu') : t('show_menu')" class="btn-icon">
<span class="mdi text-xl" :class="sidebarOpen ? 'mdi-menu-open' : 'mdi-menu'"></span>
</button>
<h1 class="text-lg font-bold text-wh dark:text-wh-dark tracking-tight truncate" x-text="t(page === 'docker' ? 'images' : page)">Dashboard</h1>
<div class="ml-auto flex items-center gap-2">
<div class="hidden sm:flex items-center gap-1.5 px-3 h-10 rounded-xl bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark text-xs font-mono text-t2 dark:text-t2-dark">
<span class="mdi mdi-play-circle-outline text-ok"></span><span x-text="docker.running ?? ''"></span>
<span class="w-px h-4 bg-bd dark:bg-bd-dark mx-1"></span>
<span class="mdi mdi-layers-outline text-bl"></span><span x-text="docker.images ?? ''"></span>
</div>
<a x-show="update.update_available" x-cloak :href="update.url" target="_blank" rel="noopener"
:title="t('update_available')"
class="flex items-center gap-1.5 px-3 h-10 rounded-xl bg-ac/10 text-ac dark:text-ac-dark text-xs font-bold hover:bg-ac/20 transition">
<span class="mdi mdi-arrow-up-bold-circle text-base"></span><span x-text="'v'+update.latest"></span>
</a>
<button @click="toggleTheme()" :title="t('theme')" class="btn-icon">
<span class="mdi text-xl" :class="theme==='dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"></span>
</button>
</div>
</header>
<!-- Scroll area -->
<main class="flex-1 overflow-y-auto">
<div class="p-4 sm:p-7 max-w-6xl mx-auto pb-48">
<!-- ══ Dashboard ══ -->
<section x-show="page === 'dashboard'" x-cloak class="anim">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-7">
<div class="stat-card">
<div class="stat-icon bg-ok/10 text-ok"><span class="mdi mdi-play-circle"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="docker.running ?? ''"></div><div class="stat-label" x-text="t('running')">Running</div></div>
</div>
<div class="stat-card">
<div class="stat-icon bg-dm/15 text-dm dark:text-dm-dark"><span class="mdi mdi-stop-circle-outline"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="(docker.containers ?? 0) - (docker.running ?? 0)"></div><div class="stat-label" x-text="t('stopped')">Stopped</div></div>
</div>
<button class="stat-card text-left hover:shadow-soft2 transition" @click="goto('docker')">
<div class="stat-icon bg-bl/10 text-bl"><span class="mdi mdi-layers-outline"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="docker.images ?? ''"></div><div class="stat-label" x-text="t('images')">Images</div></div>
</button>
<button class="stat-card text-left hover:shadow-soft2 transition" @click="goto('stacks')">
<div class="stat-icon bg-ac/10 text-ac dark:text-ac-dark"><span class="mdi mdi-folder-multiple-outline"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="stacks.length"></div><div class="stat-label">Stacks</div></div>
</button>
</div>
<div class="flex items-center gap-3 mb-3">
<h2 class="text-sm font-bold text-wh dark:text-wh-dark uppercase tracking-wide" x-text="t('stacks')">Stacks</h2>
<button @click="loadDashboard()" class="btn-icon ml-auto w-9 h-9"><span class="mdi mdi-refresh"></span></button>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="stacks.length > 0">
<template x-for="s in stacks.slice(0, 6)" :key="s.name">
<button @click="goto('stacks')" class="card card-hover p-4 flex items-center gap-3 text-left">
<template x-if="s.logo_url"><img :src="s.logo_url" class="w-8 h-8 rounded-lg object-contain bg-s2 dark:bg-s2-dark p-0.5 shrink-0" loading="lazy" onerror="this.style.display='none'"></template>
<template x-if="!s.logo_url"><span class="w-8 h-8 rounded-lg bg-s2 dark:bg-s2-dark grid place-items-center text-base shrink-0" x-text="s.icon || '📦'"></span></template>
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="s.running ? 'bg-ok shadow-[0_0_8px_#22c55e]' : 'bg-dm dark:bg-dm-dark'"></span>
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
<span class="badge" :class="s.running ? 'badge-ok' : 'badge-dim'" x-text="s.running ? t('running') : t('stopped')"></span>
</button>
</template>
</div>
<div x-show="stacks.length === 0" class="empty"><span class="mdi mdi-server-network"></span><p x-text="t('no_stacks')">Geen stacks</p></div>
</section>
<!-- ══ Stacks ══ -->
<section x-show="page === 'stacks'" x-cloak class="anim">
<div class="flex flex-wrap items-center gap-3 mb-5">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<span class="mdi mdi-magnify absolute left-3.5 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark text-lg"></span>
<input x-model="stackQuery" :placeholder="t('search')+'…'" class="form-input !pl-10">
</div>
<button @click="loadStacks()" class="btn"><span class="mdi mdi-refresh"></span> <span x-text="t('refresh')">Vernieuwen</span></button>
<button @click="checkStackUpdates()" class="btn" :disabled="checkingUpd">
<span class="mdi" :class="checkingUpd ? 'mdi-loading mdi-spin' : 'mdi-package-up'"></span>
<span x-text="t('check_updates')">Updates controleren</span>
</button>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="filteredStacks.length > 0">
<template x-for="s in filteredStacks" :key="s.name">
<div class="card card-hover flex flex-col">
<div class="px-4 py-3.5 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<template x-if="s.logo_url"><img :src="s.logo_url" class="w-8 h-8 rounded-lg object-contain bg-s2 dark:bg-s2-dark p-0.5 shrink-0" loading="lazy" onerror="this.style.display='none'"></template>
<template x-if="!s.logo_url"><span class="w-8 h-8 rounded-lg bg-s2 dark:bg-s2-dark grid place-items-center text-base shrink-0" x-text="s.icon || '📦'"></span></template>
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="!s.has_compose ? 'bg-wr' : s.running ? 'bg-ok shadow-[0_0_8px_#22c55e]' : 'bg-dm dark:bg-dm-dark'"></span>
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
<!-- Draait deze stack op een eigen IP i.p.v. hostpoorten? -->
<span x-show="s.ip" class="badge badge-info font-mono !text-[10px]"
:title="t('own_ip') + ': ' + s.network" x-text="s.ip"></span>
<span x-show="stackUpd[s.name] && stackUpd[s.name].update_available"
class="badge badge-ok" :title="stackUpdTitle(s.name)"
x-text="t('update_available')">update</span>
<span class="badge" :class="s.running ? 'badge-ok' : !s.has_compose ? 'badge-warn' : 'badge-dim'" x-text="!s.has_compose ? '—' : s.running ? t('running') : t('stopped')"></span>
</div>
<div class="p-4 flex flex-col gap-3 flex-1">
<!-- Containerchips; klik klapt het beheerpaneel per container uit -->
<div class="flex flex-wrap gap-1.5 items-center cursor-pointer"
x-show="s.containers && s.containers.length"
@click="toggleContainers(s.name)">
<template x-for="c in s.containers || []" :key="c.name">
<span class="text-[11px] font-mono px-2 py-0.5 rounded-md border" :class="c.running ? 'border-ok/30 text-ok bg-ok/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" x-text="c.service || c.name"></span>
</template>
<span class="mdi text-t2 dark:text-t2-dark text-base ml-auto"
:class="cpanel[s.name] && cpanel[s.name].open ? 'mdi-chevron-up' : 'mdi-chevron-down'"></span>
</div>
<div x-show="cpanel[s.name] && cpanel[s.name].open" x-cloak
class="rounded-xl border border-bd dark:border-bd-dark divide-y divide-bd dark:divide-bd-dark overflow-hidden">
<template x-if="cpanel[s.name] && cpanel[s.name].loading">
<div class="px-3 py-2.5 text-xs text-t2 dark:text-t2-dark flex items-center gap-2">
<span class="mdi mdi-loading mdi-spin"></span> <span x-text="t('loading')">Laden…</span>
</div>
</template>
<template x-for="c in (cpanel[s.name] && cpanel[s.name].items) || []" :key="c.name">
<div class="px-3 py-2.5 bg-s2/40 dark:bg-s2-dark/40">
<div class="flex items-center gap-2">
<span class="w-2 h-2 rounded-full shrink-0" :class="c.running ? 'bg-ok' : 'bg-dm dark:bg-dm-dark'"></span>
<span class="text-xs font-semibold text-wh dark:text-wh-dark truncate flex-1" x-text="c.service || c.name"></span>
<button class="btn-sm !h-7 !px-2" :title="c.running ? t('stop') : t('start')"
@click.stop="containerAction(s.name, c.name, c.running ? 'stop' : 'start')">
<span class="mdi" :class="c.running ? 'mdi-stop' : 'mdi-play'"></span>
</button>
<button class="btn-sm !h-7 !px-2" :title="t('restart')"
@click.stop="containerAction(s.name, c.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm !h-7 !px-2" :title="t('logs')"
@click.stop="openContainerLogs(s.name, c.name)"><span class="mdi mdi-text-box-outline"></span></button>
</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1 text-[10px] font-mono text-t2 dark:text-t2-dark"
x-show="c.stats && c.stats.cpu">
<span>CPU <span class="text-tx dark:text-tx-dark" x-text="c.stats.cpu"></span></span>
<span>MEM <span class="text-tx dark:text-tx-dark" x-text="c.stats.mem"></span></span>
<span x-show="c.stats.net">NET <span class="text-tx dark:text-tx-dark" x-text="c.stats.net"></span></span>
</div>
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5 truncate" x-show="c.ports" x-text="c.ports"></div>
</div>
</template>
</div>
<div class="flex flex-wrap gap-1.5 mt-auto" x-show="s.has_compose">
<button class="btn-sm flex-1 min-w-[84px]" :class="s.running ? 'btn-danger' : 'btn-primary'" @click="stackAction(s.name, s.running ? 'stop' : 'start')">
<span class="mdi" :class="s.running ? 'mdi-stop' : 'mdi-play'"></span>
<span x-text="s.running ? t('stop') : t('start')"></span>
</button>
<button class="btn-sm" :title="t('restart')" @click="stackAction(s.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm" :title="t('update')" @click="stackAction(s.name, 'update')"><span class="mdi mdi-update"></span></button>
<button class="btn-sm" title=".env" @click="openEditor(s.name, 'env')"><span class="mdi mdi-file-cog-outline"></span></button>
<button class="btn-sm" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button>
<button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
<button class="btn-sm" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button>
<button class="btn-sm" :title="t('backups')" @click="openBackups(s.name)"><span class="mdi mdi-history"></span></button>
<button class="btn-sm btn-danger ml-auto" :title="t('remove')" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</div>
</div>
</template>
</div>
<div x-show="filteredStacks.length === 0" class="empty"><span class="mdi mdi-server-network"></span><p x-text="t('no_stacks')">Geen stacks gevonden</p></div>
</section>
<!-- ══ App Store ══ -->
<section x-show="page === 'app_store'" x-cloak class="anim">
<div class="flex flex-wrap items-center gap-2 mb-4">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<span class="mdi mdi-magnify absolute left-3.5 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark text-lg"></span>
<input x-model="storeQuery" :placeholder="t('search_apps')" class="form-input !pl-10">
</div>
<button @click="syncAllRepos()" class="btn"><span class="mdi mdi-source-pull"></span> <span x-text="t('pull')">Pull</span></button>
<button @click="modal='add-repo'" class="btn btn-primary"><span class="mdi mdi-plus"></span> <span x-text="t('add_repo')">Repository</span></button>
</div>
<!-- Tags uit alle repo's samen; klikken filtert de lijst. -->
<div class="flex flex-wrap gap-1.5 mb-5" x-show="storeTags.length">
<button class="text-[11px] font-semibold px-2.5 py-1 rounded-lg border transition"
:class="!storeTag ? 'border-ac bg-ac/10 text-ac dark:text-ac-dark' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:border-bd2'"
@click="storeTag = ''" x-text="t('all')">Alle</button>
<template x-for="tag in storeTags" :key="tag">
<button class="text-[11px] font-mono px-2.5 py-1 rounded-lg border transition"
:class="storeTag === tag ? 'border-ac bg-ac/10 text-ac dark:text-ac-dark' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:border-bd2'"
@click="storeTag = (storeTag === tag ? '' : tag)" x-text="tag"></button>
</template>
</div>
<div class="space-y-7">
<template x-for="repo in repos" :key="repo.id">
<div x-show="!storeQuery && !storeTag || filteredStore(repo.id).length">
<div class="flex items-center gap-2 mb-3">
<span class="mdi mdi-source-branch text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="repo.name"></span>
<span class="text-[11px] font-mono text-dm dark:text-dm-dark truncate hidden sm:block" x-text="repo.url"></span>
<button @click="syncRepo(repo.id)" class="btn-icon ml-auto w-9 h-9"><span class="mdi mdi-refresh"></span></button>
<button @click="deleteRepo(repo.id)" class="btn-icon w-9 h-9 !text-er hover:!border-er/40"><span class="mdi mdi-delete-outline"></span></button>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
<template x-for="stack in filteredStore(repo.id)" :key="stack.dir">
<div class="card card-hover p-4 flex flex-col">
<div class="flex items-start gap-3 mb-3">
<template x-if="stack.logo_url">
<img :src="stack.logo_url" class="w-10 h-10 rounded-lg object-contain bg-s2 dark:bg-s2-dark p-1" loading="lazy" onerror="this.style.display='none'">
</template>
<template x-if="!stack.logo_url">
<div class="w-10 h-10 rounded-lg bg-s2 dark:bg-s2-dark grid place-items-center text-lg shrink-0" x-text="stack.icon || '📦'"></div>
</template>
<div class="flex-1 min-w-0">
<div class="font-semibold text-wh dark:text-wh-dark text-sm truncate" x-text="stack.name"></div>
<div class="text-[11px] text-t2 dark:text-t2-dark line-clamp-2 leading-snug" x-text="stack.description"></div>
</div>
<span x-show="stack.format === 'boilerplate'" class="badge badge-info shrink-0" title="ChristianLempa Boilerplate">BP</span>
</div>
<div class="flex flex-wrap gap-1 mb-3" x-show="stack.tags && stack.tags.length">
<template x-for="tag in (stack.tags || []).slice(0,3)">
<span class="text-[10px] font-mono px-2 py-0.5 rounded-md bg-s2 dark:bg-s2-dark text-t2 dark:text-t2-dark" x-text="tag"></span>
</template>
</div>
<div class="flex gap-2 mt-auto items-center">
<button @click="openInstall(stack, repo.id)" class="btn-sm btn-primary flex-1">
<span class="mdi mdi-download-outline"></span> <span x-text="t('install')">Installeren</span>
</button>
<template x-if="stack.instances && stack.instances.length">
<span class="text-[11px] font-semibold text-ok px-2 grid place-items-center" :title="stack.instances.join(', ')" x-text="'✓ '+stack.instances.length"></span>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</section>
<!-- ══ Docker Images ══ -->
<section x-show="page === 'docker'" x-cloak class="anim">
<div class="flex items-center gap-2 mb-5">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('images')">Docker Images</p>
<button @click="loadImages()" class="btn-icon"><span class="mdi mdi-refresh"></span></button>
<button @click="pruneImages()" class="btn btn-danger"><span class="mdi mdi-broom"></span> <span x-text="t('prune')">Prune</span></button>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="bg-s2 dark:bg-s2-dark text-[10px] uppercase tracking-wider text-dm dark:text-dm-dark text-left">
<th class="px-4 py-3 font-bold">Repository</th><th class="px-4 py-3 font-bold">Tag</th>
<th class="px-4 py-3 font-bold">Size</th><th class="px-4 py-3 font-bold">Age</th><th class="px-4 py-3"></th>
</tr></thead>
<tbody>
<template x-for="(img, i) in images" :key="i">
<tr class="border-t border-bd dark:border-bd-dark hover:bg-s2/60 dark:hover:bg-s2-dark/60 transition">
<td class="px-4 py-2.5 font-mono text-tx dark:text-tx-dark" x-text="img.repo"></td>
<td class="px-4 py-2.5 font-mono text-ac dark:text-ac-dark" x-text="img.tag"></td>
<td class="px-4 py-2.5 font-mono text-t2 dark:text-t2-dark" x-text="img.size"></td>
<td class="px-4 py-2.5 font-mono text-t2 dark:text-t2-dark" x-text="img.age"></td>
<td class="px-4 py-2.5 text-right"><button @click="removeImage(img.id)" class="btn-sm btn-danger"><span class="mdi mdi-delete-outline"></span></button></td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="images.length === 0" class="empty !py-10"><span class="mdi mdi-layers-outline"></span><p x-text="t('no_images')">Geen images</p></div>
</div>
</section>
<!-- ══ Audit ══ -->
<section x-show="page === 'audit'" x-cloak class="anim">
<div class="flex items-center gap-2 mb-5">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('audit')">Audit Log</p>
<button @click="loadAudit()" class="btn-icon"><span class="mdi mdi-refresh"></span></button>
<button @click="clearAudit()" class="btn btn-danger"><span class="mdi mdi-delete-outline"></span> <span x-text="t('delete')">Wissen</span></button>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="bg-s2 dark:bg-s2-dark text-[10px] uppercase tracking-wider text-dm dark:text-dm-dark text-left">
<th class="px-4 py-3 font-bold">Tijd</th><th class="px-4 py-3 font-bold">Categorie</th>
<th class="px-4 py-3 font-bold">Actie</th><th class="px-4 py-3 font-bold">Status</th><th class="px-4 py-3 font-bold">Ref</th>
</tr></thead>
<tbody>
<template x-for="(e, i) in audit" :key="i">
<tr class="border-t border-bd dark:border-bd-dark hover:bg-s2/60 dark:hover:bg-s2-dark/60 transition">
<td class="px-4 py-2 font-mono text-t2 dark:text-t2-dark whitespace-nowrap" x-text="(e.ts || '').replace('T',' ').slice(0,19)"></td>
<td class="px-4 py-2" x-text="e.category"></td>
<td class="px-4 py-2" x-text="e.action"></td>
<td class="px-4 py-2"><span class="badge" :class="e.status==='ok' ? 'badge-ok' : 'badge-danger'" x-text="e.status"></span></td>
<td class="px-4 py-2 font-mono text-ac dark:text-ac-dark" x-text="e.ref || ''"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="audit.length === 0" class="empty !py-10"><span class="mdi mdi-clipboard-text-outline"></span><p x-text="t('no_results')">Geen resultaten</p></div>
</div>
</section>
<!-- ══ Settings ══ -->
<section x-show="page === 'settings'" x-cloak class="anim space-y-5">
<div class="flex items-center gap-3">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('settings')">Instellingen</p>
<span x-show="saved" class="text-xs text-ok flex items-center gap-1.5 font-semibold"><span class="mdi mdi-check-circle"></span> <span x-text="t('auto_saved')">Opgeslagen</span></span>
</div>
<div class="card card-pad grid sm:grid-cols-2 gap-6">
<div>
<div class="form-label" x-text="t('theme')">Thema</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button @click="setThemePref('system')" class="seg" :class="themePref==='system' ? 'seg-on' : ''"><span class="mdi mdi-monitor"></span> Auto</button>
<button @click="setThemePref('light')" class="seg" :class="themePref==='light' ? 'seg-on' : ''"><span class="mdi mdi-weather-sunny"></span> Light</button>
<button @click="setThemePref('dark')" class="seg" :class="themePref==='dark' ? 'seg-on' : ''"><span class="mdi mdi-weather-night"></span> Dark</button>
</div>
</div>
<div>
<div class="form-label" x-text="t('language')">Taal</div>
<select x-model="settings.LANGUAGE" @change="setLang(settings.LANGUAGE)" class="form-input !w-auto min-w-[180px]">
<template x-for="l in languages" :key="l.code"><option :value="l.code" x-text="(l.flag||'')+' '+l.name"></option></template>
</select>
</div>
</div>
<!-- Setup wizard opnieuw doorlopen -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-wizard-hat text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('wizard')">Setup Wizard</span>
</div>
<div class="p-5 flex flex-wrap items-center gap-2">
<p class="text-xs text-t2 dark:text-t2-dark flex-1 min-w-[200px]" x-text="t('wizard_hint')"></p>
<button class="btn btn-sm" @click="openWizard()">
<span class="mdi mdi-play-outline"></span> <span x-text="t('wizard_open')">Wizard openen</span>
</button>
<button class="btn btn-sm" @click="resetWizard()">
<span class="mdi mdi-restart"></span> <span x-text="t('wizard_reset')">Opnieuw uitvoeren</span>
</button>
</div>
</div>
<!-- Beveiliging: accounts + wachtwoord -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-shield-account-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('security')">Beveiliging</span>
<span class="badge badge-dim ml-auto" x-text="t('logged_in_as', {user: auth.user})"></span>
</div>
<div class="p-5 grid md:grid-cols-2 gap-6">
<div>
<div class="form-label" x-text="t('users')">Gebruikers</div>
<div class="space-y-1.5 mb-3">
<template x-for="u in users" :key="u.name">
<div class="flex items-center gap-2 text-sm">
<span class="mdi mdi-account-outline text-t2 dark:text-t2-dark"></span>
<span class="text-tx dark:text-tx-dark flex-1 truncate" x-text="u.name"></span>
<select class="form-input !h-8 !w-auto !text-xs !px-2" :value="u.role"
@change="setRole(u.name, $event.target.value)">
<template x-for="r in roles" :key="r">
<option :value="r" x-text="t('role_'+r)"></option>
</template>
</select>
<button class="btn-sm !h-7 !px-2 btn-danger" x-show="users.length > 1"
@click="delUser(u.name)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</template>
</div>
<div class="grid gap-2">
<input class="form-input" x-model="newUser.username" :placeholder="t('username')">
<input type="password" class="form-input" x-model="newUser.password" :placeholder="t('password_min')">
<select class="form-input" x-model="newUser.role">
<template x-for="r in roles" :key="r"><option :value="r" x-text="t('role_'+r)"></option></template>
</select>
<button class="btn btn-sm" @click="addUser()"><span class="mdi mdi-account-plus-outline"></span> <span x-text="t('add_user')">Gebruiker toevoegen</span></button>
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-2" x-text="t('roles_hint')"></p>
</div>
<div>
<div class="form-label" x-text="t('change_own_password')">Eigen wachtwoord wijzigen</div>
<div class="grid gap-2">
<input type="password" class="form-input" x-model="pwForm.current" :placeholder="t('current_password')">
<input type="password" class="form-input" x-model="pwForm.new" :placeholder="t('new_password')">
<input type="password" class="form-input" x-model="pwForm.new2" :placeholder="t('new_password_repeat')">
<button class="btn btn-sm" @click="changePassword()"><span class="mdi mdi-key-variant"></span> <span x-text="t('change')">Wijzigen</span></button>
</div>
</div>
</div>
<!-- Reverse-proxy-SSO. De header wordt alleen vertrouwd vanaf de
adressen hieronder; zonder die lijst weigert de server de modus. -->
<div class="px-5 pb-5 pt-4 border-t border-bd dark:border-bd-dark">
<div class="form-label" x-text="t('auth_mode')">Hoe wordt er ingelogd?</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1 mb-2">
<button class="seg" :class="authCfg.mode==='local' ? 'seg-on' : ''"
@click="authCfg.mode='local'"><span class="mdi mdi-account-key-outline"></span> <span x-text="t('auth_mode_local')">Lokaal</span></button>
<button class="seg" :class="authCfg.mode==='proxy' ? 'seg-on' : ''"
@click="authCfg.mode='proxy'"><span class="mdi mdi-shield-link-variant-outline"></span> <span x-text="t('auth_mode_proxy')">Proxy</span></button>
<button class="seg" :class="authCfg.mode==='both' ? 'seg-on' : ''"
@click="authCfg.mode='both'"><span class="mdi mdi-check-all"></span> <span x-text="t('auth_mode_both')">Beide</span></button>
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mb-3" x-text="t('auth_mode_hint')"></p>
<div x-show="authCfg.mode !== 'local'" x-cloak class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label" x-text="t('proxy_header')">Identiteitsheader</label>
<input class="form-input font-mono" x-model="authCfg.proxy_header" placeholder="Remote-User">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('proxy_header_hint')"></p>
</div>
<div>
<label class="form-label" x-text="t('proxy_role')">Rol voor proxy-gebruikers</label>
<select class="form-input" x-model="authCfg.proxy_role">
<template x-for="r in roles" :key="r"><option :value="r" x-text="t('role_'+r)"></option></template>
</select>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('proxy_role_hint')"></p>
</div>
<div>
<label class="form-label" x-text="t('trusted_proxies')">Vertrouwde proxy-adressen</label>
<textarea class="form-input font-mono !h-auto py-2" rows="3"
x-model="authCfg.trusted_text" placeholder="10.0.20.1&#10;172.18.0.0/16"></textarea>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('trusted_proxies_hint')"></p>
</div>
</div>
<button class="btn btn-sm mt-3" @click="saveAuthMode()">
<span class="mdi mdi-content-save-outline"></span> <span x-text="t('save')">Opslaan</span>
</button>
</div>
</div>
<!-- Backups: bewaarbeleid en planning -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-archive-outline text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('backups')">Backups</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[11px] text-wr flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('backup_scope_warning')"></span>
</p>
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label" x-text="t('backup_keep')">Aantal per stack</label>
<input type="number" min="0" class="form-input" x-model.number="settings.BACKUP_KEEP" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('backup_max_age')">Maximale leeftijd</label>
<input type="number" min="0" class="form-input" x-model.number="settings.BACKUP_MAX_AGE_DAYS" @change="autoSave()">
</div>
<div>
<label class="form-label" x-text="t('backup_schedule')">Geplande backups</label>
<select class="form-input" x-model="settings.BACKUP_SCHEDULE" @change="autoSave()">
<option value="off" x-text="t('backup_schedule_off')">Uit</option>
<option value="daily" x-text="t('backup_schedule_daily')">Dagelijks</option>
<option value="weekly" x-text="t('backup_schedule_weekly')">Wekelijks</option>
</select>
</div>
<div x-show="settings.BACKUP_SCHEDULE && settings.BACKUP_SCHEDULE !== 'off'">
<label class="form-label" x-text="t('backup_schedule_hour')">Rond welk uur</label>
<input type="number" min="0" max="23" class="form-input" x-model.number="settings.BACKUP_SCHEDULE_HOUR" @change="autoSave()">
</div>
</div>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.BACKUP_BEFORE_UPDATE" @change="settings.BACKUP_BEFORE_UPDATE=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_before_update')"></span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.BACKUP_BEFORE_REMOVE" @change="settings.BACKUP_BEFORE_REMOVE=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('backup_before_remove')"></span>
</label>
<label class="flex items-center gap-2.5 cursor-pointer">
<input type="checkbox" :checked="!!settings.STACK_UPDATE_CHECK" @change="settings.STACK_UPDATE_CHECK=$event.target.checked; autoSave()" class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac">
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('stack_update_check')"></span>
</label>
</div>
</div>
<!-- Netwerken: stacks een eigen IP-adres geven -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-lan text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('networks')">Netwerken</span>
<button class="btn-sm ml-auto" @click="modal='add-network'"><span class="mdi mdi-plus"></span> <span x-text="t('network')">Netwerk</span></button>
</div>
<div class="p-5">
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('networks_hint')"></p>
<div class="space-y-2">
<template x-for="n in netList" :key="n.name">
<div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
<span class="w-2 h-2 rounded-full shrink-0" :class="n.created ? 'bg-ok' : 'bg-wr'"></span>
<span class="font-semibold text-sm text-wh dark:text-wh-dark" x-text="n.name"></span>
<span class="badge badge-dim" x-text="n.driver"></span>
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark" x-text="n.subnet + ' @ ' + n.parent"></span>
<span class="text-[11px] text-dm dark:text-dm-dark" x-show="n.next_ip"><span x-text="t('next_free')"></span>: <span class="font-mono" x-text="n.next_ip"></span></span>
<button class="btn-sm !h-7 !px-2 btn-danger ml-auto" @click="deleteNetwork(n.name)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</template>
</div>
<div x-show="netList.length === 0" class="text-xs text-dm dark:text-dm-dark" x-text="t('no_networks')">Nog geen netwerken.</div>
</div>
</div>
<!-- Updates -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-update text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('updates')">Updates</span>
<template x-if="update.update_available"><span class="badge badge-ok" x-text="t('update_available')">update beschikbaar</span></template>
<template x-if="update.enabled && !update.update_available && !update.error"><span class="badge badge-dim" x-text="t('up_to_date')">up-to-date</span></template>
<button class="btn-sm ml-auto" @click="loadUpdate()"><span class="mdi mdi-refresh"></span> <span x-text="t('check_now')">Controleren</span></button>
</div>
<div class="p-5 space-y-4">
<div class="flex flex-wrap gap-x-8 gap-y-2 text-sm">
<div><div class="form-label" x-text="t('current_version')">Huidige versie</div><div class="font-mono font-semibold text-wh dark:text-wh-dark" x-text="'v'+(update.current||version)"></div></div>
<div x-show="update.latest"><div class="form-label" x-text="t('latest_version')">Laatste release</div><div class="font-mono font-semibold" :class="update.update_available ? 'text-ac dark:text-ac-dark' : 'text-wh dark:text-wh-dark'" x-text="'v'+update.latest"></div></div>
</div>
<template x-if="update.update_available && update.notes">
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark bg-s2/60 dark:bg-s2-dark/60 flex items-center gap-2"><span class="mdi mdi-text-box-outline"></span> <span x-text="t('release_notes')">Release notes</span></summary>
<pre class="p-3.5 text-xs whitespace-pre-wrap break-words text-t2 dark:text-t2-dark max-h-64 overflow-auto" x-text="update.notes"></pre>
</details>
</template>
<div class="flex flex-wrap gap-2" x-show="update.update_available">
<button class="btn btn-primary" @click="applyUpdate()" :disabled="!update.can_apply || updating">
<span class="mdi" :class="updating ? 'mdi-loading mdi-spin' : 'mdi-download'"></span>
<span x-text="updating ? t('updating') : t('update_now', {version: update.latest})"></span>
</button>
<a :href="update.url" target="_blank" rel="noopener" class="btn"><span class="mdi mdi-open-in-new"></span> <span x-text="t('view_release')">Bekijk release</span></a>
</div>
<!-- Waarom de knop niet bruikbaar is (geen registry-image, geen
compose-labels, …) — anders staat er alleen een dode knop. -->
<!-- Bij een zelfgebouwd image is dit geen storing maar de opzet:
dan is Git de weg om bij te werken, niet deze knop. -->
<p x-show="update.update_available && !update.can_apply"
class="text-xs flex items-start gap-1.5"
:class="update.apply_mode === 'local-build' ? 'text-t2 dark:text-t2-dark' : 'text-wr'">
<span class="mdi shrink-0"
:class="update.apply_mode === 'local-build' ? 'mdi-information-outline' : 'mdi-alert-outline'"></span>
<span x-text="update.apply_reason"></span>
</p>
<p x-show="update.error" class="text-xs text-er" x-text="update.error"></p>
<p x-show="update.checked_at" class="text-[11px] text-dm dark:text-dm-dark">
<span x-text="t('last_checked')"></span>: <span x-text="fmtTime(update.checked_at)"></span>
</p>
<div class="pt-2 border-t border-bd dark:border-bd-dark space-y-3">
<div>
<label class="form-label" x-text="t('channel')">Kanaal</label>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button class="seg" :class="updChannel()==='stable' ? 'seg-on' : ''"
@click="setChannel('stable')"><span class="mdi mdi-check-decagram"></span> Stable</button>
<button class="seg" :class="updChannel()==='beta' ? 'seg-on' : ''"
@click="setChannel('beta')"><span class="mdi mdi-flask-outline"></span> Beta</button>
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"
x-text="updChannel()==='beta' ? t('channel_beta_hint') : t('channel_stable_hint')"></p>
</div>
<div>
<label class="form-label" x-text="t('update_api_url')">Releases-API URL</label>
<input class="form-input font-mono" x-model="settings.UPDATE_API_URL" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="http://git.example.com/api/v1/repos/bes-r/server-up/releases">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_api_hint')">Forgejo/GitHub Releases-API. Leeg = uit.</p>
</div>
<div>
<label class="form-label" x-text="t('update_image')">Image</label>
<input class="form-input font-mono" x-model="settings.UPDATE_IMAGE" @change="autoSave(); setTimeout(()=>loadUpdate(true), 600)" placeholder="git.example.com/bes-r/server-up">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('update_image_hint')"></p>
</div>
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="form-label" x-text="t('registry_user')">Registry-gebruiker</label>
<input class="form-input" x-model="settings.UPDATE_REGISTRY_USER" @change="autoSave()" placeholder="bes-r">
</div>
<div>
<label class="form-label" x-text="t('registry_token')">Registry-token</label>
<input type="password" class="form-input" x-model="settings.UPDATE_REGISTRY_TOKEN" @change="autoSave()"
:placeholder="settings.has_update_registry_token ? t('registry_token_set') : t('registry_token_ph')">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('registry_token_hint')"></p>
</div>
</div>
<div x-show="update.previous_tag" class="pt-2 border-t border-bd dark:border-bd-dark">
<label class="form-label" x-text="t('rollback')">Terugrollen</label>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-t2 dark:text-t2-dark"><span x-text="t('previous_version')"></span>:
<span class="font-mono text-tx dark:text-tx-dark" x-text="update.previous_tag"></span></span>
<button class="btn-sm btn-danger" @click="rollbackUpdate()" :disabled="updating">
<span class="mdi mdi-undo-variant"></span> <span x-text="t('rollback_button')">Terug naar deze versie</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Taalmodule (add-on) -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-translate text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('language_addon')">Taalmodule</span>
<span class="badge badge-info" x-text="languages.length"></span>
<button class="btn-sm btn-primary ml-auto" @click="openLangEditor(null)"><span class="mdi mdi-plus"></span> <span x-text="t('add_language')">Taal toevoegen</span></button>
</div>
<div class="p-5">
<p class="text-xs text-t2 dark:text-t2-dark mb-4" x-text="t('language_addon_hint')">Voeg extra talen toe als add-on.</p>
<div class="grid sm:grid-cols-2 gap-3">
<template x-for="l in languages" :key="l.code">
<div class="flex items-center gap-3 p-3 rounded-xl border border-bd dark:border-bd-dark bg-s2/50 dark:bg-s2-dark/50">
<span class="text-2xl shrink-0" x-text="l.flag || '🌐'"></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-wh dark:text-wh-dark text-sm truncate"><span x-text="l.name"></span> <span class="font-mono text-[11px] text-dm dark:text-dm-dark" x-text="'('+l.code+')'"></span></div>
<div class="text-[11px] text-dm dark:text-dm-dark"><span x-text="l.count"></span> <span x-text="t('strings_count')">strings</span><template x-if="l.builtin"><span class="ml-1.5 badge badge-dim" x-text="t('builtin')">ingebouwd</span></template></div>
</div>
<template x-if="!l.builtin"><button class="btn-icon w-9 h-9" :title="t('edit')" @click="openLangEditor(l.code)"><span class="mdi mdi-pencil-outline"></span></button></template>
<template x-if="!l.builtin"><button class="btn-icon w-9 h-9 !text-er hover:!border-er/40" :title="t('delete')" @click="deleteLang(l.code)"><span class="mdi mdi-delete-outline"></span></button></template>
<template x-if="l.builtin"><button class="btn-icon w-9 h-9" :title="t('duplicate')" @click="openLangEditor(l.code)"><span class="mdi mdi-content-copy"></span></button></template>
</div>
</template>
</div>
</div>
</div>
<details class="card" open>
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-folder-cog-outline text-ac dark:text-ac-dark text-lg"></span> <span x-text="t('paths')">Paden</span></summary>
<div class="px-5 pb-5 grid sm:grid-cols-3 gap-4">
<div><label class="form-label" x-text="t('library_dir')">Library map</label><input class="form-input" x-model="settings.LIBRARY_DIR" @change="autoSave()"></div>
<div><label class="form-label" x-text="t('data_dir')">Data map</label><input class="form-input" x-model="settings.DATA_DIR" @change="autoSave()"></div>
<div><label class="form-label" x-text="t('backup_dir')">Backup map</label><input class="form-input" x-model="settings.BACKUP_DIR" @change="autoSave()"></div>
</div>
</details>
<details class="card">
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-source-branch text-ac dark:text-ac-dark text-lg"></span> Git Repositories</summary>
<div class="px-5 pb-5 space-y-2">
<template x-for="r in repos" :key="r.id">
<div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0">
<span class="mdi mdi-source-branch text-dm dark:text-dm-dark"></span>
<span class="font-mono text-xs flex-1 truncate" x-text="r.name"></span>
<span class="text-[10px] text-t2 dark:text-t2-dark" x-text="r.subdir||''"></span>
<button @click="syncRepo(r.id)" class="btn-icon w-9 h-9"><span class="mdi mdi-refresh"></span></button>
<button @click="deleteRepo(r.id)" class="btn-icon w-9 h-9 !text-er hover:!border-er/40"><span class="mdi mdi-delete-outline"></span></button>
</div>
</template>
<button @click="modal='add-repo'" class="btn btn-primary mt-3"><span class="mdi mdi-plus"></span> <span x-text="t('add')">Toevoegen</span></button>
</div>
</details>
<details class="card">
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-restart text-ac dark:text-ac-dark text-lg"></span> <span x-text="t('container_restart')">Container herstarten</span></summary>
<div class="px-5 pb-5">
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('container_restart_hint')">Herstart de Server Up container.</p>
<button @click="restartSelf()" class="btn btn-danger"><span class="mdi mdi-restart-alert"></span> <span x-text="t('container_restart')">Container herstarten</span></button>
</div>
</details>
</section>
</div>
</main>
</div>
<!-- ░░ Terminal ░░ -->
<div class="fixed bottom-0 right-0 left-0 bg-[#0b1018] border-t border-black/40 transition-all duration-200 z-30"
:class="[(sidebarOpen && !isMobile) ? 'md:left-[264px]' : 'left-0', termOpen ? 'translate-y-0' : 'translate-y-[184px]']">
<div @click="termOpen = !termOpen" class="h-10 px-4 flex items-center gap-2.5 border-b border-white/5 cursor-pointer">
<div class="flex gap-1.5"><span class="w-3 h-3 rounded-full bg-[#ff5f57]"></span><span class="w-3 h-3 rounded-full bg-[#febc2e]"></span><span class="w-3 h-3 rounded-full bg-[#28c840]"></span></div>
<span class="text-xs font-mono text-slate-300 flex-1"><span class="mdi mdi-console"></span> Log</span>
<span class="badge" :class="job.status==='running'?'badge-info':job.status==='error'?'badge-danger':'badge-dim'" x-text="job.status==='running' ? t('busy') : (job.status==='error' ? t('error') : (job.status==='done' ? t('done') : t('ready')))"></span>
<span class="mdi text-slate-400 text-lg" :class="termOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'"></span>
</div>
<div x-ref="termOut" class="h-44 overflow-y-auto px-4 py-3 font-mono text-xs leading-relaxed text-slate-200">
<template x-for="(line, i) in job.lines" :key="i">
<div class="flex gap-2" :class="'log-'+line.level">
<span class="w-3 text-center shrink-0" x-text="({ok:'✔',error:'✖',warn:'⚠',info:'',dim:'·',section:''})[line.level] || '·'"></span>
<span x-text="line.text"></span>
</div>
</template>
<div x-show="job.lines.length === 0" class="text-slate-500"><span x-text="t('ready')">gereed</span></div>
</div>
</div>
<!-- ░░ Toasts ░░ -->
<div class="fixed bottom-6 right-6 z-[70] flex flex-col gap-2 items-end pointer-events-none">
<template x-for="ts in toasts" :key="ts.id">
<div class="anim pointer-events-auto pl-3 pr-4 py-2.5 rounded-xl border bg-s1 dark:bg-s1-dark shadow-pop flex items-center gap-2.5 text-sm font-medium"
:class="({ok:'border-ok/30',er:'border-er/30',wr:'border-wr/30',in:'border-bl/30'})[ts.kind]">
<span class="mdi text-lg" :class="({ok:'mdi-check-circle text-ok',er:'mdi-alert-circle text-er',wr:'mdi-alert text-wr',in:'mdi-information text-bl'})[ts.kind]"></span>
<span class="text-tx dark:text-tx-dark" x-text="ts.msg"></span>
</div>
</template>
</div>
<!-- ░░ Modals ░░ -->
<div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] grid place-items-center p-4">
<!-- Add repo -->
<div x-show="modal==='add-repo'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-store-plus-outline text-ac dark:text-ac-dark"></span> <span x-text="t('add_repo')">Repository toevoegen</span></div>
<div class="modal-body space-y-3">
<div><label class="form-label" x-text="t('repo_name')">Naam</label><input class="form-input" x-model="newRepo.name"></div>
<div><label class="form-label" x-text="t('repo_url')">Git URL</label><input class="form-input" x-model="newRepo.url" placeholder="https://github.com/..."></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="form-label" x-text="t('repo_branch')">Branch</label><input class="form-input" x-model="newRepo.branch"></div>
<div><label class="form-label" x-text="t('repo_subdir')">Submap</label><input class="form-input" x-model="newRepo.subdir" placeholder="apps"></div>
</div>
<p class="text-[11px] text-t2 dark:text-t2-dark">Tip: voor de ChristianLempa Boilerplates gebruik je submap <code class="font-mono text-ac dark:text-ac-dark">compose</code>.</p>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="addRepo()"><span class="mdi mdi-plus"></span> <span x-text="t('add')">Toevoegen</span></button>
</div>
</div>
<!-- Backups van één stack -->
<div x-show="modal==='backups'" class="modal max-w-xl flex flex-col max-h-[85vh]">
<div class="modal-head">
<span class="mdi mdi-history text-ac dark:text-ac-dark"></span>
<span x-text="t('backups')">Backups</span>
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="bkStack"></span>
<button class="btn-sm ml-auto" @click="createBackup(bkStack)">
<span class="mdi mdi-archive-arrow-down-outline"></span> <span x-text="t('backup')">Backup</span>
</button>
</div>
<div class="modal-body overflow-y-auto">
<p class="text-[11px] text-wr mb-3 flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('backup_scope_warning')"></span>
</p>
<div class="space-y-2">
<template x-for="b in bkList" :key="b.file">
<div class="flex flex-wrap items-center gap-2 px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
<span class="mdi mdi-package-variant-closed text-t2 dark:text-t2-dark"></span>
<div class="min-w-0 flex-1">
<div class="text-xs font-mono text-tx dark:text-tx-dark truncate" x-text="b.file"></div>
<div class="text-[10px] text-dm dark:text-dm-dark">
<span x-text="fmtTime(b.ts)"></span> · <span x-text="b.size_human"></span>
<span x-show="b.reason"> · <span x-text="b.reason"></span></span>
</div>
</div>
<button class="btn-sm !h-8" @click="restoreBackup(bkStack, b.file)">
<span class="mdi mdi-restore"></span> <span x-text="t('restore')">Terugzetten</span>
</button>
<button class="btn-sm !h-8 !px-2 btn-danger" @click="deleteBackup(b.file)">
<span class="mdi mdi-delete-outline"></span>
</button>
</div>
</template>
</div>
<div x-show="bkList.length === 0" class="empty !py-8">
<span class="mdi mdi-archive-outline"></span><p x-text="t('no_backups')">Nog geen backups</p>
</div>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button>
</div>
</div>
<!-- Netwerk toevoegen -->
<div x-show="modal==='add-network'" class="modal max-w-xl flex flex-col max-h-[88vh]">
<div class="modal-head"><span class="mdi mdi-lan-connect text-ac dark:text-ac-dark"></span> <span x-text="t('add_network')">Netwerk toevoegen</span></div>
<div class="modal-body overflow-y-auto space-y-4">
<!-- Gedetecteerd: één klik vult alles in -->
<template x-if="netDetected.length">
<div>
<div class="form-label" x-text="t('detected_networks')">Gevonden op deze server</div>
<div class="space-y-2">
<template x-for="d in netDetected" :key="d.interface">
<button type="button" @click="useDetected(d)"
class="w-full text-left px-3.5 py-3 rounded-xl border transition"
:class="newNet.parent === d.interface ? 'border-ac bg-ac/5' : 'border-bd dark:border-bd-dark hover:border-bd2 dark:hover:border-bd2-dark'">
<div class="flex items-center gap-2 mb-1">
<span class="mdi mdi-ethernet text-ac dark:text-ac-dark"></span>
<span class="font-semibold text-sm text-wh dark:text-wh-dark font-mono" x-text="d.interface"></span>
<span class="badge ml-auto" :class="d.configured ? 'badge-ok' : 'badge-dim'"
x-text="d.configured ? t('already_added') : t('use_this')">Gebruiken</span>
</div>
<div class="text-[11px] font-mono text-t2 dark:text-t2-dark grid gap-0.5">
<div><span x-text="t('subnet')"></span>: <span x-text="d.subnet"></span></div>
<div><span x-text="t('gateway')"></span>: <span x-text="d.gateway"></span></div>
<div><span x-text="t('ip_range')"></span>: <span x-text="d.suggested_range"></span></div>
</div>
</button>
</template>
</div>
</div>
</template>
<p x-show="!netDetected.length && netDetectError" class="text-[11px] text-wr flex items-start gap-1.5">
<span class="mdi mdi-alert-outline shrink-0"></span>
<span><span x-text="t('detect_failed')"></span> <span class="font-mono" x-text="netDetectError"></span></span>
</p>
<!-- Detectie gelukt maar niets bruikbaars gevonden: dan mag het blok niet
stilzwijgend leeg blijven. -->
<p x-show="!netDetected.length && !netDetectError" class="text-[11px] text-t2 dark:text-t2-dark flex items-start gap-1.5">
<span class="mdi mdi-information-outline shrink-0"></span>
<span x-text="t('detect_empty')"></span>
</p>
<!-- Uitleg: hoe subnet, gateway en range zich tot elkaar verhouden -->
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark bg-s2/60 dark:bg-s2-dark/60 flex items-center gap-2">
<span class="mdi mdi-help-circle-outline"></span> <span x-text="t('how_it_fits')">Hoe hangt dit samen?</span>
</summary>
<div class="p-3.5 text-[11px] text-t2 dark:text-t2-dark space-y-2 leading-relaxed">
<p><strong x-text="t('subnet')"></strong><span x-text="t('tip_subnet')"></span></p>
<p><strong x-text="t('gateway')"></strong><span x-text="t('tip_gateway')"></span></p>
<p><strong x-text="t('ip_range')"></strong><span x-text="t('tip_range')"></span></p>
<p><strong x-text="t('host_interface')"></strong><span x-text="t('tip_parent')"></span></p>
<div class="pt-2 border-t border-bd dark:border-bd-dark">
<p class="font-semibold text-tx dark:text-tx-dark mb-1" x-text="t('tip_example_title')">Voorbeeld</p>
<pre class="font-mono text-[10px] leading-snug whitespace-pre-wrap" x-text="t('tip_example')"></pre>
</div>
</div>
</details>
<div class="grid gap-3">
<div><label class="form-label" x-text="t('lang_name')">Naam</label>
<input class="form-input" x-model="newNet.name" @input="reviewNet()" placeholder="lan"></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="form-label" x-text="t('driver')">Driver</label>
<select class="form-input" x-model="newNet.driver" @change="reviewNet()">
<option value="macvlan">macvlan</option>
<option value="ipvlan">ipvlan</option>
</select></div>
<div><label class="form-label" x-text="t('host_interface')">Host-interface</label>
<input class="form-input font-mono" x-model="newNet.parent" @input="reviewNet()" placeholder="eth0" list="ifaces">
<datalist id="ifaces"><template x-for="i in netIfaces" :key="i"><option :value="i"></option></template></datalist>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div><label class="form-label" x-text="t('subnet')">Subnet</label>
<input class="form-input font-mono" x-model="newNet.subnet" @input="reviewNet()" placeholder="192.168.1.0/24"></div>
<div><label class="form-label" x-text="t('gateway')">Gateway</label>
<input class="form-input font-mono" x-model="newNet.gateway" @input="reviewNet()" placeholder="192.168.1.1"></div>
</div>
<div><label class="form-label" x-text="t('ip_range')">IP-range</label>
<div class="flex gap-2">
<input class="form-input font-mono" x-model="newNet.ip_range" @input="reviewNet()" placeholder="192.168.1.240/28">
<button type="button" class="btn-sm shrink-0" @click="suggestRange()" :title="t('suggest_range')">
<span class="mdi mdi-auto-fix"></span>
</button>
</div>
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('ip_range_hint')"></p>
<!-- Van-tot omrekenen. CIDR kan alleen uitgelijnde blokken van een
macht van twee, dus '.200 tot .254' bestaat niet als notatie. -->
<details class="mt-2 rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
<summary class="px-3 py-2 cursor-pointer text-[11px] font-bold text-wh dark:text-wh-dark bg-s2/60 dark:bg-s2-dark/60 flex items-center gap-1.5">
<span class="mdi mdi-calculator-variant-outline"></span>
<span x-text="t('range_helper')">Ik wil een bepaald bereik</span>
</summary>
<div class="p-3 space-y-2">
<div class="flex items-center gap-2">
<span class="text-[11px] text-t2 dark:text-t2-dark shrink-0" x-text="t('from')">van</span>
<input class="form-input !h-9 font-mono !text-xs" x-model="rangeCalc.first"
@input="calcRange()" placeholder="10.0.20.200">
<span class="text-[11px] text-t2 dark:text-t2-dark shrink-0" x-text="t('to')">tot</span>
<input class="form-input !h-9 font-mono !text-xs" x-model="rangeCalc.last"
@input="calcRange()" placeholder="10.0.20.254">
</div>
<p class="text-[11px] text-dm dark:text-dm-dark" x-text="t('range_helper_hint')"></p>
<p x-show="rangeCalc.msg" class="text-[11px] text-wr" x-text="rangeCalc.msg"></p>
<template x-for="o in (rangeCalc.options || [])" :key="o.cidr">
<button type="button" @click="newNet.ip_range = o.cidr; reviewNet()"
class="w-full text-left px-3 py-2 rounded-lg border border-bd dark:border-bd-dark hover:border-ac transition">
<div class="flex items-center gap-2">
<span class="font-mono text-xs font-semibold text-wh dark:text-wh-dark" x-text="o.cidr"></span>
<span class="badge" :class="o.kind === 'ruim' ? 'badge-ok' : 'badge-dim'"
x-text="t('range_' + o.kind)"></span>
<span class="text-[11px] text-t2 dark:text-t2-dark ml-auto"
x-text="t('range_count', {count: o.count})"></span>
</div>
<div class="text-[10px] font-mono text-dm dark:text-dm-dark mt-0.5"
x-text="o.first + ' … ' + o.last"></div>
</button>
</template>
</div>
</details>
</div>
</div>
<!-- Live beoordeling -->
<div x-show="netReview.info && netReview.info.count" class="rounded-xl border border-ok/30 bg-ok/5 px-3.5 py-2.5">
<div class="text-[11px] text-tx dark:text-tx-dark"
x-text="t('range_result', {count: (netReview.info||{}).count, first: (netReview.info||{}).first, last: (netReview.info||{}).last})"></div>
</div>
<template x-for="e in (netReview.errors || [])" :key="e">
<p class="text-[11px] text-er flex items-start gap-1.5">
<span class="mdi mdi-close-circle-outline shrink-0"></span><span x-text="e"></span></p>
</template>
<template x-for="w in (netReview.warnings || [])" :key="w">
<p class="text-[11px] text-wr flex items-start gap-1.5">
<span class="mdi mdi-alert-outline shrink-0"></span><span x-text="w"></span></p>
</template>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="addNetwork()">
<span class="mdi mdi-plus"></span> <span x-text="t('add')">Aanmaken</span>
</button>
</div>
</div>
<!-- Install -->
<div x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
<div class="modal-head">
<span class="mdi mdi-download-outline text-ac dark:text-ac-dark"></span>
<span x-text="t('install_stack')">Stack installeren</span>
<span x-show="install.format === 'boilerplate'" class="badge badge-info">Boilerplate</span>
</div>
<div class="modal-body overflow-y-auto space-y-3">
<div><label class="form-label">Stack</label><input class="form-input" :value="install.stack" readonly></div>
<div>
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
<input class="form-input" x-model="install.instance">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
</div>
<!-- Netwerk: poorten op de host, of een eigen IP-adres (macvlan/ipvlan) -->
<div>
<label class="form-label" x-text="t('network')">Netwerk</label>
<select class="form-input" x-model="install.network" @change="pickIp()">
<option value="" x-text="t('ports_on_host')">Poorten op de host</option>
<template x-for="n in netList" :key="n.name">
<option :value="n.name" x-text="n.name + ' — ' + t('own_ip') + ' (' + n.subnet + ')'"></option>
</template>
</select>
<template x-if="install.network">
<div class="mt-2">
<label class="form-label" x-text="t('ip_address')">IP-adres</label>
<input class="form-input font-mono" x-model="install.ip" placeholder="192.168.1.240">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('own_ip_hint')"></p>
</div>
</template>
<p x-show="!install.network && netList.length === 0"
class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('no_networks_hint')"></p>
</div>
<div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div>
<!-- Boilerplate fields -->
<template x-if="install.format === 'boilerplate' && !install.loading">
<div class="space-y-3">
<template x-for="grp in groupedFields" :key="grp.name">
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden" :open="!grp.toggle || !!install.values[grp.toggle]">
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60">
<span x-text="grp.name"></span>
<template x-if="grp.toggle">
<label class="ml-auto inline-flex items-center cursor-pointer" @click.stop>
<input type="checkbox" :checked="!!install.values[grp.toggle]" @change="install.values[grp.toggle] = $event.target.checked" class="sr-only peer">
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
</label>
</template>
</summary>
<div class="px-3.5 py-3 grid sm:grid-cols-2 gap-3" x-show="!grp.toggle || install.values[grp.toggle]">
<template x-for="f in grp.items" :key="f.name">
<div x-show="bpFieldVisible(f)" :class="f.type==='bool' ? 'sm:col-span-2' : ''">
<label class="form-label flex items-center gap-1"><span x-text="f.title"></span><span x-show="f.required" class="text-er">*</span></label>
<template x-if="f.type === 'enum'">
<select class="form-input" x-model="install.values[f.name]"><template x-for="opt in f.options" :key="opt"><option :value="opt" x-text="opt"></option></template></select>
</template>
<template x-if="f.type === 'bool'">
<label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label>
</template>
<template x-if="f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
<template x-if="f.type === 'str' && !f.secret"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template>
<!-- Geheim veld: genereerknop, zodat niemand 'admin123' invult -->
<template x-if="f.type === 'str' && f.secret">
<div class="flex gap-2">
<input class="form-input font-mono !text-xs" x-model="install.values[f.name]" :placeholder="f.placeholder">
<button type="button" class="btn-sm shrink-0" :title="t('generate_value')"
@click="install.values[f.name] = randomSecret()"><span class="mdi mdi-dice-5-outline"></span></button>
</div>
</template>
<p x-show="f.port_taken" class="text-[11px] text-wr mt-1"
x-text="t('port_taken', {port: f.port_taken, suggested: f.suggested})"></p>
<p x-show="f.description && f.type !== 'bool'" class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="f.description"></p>
</div>
</template>
</div>
</details>
</template>
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60"><span class="mdi mdi-eye-outline"></span> Preview</summary>
<div class="p-3"><pre class="bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto max-h-64" x-text="bpPreview()"></pre></div>
</details>
</div>
</template>
<!-- Legacy fields -->
<template x-if="install.format === 'compose' && !install.loading">
<div class="space-y-4">
<template x-if="install.images.length">
<div>
<div class="text-[10px] font-bold tracking-wider uppercase text-pu mb-2"><span class="mdi mdi-docker"></span> Image Tags</div>
<template x-for="img in install.images" :key="img.service">
<div class="flex gap-2 items-center mb-1.5">
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1 truncate" x-text="img.service+': '+img.repo"></span>
<input class="form-input !w-36" :placeholder="img.tag" x-model="install.imageTags[img.service]">
</div>
</template>
</div>
</template>
<template x-if="install.envVars.length">
<div>
<div class="text-[10px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-2"><span class="mdi mdi-file-cog-outline"></span> Omgevingsvariabelen</div>
<template x-for="ev in install.envVars" :key="ev.key">
<div class="flex gap-2 items-center mb-1.5">
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1 truncate" :title="ev.key" x-text="ev.key"></span>
<input class="form-input !w-44" :placeholder="ev.value" x-model="install.envOverrides[ev.key]">
</div>
</template>
</div>
</template>
</div>
</template>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="doInstall()"><span class="mdi mdi-rocket-launch"></span> <span x-text="t('install')">Installeren</span></button>
</div>
</div>
<!-- Remove confirm -->
<div x-show="modal==='remove'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-delete-alert-outline text-er"></span> <span x-text="t('remove_stack')">Verwijderen</span></div>
<div class="modal-body">
<p class="text-sm mb-4 text-t2 dark:text-t2-dark">Stack: <strong class="text-wh dark:text-wh-dark" x-text="install.stack"></strong></p>
<div class="flex flex-col gap-2">
<button class="btn" @click="doRemove('stack')"><span class="mdi mdi-stop-circle-outline"></span> <span x-text="t('remove_stack_only')">Stop + volumes</span></button>
<button class="btn btn-danger" @click="doRemove('all')"><span class="mdi mdi-delete-outline"></span> <span x-text="t('remove_all')">Verwijder alles</span></button>
</div>
</div>
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button></div>
</div>
<!-- Editor -->
<div x-show="modal==='editor'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
<div class="modal-head"><span class="mdi" :class="editor.type==='env' ? 'mdi-file-cog-outline' : 'mdi-file-code-outline'"></span> <span x-text="editor.title"></span></div>
<div class="modal-body flex-1 flex"><textarea x-model="editor.content" class="flex-1 form-input !bg-[#0b1018] !text-slate-200 !border-black/30 font-mono text-xs min-h-[300px] leading-relaxed"></textarea></div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="saveEditor()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
</div>
</div>
<!-- Logs -->
<div x-show="modal==='logs'" class="modal max-w-3xl flex flex-col max-h-[88vh]">
<div class="modal-head"><span class="mdi mdi-text-box-outline"></span> <span x-text="logs.title"></span></div>
<div class="modal-body flex-1 overflow-hidden flex"><pre class="flex-1 bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto whitespace-pre-wrap break-all" x-text="logs.content"></pre></div>
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button></div>
</div>
<!-- Language editor -->
<div x-show="modal==='lang'" class="modal max-w-2xl flex flex-col max-h-[90vh]">
<div class="modal-head"><span class="mdi mdi-translate text-ac dark:text-ac-dark"></span> <span x-text="langEdit.isNew ? t('add_language') : t('edit_language')">Taal</span></div>
<div class="modal-body flex flex-col gap-3 overflow-hidden flex-1">
<div class="grid sm:grid-cols-3 gap-3">
<div><label class="form-label" x-text="t('lang_code')">Code</label><input class="form-input" x-model="langEdit.code" :readonly="!langEdit.isNew" placeholder="de, fr, pt-br"></div>
<div><label class="form-label" x-text="t('lang_name')">Naam</label><input class="form-input" x-model="langEdit.name" placeholder="Deutsch"></div>
<div><label class="form-label" x-text="t('lang_flag')">Vlag</label><input class="form-input" x-model="langEdit.flag" placeholder="🇩🇪" maxlength="8"></div>
</div>
<div class="relative">
<span class="mdi mdi-magnify absolute left-3.5 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark text-lg"></span>
<input x-model="langEdit.query" :placeholder="t('search_keys')" class="form-input !pl-10">
</div>
<div class="flex-1 overflow-y-auto -mx-1 px-1 space-y-2">
<template x-for="k in langKeysFiltered" :key="k">
<div class="grid sm:grid-cols-2 gap-2 items-center">
<div class="text-xs min-w-0"><div class="font-mono text-[11px] text-ac dark:text-ac-dark truncate" x-text="k"></div><div class="text-t2 dark:text-t2-dark text-[11px] truncate" :title="langEdit.ref[k]" x-text="langEdit.ref[k]"></div></div>
<input class="form-input" x-model="langEdit.values[k]" :placeholder="langEdit.ref[k]">
</div>
</template>
<p x-show="langKeysFiltered.length===0" class="text-center text-dm dark:text-dm-dark text-xs py-6" x-text="t('no_results')">Geen resultaten</p>
</div>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="saveLang()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
</div>
</div>
</div>
<!-- ░░ First-run wizard ░░ -->
<div x-show="wizard.open" x-cloak class="fixed inset-0 bg-slate-900/80 backdrop-blur z-[80] grid place-items-center p-4">
<div class="bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full max-w-xl shadow-pop overflow-hidden">
<div class="h-1.5 bg-s2 dark:bg-s2-dark"><div class="h-full bg-gradient-to-r from-ac to-ac2 transition-all" :style="`width:${(wizard.step+1)/(wizard.steps.length||1)*100}%`"></div></div>
<div class="p-7 min-h-[270px]">
<h2 class="text-2xl font-extrabold text-wh dark:text-wh-dark mb-1 tracking-tight" x-text="wizard.steps[wizard.step]?.title || ''"></h2>
<p class="text-sm text-t2 dark:text-t2-dark mb-6" x-text="wizard.steps[wizard.step]?.subtitle || ''"></p>
<div x-show="wizard.step === 0">
<p class="text-sm">Doorloop deze korte wizard om Server Up te configureren.</p>
<ul class="text-sm mt-4 space-y-2 text-t2 dark:text-t2-dark">
<li class="flex gap-2"><span class="mdi mdi-check-circle text-ok"></span> Beheer Docker stacks via één UI</li>
<li class="flex gap-2"><span class="mdi mdi-check-circle text-ok"></span> Installeer apps uit je eigen repo of de Boilerplates</li>
<li class="flex gap-2"><span class="mdi mdi-check-circle text-ok"></span> Modules toevoegen voor MQTT, Home Assistant, etc.</li>
</ul>
</div>
<div x-show="wizard.step === 1" class="space-y-3">
<div><label class="form-label">Library map</label><input class="form-input" x-model="wizard.LIBRARY_DIR"></div>
<div><label class="form-label">Data map</label><input class="form-input" x-model="wizard.DATA_DIR"></div>
<div><label class="form-label">Backup map</label><input class="form-input" x-model="wizard.BACKUP_DIR"></div>
</div>
<div x-show="wizard.step === 2">
<p class="text-sm mb-3">Server Up start met deze repos. Je kunt later meer toevoegen via Instellingen → Git Repositories.</p>
<ul class="text-sm space-y-2.5">
<li class="flex items-start gap-2"><span class="mdi mdi-check-circle text-ok mt-0.5"></span><span><strong>bes-r/server-up</strong> — eigen stacks &amp; modules <span class="text-dm dark:text-dm-dark">(submap: apps / modules)</span></span></li>
<li class="flex items-start gap-2"><span class="mdi mdi-check-circle text-ok mt-0.5"></span><span><strong>ChristianLempa/boilerplates-library</strong> — community templates <span class="text-dm dark:text-dm-dark">(submap: compose)</span></span></li>
</ul>
</div>
<div x-show="wizard.step === 3" class="space-y-3">
<div class="grid grid-cols-3 gap-2">
<button @click="wizard.git.choice = 'existing'" :class="wizard.git.choice === 'existing' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
<span class="mdi mdi-server text-2xl"></span><span class="font-semibold">Al een server</span><span class="text-[10px] text-center opacity-70">Gitea / Forgejo of andere git</span>
</button>
<button @click="wizard.git.choice = 'install'" :class="wizard.git.choice === 'install' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
<span class="mdi mdi-download-circle-outline text-2xl"></span><span class="font-semibold">Installeer Forgejo</span><span class="text-[10px] text-center opacity-70">Forgejo als stack</span>
</button>
<button @click="wizard.git.choice = 'skip'" :class="wizard.git.choice === 'skip' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
<span class="mdi mdi-skip-next-circle-outline text-2xl"></span><span class="font-semibold">Overslaan</span><span class="text-[10px] text-center opacity-70">Later toe te voegen</span>
</button>
</div>
<div x-show="wizard.git.choice === 'existing'" class="space-y-2 pt-1">
<div><label class="form-label">Repo URL</label><input class="form-input" x-model="wizard.git.url" placeholder="https://gitea.example.com/gebruiker/boilerplates.git"></div>
<div><label class="form-label">Token <span class="normal-case font-normal opacity-60">(optioneel)</span></label><input class="form-input" type="password" x-model="wizard.git.token" placeholder="glpat-…"></div>
<div><label class="form-label">Submap <span class="normal-case font-normal opacity-60">(optioneel)</span></label><input class="form-input" x-model="wizard.git.subdir" placeholder="apps"></div>
</div>
<div x-show="wizard.git.choice === 'install'" class="space-y-2 pt-1">
<div class="grid grid-cols-2 gap-2">
<div><label class="form-label">Web poort</label><input class="form-input" type="number" x-model.number="wizard.git.port_web" placeholder="3000"></div>
<div><label class="form-label">SSH poort</label><input class="form-input" type="number" x-model.number="wizard.git.port_ssh" placeholder="2222"></div>
</div>
<p class="text-[11px] text-t2 dark:text-t2-dark">Forgejo wordt geïnstalleerd en gestart. Daarna kun je er zelf repos in aanmaken.</p>
</div>
</div>
<div x-show="wizard.step === 4"><p class="text-sm">We synchroniseren nu de repos en zijn klaar voor gebruik.</p></div>
</div>
<div class="px-7 py-4 border-t border-bd dark:border-bd-dark flex justify-end gap-2">
<button class="btn" @click="wizard.open = false" x-text="t('close')">Sluiten</button>
<button class="btn" @click="wizard.step = Math.max(0, wizard.step-1)" :disabled="wizard.step===0" x-text="t('prev')">Terug</button>
<button class="btn btn-primary" @click="wizardNext()"><span x-text="wizard.step < wizard.steps.length-1 ? 'Volgende' : 'Klaar'"></span></button>
</div>
</div>
</div>
<!-- ░░ Component classes ░░ -->
<style type="text/tailwindcss">
@layer components {
.btn { @apply inline-flex items-center justify-center gap-2 px-4 h-11 rounded-xl border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-sm font-semibold shadow-soft hover:border-bd2 dark:hover:border-bd2-dark hover:bg-s2 dark:hover:bg-s2-dark active:scale-[.98] transition cursor-pointer; }
.btn-primary{ @apply !bg-ac !text-white !border-ac shadow-soft2 hover:!bg-ac2 hover:!border-ac2; }
.btn-danger { @apply !text-er !border-er/30 !bg-er/5 hover:!bg-er/10 hover:!border-er/40; }
.btn-icon { @apply w-10 h-10 grid place-items-center rounded-xl border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark hover:border-bd2 dark:hover:border-bd2-dark active:scale-95 transition cursor-pointer shrink-0; }
.btn-sm { @apply inline-flex items-center justify-center gap-1.5 px-3 h-9 rounded-lg border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-xs font-semibold hover:bg-s2 dark:hover:bg-s2-dark hover:border-bd2 dark:hover:border-bd2-dark active:scale-95 cursor-pointer transition shrink-0; }
.seg { @apply inline-flex items-center gap-1.5 px-3 h-9 rounded-lg text-xs font-semibold text-t2 dark:text-t2-dark cursor-pointer transition; }
.seg-on { @apply bg-s1 dark:bg-s1-dark text-ac dark:text-ac-dark shadow-soft; }
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft; }
.card-hover { @apply hover:shadow-soft2 hover:-translate-y-0.5 transition duration-200; }
.card-pad { @apply p-5; }
.nav-link { @apply flex items-center gap-3 px-3.5 h-11 rounded-xl text-sm font-semibold text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark cursor-pointer transition mb-0.5; }
.nav-active { @apply bg-ac/10 !text-ac dark:!text-ac-dark; }
.stat-card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft p-4 sm:p-5 flex items-center gap-4; }
.stat-icon { @apply w-12 h-12 rounded-xl grid place-items-center text-2xl shrink-0; }
.stat-value { @apply text-2xl sm:text-3xl font-extrabold text-wh dark:text-wh-dark leading-none tracking-tight; }
.stat-label { @apply text-[11px] text-t2 dark:text-t2-dark mt-1.5 uppercase tracking-wider font-semibold truncate; }
.form-label { @apply block text-[11px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5; }
.form-input { @apply w-full bg-s1 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-xl px-3.5 h-11 text-sm text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-4 focus:ring-ac/10 transition placeholder:text-dm dark:placeholder:text-dm-dark; }
textarea.form-input { @apply h-auto py-2.5; }
.badge { @apply inline-flex items-center text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-md; }
.badge-ok { @apply bg-ok/10 text-ok; }
.badge-warn { @apply bg-wr/10 text-wr; }
.badge-info { @apply bg-ac/10 text-ac dark:text-ac-dark; }
.badge-danger{@apply bg-er/10 text-er; }
.badge-dim { @apply bg-s2 dark:bg-s3-dark text-t2 dark:text-t2-dark; }
.empty { @apply text-center py-16 text-dm dark:text-dm-dark; }
.empty span { @apply text-5xl opacity-40 block mb-2; }
.empty p { @apply text-sm; }
.modal { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full shadow-pop; }
.modal-head { @apply px-5 py-4 text-base font-extrabold text-wh dark:text-wh-dark flex items-center gap-2.5 border-b border-bd dark:border-bd-dark; }
.modal-body { @apply px-5 py-4; }
.modal-foot { @apply px-5 py-4 flex justify-end gap-2 border-t border-bd dark:border-bd-dark; }
}
</style>
<script>
function app() {
return {
version: '', // komt van de server (VERSION-bestand), niet hier hardcoden
page: location.hash.slice(1) || 'dashboard',
isMobile: window.innerWidth < 768,
sidebarOpen: window.innerWidth >= 768 && localStorage.getItem('sidebar') !== 'closed',
termOpen: false,
themePref: localStorage.getItem('theme') || 'system',
theme: 'light',
saved: false,
saveTimer: null,
toasts: [],
modal: null,
// Authenticatie. De app-shell wordt pas getoond als auth.authenticated waar
// is; tot die tijd staat het login- of setupscherm ervoor.
auth: { ready: false, authenticated: false, user: '', needsSetup: false, mode: 'local', csrf: '' },
loginForm: { username: '', password: '', password2: '', error: '', busy: false },
users: [], roles: ['admin','operator','viewer'],
newUser: { username: '', password: '', role: 'operator' },
pwForm: { current: '', new: '', new2: '' },
authCfg: { mode: 'local', proxy_header: 'Remote-User', trusted_text: '' },
// Uitklapbaar containerpaneel per stack: { <stack>: {open, loading, items} }
cpanel: {},
// Backups en updatestatus per stack
bkStack: '', bkList: [], bkSettings: {},
stackUpd: {}, checkingUpd: false,
// Macvlan/ipvlan-netwerken waarmee stacks een eigen IP krijgen
netList: [], netIfaces: [], netDetected: [], netDetectError: '',
netReview: {}, netReviewTimer: null, netReviewSeq: 0,
rangeCalc: { first: '', last: '', options: [], msg: '' }, rangeCalcTimer: null,
newNet: { name: '', driver: 'macvlan', parent: '', subnet: '', gateway: '', ip_range: '' },
docker: { running: 0, containers: 0, images: 0 },
stacks: [],
stackQuery: '',
storeQuery: '', storeTag: '',
images: [],
audit: [],
repos: [],
storeStacks: {},
settings: {},
languages: [],
modules: [],
update: { enabled:false, update_available:false, current:'', latest:'', latest_tag:'',
url:'', notes:'', prerelease:false, channel:'stable', can_apply:false,
apply_reason:'', previous_tag:'', checked_at:0 },
updating: false,
T: {},
job: { id: null, lines: [], status: 'idle', timer: null },
newRepo: { name:'', url:'', branch:'main', subdir:'apps' },
install: {
stack: '', repo_id: '', instance: '',
format: 'compose', loading: false,
envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {}
},
editor: { stack:'', type:'env', title:'', content:'' },
logs: { title:'', content:'' },
langEdit: { isNew:true, code:'', name:'', flag:'', keys:[], ref:{}, values:{}, query:'' },
wizard: { open:false, step:0, steps: [], LIBRARY_DIR:'', DATA_DIR:'', BACKUP_DIR:'',
git: { choice:'skip', url:'', token:'', subdir:'', port_web:3000, port_ssh:2222 } },
navItems: [
{id:'dashboard', icon:'mdi-view-dashboard-outline', label:'dashboard'},
{id:'stacks', icon:'mdi-server-network', label:'stacks'},
{id:'app_store', icon:'mdi-store-outline', label:'app_store'},
{id:'docker', icon:'mdi-docker', label:'images'},
{id:'audit', icon:'mdi-clipboard-text-outline', label:'audit'},
{id:'settings', icon:'mdi-cog-outline', label:'settings'},
],
async init() {
this.applyThemePref();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.themePref === 'system') this.applyThemePref();
});
}
window.addEventListener('hashchange', () => this.page = location.hash.slice(1) || 'dashboard');
window.addEventListener('resize', () => {
const m = window.innerWidth < 768;
if (m !== this.isMobile) { this.isMobile = m; if (m) this.sidebarOpen = false; else this.sidebarOpen = localStorage.getItem('sidebar') !== 'closed'; }
});
await this.loadLangs();
await this.loadLang(localStorage.getItem('lang') || 'nl');
await this.loadAuth();
if (!this.auth.authenticated) return; // login-/setupscherm neemt het over
await this.startApp();
},
// Alles wat een ingelogde sessie vereist. Wordt ook na een succesvolle
// login aangeroepen, zodat je niet hoeft te herladen.
async startApp() {
await this.checkWizard();
await this.loadDocker();
await this.loadModules();
await this.loadDashboard();
this.loadUpdate();
setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000);
this.$watch('page', async (p) => {
if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); }
if (p === 'app_store') this.loadAppStore();
if (p === 'docker') this.loadImages();
if (p === 'audit') this.loadAudit();
if (p === 'settings') this.loadSettings();
});
},
applyThemePref() {
const sys = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
this.theme = this.themePref === 'system' ? (sys ? 'dark' : 'light') : this.themePref;
},
setThemePref(p) { this.themePref = p; localStorage.setItem('theme', p); this.applyThemePref(); this.autoSave(); },
toggleTheme() { this.setThemePref(this.theme === 'dark' ? 'light' : 'dark'); },
// t('key') zoekt de vertaling op; t('key', {n: 3}) vervangt daarnaast
// {n}-plaatshouders, zodat een zin niet in losse stukjes hoeft.
t(k, vars) {
let s = this.T[k] || k;
if (vars) for (const [naam, waarde] of Object.entries(vars)) {
s = s.split('{' + naam + '}').join(waarde);
}
return s;
},
async loadLangs() { try { const d = await fetch('/api/i18n').then(r=>r.json()); this.languages = d.languages || []; } catch(e) {} },
async loadLang(code) { try { const d = await fetch('/api/i18n/'+code).then(r=>r.json()); this.T = d.strings || {}; } catch(e) {} },
setLang(c) { localStorage.setItem('lang', c); this.loadLang(c); },
async openLangEditor(code) {
const ref = await this.rpc('/api/i18n/keys');
const keys = ref.keys || [];
const refMap = ref.en || {};
let values = {}, meta = { code:'', name:'', flag:'' }, isNew = true;
if (code) {
const raw = await this.rpc('/api/i18n/'+code+'/raw');
const existing = this.languages.find(l => l.code === code) || {};
if (existing.builtin) {
values = { ...(raw.strings || {}) };
meta = { code:'', name: (existing.name||'')+' (kopie)', flag: existing.flag||'' };
isNew = true;
} else {
values = { ...(raw.strings || {}) };
meta = { code, name: existing.name||'', flag: existing.flag||'' };
isNew = false;
}
}
this.langEdit = { isNew, ...meta, keys, ref: refMap, values, query:'' };
this.modal = 'lang';
},
get langKeysFiltered() {
const q = (this.langEdit.query||'').toLowerCase();
if (!q) return this.langEdit.keys;
return this.langEdit.keys.filter(k =>
k.toLowerCase().includes(q) || String(this.langEdit.ref[k]||'').toLowerCase().includes(q));
},
async saveLang() {
const e = this.langEdit;
if (!e.code.trim()) { this.toast(this.t('lang_code')+' ?', 'wr'); return; }
const strings = {};
for (const [k,v] of Object.entries(e.values)) { if (v !== undefined && v !== null && String(v).trim() !== '') strings[k] = v; }
const d = await this.rpc('/api/i18n', { code: e.code.trim().toLowerCase(), name: e.name.trim(), flag: e.flag.trim(), strings });
if (d.ok) {
this.toast(d.msg || this.t('saved'), 'ok');
this.modal = null;
await this.loadLangs();
if (d.code && d.code === (localStorage.getItem('lang') || 'nl')) await this.loadLang(d.code);
} else { this.toast(d.msg || 'Fout', 'er'); }
},
async deleteLang(code) {
if (!confirm(this.t('delete_language_confirm') || ('Taal '+code+' verwijderen?'))) return;
const d = await this.req('/api/i18n/'+code, { method:'DELETE' });
if (d.ok) {
this.toast(d.msg || this.t('deleted'), 'ok');
if (localStorage.getItem('lang') === code) this.setLang('nl');
await this.loadLangs();
} else { this.toast(d.msg || 'Fout', 'er'); }
},
toast(msg, kind='in') {
const id = Date.now()+Math.random();
this.toasts.push({id, msg, kind});
setTimeout(() => this.toasts = this.toasts.filter(t=>t.id!==id), 2500);
},
// ── Authenticatie ────────────────────────────────────────────────────────
async loadAuth() {
try {
const d = await (await fetch('/api/auth/me')).json();
this.auth = { ready: true, authenticated: !!d.authenticated, user: d.user || '',
needsSetup: !!d.needs_setup, mode: d.mode || 'local',
csrf: d.csrf_token || '' };
if (d.version) this.version = d.version;
} catch(e) {
this.auth = { ...this.auth, ready: true, authenticated: false };
}
},
async doLogin() {
this.loginForm.error = ''; this.loginForm.busy = true;
const d = await this.req('/api/auth/login', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false;
if (!d.ok) { this.loginForm.error = d.msg || this.t('login_failed'); return; }
this.auth.authenticated = true;
this.auth.user = d.user; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
await this.startApp();
},
async doSetup() {
this.loginForm.error = '';
if (this.loginForm.password !== this.loginForm.password2) {
this.loginForm.error = this.t('passwords_differ'); return;
}
this.loginForm.busy = true;
const d = await this.req('/api/auth/setup', { method:'POST', json: {
username: this.loginForm.username, password: this.loginForm.password } });
this.loginForm.busy = false;
if (!d.ok) { this.loginForm.error = d.msg || this.t('login_failed'); return; }
this.auth.authenticated = true; this.auth.needsSetup = false;
this.auth.user = this.loginForm.username; this.auth.csrf = d.csrf_token || '';
this.loginForm = { username:'', password:'', password2:'', error:'', busy:false };
await this.startApp();
},
async logout() {
await this.req('/api/auth/logout', { method:'POST', json: {} });
this.auth.authenticated = false; this.auth.user = ''; this.auth.csrf = '';
},
async loadUsers() {
const d = await this.rpc('/api/auth/users');
this.users = d.users || [];
this.roles = d.roles || this.roles;
this.authCfg = {
mode: d.mode || 'local',
proxy_header: d.proxy_header || 'Remote-User',
proxy_role: d.proxy_role || 'admin',
trusted_text: (d.trusted_proxies || []).join('\n'),
};
},
async setRole(name, role) {
const d = await this.req('/api/auth/users/' + encodeURIComponent(name) + '/role',
{ method: 'PUT', json: { role } });
this.toast(d.msg || this.t(d.ok ? 'saved' : 'error'), d.ok ? 'ok' : 'er');
this.loadUsers();
// Je eigen rol kan gewijzigd zijn; dat bepaalt wat de UI toont.
if (name === this.auth.user) this.loadAuth();
},
async saveAuthMode() {
const body = {
mode: this.authCfg.mode,
proxy_header: this.authCfg.proxy_header,
proxy_role: this.authCfg.proxy_role,
trusted_proxies: this.authCfg.trusted_text.split('\n').map(s => s.trim()).filter(Boolean),
};
// De server weigert 'proxy' zonder bevestiging: die modus schakelt de
// lokale login uit, dus je sluit jezelf buiten als de proxy niet werkt.
if (body.mode === 'proxy') {
if (!confirm(this.t('proxy_lockout_confirm'))) return;
body.confirm_lockout = true;
}
const d = await this.req('/api/auth/mode', { method: 'PUT', json: body });
this.toast(d.msg || this.t(d.ok ? 'save' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) this.loadUsers();
},
async addUser() {
const d = await this.req('/api/auth/users', { method:'POST', json: this.newUser });
this.toast(d.msg || this.t(d.ok ? 'add_user' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) { this.newUser = { username:'', password:'' }; this.loadUsers(); }
},
async delUser(name) {
if (!confirm(this.t('delete_user_confirm', {name}))) return;
const d = await this.req('/api/auth/users/' + encodeURIComponent(name), { method:'DELETE' });
this.toast(d.msg || (d.ok ? 'Verwijderd' : 'Fout'), d.ok ? 'ok' : 'er');
this.loadUsers();
},
async changePassword() {
if (this.pwForm.new !== this.pwForm.new2) { this.toast(this.t('passwords_differ'), 'er'); return; }
const d = await this.req('/api/auth/password', { method:'POST', json: {
current: this.pwForm.current, new: this.pwForm.new } });
this.toast(d.msg || this.t(d.ok ? 'change' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) this.pwForm = { current:'', new:'', new2:'' };
},
// Centrale fetch: voegt het CSRF-token toe aan elke mutatie en stuurt de
// gebruiker terug naar het loginscherm zodra de sessie verlopen is.
async req(url, { method = 'GET', json } = {}) {
const opts = { method, headers: {} };
if (json !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(json);
}
if (method !== 'GET' && method !== 'HEAD') {
opts.headers['X-CSRF-Token'] = this.auth.csrf;
}
try {
const r = await fetch(url, opts);
if (r.status === 401) {
this.auth.authenticated = false;
await this.loadAuth();
return { ok: false, msg: this.t('session_expired') };
}
return await r.json();
} catch(e) { return { ok: false, error: e.message, msg: e.message }; }
},
async rpc(url, body) {
return body !== undefined ? this.req(url, { method:'POST', json: body })
: this.req(url);
},
goto(id) {
this.page = id;
location.hash = '#' + id;
if (this.isMobile) this.sidebarOpen = false;
},
toggleSidebar() {
this.sidebarOpen = !this.sidebarOpen;
if (!this.isMobile) localStorage.setItem('sidebar', this.sidebarOpen ? 'open' : 'closed');
},
pollJob(jid, onDone) {
if (this.job.timer) clearInterval(this.job.timer);
this.job = { id: jid, lines: [], status: 'running', timer: null };
this.termOpen = true;
let off = 0;
this.job.timer = setInterval(async () => {
const d = await this.rpc(`/api/jobs/${jid}?offset=${off}`);
(d.lines || []).forEach(l => { this.job.lines.push(l); off++; });
if (d.status && d.status !== 'running') {
clearInterval(this.job.timer); this.job.timer = null;
this.job.status = d.status;
if (onDone) onDone(d.status);
}
this.$nextTick(() => { if (this.$refs.termOut) this.$refs.termOut.scrollTop = this.$refs.termOut.scrollHeight; });
}, 400);
},
async loadDocker() {
const d = await this.rpc('/api/docker/info');
if (!d.error) this.docker = d;
if (d.su_version) this.version = d.su_version;
},
async loadDashboard() { await this.loadDocker(); await this.loadStacks(); },
async loadStacks() { const d = await this.rpc('/api/stacks'); this.stacks = Array.isArray(d) ? d : (d.stacks || []); },
// Apps filteren op naam, omschrijving en tags; tagfilter werkt aanvullend.
filteredStore(repoId) {
const lijst = this.storeStacks[repoId] || [];
const q = this.storeQuery.trim().toLowerCase();
return lijst.filter(s => {
if (this.storeTag && !(s.tags || []).includes(this.storeTag)) return false;
if (!q) return true;
return (s.name || '').toLowerCase().includes(q)
|| (s.dir || '').toLowerCase().includes(q)
|| (s.description || '').toLowerCase().includes(q)
|| (s.tags || []).some(t => String(t).toLowerCase().includes(q));
});
},
// Alle tags uit alle repo's, op frequentie gesorteerd zodat de bruikbaarste
// filters vooraan staan.
get storeTags() {
const telling = {};
for (const lijst of Object.values(this.storeStacks || {})) {
for (const s of lijst) for (const t of (s.tags || [])) {
telling[t] = (telling[t] || 0) + 1;
}
}
return Object.keys(telling)
.sort((a, b) => telling[b] - telling[a] || a.localeCompare(b))
.slice(0, 14);
},
get filteredStacks() { const q = this.stackQuery.toLowerCase(); return q ? this.stacks.filter(s => (s.name||'').toLowerCase().includes(q)) : this.stacks; },
async stackAction(name, action) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/${action}`, {});
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
else if (d.error) this.toast('Fout: '+d.error, 'er');
},
confirmRemove(name) { this.install.stack = name; this.modal = 'remove'; },
async doRemove(mode) {
const name = this.install.stack;
this.modal = null;
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/remove`, { mode });
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
},
async openEditor(name, type) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/${type}`);
this.editor = { stack: name, type, title: name+' · '+type, content: d.content || '' };
this.modal = 'editor';
},
async saveEditor() {
const d = await this.req(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${this.editor.type}`,
{ method:'PUT', json: { content: this.editor.content } });
if (d.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
else this.toast(d.msg || 'Fout bij opslaan', 'er');
},
// ── Backups ──────────────────────────────────────────────────────────────
async openBackups(stack) {
this.bkStack = stack;
this.bkList = [];
this.modal = 'backups';
await this.loadBackups(stack);
},
async loadBackups(stack) {
const d = await this.rpc('/api/backups?stack=' + encodeURIComponent(stack));
this.bkList = d.backups || [];
this.bkSettings = d.settings || {};
},
async createBackup(stack) {
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/create`, {});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.pollJob(d.job_id, () => this.loadBackups(stack));
},
async restoreBackup(stack, file) {
if (!confirm(this.t('restore_confirm', {file}))) return;
const d = await this.rpc(`/api/backups/${encodeURIComponent(stack)}/restore`, {file});
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
this.modal = null;
this.termOpen = true;
this.pollJob(d.job_id, () => { this.loadStacks(); this.toast(this.t('restore'), 'ok'); });
},
async deleteBackup(file) {
if (!confirm(this.t('delete_backup_confirm'))) return;
const d = await this.req('/api/backups/' + encodeURIComponent(file), { method:'DELETE' });
this.toast(d.msg || this.t(d.ok ? 'deleted' : 'error'), d.ok ? 'ok' : 'er');
this.loadBackups(this.bkStack);
},
// ── Updates van geïnstalleerde stacks ────────────────────────────────────
async loadStackUpdates() {
const d = await this.rpc('/api/stacks/updates');
this.stackUpd = d.stacks || {};
},
async checkStackUpdates() {
this.checkingUpd = true;
const d = await this.rpc('/api/stacks/updates/check', {});
if (!d.job_id) { this.checkingUpd = false; this.toast(d.msg || this.t('error'), 'er'); return; }
this.termOpen = true;
this.pollJob(d.job_id, async () => {
this.checkingUpd = false;
await this.loadStackUpdates();
});
},
stackUpdTitle(naam) {
const r = this.stackUpd[naam];
if (!r) return '';
return (r.services || []).filter(s => s.status === 'update')
.map(s => s.service + ': ' + s.image).join('\n');
},
// ── Netwerken ────────────────────────────────────────────────────────────
async loadNetworks() {
const d = await this.rpc('/api/networks');
this.netList = d.networks || [];
this.netIfaces = d.interfaces || [];
this.netDetected = d.detected || [];
this.netDetectError = d.detect_error || '';
},
// Eén klik op een gevonden netwerk vult het hele formulier.
useDetected(d) {
this.newNet = {
name: d.name || 'lan', driver: d.driver || 'macvlan',
parent: d.interface, subnet: d.subnet, gateway: d.gateway,
ip_range: d.suggested_range || '',
};
this.reviewNet();
},
// Laat de server een vrij blok voorstellen op basis van het subnet.
async suggestRange() {
const d = this.netDetected.find(x => x.subnet === this.newNet.subnet);
if (d && d.suggested_range) { this.newNet.ip_range = d.suggested_range; this.reviewNet(); return; }
const r = await this.rpc('/api/networks/review', { ...this.newNet, ip_range: '' });
if (r.info && r.info.subnet) {
// Val terug op het laatste /28 van het subnet.
const [basis, prefix] = r.info.subnet.split('/');
if (prefix && Number(prefix) <= 28) {
const d4 = basis.split('.'); d4[3] = '240';
this.newNet.ip_range = d4.join('.') + '/28';
}
}
this.reviewNet();
},
// 'Van adres X tot Y' omrekenen naar een CIDR-blok dat Docker accepteert.
calcRange() {
if (this.rangeCalcTimer) clearTimeout(this.rangeCalcTimer);
this.rangeCalcTimer = setTimeout(async () => {
const { first, last } = this.rangeCalc;
if (!first || !last) { this.rangeCalc.options = []; this.rangeCalc.msg = ''; return; }
const d = await this.rpc('/api/networks/range',
{ first, last, subnet: this.newNet.subnet });
this.rangeCalc.options = d.options || [];
this.rangeCalc.msg = d.ok ? '' : (d.msg || '');
}, 350);
},
// Live meelezen terwijl je typt; even wachten zodat niet elke toetsaanslag
// een verzoek stuurt.
reviewNet() {
if (this.netReviewTimer) clearTimeout(this.netReviewTimer);
this.netReviewTimer = setTimeout(async () => {
if (!this.newNet.subnet || !this.newNet.parent || !this.newNet.name) {
this.netReview = {}; return;
}
// Volgnummer tegen antwoorden die te laat binnenkomen. Tijdens het
// typen van '/28' ga je langs '/2', en dat is een geldig maar veel te
// groot netwerk buiten je subnet. Kwam dat antwoord ná het goede
// binnen, dan bleef die foutmelding staan en bleef de knop uit.
const volgnummer = ++this.netReviewSeq;
const antwoord = await this.rpc('/api/networks/review', this.newNet);
if (volgnummer === this.netReviewSeq) this.netReview = antwoord;
}, 350);
},
// Vul automatisch het eerstvolgende vrije adres in als je een netwerk kiest.
pickIp() {
const n = this.netList.find(x => x.name === this.install.network);
this.install.ip = n ? (n.next_ip || '') : '';
},
async addNetwork() {
// Eerst de laatste stand laten beoordelen, zodat een fout uit een
// afgebroken review niet blijft hangen.
const d = await this.rpc('/api/networks', this.newNet);
this.toast(d.msg || this.t(d.ok ? 'add' : 'error'), d.ok ? 'ok' : 'er');
if (d.ok) {
this.newNet = { name:'', driver:'macvlan', parent:'', subnet:'', gateway:'', ip_range:'' };
this.netReview = {};
this.modal = null;
this.loadNetworks();
}
},
async deleteNetwork(name) {
if (!confirm(this.t('delete_network_confirm', {name}))) return;
const d = await this.req('/api/networks/' + encodeURIComponent(name), { method:'DELETE' });
this.toast(d.msg || this.t(d.ok ? 'deleted' : 'error'), d.ok ? 'ok' : 'er');
this.loadNetworks();
},
// Cryptografisch willekeurige waarde voor admin-tokens en wachtwoorden.
// Lokaal gegenereerd, dus het geheim gaat niet heen en weer over het net.
randomSecret(bytes = 24) {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return btoa(String.fromCharCode(...buf))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
},
// ── Containers binnen een stack ──────────────────────────────────────────
async toggleContainers(stack) {
const cur = this.cpanel[stack] || { open: false, loading: false, items: [] };
if (cur.open) { this.cpanel = { ...this.cpanel, [stack]: { ...cur, open: false } }; return; }
this.cpanel = { ...this.cpanel, [stack]: { ...cur, open: true, loading: true } };
await this.loadContainers(stack);
},
async loadContainers(stack) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/containers`);
const cur = this.cpanel[stack] || { open: true };
this.cpanel = { ...this.cpanel,
[stack]: { ...cur, loading: false, items: d.containers || [] } };
},
async containerAction(stack, container, action) {
const d = await this.rpc(
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/${action}`, {});
this.toast(d.ok ? this.t(action) + ' ✓' : (d.msg || this.t('error')), d.ok ? 'ok' : 'er');
await this.loadContainers(stack);
this.loadStacks();
},
async openContainerLogs(stack, container) {
const d = await this.rpc(
`/api/stacks/${encodeURIComponent(stack)}/containers/${encodeURIComponent(container)}/logs`);
this.logs = { title: container + ' · logs', content: d.logs || '(geen output)' };
this.modal = 'logs';
},
async openLogs(name) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`);
this.logs = { title: name+' · logs', content: d.logs || '(geen output)' };
this.modal = 'logs';
},
async loadAppStore() {
const repos = await this.rpc('/api/repos');
this.repos = Array.isArray(repos) ? repos : [];
this.storeStacks = {};
for (const r of this.repos) {
const d = await this.rpc(`/api/repos/${r.id}/stacks`);
this.storeStacks[r.id] = d.stacks || [];
}
},
async syncRepo(rid) {
const d = await this.rpc(`/api/repos/${rid}/sync`, {});
if (d.job_id) this.pollJob(d.job_id, () => this.loadAppStore());
},
async syncAllRepos() { for (const r of this.repos) await this.syncRepo(r.id); },
async deleteRepo(rid) {
if (!confirm('Repository verwijderen?')) return;
await this.req(`/api/repos/${encodeURIComponent(rid)}`, { method:'DELETE' });
this.toast('Verwijderd', 'ok');
this.loadAppStore();
},
async addRepo() {
const r = await this.rpc('/api/repos', this.newRepo);
if (r.ok) { this.toast('Toegevoegd', 'ok'); this.modal=null; this.newRepo = { name:'', url:'', branch:'main', subdir:'apps' }; this.loadAppStore(); }
else this.toast(r.msg || 'Fout', 'er');
},
async openInstall(stack, rid) {
this.install = {
stack: stack.dir || stack.name, repo_id: rid,
instance: stack.dir || stack.name,
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
loading: true,
envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {},
network: '', ip: ''
};
this.modal = 'install';
if (!this.netList.length) await this.loadNetworks();
const d = await this.rpc('/api/store/preview', { stack: this.install.stack, repo_id: rid });
this.install.loading = false;
if (!d.ok) { this.toast(d.msg || 'Preview faalde', 'er'); this.modal = null; return; }
this.install.format = d.format || 'compose';
if (d.format === 'boilerplate') {
this.install.fields = d.fields || [];
const init = {};
(d.fields || []).forEach(f => {
if (f.default !== null && f.default !== undefined) init[f.name] = f.default;
else if (f.type === 'bool') init[f.name] = false;
else init[f.name] = '';
});
if (init.service_name !== undefined && !init.service_name) init.service_name = this.install.instance;
this.install.values = init;
this.install.preview_files = d.preview_files || {};
} else {
this.install.envVars = d.env_vars || [];
this.install.images = d.images || [];
}
},
get groupedFields() {
const map = new Map();
for (const f of this.install.fields) {
const g = f.group || 'Algemeen';
if (!map.has(g)) map.set(g, { name: g, toggle: f.group_toggle || null, items: [] });
map.get(g).items.push(f);
}
return Array.from(map.values());
},
bpFieldVisible(f) {
for (const cond of (f.needs || [])) {
const m = cond.match(/^(\w+)\s*(==|!=|=)\s*(.+)$/);
if (!m) continue;
const k = m[1], op = m[2], raw = m[3];
const want = raw.replace(/['"]/g, '').trim().toLowerCase();
const haveRaw = this.install.values[k];
const have = String(haveRaw ?? '').toLowerCase();
const eq = have === want || (want === 'true' && haveRaw === true) || (want === 'false' && (haveRaw === false || haveRaw === '' || haveRaw === undefined));
if ((op === '==' || op === '=') && !eq) return false;
if (op === '!=' && eq) return false;
}
return true;
},
bpPreview() {
const f = this.install.preview_files || {};
return f['compose.yaml'] || f['docker-compose.yml'] || f['compose.yml'] || Object.values(f)[0] || '(geen preview)';
},
async doInstall() {
const body = {
stack: this.install.stack,
repo_id: this.install.repo_id,
instance: this.install.instance || this.install.stack,
};
if (this.install.network) { body.network = this.install.network; body.ip = this.install.ip; }
if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
const d = await this.rpc('/api/store/install', body);
if (d.job_id) {
this.modal = null;
this.pollJob(d.job_id, () => { this.loadStacks(); this.toast('Installatie voltooid', 'ok'); });
} else { this.toast(d.msg || 'Fout', 'er'); }
},
async loadImages() { const d = await this.rpc('/api/docker/images'); this.images = d.images || []; },
async removeImage(id) {
if (!confirm('Image verwijderen?')) return;
const d = await this.rpc('/api/docker/images/remove', { id, force: true });
if (d.ok) { this.toast('Verwijderd', 'ok'); this.loadImages(); }
else this.toast(d.msg || 'Fout', 'er');
},
async pruneImages() {
if (!confirm('Alle ongebruikte images opruimen?')) return;
const d = await this.rpc('/api/docker/images/prune', {});
if (d.ok) { this.toast('Opgeruimd', 'ok'); this.loadImages(); }
else this.toast(d.msg || 'Fout', 'er');
},
async loadAudit() { const d = await this.rpc('/api/audit'); this.audit = d.entries || []; },
async clearAudit() {
if (!confirm('Audit log wissen?')) return;
await this.rpc('/api/audit/clear', {});
this.loadAudit();
},
async loadSettings() {
const d = await this.rpc('/api/settings'); this.settings = d || {};
await this.loadAppStore();
await this.loadUsers();
await this.loadNetworks();
},
autoSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(async () => {
const body = { ...this.settings, LANGUAGE: this.settings.LANGUAGE, THEME: this.theme };
await this.req('/api/settings', { method:'PUT', json: body });
this.saved = true; setTimeout(() => this.saved = false, 1500);
}, 400);
},
async restartSelf() {
if (!confirm('Server Up container herstarten?')) return;
const d = await this.rpc('/api/docker/restart', {});
this.toast(d.msg || (d.ok ? 'Bezig...' : 'Fout'), d.ok ? 'in' : 'er');
},
async loadModules() { const d = await this.rpc('/api/modules'); this.modules = Array.isArray(d) ? d : []; },
async loadUpdate(force) {
const d = await this.rpc('/api/update' + (force ? '?force=1' : ''));
if (d && typeof d === 'object') this.update = d;
},
updChannel() { return this.settings.UPDATE_CHANNEL || this.update.channel || 'stable'; },
setChannel(ch) {
this.settings.UPDATE_CHANNEL = ch;
this.autoSave();
setTimeout(() => this.loadUpdate(true), 600);
},
fmtTime(ts) {
if (!ts) return '';
return new Date(ts * 1000).toLocaleString();
},
// Bijwerken: de container vervangt zichzelf, dus na het startsein is deze
// pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft.
async applyUpdate() {
const naar = this.update.latest_tag || this.update.latest;
if (!confirm(this.t('update_confirm', {tag: naar}))) return;
this.updating = true;
const d = await this.rpc('/api/update/apply', { tag: naar });
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
this.termOpen = true;
this.pollJob(d.job_id, () => this.waitForRestart());
},
async rollbackUpdate() {
if (!confirm(this.t('rollback_confirm', {tag: this.update.previous_tag}))) return;
this.updating = true;
const d = await this.rpc('/api/update/rollback', {});
if (!d.job_id) { this.updating = false; this.toast(d.msg || 'Fout', 'er'); return; }
this.termOpen = true;
this.pollJob(d.job_id, () => this.waitForRestart());
},
async waitForRestart() {
this.toast(this.t('restarting'), 'in');
// Even wachten tot de oude container écht weg is, anders zien we hem nog.
await new Promise(r => setTimeout(r, 8000));
for (let i = 0; i < 60; i++) {
try {
const r = await fetch('/healthz', { cache: 'no-store' });
if (r.ok) {
const d = await r.json();
if (d.version && d.version !== this.update.current) {
this.updating = false;
this.toast(this.t('updated_to', {version: d.version}), 'ok');
setTimeout(() => location.reload(), 1200);
return;
}
}
} catch (e) { /* container is nog aan het herstarten */ }
await new Promise(r => setTimeout(r, 2000));
}
this.updating = false;
this.toast(this.t('restart_slow'), 'wr');
},
// Bij het opstarten: alleen openen als de wizard nog niet is afgerond.
async checkWizard() {
const d = await this.rpc('/api/wizard');
if (!d.done) await this.openWizard();
},
// Handmatig openen vanuit Instellingen, ongeacht of hij al gedraaid heeft.
async openWizard() {
const d = await this.rpc('/api/wizard');
this.wizard.steps = [
{ title: this.t('wizard_welcome') + ' 🚀', subtitle: this.t('wizard_welcome_sub') },
{ title: this.t('wizard_paths'), subtitle: this.t('wizard_paths_sub') },
{ title: this.t('module_repos'), subtitle: 'Server Up start met een ingebouwde stacks-repo en de ChristianLempa Boilerplates.' },
{ title: this.t('wizard_git'), subtitle: this.t('wizard_git_sub') },
{ title: this.t('wizard_done'), subtitle: this.t('wizard_done_sub') },
];
this.wizard.step = 0;
this.wizard.LIBRARY_DIR = d.defaults?.LIBRARY_DIR || '/opt/serverup/stacks';
this.wizard.DATA_DIR = d.defaults?.DATA_DIR || '/opt/serverup/appdata';
this.wizard.BACKUP_DIR = d.defaults?.BACKUP_DIR || '/opt/serverup/backups';
this.wizard.open = true;
},
// Opnieuw uitvoeren: markeer als niet-afgerond en open hem.
async resetWizard() {
await this.rpc('/api/wizard/reset', {});
await this.openWizard();
},
async wizardNext() {
if (this.wizard.step < this.wizard.steps.length - 1) {
if (this.wizard.step === 1) {
await this.rpc('/api/wizard/paths', {
LIBRARY_DIR: this.wizard.LIBRARY_DIR,
DATA_DIR: this.wizard.DATA_DIR,
BACKUP_DIR: this.wizard.BACKUP_DIR,
});
}
if (this.wizard.step === 3) {
const g = this.wizard.git;
if (g.choice === 'existing' && g.url.trim()) {
await this.rpc('/api/wizard/git', { action: 'add', url: g.url.trim(), token: g.token.trim(), subdir: g.subdir.trim() });
} else if (g.choice === 'install') {
const d = await this.rpc('/api/wizard/git', {
action: 'install_forgejo',
data_dir: this.wizard.DATA_DIR,
port_web: g.port_web,
port_ssh: g.port_ssh,
});
if (d.job_id) this.pollJob(d.job_id);
}
}
this.wizard.step++;
} else {
const d = await this.rpc('/api/wizard/sync', {});
if (d.job_id) this.pollJob(d.job_id, async () => {
await this.rpc('/api/wizard/complete', {});
this.wizard.open = false;
this.loadDashboard();
});
}
},
}
}
</script>
</body>
</html>