Some checks failed
Deploy server-up (dev) / deploy (push) Failing after 25s
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
2184 lines
133 KiB
HTML
2184 lines
133 KiB
HTML
<!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 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 & 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>
|