All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 13m11s
- Twintig categorieknoppen en veertien labelknoppen stonden altijd uitgeklapt boven de store; bij 256 apps vulde dat een half scherm. Nu een knop Filter met een popover: categorieen in een raster van twee kolommen met kleur en aantal, labels met een eigen zoekveld, en onderaan het aantal resultaten met een wisknop. Sluit met Escape of door ernaast te klikken - De labelfilter werkte maar half: de rij was afgekapt op veertien terwijl er 297 labels zijn, dus 283 daarvan waren niet te kiezen. Het menu zoekt nu in de volledige lijst; zonder zoekterm de veertig meest gebruikte, en een gekozen label blijft zichtbaar ook als het zeldzaam is - Actieve filters staan als chips naast de knop, met een kruisje per stuk. Het zoekveld heeft een wisknop - Nieuwe test die het hele sjabloon op gebalanceerde HTML-tags controleert. Een verkeerd geplaatste </div> schuift de rest van de pagina in een andere tak en dat merk je anders pas in de browser - Nieuwe JS-tests voor de filterlogica: filteren op categorie en label, de teller, wissen, en zoeken naar een zeldzaam label Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
3605 lines
214 KiB
HTML
3605 lines
214 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">
|
||
<button class="stat-card text-left hover:shadow-soft2 transition" @click="goto('stacks')">
|
||
<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>
|
||
<!-- De teller komt van de Docker-daemon en telt dus ook containers
|
||
die niet door Server Up zijn aangemaakt. -->
|
||
<div class="text-[10px] text-dm dark:text-dm-dark mt-0.5"
|
||
x-show="hostContainers.total"
|
||
x-text="t('of_which_managed', {n: hostContainers.managed})"></div>
|
||
</div>
|
||
</button>
|
||
<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>
|
||
|
||
<!-- Categorieën van wat je hebt draaien; klikken filtert. -->
|
||
<div class="flex flex-wrap gap-1.5 mb-5" x-show="stackCategories.length">
|
||
<button class="text-[11px] font-semibold px-2.5 py-1 rounded-lg border transition"
|
||
:class="!stackCat ? '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="stackCat = ''" x-text="t('all')">Alle</button>
|
||
<template x-for="c in stackCategories" :key="c.id">
|
||
<button class="text-[11px] font-semibold px-2.5 py-1 rounded-lg border transition inline-flex items-center gap-1.5"
|
||
:style="stackCat === c.id ? catStyle(c.id) : ''"
|
||
:class="stackCat === c.id ? '' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:border-bd2'"
|
||
@click="stackCat = (stackCat === c.id ? '' : c.id)">
|
||
<span class="mdi" :class="c.icon" :style="stackCat === c.id ? '' : 'color:' + c.color"></span>
|
||
<span x-text="c.name"></span>
|
||
<span class="opacity-60" x-text="c.count"></span>
|
||
</button>
|
||
</template>
|
||
</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 border-l-4"
|
||
:style="'border-left-color:' + cat((s.categories || [])[0] || 'overig').color">
|
||
<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">
|
||
<div class="flex flex-wrap gap-1" x-show="(s.categories || []).length">
|
||
<template x-for="c in (s.categories || [])" :key="c">
|
||
<button type="button" @click="stackCat = c"
|
||
class="text-[10px] font-semibold px-2 py-0.5 rounded-md border inline-flex items-center gap-1"
|
||
:style="catStyle(c)" :title="t('filter_on_category')">
|
||
<span class="mdi text-[11px]" :class="cat(c).icon"></span>
|
||
<span x-text="cat(c).name"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 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>
|
||
<!-- Bij een stack met vijftig containers is de lijst zonder
|
||
filter niet te doen. -->
|
||
<div x-show="((cpanel[s.name] && cpanel[s.name].items) || []).length > 8"
|
||
class="px-3 py-2 bg-s2/40 dark:bg-s2-dark/40">
|
||
<input class="form-input !h-8 !text-xs" :placeholder="t('search')"
|
||
@click.stop :value="(cpanel[s.name] || {}).filter || ''"
|
||
@input="cpanel[s.name].filter = $event.target.value">
|
||
</div>
|
||
<template x-for="c in containerLijst(s.name)" :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('credentials')" @click="openCredentials(s.name)"><span class="mdi mdi-account-key-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" :title="t('stack_settings')" @click="openReconfigure(s.name)"
|
||
:disabled="!s.editable"><span class="mdi mdi-tune"></span></button>
|
||
<button class="btn-sm" :title="s.connected ? t('connected_to_apps') : t('connect_to_apps')"
|
||
:class="s.connected ? '!text-ok !border-ok/30' : ''"
|
||
@click="connectStack(s.name)" :disabled="s.connected">
|
||
<span class="mdi" :class="s.connected ? 'mdi-lan-connect' : 'mdi-lan-pending'"></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>
|
||
|
||
<!-- Containers die niet bij een door Server Up beheerde stack horen.
|
||
Zonder dit blok tellen ze wel mee op het dashboard maar zijn ze
|
||
nergens terug te vinden. -->
|
||
<div x-show="unmanaged.length" class="mt-8">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="mdi mdi-cube-outline text-t2 dark:text-t2-dark"></span>
|
||
<h3 class="text-sm font-bold text-wh dark:text-wh-dark" x-text="t('other_containers')">Overige containers</h3>
|
||
<span class="badge badge-dim" x-text="unmanaged.length"></span>
|
||
<button class="btn-icon ml-auto w-9 h-9" @click="loadHostContainers()"><span class="mdi mdi-refresh"></span></button>
|
||
</div>
|
||
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('other_containers_hint')"></p>
|
||
<div class="card divide-y divide-bd dark:divide-bd-dark">
|
||
<template x-for="c in unmanaged" :key="c.id">
|
||
<div class="px-4 py-3 flex flex-wrap items-center gap-2">
|
||
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="c.running ? 'bg-ok' : 'bg-dm dark:bg-dm-dark'"></span>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="text-sm font-semibold text-wh dark:text-wh-dark truncate" x-text="c.name"></div>
|
||
<div class="text-[10px] font-mono text-dm dark:text-dm-dark truncate">
|
||
<span x-text="c.image"></span><span x-show="c.ports"> · </span><span x-text="c.ports"></span>
|
||
</div>
|
||
</div>
|
||
<span class="badge badge-dim" x-show="c.project" x-text="c.project"></span>
|
||
<span class="badge badge-info" x-show="c.self" x-text="t('this_app')">deze app</span>
|
||
<template x-if="!c.self">
|
||
<div class="flex gap-1.5">
|
||
<button class="btn-sm !h-8 !px-2" :title="c.running ? t('stop') : t('start')"
|
||
@click="hostContainerAction(c.name, c.running ? 'stop' : 'start')">
|
||
<span class="mdi" :class="c.running ? 'mdi-stop' : 'mdi-play'"></span>
|
||
</button>
|
||
<button class="btn-sm !h-8 !px-2" :title="t('restart')"
|
||
@click="hostContainerAction(c.name, 'restart')"><span class="mdi mdi-restart"></span></button>
|
||
<button class="btn-sm !h-8 !px-2" :title="t('logs')"
|
||
@click="openHostLogs(c.name)"><span class="mdi mdi-text-box-outline"></span></button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ══ App Store ══ -->
|
||
<section x-show="page === 'app_store'" x-cloak class="anim">
|
||
<!-- Zoeken, filtermenu en de actieve filters op één regel. Eerder stonden
|
||
hier twee rijen knoppen: twintig categorieën en veertien labels. Dat
|
||
vulde een half scherm, en van de 297 labels waren er 283 onbereikbaar
|
||
omdat de rij werd afgekapt. -->
|
||
<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 !pr-9">
|
||
<button type="button" x-show="storeQuery" @click="storeQuery = ''"
|
||
class="absolute right-2.5 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark hover:text-tx dark:hover:text-tx-dark"
|
||
:title="t('clear')">
|
||
<span class="mdi mdi-close-circle"></span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Filtermenu -->
|
||
<div class="relative" @keydown.escape.window="filterOpen = false">
|
||
<button type="button" class="btn !px-3" @click="filterOpen = !filterOpen"
|
||
:class="filterCount ? '!border-ac !text-ac dark:!text-ac-dark !bg-ac/5' : ''">
|
||
<span class="mdi mdi-tune-variant"></span>
|
||
<span x-text="t('filter')">Filter</span>
|
||
<span x-show="filterCount" class="badge badge-info" x-text="filterCount"></span>
|
||
<span class="mdi text-xs transition-transform"
|
||
:class="filterOpen ? 'mdi-chevron-down rotate-180' : 'mdi-chevron-down'"></span>
|
||
</button>
|
||
|
||
<div x-show="filterOpen" x-cloak @click.outside="filterOpen = false"
|
||
x-transition:enter="transition ease-out duration-150"
|
||
x-transition:enter-start="opacity-0 -translate-y-2 scale-95"
|
||
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
|
||
x-transition:leave="transition ease-in duration-100"
|
||
x-transition:leave-start="opacity-100 scale-100"
|
||
x-transition:leave-end="opacity-0 scale-95"
|
||
class="card shadow-pop absolute left-0 z-40 mt-2 w-[min(92vw,27rem)] origin-top p-4 space-y-4">
|
||
|
||
<!-- Categorie -->
|
||
<div x-show="storeCategories.length">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-[10px] font-bold uppercase tracking-wider text-dm dark:text-dm-dark"
|
||
x-text="t('category')">Categorie</span>
|
||
<span class="h-px flex-1 bg-bd dark:bg-bd-dark"></span>
|
||
<button type="button" x-show="storeCat" @click="storeCat = ''"
|
||
class="text-[11px] text-t2 dark:text-t2-dark hover:text-ac"
|
||
x-text="t('all')">Alle</button>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-1.5">
|
||
<template x-for="c in storeCategories" :key="c.id">
|
||
<button type="button"
|
||
class="flex items-center gap-2 px-2.5 py-1.5 rounded-xl border text-[11px] font-semibold transition text-left"
|
||
:style="storeCat === c.id ? catStyle(c.id) : ''"
|
||
:class="storeCat === c.id ? '' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:border-bd2 dark:hover:border-bd2-dark'"
|
||
@click="storeCat = (storeCat === c.id ? '' : c.id)">
|
||
<span class="mdi shrink-0" :class="c.icon"
|
||
:style="storeCat === c.id ? '' : 'color:' + c.color"></span>
|
||
<span class="truncate flex-1" x-text="c.name"></span>
|
||
<span class="opacity-60 tabular-nums" x-text="c.count"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Label. Met bijna driehonderd labels is zoeken de enige manier
|
||
om er nog iets in te vinden. -->
|
||
<div x-show="storeTags.length">
|
||
<div class="flex items-center gap-2 mb-2">
|
||
<span class="text-[10px] font-bold uppercase tracking-wider text-dm dark:text-dm-dark"
|
||
x-text="t('tag')">Label</span>
|
||
<span class="h-px flex-1 bg-bd dark:bg-bd-dark"></span>
|
||
<button type="button" x-show="storeTag" @click="storeTag = ''"
|
||
class="text-[11px] text-t2 dark:text-t2-dark hover:text-ac"
|
||
x-text="t('all')">Alle</button>
|
||
</div>
|
||
<input x-model="filterTagQuery" class="form-input !h-9 !text-xs mb-2"
|
||
:placeholder="t('search_tags')">
|
||
<div class="flex flex-wrap gap-1.5 max-h-40 overflow-y-auto pr-1">
|
||
<template x-for="tg in zichtbareTags" :key="tg.tag">
|
||
<button type="button"
|
||
class="text-[11px] font-mono px-2.5 py-1 rounded-lg border transition inline-flex items-center gap-1.5"
|
||
:class="storeTag === tg.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 dark:hover:border-bd2-dark'"
|
||
@click="storeTag = (storeTag === tg.tag ? '' : tg.tag)">
|
||
<span x-text="tg.tag"></span>
|
||
<span class="opacity-50 tabular-nums" x-text="tg.count"></span>
|
||
</button>
|
||
</template>
|
||
<p x-show="!zichtbareTags.length" class="text-[11px] text-dm dark:text-dm-dark py-1"
|
||
x-text="t('no_results')">Niets gevonden</p>
|
||
</div>
|
||
<p x-show="verborgenTags > 0" class="text-[10px] text-dm dark:text-dm-dark mt-1.5"
|
||
x-text="t('more_tags', {n: verborgenTags})"></p>
|
||
</div>
|
||
|
||
<!-- Wat het oplevert, en alles in één keer weg -->
|
||
<div class="flex items-center gap-2 pt-1 border-t border-bd dark:border-bd-dark">
|
||
<span class="text-[11px] text-t2 dark:text-t2-dark"
|
||
x-text="t('n_apps_found', {n: storeResultaat})"></span>
|
||
<button type="button" class="btn-sm ml-auto" x-show="filterCount"
|
||
@click="wisFilters()">
|
||
<span class="mdi mdi-filter-remove-outline"></span>
|
||
<span x-text="t('clear_filters')">Wissen</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Wat er aan staat, ook zonder het menu te openen -->
|
||
<template x-if="storeCat">
|
||
<button type="button" class="text-[11px] font-semibold px-2.5 py-1.5 rounded-lg border inline-flex items-center gap-1.5"
|
||
:style="catStyle(storeCat)" @click="storeCat = ''">
|
||
<span class="mdi" :class="cat(storeCat).icon"></span>
|
||
<span x-text="cat(storeCat).name"></span>
|
||
<span class="mdi mdi-close opacity-70"></span>
|
||
</button>
|
||
</template>
|
||
<template x-if="storeTag">
|
||
<button type="button" @click="storeTag = ''"
|
||
class="text-[11px] font-mono px-2.5 py-1.5 rounded-lg border border-ac bg-ac/10 text-ac dark:text-ac-dark inline-flex items-center gap-1.5">
|
||
<span x-text="storeTag"></span>
|
||
<span class="mdi mdi-close opacity-70"></span>
|
||
</button>
|
||
</template>
|
||
|
||
<button @click="syncAllRepos()" class="btn ml-auto"><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>
|
||
<div class="space-y-7">
|
||
<template x-for="repo in repos" :key="repo.id">
|
||
<div x-show="!storeQuery && !storeTag && !storeCat || 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.categories || []).length || (stack.tags || []).length">
|
||
<template x-for="c in (stack.categories || [])" :key="c">
|
||
<button type="button" @click.stop="storeCat = c"
|
||
class="text-[10px] font-semibold px-2 py-0.5 rounded-md border inline-flex items-center gap-1"
|
||
:style="catStyle(c)" :title="t('filter_on_category')">
|
||
<span class="mdi text-[11px]" :class="cat(c).icon"></span>
|
||
<span x-text="cat(c).name"></span>
|
||
</button>
|
||
</template>
|
||
<template x-for="tag in (stack.tags || []).slice(0,2)">
|
||
<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 flex-wrap items-center gap-2 mb-4">
|
||
<div class="relative flex-1 min-w-[180px] max-w-xs">
|
||
<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="auditFilter.q" @input="loadAuditDebounced()" :placeholder="t('search')+'…'" class="form-input !pl-10 !h-9 !text-xs">
|
||
</div>
|
||
<select class="form-input !w-auto !h-9 !text-xs" x-model="auditFilter.src" @change="loadAudit()">
|
||
<option value="" x-text="t('all')">Alle</option>
|
||
<template x-for="v in (auditFacets.src || [])" :key="v"><option :value="v" x-text="v"></option></template>
|
||
</select>
|
||
<select class="form-input !w-auto !h-9 !text-xs" x-model="auditFilter.action" @change="loadAudit()">
|
||
<option value="" x-text="t('all')">Alle</option>
|
||
<template x-for="v in (auditFacets.action || [])" :key="v"><option :value="v" x-text="v"></option></template>
|
||
</select>
|
||
<select class="form-input !w-auto !h-9 !text-xs" x-model="auditFilter.status" @change="loadAudit()">
|
||
<option value="" x-text="t('all')">Alle</option>
|
||
<template x-for="v in (auditFacets.status || [])" :key="v"><option :value="v" x-text="v"></option></template>
|
||
</select>
|
||
<select class="form-input !w-auto !h-9 !text-xs" x-model="auditFilter.days" @change="loadAudit()">
|
||
<option value="" x-text="t('all_time')">Altijd</option>
|
||
<option value="1" x-text="t('last_day')">24 uur</option>
|
||
<option value="7" x-text="t('last_week')">7 dagen</option>
|
||
<option value="30" x-text="t('last_month')">30 dagen</option>
|
||
</select>
|
||
<span class="text-[11px] text-t2 dark:text-t2-dark ml-auto"
|
||
x-text="t('audit_count', {shown: audit.length, total: auditTotal})"></span>
|
||
</div>
|
||
<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>
|
||
<label class="flex items-center gap-2.5 cursor-pointer">
|
||
<input type="checkbox" :checked="!!settings.BACKUP_VERIFY" @change="settings.BACKUP_VERIFY=$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_verify')"></span>
|
||
</label>
|
||
<div>
|
||
<label class="form-label" x-text="t('backup_offsite_dir')">Tweede bestemming</label>
|
||
<input class="form-input" placeholder="/mnt/backup" x-model="settings.BACKUP_OFFSITE_DIR" @change="autoSave()">
|
||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('backup_offsite_hint')"></p>
|
||
</div>
|
||
<div class="grid sm:grid-cols-2 gap-3 pt-1 border-t border-bd dark:border-bd-dark">
|
||
<div>
|
||
<label class="form-label" x-text="t('disk_min_free')">Reserve houden (GB)</label>
|
||
<input type="number" min="0" max="1024" class="form-input" x-model.number="settings.DISK_MIN_FREE_GB" @change="autoSave()">
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('disk_warn_pct')">Waarschuwen onder (%)</label>
|
||
<input type="number" min="0" max="99" class="form-input" x-model.number="settings.DISK_WARN_PCT" @change="autoSave()">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Meldingen: je hoort het als er 's nachts iets misgaat -->
|
||
<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-bell-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('notifications')">Meldingen</span>
|
||
</div>
|
||
<div class="p-5 space-y-4">
|
||
<p class="text-[11px] text-dm dark:text-dm-dark" x-text="t('notify_intro')"></p>
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_channel')">Kanaal</label>
|
||
<select class="form-input" x-model="settings.NOTIFY_CHANNEL" @change="autoSave()">
|
||
<option value="" x-text="t('notify_channel_off')">Uit</option>
|
||
<option value="ntfy">ntfy</option>
|
||
<option value="webhook" x-text="t('notify_channel_webhook')">Webhook (Discord, Slack, …)</option>
|
||
<option value="email" x-text="t('notify_channel_email')">E-mail</option>
|
||
</select>
|
||
</div>
|
||
|
||
<template x-if="settings.NOTIFY_CHANNEL === 'ntfy' || settings.NOTIFY_CHANNEL === 'webhook'">
|
||
<div class="space-y-3">
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_url')">URL</label>
|
||
<input class="form-input" placeholder="https://ntfy.sh/mijn-topic"
|
||
x-model="settings.NOTIFY_URL" @change="autoSave()">
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_token')">Token (optioneel)</label>
|
||
<input type="password" class="form-input" :placeholder="notify.has_token ? '••••••••' : ''"
|
||
x-model="settings.NOTIFY_TOKEN" @change="autoSave()">
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="settings.NOTIFY_CHANNEL === 'email'">
|
||
<div class="grid sm:grid-cols-2 gap-3">
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_smtp_host')">SMTP-server</label>
|
||
<input class="form-input" x-model="settings.NOTIFY_EMAIL_HOST" @change="autoSave()">
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_smtp_port')">Poort</label>
|
||
<input type="number" min="1" max="65535" class="form-input" x-model.number="settings.NOTIFY_EMAIL_PORT" @change="autoSave()">
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_smtp_security')">Beveiliging</label>
|
||
<select class="form-input" x-model="settings.NOTIFY_EMAIL_SECURITY" @change="autoSave()">
|
||
<option value="starttls">STARTTLS</option>
|
||
<option value="ssl">SSL/TLS</option>
|
||
<option value="geen" x-text="t('none')">Geen</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_smtp_user')">Gebruikersnaam</label>
|
||
<input class="form-input" x-model="settings.NOTIFY_EMAIL_USER" @change="autoSave()">
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_smtp_password')">Wachtwoord</label>
|
||
<input type="password" class="form-input" :placeholder="notify.has_email_password ? '••••••••' : ''"
|
||
x-model="settings.NOTIFY_EMAIL_PASSWORD" @change="autoSave()">
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('notify_email_from')">Afzender</label>
|
||
<input class="form-input" x-model="settings.NOTIFY_EMAIL_FROM" @change="autoSave()">
|
||
</div>
|
||
<div class="sm:col-span-2">
|
||
<label class="form-label" x-text="t('notify_email_to')">Ontvanger</label>
|
||
<input class="form-input" x-model="settings.NOTIFY_EMAIL_TO" @change="autoSave()">
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template x-if="settings.NOTIFY_CHANNEL">
|
||
<div class="space-y-3">
|
||
<div>
|
||
<div class="form-label" x-text="t('notify_events')">Waarover melden</div>
|
||
<div class="space-y-1.5">
|
||
<template x-for="(label, key) in notify.available" :key="key">
|
||
<label class="flex items-center gap-2.5 cursor-pointer">
|
||
<input type="checkbox" :checked="(settings.NOTIFY_EVENTS||[]).includes(key)"
|
||
@change="toggleNotifyEvent(key, $event.target.checked)"
|
||
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="label"></span>
|
||
</label>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
<button class="btn" @click="testNotify()" :disabled="notifyTesting">
|
||
<span class="mdi" :class="notifyTesting ? 'mdi-loading mdi-spin' : 'mdi-send-outline'"></span>
|
||
<span x-text="t('notify_test')">Test versturen</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Pangolin: publieke URL's zonder poorten open te zetten -->
|
||
<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-earth text-ac dark:text-ac-dark text-lg"></span>
|
||
<span class="font-bold text-wh dark:text-wh-dark text-sm">Pangolin</span>
|
||
</div>
|
||
<div class="p-5 space-y-4">
|
||
<p class="text-[11px] text-dm dark:text-dm-dark" x-text="t('pangolin_intro')"></p>
|
||
<div class="grid sm:grid-cols-2 gap-3">
|
||
<div class="sm:col-span-2">
|
||
<label class="form-label" x-text="t('pangolin_url')">Adres</label>
|
||
<input class="form-input" placeholder="https://pangolin.voorbeeld.nl"
|
||
x-model="settings.PANGOLIN_URL" @change="autoSave()">
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('pangolin_key')">API-sleutel</label>
|
||
<input type="password" class="form-input"
|
||
:placeholder="pangolin.has_key ? '••••••••' : ''"
|
||
x-model="settings.PANGOLIN_API_KEY" @change="autoSave()">
|
||
</div>
|
||
<div>
|
||
<label class="form-label" x-text="t('pangolin_org')">Organisatie-id</label>
|
||
<input class="form-input" x-model="settings.PANGOLIN_ORG_ID" @change="autoSave()">
|
||
</div>
|
||
<div x-show="pangolin.sites && pangolin.sites.length">
|
||
<label class="form-label" x-text="t('pangolin_site')">Site</label>
|
||
<select class="form-input" x-model="settings.PANGOLIN_SITE_ID" @change="autoSave()">
|
||
<template x-for="s in pangolin.sites" :key="s.id">
|
||
<option :value="s.id" x-text="s.name"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
<div x-show="pangolin.domains && pangolin.domains.length">
|
||
<label class="form-label" x-text="t('pangolin_domain')">Domein</label>
|
||
<select class="form-input" x-model="settings.PANGOLIN_DOMAIN_ID" @change="autoSave()">
|
||
<template x-for="d in pangolin.domains" :key="d.id">
|
||
<option :value="d.id" x-text="d.domain"></option>
|
||
</template>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<p x-show="pangolin.error" class="text-[11px] text-wr" x-text="pangolin.error"></p>
|
||
<button class="btn" @click="testPangolin()" :disabled="pangolinTesting">
|
||
<span class="mdi" :class="pangolinTesting ? 'mdi-loading mdi-spin' : 'mdi-lan-connect'"></span>
|
||
<span x-text="t('test_connection')">Verbinding testen</span>
|
||
</button>
|
||
</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('appdata_dir')">Appdata-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>
|
||
<label class="form-label" x-text="t('timezone')">Tijdzone</label>
|
||
<input class="form-input" x-model="settings.TIMEZONE" @change="autoSave()"
|
||
:placeholder="settings.host_timezone || 'UTC'">
|
||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1"
|
||
x-text="t('timezone_hint', {tz: settings.host_timezone || 'UTC'})"></p>
|
||
</div>
|
||
<label class="flex items-center gap-2.5 cursor-pointer">
|
||
<input type="checkbox" :checked="!!settings.TIMEZONE_ALL_APPS"
|
||
@change="settings.TIMEZONE_ALL_APPS=$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('timezone_all_apps')"></span>
|
||
</label>
|
||
</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>
|
||
|
||
<!-- Schijfruimte: hier is het relevant, want dit is waar het vol loopt -->
|
||
<template x-if="bkDisk.volumes && bkDisk.volumes.length">
|
||
<div class="mb-3 space-y-1.5">
|
||
<template x-for="v in bkDisk.volumes" :key="v.path">
|
||
<div class="px-3 py-2 rounded-xl border"
|
||
:class="v.low ? 'border-wr/40 bg-wr/5' : 'border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40'">
|
||
<div class="flex items-center gap-2 text-[11px]">
|
||
<span class="mdi" :class="v.low ? 'mdi-alert text-wr' : 'mdi-harddisk text-t2 dark:text-t2-dark'"></span>
|
||
<span class="text-tx dark:text-tx-dark" x-text="v.label"></span>
|
||
<span class="ml-auto text-dm dark:text-dm-dark">
|
||
<span x-text="v.free_human"></span> <span x-text="t('disk_free_of')">vrij van</span> <span x-text="v.total_human"></span>
|
||
</span>
|
||
</div>
|
||
<div class="mt-1.5 h-1.5 rounded-full bg-bd dark:bg-bd-dark overflow-hidden">
|
||
<div class="h-full rounded-full" :class="v.low ? 'bg-wr' : 'bg-ac dark:bg-ac-dark'"
|
||
:style="`width: ${Math.min(100, v.pct_used)}%`"></div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<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 flex flex-wrap items-center gap-x-1.5">
|
||
<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>
|
||
<!-- Geverifieerd en extern weggezet: precies wat je wil weten
|
||
vóórdat je op deze backup moet vertrouwen. -->
|
||
<span x-show="b.verified_ok === true" class="text-ok" :title="t('backup_verified')">
|
||
· <span class="mdi mdi-shield-check"></span><span x-show="b.verified_deep" class="mdi mdi-check"></span>
|
||
</span>
|
||
<span x-show="b.verified_ok === false" class="text-wr" :title="b.verified_msg">
|
||
· <span class="mdi mdi-shield-alert"></span> <span x-text="t('backup_broken')">beschadigd</span>
|
||
</span>
|
||
<span x-show="b.offsite_ok" class="text-ok" :title="b.offsite_path">
|
||
· <span class="mdi mdi-cloud-check-outline"></span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<a class="btn-sm !h-8 !px-2" :href="`/api/backups/${encodeURIComponent(b.file)}/download`"
|
||
:title="t('download')" download>
|
||
<span class="mdi mdi-download"></span>
|
||
</a>
|
||
<button class="btn-sm !h-8 !px-2" @click="verifyBackup(b.file, false)" :title="t('verify')">
|
||
<span class="mdi mdi-shield-search"></span>
|
||
</button>
|
||
<button class="btn-sm !h-8 !px-2" x-show="bkSettings.BACKUP_OFFSITE_DIR"
|
||
@click="offsiteBackup(b.file)" :title="t('copy_offsite')">
|
||
<span class="mdi mdi-cloud-upload-outline"></span>
|
||
</button>
|
||
<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="install.mode === 'edit' ? t('stack_settings') : 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">
|
||
<!-- Bij wijzigen wordt de stack opnieuw gerenderd; dat overschrijft
|
||
handmatige aanpassingen in het compose-bestand. -->
|
||
<p x-show="install.mode === 'edit'" class="text-[11px] text-wr flex items-start gap-1.5">
|
||
<span class="mdi mdi-alert-outline shrink-0"></span>
|
||
<span x-text="t('reconfigure_warning')"></span>
|
||
</p>
|
||
|
||
<!-- Uitleg bij het veld waar je op het info-icoon klikte -->
|
||
<div x-show="fieldInfo" x-cloak
|
||
class="rounded-xl border border-ac/30 bg-ac/5 px-3.5 py-3">
|
||
<div class="flex items-start gap-2">
|
||
<span class="mdi mdi-information-outline text-ac dark:text-ac-dark shrink-0"></span>
|
||
<div class="min-w-0 flex-1">
|
||
<div class="text-xs font-bold text-wh dark:text-wh-dark" x-text="(fieldInfo||{}).title"></div>
|
||
<p class="text-[11px] text-t2 dark:text-t2-dark mt-0.5"
|
||
x-text="(fieldInfo||{}).help || (fieldInfo||{}).description"></p>
|
||
<p class="text-[10px] font-mono text-dm dark:text-dm-dark mt-1"
|
||
x-show="(fieldInfo||{}).default !== null && (fieldInfo||{}).default !== undefined && (fieldInfo||{}).default !== ''"
|
||
x-text="t('default_value') + ': ' + (fieldInfo||{}).default"></p>
|
||
</div>
|
||
<button @click="fieldInfo = null" class="text-t2 dark:text-t2-dark hover:text-tx">
|
||
<span class="mdi mdi-close"></span>
|
||
</button>
|
||
</div>
|
||
</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>
|
||
|
||
<!-- Invulmenu: vaste stappen, ongeacht hoeveel groepen de app heeft -->
|
||
<template x-if="install.format === 'boilerplate' && !install.loading">
|
||
<div class="space-y-3">
|
||
|
||
<!-- Wat deze app nodig heeft en nog niet draait. Advies, geen
|
||
blokkade: je kunt de broker of Home Assistant net zo goed op een
|
||
andere machine hebben staan. -->
|
||
<div x-show="ontbrekendeAfhankelijkheden().length"
|
||
class="rounded-xl border border-wr/40 bg-wr/5 px-3.5 py-3 space-y-2">
|
||
<div class="flex items-center gap-2 text-xs font-bold text-wr">
|
||
<span class="mdi mdi-alert-outline"></span>
|
||
<span x-text="t('depends_on_missing')">Deze app heeft nog iets nodig</span>
|
||
</div>
|
||
<template x-for="d in ontbrekendeAfhankelijkheden()" :key="d.app">
|
||
<div class="flex items-start gap-2.5">
|
||
<span class="min-w-0 flex-1">
|
||
<span class="text-sm font-semibold text-tx dark:text-tx-dark" x-text="d.app"></span>
|
||
<span x-show="d.reason" class="block text-[11px] text-t2 dark:text-t2-dark leading-snug"
|
||
x-text="d.reason"></span>
|
||
</span>
|
||
<button type="button" class="btn-sm shrink-0" @click="installeerAfhankelijkheid(d)">
|
||
<span class="mdi mdi-download-outline"></span>
|
||
<span x-text="t('depends_on_install')">Eerst installeren</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Stapindicator; klikbaar om terug te springen -->
|
||
<div class="flex items-center gap-1 overflow-x-auto pb-1" x-show="!onePage">
|
||
<template x-for="(s, i) in wizardSteps()" :key="s.id">
|
||
<button type="button" @click="gotoStep(i)"
|
||
class="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-[11px] whitespace-nowrap transition"
|
||
:class="i === install.step
|
||
? 'bg-ac text-white font-bold'
|
||
: (i < install.step ? 'text-ok' : 'text-dm dark:text-dm-dark hover:text-tx')">
|
||
<span class="mdi" :class="i < install.step ? 'mdi-check-circle' : s.icon"></span>
|
||
<span x-text="s.title"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- Altijd bereikbaar: geavanceerde opties en alles-op-één-pagina -->
|
||
<div class="flex items-center gap-3 flex-wrap text-[11px]">
|
||
<label class="inline-flex items-center gap-2 cursor-pointer">
|
||
<input type="checkbox" :checked="showAdvanced"
|
||
@change="showAdvanced = $event.target.checked; saveUiKeuze()" 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>
|
||
<span class="mdi mdi-tune text-t2 dark:text-t2-dark"></span>
|
||
<span class="text-t2 dark:text-t2-dark" x-text="t('advanced_options')">Geavanceerde opties</span>
|
||
<span x-show="advancedCount() > 0" class="badge badge-dim" x-text="advancedCount()"></span>
|
||
</label>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer ml-auto">
|
||
<input type="checkbox" :checked="onePage"
|
||
@change="onePage = $event.target.checked; saveUiKeuze()" 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>
|
||
<span class="text-t2 dark:text-t2-dark" x-text="t('one_page')">Alles op één pagina</span>
|
||
</label>
|
||
</div>
|
||
|
||
<!-- De stappen zelf -->
|
||
<template x-for="(s, i) in wizardSteps()" :key="s.id">
|
||
<div x-show="onePage || i === install.step" class="space-y-3">
|
||
<div x-show="onePage" class="text-[10px] font-bold tracking-wider uppercase text-ac dark:text-ac-dark pt-2"
|
||
x-text="s.title"></div>
|
||
|
||
<!-- Verbinden: netwerk en koppelingen -->
|
||
<template x-if="s.id === 'verbinden'">
|
||
<div class="space-y-3">
|
||
<!-- Meerdere netwerken: niet elke app hoeft elke andere te
|
||
kunnen bereiken. -->
|
||
<div>
|
||
<div class="form-label" x-text="t('app_networks')">Gedeelde netwerken</div>
|
||
<p class="text-[11px] text-dm dark:text-dm-dark mb-2" x-text="t('app_networks_hint')"></p>
|
||
<div class="space-y-1.5">
|
||
<template x-for="n in appNets" :key="n.name">
|
||
<label class="flex items-center gap-2.5 cursor-pointer px-3 py-2 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
|
||
<input type="checkbox" :checked="(install.app_networks||[]).includes(n.name)"
|
||
@change="toggleAppNet(n.name, $event.target.checked)"
|
||
class="w-5 h-5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
|
||
<span class="min-w-0 flex-1">
|
||
<span class="text-sm font-mono text-tx dark:text-tx-dark" x-text="n.name"></span>
|
||
<span x-show="n.default" class="badge badge-dim ml-1.5" x-text="t('default_network')">standaard</span>
|
||
</span>
|
||
<span class="text-[11px] text-dm dark:text-dm-dark"
|
||
x-text="t('n_apps', {n: n.containers})"></span>
|
||
<button type="button" class="btn-sm !h-7 !px-2" x-show="!n.default"
|
||
@click.prevent="removeAppNet(n.name)" :title="t('delete')">
|
||
<span class="mdi mdi-delete-outline"></span>
|
||
</button>
|
||
</label>
|
||
</template>
|
||
</div>
|
||
<div class="flex gap-2 mt-2">
|
||
<input class="form-input font-mono !text-xs" x-model="newNetName"
|
||
:placeholder="t('new_network_placeholder')">
|
||
<button type="button" class="btn-sm shrink-0" @click="createAppNet()">
|
||
<span class="mdi mdi-plus"></span> <span x-text="t('new_network')">Netwerk maken</span>
|
||
</button>
|
||
</div>
|
||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('new_network_hint')"></p>
|
||
</div>
|
||
<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>
|
||
<!-- Meerdere containers: elk zijn eigen adres. Wie geen adres
|
||
krijgt, houdt zijn poort op de host. -->
|
||
<template x-if="install.network && ipContainers().length > 1">
|
||
<div class="mt-2 space-y-1.5">
|
||
<div class="form-label" x-text="t('ip_per_container')">Adres per container</div>
|
||
<p class="text-[11px] text-dm dark:text-dm-dark" x-text="t('ip_per_container_hint')"></p>
|
||
<template x-for="c in ipContainers()" :key="c">
|
||
<div class="flex items-center gap-2">
|
||
<label class="inline-flex items-center gap-2 cursor-pointer shrink-0 w-40 min-w-0">
|
||
<input type="checkbox" :checked="c in install.ips"
|
||
@change="zetContainerIp(c, $event.target.checked)" class="sr-only peer">
|
||
<span class="shrink-0 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>
|
||
<span class="text-xs font-mono truncate text-tx dark:text-tx-dark" x-text="c"></span>
|
||
</label>
|
||
<input class="form-input font-mono !text-xs" x-show="c in install.ips"
|
||
:value="install.ips[c]" @input="install.ips[c] = $event.target.value"
|
||
placeholder="192.168.1.240">
|
||
<span x-show="!(c in install.ips)" class="text-[11px] text-dm dark:text-dm-dark"
|
||
x-text="t('port_on_host')">poort op de host</span>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template x-if="install.network && ipContainers().length <= 1">
|
||
<div class="mt-2">
|
||
<label class="form-label" x-text="t('ip_address')">IP-adres</label>
|
||
<div class="flex gap-2">
|
||
<input class="form-input font-mono" x-model="install.ip"
|
||
@change="checkIp()" placeholder="192.168.1.240">
|
||
<button type="button" class="btn-sm shrink-0" @click="checkIp()"
|
||
:title="t('check_availability')">
|
||
<span class="mdi mdi-magnify"></span>
|
||
</button>
|
||
</div>
|
||
<!-- Docker weet niets van fysieke apparaten op je LAN; die
|
||
botsing merk je pas weken later. -->
|
||
<p x-show="ipCheck.reason" class="text-[11px] text-wr mt-1">
|
||
<span class="mdi mdi-alert-circle-outline"></span>
|
||
<span x-text="ipCheck.reason"></span>
|
||
<button type="button" x-show="ipCheck.suggested" class="underline ml-1"
|
||
@click="install.ip = ipCheck.suggested; checkIp()"
|
||
x-text="t('use_free_address', {ip: ipCheck.suggested})"></button>
|
||
</p>
|
||
<p x-show="ipCheck.free" class="text-[11px] text-ok mt-1">
|
||
<span class="mdi mdi-check-circle-outline"></span>
|
||
<span x-text="t('address_available')"></span>
|
||
</p>
|
||
<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>
|
||
</template>
|
||
|
||
<!-- Controleren: het gerenderde resultaat, bewerkbaar -->
|
||
<template x-if="s.id === 'controleren'">
|
||
<div class="space-y-2">
|
||
<div class="flex items-center gap-1">
|
||
<button type="button" class="btn-sm" :class="install.tab==='compose' ? '!bg-ac !text-white' : ''"
|
||
@click="install.tab='compose'">docker-compose.yml</button>
|
||
<button type="button" class="btn-sm" :class="install.tab==='env' ? '!bg-ac !text-white' : ''"
|
||
@click="install.tab='env'">.env</button>
|
||
<button type="button" class="btn-sm ml-auto" @click="toggleEdit()">
|
||
<span class="mdi" :class="install.editing ? 'mdi-eye-outline' : 'mdi-pencil-outline'"></span>
|
||
<span x-text="install.editing ? t('preview') : t('edit')"></span>
|
||
</button>
|
||
</div>
|
||
<p x-show="install.editing" class="text-[11px] text-wr flex items-start gap-1.5">
|
||
<span class="mdi mdi-alert-outline shrink-0"></span>
|
||
<span x-text="t('manual_edit_warning')"></span>
|
||
</p>
|
||
<textarea x-show="install.editing" rows="16"
|
||
class="form-input font-mono !text-[11px] leading-snug"
|
||
x-model="install.tab==='compose' ? install.composeText : install.envText"
|
||
@input="install.tab==='compose'
|
||
? install.composeText = $event.target.value
|
||
: install.envText = $event.target.value"></textarea>
|
||
<pre x-show="!install.editing"
|
||
class="bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto max-h-80"
|
||
x-text="(install.tab==='compose' ? install.composeText : install.envText) || t('nothing_here')"></pre>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Onderdelen kiezen: één schakelaar per onderdeel, per kopje
|
||
gegroepeerd. Een stack als de ARR-stack heeft er vijftig; die
|
||
horen niet verspreid door de andere stappen. -->
|
||
<template x-if="s.id === 'onderdelen'">
|
||
<div class="space-y-4">
|
||
<p class="text-[11px] text-dm dark:text-dm-dark"
|
||
x-text="t('components_hint', {n: gekozenOnderdelen(), totaal: stepFields('onderdelen').length})"></p>
|
||
<template x-for="sec in stepSections('onderdelen')" :key="sec.name">
|
||
<div>
|
||
<div x-show="sec.name" class="flex items-center gap-2 mb-1.5">
|
||
<span class="text-[10px] font-bold tracking-wider uppercase text-ac dark:text-ac-dark"
|
||
x-text="sec.name"></span>
|
||
<span class="h-px flex-1 bg-bd dark:bg-bd-dark"></span>
|
||
<button type="button" class="text-[11px] text-t2 dark:text-t2-dark hover:text-ac"
|
||
@click="zetSectie(sec, !sec.items.every(f => install.values[f.name]))"
|
||
x-text="sec.items.every(f => install.values[f.name]) ? t('none') : t('all')"></button>
|
||
</div>
|
||
<div class="grid sm:grid-cols-2 xl:grid-cols-3 gap-2">
|
||
<template x-for="f in sec.items" :key="f.name">
|
||
<label class="flex items-start gap-2.5 cursor-pointer px-3 py-2.5 rounded-xl border transition"
|
||
:class="install.values[f.name]
|
||
? 'border-ac bg-ac/5'
|
||
: 'border-bd dark:border-bd-dark hover:border-ac/50'">
|
||
<input type="checkbox" :checked="!!install.values[f.name]"
|
||
@change="install.values[f.name] = $event.target.checked"
|
||
class="sr-only peer">
|
||
<span class="mt-0.5 shrink-0 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>
|
||
<span class="min-w-0">
|
||
<span class="block text-sm font-bold text-tx dark:text-tx-dark" x-text="f.title"></span>
|
||
<span x-show="f.description" class="block text-[11px] text-dm dark:text-dm-dark leading-snug"
|
||
x-text="f.description"></span>
|
||
</span>
|
||
</label>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Basis, instellingen en geheimen: de velden van die stap -->
|
||
<template x-if="!['onderdelen','verbinden','controleren'].includes(s.id)">
|
||
<div class="space-y-3">
|
||
<div x-show="s.id === 'basis' && install.mode !== 'edit'">
|
||
<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>
|
||
<template x-for="grp in stepGroups(s.id)" :key="s.id + '/' + grp.name">
|
||
<div class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
|
||
<div x-show="grp.showHeader"
|
||
class="px-3.5 py-2.5 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">
|
||
<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>
|
||
</div>
|
||
<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="!f.is_group_toggle && 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>
|
||
<span x-show="f.advanced" class="mdi mdi-tune text-[11px] text-dm dark:text-dm-dark" :title="t('advanced_options')"></span>
|
||
<button type="button" x-show="f.help || f.description"
|
||
@click="fieldInfo = (fieldInfo && fieldInfo.name === f.name) ? null : f"
|
||
class="text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark"
|
||
:title="t('what_does_this_do')">
|
||
<span class="mdi mdi-information-outline text-sm"></span>
|
||
</button>
|
||
</label>
|
||
<template x-if="f.connect">
|
||
<div class="space-y-1.5">
|
||
<select class="form-input" x-model="install.values[f.name]">
|
||
<template x-for="doel in connectTargets(f)" :key="doel.value">
|
||
<option :value="doel.value" x-text="doel.label"></option>
|
||
</template>
|
||
<option value="__handmatig__" x-text="t('enter_manually')">Handmatig invullen</option>
|
||
</select>
|
||
<input x-show="install.values[f.name] === '__handmatig__'"
|
||
class="form-input font-mono !text-xs" :placeholder="f.placeholder"
|
||
@input="install.values[f.name] = $event.target.value">
|
||
<p x-show="!connectTargets(f).length" class="text-[11px] text-wr"
|
||
x-text="t('no_connectable_app', {app: (f.connect || {}).app || ''})"></p>
|
||
</div>
|
||
</template>
|
||
<template x-if="!f.connect && 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.connect && 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.connect && f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||
<template x-if="!f.connect && f.type === 'str' && !f.secret"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></template>
|
||
<!-- Geheim veld: verborgen tekst, oog om te tonen, dobbelsteen waar een
|
||
willekeurige waarde klopt (niet bij een sleutel die ergens bij moet passen) -->
|
||
<template x-if="!f.connect && f.type === 'str' && f.secret">
|
||
<div class="flex gap-2">
|
||
<input :type="revealed[f.name] ? 'text' : 'password'"
|
||
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('show_value')"
|
||
@click="revealed[f.name] = !revealed[f.name]">
|
||
<span class="mdi" :class="revealed[f.name] ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"></span>
|
||
</button>
|
||
<button type="button" class="btn-sm shrink-0" x-show="f.generate" :title="t('generate_value')"
|
||
@click="install.values[f.name] = veldSecret(f); revealed[f.name] = true"><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>
|
||
</div>
|
||
</template>
|
||
<p x-show="!stepGroups(s.id).length" class="text-[11px] text-dm dark:text-dm-dark"
|
||
x-text="t('nothing_to_choose')"></p>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Wat er nog leeg is; blokkeert Volgende -->
|
||
<p x-show="stepMissing().length" class="text-[11px] text-wr flex items-start gap-1.5">
|
||
<span class="mdi mdi-alert-circle-outline shrink-0"></span>
|
||
<span x-text="t('still_empty', {fields: stepMissing().join(', ')})"></span>
|
||
</p>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Legacy fields -->
|
||
<template x-if="install.format === 'compose' && !install.loading">
|
||
<div class="space-y-4">
|
||
<div><label class="form-label">Stack</label><input class="form-input" :value="install.stack" readonly></div>
|
||
<div x-show="install.mode !== 'edit'">
|
||
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
|
||
<input class="form-input" x-model="install.instance">
|
||
</div>
|
||
<label class="flex items-start gap-2.5 cursor-pointer">
|
||
<input type="checkbox" :checked="install.connect_apps !== false"
|
||
@change="install.connect_apps = $event.target.checked"
|
||
class="w-5 h-5 mt-0.5 rounded border-bd dark:border-bd-dark text-ac shrink-0">
|
||
<span class="min-w-0">
|
||
<span class="text-sm text-tx dark:text-tx-dark" x-text="t('connect_apps')">Verbinden met andere apps</span>
|
||
<span class="block text-[11px] text-dm dark:text-dm-dark"
|
||
x-text="t('connect_apps_hint', {net: sharedNetwork})"></span>
|
||
</span>
|
||
</label>
|
||
<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">
|
||
</div>
|
||
</template>
|
||
</div>
|
||
<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" x-show="wizardActief() && install.step > 0" @click="prevStep()">
|
||
<span class="mdi mdi-chevron-left"></span> <span x-text="t('previous')">Vorige</span>
|
||
</button>
|
||
<button class="btn btn-primary" x-show="wizardActief() && !opLaatsteStap()"
|
||
@click="nextStep()" :disabled="stepMissing().length > 0">
|
||
<span x-text="t('next')">Volgende</span> <span class="mdi mdi-chevron-right"></span>
|
||
</button>
|
||
<button class="btn btn-primary" x-show="!wizardActief() || opLaatsteStap()"
|
||
@click="install.mode === 'edit' ? doReconfigure() : doInstall()">
|
||
<span class="mdi" :class="install.mode === 'edit' ? 'mdi-content-save-outline' : 'mdi-rocket-launch'"></span>
|
||
<span x-text="install.mode === 'edit' ? t('save') : 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 mdi-file-document-edit-outline text-ac dark:text-ac-dark"></span>
|
||
<span x-text="editor.stack"></span>
|
||
<!-- Tabbladen: tot nu toe moest je de modal sluiten en opnieuw openen om
|
||
van compose naar .env te gaan. -->
|
||
<div class="ml-auto flex items-center gap-1">
|
||
<button class="btn-sm" :class="editor.type==='compose' ? '!bg-ac !text-white' : ''"
|
||
@click="switchEditor('compose')">docker-compose.yml</button>
|
||
<button class="btn-sm" :class="editor.type==='env' ? '!bg-ac !text-white' : ''"
|
||
@click="switchEditor('env')">.env</button>
|
||
</div>
|
||
</div>
|
||
<div class="modal-body flex-1 flex flex-col gap-2">
|
||
<p x-show="editor.type==='env'" class="text-[11px] text-dm dark:text-dm-dark flex items-start gap-1.5">
|
||
<span class="mdi mdi-key-variant shrink-0"></span>
|
||
<span x-text="t('env_holds_secrets')"></span>
|
||
</p>
|
||
<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>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer text-[11px] mr-auto">
|
||
<input type="checkbox" :checked="editor.restart" @change="editor.restart = $event.target.checked"
|
||
class="w-4 h-4 rounded border-bd dark:border-bd-dark text-ac">
|
||
<span class="text-t2 dark:text-t2-dark" x-text="t('restart_after_save')">Herstarten na opslaan</span>
|
||
</label>
|
||
<button class="btn btn-primary" @click="saveEditor()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Inloggegevens: waarmee log je bij deze app in? -->
|
||
<div x-show="modal==='credentials'" class="modal max-w-lg flex flex-col max-h-[85vh]">
|
||
<div class="modal-head">
|
||
<span class="mdi mdi-account-key-outline text-ac dark:text-ac-dark"></span>
|
||
<span x-text="t('credentials')">Inloggegevens</span>
|
||
<span class="font-mono text-xs text-t2 dark:text-t2-dark" x-text="creds.stack"></span>
|
||
</div>
|
||
<div class="modal-body overflow-y-auto space-y-3">
|
||
<template x-if="creds.address">
|
||
<a :href="creds.address" target="_blank" rel="noopener"
|
||
class="flex 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 hover:border-ac">
|
||
<span class="mdi mdi-open-in-new text-ac dark:text-ac-dark"></span>
|
||
<span class="font-mono text-xs text-tx dark:text-tx-dark" x-text="creds.address"></span>
|
||
</a>
|
||
</template>
|
||
<template x-for="c in creds.items" :key="c.title">
|
||
<div class="px-3 py-2.5 rounded-xl border border-bd dark:border-bd-dark bg-s2/40 dark:bg-s2-dark/40">
|
||
<div class="text-[10px] uppercase tracking-wider text-dm dark:text-dm-dark" x-text="c.title"></div>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<span class="font-mono text-xs text-tx dark:text-tx-dark flex-1 break-all"
|
||
x-text="(c.secret && !creds.reveal[c.title]) ? '••••••••••••' : c.value"></span>
|
||
<button class="btn-sm !h-7 !px-2" x-show="c.secret" :title="t('show_value')"
|
||
@click="creds.reveal[c.title] = !creds.reveal[c.title]">
|
||
<span class="mdi" :class="creds.reveal[c.title] ? 'mdi-eye-off-outline' : 'mdi-eye-outline'"></span>
|
||
</button>
|
||
<button class="btn-sm !h-7 !px-2" :title="t('copy')" @click="kopieer(c.value)">
|
||
<span class="mdi mdi-content-copy"></span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<!-- Publieke URL via Pangolin -->
|
||
<template x-if="pangolin.configured">
|
||
<div class="pt-1">
|
||
<template x-if="creds.pangolin_url">
|
||
<a :href="creds.pangolin_url" target="_blank" rel="noopener"
|
||
class="flex items-center gap-2 px-3 py-2.5 rounded-xl border border-ok/40 bg-ok/5">
|
||
<span class="mdi mdi-earth text-ok"></span>
|
||
<span class="font-mono text-xs text-tx dark:text-tx-dark" x-text="creds.pangolin_url"></span>
|
||
</a>
|
||
</template>
|
||
<button x-show="!creds.pangolin_url" class="btn w-full" @click="publiceerPangolin()"
|
||
:disabled="credsBusy">
|
||
<span class="mdi" :class="credsBusy ? 'mdi-loading mdi-spin' : 'mdi-earth-plus'"></span>
|
||
<span x-text="t('create_public_url')">Publieke URL aanmaken</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
<p x-show="creds.self_signup" 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('no_credentials_hint')"></span>
|
||
</p>
|
||
</div>
|
||
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('close')">Sluiten</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: {},
|
||
|
||
// Containers op deze host, ook die Server Up niet zelf beheert
|
||
hostContainers: { containers: [], managed: 0, total: 0, running: 0 },
|
||
|
||
// Koppelbare apps en het info-venster in de installatiemodal
|
||
connectApps: [], sharedNetwork: 'serverup', fieldInfo: null,
|
||
|
||
// Backups en updatestatus per stack
|
||
bkStack: '', bkList: [], bkSettings: {}, bkDisk: {volumes: []},
|
||
notify: {channel: '', url: '', events: [], email: {}, available: {}, channels: []},
|
||
// Invulmenu: keuzes die over installaties heen blijven gelden.
|
||
showAdvanced: false, onePage: false, revealed: {},
|
||
appNets: [], newNetName: '', ipCheck: {}, portChecks: {},
|
||
pangolin: {configured: false, sites: [], domains: [], error: ''},
|
||
pangolinTesting: false, credsBusy: false,
|
||
creds: {stack: '', address: '', items: [], self_signup: false, reveal: {}},
|
||
notifyTesting: false,
|
||
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: '', stackCat: '',
|
||
storeQuery: '', storeTag: '', storeCat: '',
|
||
filterOpen: false, filterTagQuery: '',
|
||
categories: [], catMap: {},
|
||
images: [],
|
||
audit: [], auditTotal: 0, auditFacets: {}, auditTimer: null,
|
||
auditFilter: { q: '', src: '', action: '', status: '', days: '' },
|
||
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: {},
|
||
step: 0, tab: 'compose', editing: false, composeText: '', envText: ''
|
||
},
|
||
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.laadUiKeuzes();
|
||
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.loadCategories();
|
||
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.loadHostContainers();
|
||
this.loadUpdate();
|
||
setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000);
|
||
this.$watch('page', async (p) => {
|
||
if (p === 'stacks') { this.loadStacks(); this.loadStackUpdates(); this.loadHostContainers(); }
|
||
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 || []); },
|
||
async loadCategories() {
|
||
const d = await this.rpc('/api/categories?lang=' + (localStorage.getItem('lang') || 'nl'));
|
||
this.categories = d.categories || [];
|
||
this.catMap = Object.fromEntries(this.categories.map(c => [c.id, c]));
|
||
},
|
||
cat(id) { return this.catMap[id] || { id, name: id, color: '#94a3b8', icon: 'mdi-shape-outline' }; },
|
||
// Doorzichtige achtergrond in dezelfde kleurtoon, zodat het in licht en
|
||
// donker leesbaar blijft zonder twee paletten bij te houden.
|
||
catStyle(id) {
|
||
const c = this.cat(id).color;
|
||
return `color:${c};background:${c}1a;border-color:${c}40`;
|
||
},
|
||
// Welke categorieën komen voor in de app store, met hun aantal.
|
||
get storeCategories() {
|
||
const telling = {};
|
||
for (const lijst of Object.values(this.storeStacks || {})) {
|
||
for (const st of lijst) for (const c of (st.categories || [])) {
|
||
telling[c] = (telling[c] || 0) + 1;
|
||
}
|
||
}
|
||
return this.categories
|
||
.filter(c => telling[c.id])
|
||
.map(c => ({ ...c, count: telling[c.id] }));
|
||
},
|
||
|
||
// 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.storeCat && !(s.categories || []).includes(this.storeCat)) return false;
|
||
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 labels uit alle repo's met hun aantal, op frequentie gesorteerd
|
||
// zodat de bruikbaarste vooraan staan. Dit was afgekapt op veertien, wat bij
|
||
// 297 labels betekende dat je de rest niet kon kiezen; het menu zoekt nu in
|
||
// de volledige lijst.
|
||
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))
|
||
.map(tag => ({ tag, count: telling[tag] }));
|
||
},
|
||
// Wat er in het menu past: de zoekterm eroverheen, en anders de eerste
|
||
// veertig. Alles tonen maakt van het menu weer een muur.
|
||
get zichtbareTags() {
|
||
const q = this.filterTagQuery.trim().toLowerCase();
|
||
const lijst = this.storeTags;
|
||
if (q) return lijst.filter(t => t.tag.toLowerCase().includes(q)).slice(0, 60);
|
||
// Het gekozen label hoort zichtbaar te blijven, ook als het zeldzaam is.
|
||
const top = lijst.slice(0, 40);
|
||
if (this.storeTag && !top.some(t => t.tag === this.storeTag)) {
|
||
const gekozen = lijst.find(t => t.tag === this.storeTag);
|
||
if (gekozen) top.unshift(gekozen);
|
||
}
|
||
return top;
|
||
},
|
||
get verborgenTags() {
|
||
if (this.filterTagQuery.trim()) return 0;
|
||
return Math.max(0, this.storeTags.length - this.zichtbareTags.length);
|
||
},
|
||
get filterCount() {
|
||
return (this.storeCat ? 1 : 0) + (this.storeTag ? 1 : 0);
|
||
},
|
||
// Hoeveel apps er over blijven met de huidige keuze, over alle repo's heen.
|
||
get storeResultaat() {
|
||
return Object.keys(this.storeStacks || {})
|
||
.reduce((n, id) => n + this.filteredStore(id).length, 0);
|
||
},
|
||
wisFilters() {
|
||
this.storeCat = '';
|
||
this.storeTag = '';
|
||
this.filterTagQuery = '';
|
||
},
|
||
|
||
get filteredStacks() {
|
||
const q = this.stackQuery.toLowerCase();
|
||
return this.stacks.filter(s => {
|
||
if (this.stackCat && !(s.categories || []).includes(this.stackCat)) return false;
|
||
return !q || (s.name || '').toLowerCase().includes(q);
|
||
});
|
||
},
|
||
// Categorieën van de geïnstalleerde stacks, met hun aantal.
|
||
get stackCategories() {
|
||
const telling = {};
|
||
for (const s of this.stacks) for (const c of (s.categories || [])) {
|
||
telling[c] = (telling[c] || 0) + 1;
|
||
}
|
||
return this.categories.filter(c => telling[c.id]).map(c => ({ ...c, count: telling[c.id] }));
|
||
},
|
||
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 openCredentials(stack) {
|
||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/credentials`);
|
||
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||
this.creds = { stack, address: d.address || '', items: d.items || [],
|
||
self_signup: !!d.self_signup, reveal: {} };
|
||
this.modal = 'credentials';
|
||
},
|
||
async kopieer(tekst) {
|
||
try {
|
||
await navigator.clipboard.writeText(tekst);
|
||
this.toast(this.t('copied'), 'ok');
|
||
} catch (e) {
|
||
// Zonder https staat de clipboard-API niet toe; dan maar selecteren.
|
||
this.toast(this.t('copy_failed'), 'er');
|
||
}
|
||
},
|
||
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 || '', restart: false };
|
||
this.modal = 'editor';
|
||
},
|
||
// Wisselen van tabblad: eerst opslaan wat je hebt getypt zou verrassend
|
||
// zijn, dus we waarschuwen alleen als er iets gewijzigd is.
|
||
async switchEditor(type) {
|
||
if (type === this.editor.type) return;
|
||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${type}`);
|
||
this.editor.type = type;
|
||
this.editor.title = this.editor.stack + ' · ' + type;
|
||
this.editor.content = d.content || '';
|
||
},
|
||
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(d.msg || this.t('error'), 'er'); return; }
|
||
this.toast(this.t('saved'), 'ok');
|
||
if (this.editor.restart) {
|
||
// Een gewijzigde compose of .env doet pas iets na een herstart.
|
||
await this.stackAction(this.editor.stack, 'restart');
|
||
}
|
||
this.modal = null;
|
||
},
|
||
// ── 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 || {};
|
||
this.bkDisk = d.disk || {volumes: []};
|
||
},
|
||
async verifyBackup(file, deep) {
|
||
const d = await this.rpc(`/api/backups/${encodeURIComponent(file)}/verify`, {deep});
|
||
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||
this.termOpen = true;
|
||
this.pollJob(d.job_id, () => this.loadBackups(this.bkStack));
|
||
},
|
||
// ── Pangolin ─────────────────────────────────────────────────────────────
|
||
async loadPangolin() {
|
||
const d = await this.rpc('/api/pangolin');
|
||
if (d && typeof d === 'object') this.pangolin = d;
|
||
},
|
||
async testPangolin() {
|
||
this.pangolinTesting = true;
|
||
try {
|
||
await this.saveSettings();
|
||
const d = await this.rpc('/api/pangolin/test', {});
|
||
this.toast(d.msg || this.t(d.ok ? 'done' : 'error'), d.ok ? 'ok' : 'er');
|
||
await this.loadPangolin();
|
||
} finally { this.pangolinTesting = false; }
|
||
},
|
||
async publiceerPangolin() {
|
||
this.credsBusy = true;
|
||
try {
|
||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(this.creds.stack)}/pangolin`, {});
|
||
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||
this.creds.pangolin_url = d.url;
|
||
this.toast(this.t('public_url_created'), 'ok');
|
||
} finally { this.credsBusy = false; }
|
||
},
|
||
|
||
// ── Gedeelde netwerken ───────────────────────────────────────────────────
|
||
async loadAppNets(voor) {
|
||
const d = await this.rpc('/api/appnetworks?for=' + encodeURIComponent(voor || ''));
|
||
this.appNets = d.networks || [];
|
||
if (!this.newNetName) this.newNetName = d.suggested || '';
|
||
},
|
||
toggleAppNet(naam, aan) {
|
||
const lijst = new Set(this.install.app_networks || []);
|
||
aan ? lijst.add(naam) : lijst.delete(naam);
|
||
this.install.app_networks = [...lijst];
|
||
// De oude schakelaar blijft meelopen voor de klassieke stacks.
|
||
this.install.connect_apps = this.install.app_networks.length > 0;
|
||
},
|
||
async createAppNet() {
|
||
const d = await this.rpc('/api/appnetworks', { name: this.newNetName,
|
||
for: this.install.instance });
|
||
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||
this.appNets = d.networks || [];
|
||
this.toggleAppNet(d.name, true);
|
||
this.newNetName = '';
|
||
this.toast(this.t('network_created', {name: d.name}), 'ok');
|
||
},
|
||
async removeAppNet(naam) {
|
||
if (!confirm(this.t('delete_network_confirm', {name: naam}))) return;
|
||
const d = await this.req('/api/appnetworks/' + encodeURIComponent(naam),
|
||
{ method: 'DELETE' });
|
||
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||
this.toggleAppNet(naam, false);
|
||
await this.loadAppNets();
|
||
},
|
||
// Controleert subnet, bereik, Docker én de ARP-tabel van de host.
|
||
async checkIp() {
|
||
this.ipCheck = {};
|
||
if (!this.install.network || !this.install.ip) return;
|
||
const d = await this.rpc(`/api/networks/${encodeURIComponent(this.install.network)}`
|
||
+ `/ip-check?ip=${encodeURIComponent(this.install.ip)}`);
|
||
if (d && d.ok) this.ipCheck = d;
|
||
},
|
||
|
||
// ── Meldingen ────────────────────────────────────────────────────────────
|
||
async loadNotify() {
|
||
this.notify = await this.rpc('/api/notify');
|
||
},
|
||
toggleNotifyEvent(key, aan) {
|
||
const lijst = new Set(this.settings.NOTIFY_EVENTS || []);
|
||
aan ? lijst.add(key) : lijst.delete(key);
|
||
this.settings.NOTIFY_EVENTS = [...lijst];
|
||
this.autoSave();
|
||
},
|
||
async testNotify() {
|
||
// Eerst opslaan: anders test hij de vorige instellingen en denk je dat
|
||
// het werkt terwijl je net iets anders hebt ingevuld.
|
||
this.notifyTesting = true;
|
||
try {
|
||
await this.saveSettings();
|
||
const d = await this.rpc('/api/notify/test', {});
|
||
this.toast(d.msg || this.t(d.ok ? 'done' : 'error'), d.ok ? 'ok' : 'er');
|
||
await this.loadNotify();
|
||
} finally {
|
||
this.notifyTesting = false;
|
||
}
|
||
},
|
||
async offsiteBackup(file) {
|
||
const d = await this.rpc(`/api/backups/${encodeURIComponent(file)}/offsite`, {});
|
||
this.toast(d.msg || this.t(d.ok ? 'done' : 'error'), d.ok ? 'ok' : 'er');
|
||
this.loadBackups(this.bkStack);
|
||
},
|
||
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();
|
||
},
|
||
|
||
// ── Apps onderling koppelen ──────────────────────────────────────────────
|
||
async loadConnectApps() {
|
||
const d = await this.rpc('/api/apps/connectable');
|
||
this.connectApps = d.apps || [];
|
||
this.sharedNetwork = d.shared_network || 'serverup';
|
||
if (this.install && this.install.connect_apps === undefined) {
|
||
this.install.connect_apps = d.connect_default !== false;
|
||
}
|
||
},
|
||
// Wat dit sjabloon nodig heeft en nog niet draait. De backend heeft de
|
||
// status er al bij gezet; hier blijft alleen het filteren over.
|
||
ontbrekendeAfhankelijkheden() {
|
||
return (this.install.depends_on || []).filter(d => !d.installed);
|
||
},
|
||
// Deze modal sluiten en die van de benodigde app openen. openInstall()
|
||
// vervangt install helemaal, dus er blijft niets van de vorige staan.
|
||
async installeerAfhankelijkheid(d) {
|
||
for (const [repoId, lijst] of Object.entries(this.storeStacks || {})) {
|
||
const st = (lijst || []).find(x => x.dir === d.app);
|
||
if (st) { await this.openInstall(st, repoId); return; }
|
||
}
|
||
this.toast(this.t('depends_on_not_in_store', {app: d.app}), 'er');
|
||
},
|
||
// Adressen van al geïnstalleerde apps die bij dit veld passen. Een veld met
|
||
// {"connect": {"app": "mosquitto", "scheme": "mqtt", "port": 1883}} levert
|
||
// mqtt://<containernaam>:1883 op voor elke geïnstalleerde Mosquitto.
|
||
connectTargets(f) {
|
||
const c = f.connect || {};
|
||
const uit = [];
|
||
for (const app of this.connectApps) {
|
||
if (c.app && app.source !== c.app) continue;
|
||
for (const ct of app.containers) {
|
||
const schema = c.scheme ? c.scheme + '://' : '';
|
||
const poort = c.port ? ':' + c.port : '';
|
||
const pad = c.path || '';
|
||
uit.push({
|
||
value: schema + ct.name + poort + pad,
|
||
label: app.name + ' → ' + ct.name + (c.port ? ' :' + c.port : ''),
|
||
});
|
||
}
|
||
}
|
||
return uit;
|
||
},
|
||
|
||
// Cryptografisch willekeurige waarde voor admin-tokens en wachtwoorden.
|
||
// Lokaal gegenereerd, dus het geheim gaat niet heen en weer over het net.
|
||
// Niet elke sleutel mag er hetzelfde uitzien. Laravel wil `base64:` met
|
||
// standaard-base64 ervoor, Homarr en LibreChat willen hexadecimaal van een
|
||
// vaste lengte. Een waarde in het verkeerde formaat levert een container op
|
||
// die niet start — met een foutmelding die nergens naar een sleutel wijst.
|
||
randomSecret(bytes = 24, formaat = 'base64url') {
|
||
const buf = new Uint8Array(bytes);
|
||
crypto.getRandomValues(buf);
|
||
if (formaat === 'hex')
|
||
return Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
|
||
const b64 = btoa(String.fromCharCode(...buf));
|
||
if (formaat === 'laravel') return 'base64:' + b64;
|
||
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||
},
|
||
// De sleutel zoals dít veld hem wil hebben.
|
||
veldSecret(f) {
|
||
return this.randomSecret(f.secret_bytes || 24, f.secret_format || 'base64url');
|
||
},
|
||
|
||
// ── Containers op deze host ──────────────────────────────────────────────
|
||
async loadHostContainers() {
|
||
const d = await this.rpc('/api/containers');
|
||
this.hostContainers = d && d.containers ? d : { containers: [], managed: 0, total: 0, running: 0 };
|
||
},
|
||
get unmanaged() {
|
||
return (this.hostContainers.containers || []).filter(c => !c.managed);
|
||
},
|
||
async hostContainerAction(naam, actie) {
|
||
const d = await this.rpc(`/api/containers/${encodeURIComponent(naam)}/${actie}`, {});
|
||
this.toast(d.ok ? this.t(actie) + ' ✓' : (d.msg || this.t('error')), d.ok ? 'ok' : 'er');
|
||
await this.loadHostContainers();
|
||
this.loadDocker();
|
||
},
|
||
async openHostLogs(naam) {
|
||
const d = await this.rpc(`/api/containers/${encodeURIComponent(naam)}/logs`);
|
||
this.logs = { title: naam + ' · logs', content: d.logs || '(geen output)' };
|
||
this.modal = 'logs';
|
||
},
|
||
|
||
// ── 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 || [] } };
|
||
},
|
||
// De containers van één stack, gefilterd op wat je intypt. Een stack als de
|
||
// ARR-stack heeft er vijftig; zonder filter scroll je jezelf suf.
|
||
containerLijst(stack) {
|
||
const p = this.cpanel[stack] || {};
|
||
const items = p.items || [];
|
||
const zoek = (p.filter || '').trim().toLowerCase();
|
||
if (!zoek) return items;
|
||
return items.filter(c => (c.service || c.name || '').toLowerCase().includes(zoek));
|
||
},
|
||
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 = {
|
||
mode: 'new',
|
||
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: {},
|
||
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
||
network: '', ip: '', ips: {}, services: [], depends_on: []
|
||
};
|
||
this.modal = 'install';
|
||
this.fieldInfo = null;
|
||
if (!this.netList.length) await this.loadNetworks();
|
||
await this.loadConnectApps();
|
||
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;
|
||
// Wijst een veld naar een andere app en staat die al geïnstalleerd,
|
||
// vul dan meteen het juiste adres in.
|
||
for (const f of (d.fields || [])) {
|
||
if (!f.connect) continue;
|
||
const doelen = this.connectTargets(f);
|
||
if (doelen.length) init[f.name] = doelen[0].value;
|
||
}
|
||
// Database-wachtwoorden en interne sleutels meteen invullen. Die hoef
|
||
// je nooit zelf te typen, en een leeg veld leverde een stack op die
|
||
// niet startte of met een leeg geheim draaide.
|
||
for (const f of (d.fields || [])) {
|
||
// 'auto': waarden die je nooit hoeft te kennen. Een inlogwachtwoord
|
||
// krijgt alleen een knop, want dat moet je zelf noteren.
|
||
if (f.generate === 'auto' && !init[f.name]) init[f.name] = this.veldSecret(f);
|
||
}
|
||
this.install.values = init;
|
||
this.install.preview_files = d.preview_files || {};
|
||
this.install.depends_on = d.depends_on || [];
|
||
this.install.step = 0;
|
||
this.revealed = {};
|
||
this.ipCheck = {};
|
||
await this.loadAppNets(this.install.instance);
|
||
// Standaard alleen het gedeelde netwerk aanvinken, niet alle.
|
||
if (this.install.app_networks === undefined) {
|
||
const std = this.appNets.find(n => n.default);
|
||
this.install.app_networks = (this.install.connect_apps !== false && std)
|
||
? [std.name] : [];
|
||
}
|
||
} 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());
|
||
},
|
||
|
||
// ── Invulmenu ────────────────────────────────────────────────────────────
|
||
// Vaste stappen voor elke app. Eén stap per groep werkt niet: de mediane app
|
||
// heeft één groep, arr-stack heeft er vijftig. 'Onderdelen' verschijnt
|
||
// alleen bij een sjabloon dat meer dan één schakelbare groep heeft.
|
||
STAP_INFO: {
|
||
onderdelen: { icon: 'mdi-view-grid-plus-outline', key: 'step_components' },
|
||
basis: { icon: 'mdi-tune-variant', key: 'step_basics' },
|
||
verbinden: { icon: 'mdi-lan-connect', key: 'step_network' },
|
||
instellingen: { icon: 'mdi-cog-outline', key: 'step_settings' },
|
||
geheimen: { icon: 'mdi-key-variant', key: 'step_secrets' },
|
||
controleren: { icon: 'mdi-file-check-outline', key: 'step_review' },
|
||
},
|
||
wizardActief() {
|
||
return this.install.format === 'boilerplate' && !this.install.loading && !this.onePage;
|
||
},
|
||
// Velden van één stap, met de geavanceerde erbij of niet.
|
||
stepFields(stap) {
|
||
return (this.install.fields || []).filter(f =>
|
||
(f.step || 'instellingen') === stap && (this.showAdvanced || !f.advanced));
|
||
},
|
||
advancedCount() {
|
||
return (this.install.fields || []).filter(f => f.advanced).length;
|
||
},
|
||
// Een lege stap slaan we over; anders klik je door schermen zonder inhoud.
|
||
wizardSteps() {
|
||
const uit = [];
|
||
for (const id of ['onderdelen', 'basis', 'verbinden', 'instellingen', 'geheimen']) {
|
||
if (id === 'verbinden') { uit.push(this._stap(id)); continue; }
|
||
// 'basis' toont ook de instantienaam, dus die stap is nooit leeg.
|
||
if (id === 'basis' || this.stepFields(id).length) uit.push(this._stap(id));
|
||
}
|
||
uit.push(this._stap('controleren'));
|
||
return uit;
|
||
},
|
||
_stap(id) {
|
||
const info = this.STAP_INFO[id];
|
||
return { id, icon: info.icon, title: this.t(info.key) };
|
||
},
|
||
// De onderdelen van één stap, gebundeld onder hun kopje.
|
||
stepSections(stap) {
|
||
const map = new Map();
|
||
for (const f of this.stepFields(stap)) {
|
||
const s = f.section || '';
|
||
if (!map.has(s)) map.set(s, { name: s, items: [] });
|
||
map.get(s).items.push(f);
|
||
}
|
||
return Array.from(map.values());
|
||
},
|
||
gekozenOnderdelen() {
|
||
return this.stepFields('onderdelen').filter(f => this.install.values[f.name]).length;
|
||
},
|
||
zetSectie(sec, aan) {
|
||
for (const f of sec.items) this.install.values[f.name] = aan;
|
||
},
|
||
stepGroups(stap) {
|
||
const map = new Map();
|
||
for (const f of this.stepFields(stap)) {
|
||
// Een onderdeel dat uit staat heeft hier niets te zoeken: anders krijg
|
||
// je een kaart met een lege inhoud voor iets dat je net hebt afgezet.
|
||
if (f.group_toggle && !this.install.values[f.group_toggle]) continue;
|
||
const g = f.group || 'Algemeen';
|
||
if (!map.has(g)) map.set(g, { name: g, items: [] });
|
||
map.get(g).items.push(f);
|
||
}
|
||
const groepen = Array.from(map.values());
|
||
// Eén naamloze groep hoeft geen kop; dat is bij de meeste apps het geval.
|
||
// De schakelaar staat in de stap Onderdelen en niet nog eens hier.
|
||
for (const g of groepen) { g.toggle = null; g.showHeader = groepen.length > 1; }
|
||
return groepen;
|
||
},
|
||
// Containers waaraan een eigen adres toegekend kan worden. De namen komen
|
||
// uit de gerenderde voorvertoning, niet uit de veldnamen: een onderdeel kan
|
||
// meer dan één container opleveren.
|
||
ipContainers() {
|
||
return this.install.services || [];
|
||
},
|
||
zetContainerIp(container, aan) {
|
||
if (aan) this.install.ips[container] = '';
|
||
else delete this.install.ips[container];
|
||
},
|
||
// Verplichte velden van de huidige stap die nog leeg zijn.
|
||
stepMissing() {
|
||
if (!this.wizardActief()) return [];
|
||
const s = this.wizardSteps()[this.install.step];
|
||
if (!s || s.id === 'controleren' || s.id === 'verbinden') return [];
|
||
return this.stepFields(s.id).filter(f => {
|
||
if (!f.required || f.is_group_toggle) return false;
|
||
if (f.group_toggle && !this.install.values[f.group_toggle]) return false;
|
||
if (!this.bpFieldVisible(f)) return false;
|
||
const v = this.install.values[f.name];
|
||
return v === undefined || v === null || String(v).trim() === '';
|
||
}).map(f => f.title);
|
||
},
|
||
opLaatsteStap() {
|
||
return this.install.step >= this.wizardSteps().length - 1;
|
||
},
|
||
async gotoStep(i) {
|
||
const stappen = this.wizardSteps();
|
||
this.install.step = Math.max(0, Math.min(i, stappen.length - 1));
|
||
this.fieldInfo = null;
|
||
// Ook bij Verbinden: daar staat de lijst containers die uit de
|
||
// voorvertoning komt, en die verandert met elk onderdeel dat je aanzet.
|
||
if (['controleren', 'verbinden'].includes(stappen[this.install.step].id))
|
||
await this.refreshPreview();
|
||
},
|
||
async nextStep() {
|
||
if (this.stepMissing().length) return;
|
||
await this.gotoStep(this.install.step + 1);
|
||
},
|
||
prevStep() { this.gotoStep(this.install.step - 1); },
|
||
// Het gerenderde resultaat ophalen met de waarden die nu ingevuld staan.
|
||
async refreshPreview() {
|
||
if (this.install.editing) return; // niet overschrijven wat je zelf typte
|
||
const d = await this.rpc('/api/store/preview', {
|
||
stack: this.install.stack, repo_id: this.install.repo_id,
|
||
values: this.install.values,
|
||
});
|
||
if (!d || !d.ok) return;
|
||
this.install.preview_files = d.preview_files || {};
|
||
this.install.services = d.services || [];
|
||
this.install.composeText = this.bpPreview();
|
||
this.install.envText = d.env_preview || '';
|
||
// Een onderdeel dat je hebt uitgezet levert geen container meer op; zijn
|
||
// adres moet dan ook niet blijven staan.
|
||
for (const c of Object.keys(this.install.ips || {})) {
|
||
if (!this.install.services.includes(c)) delete this.install.ips[c];
|
||
}
|
||
},
|
||
toggleEdit() {
|
||
this.install.editing = !this.install.editing;
|
||
if (this.install.editing) this.toast(this.t('manual_edit_warning'), 'in');
|
||
},
|
||
saveUiKeuze() {
|
||
try {
|
||
localStorage.setItem('su_advanced', this.showAdvanced ? '1' : '0');
|
||
localStorage.setItem('su_onepage', this.onePage ? '1' : '0');
|
||
} catch (e) { /* privémodus: dan onthouden we het gewoon niet */ }
|
||
},
|
||
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)';
|
||
},
|
||
// Bestaande stack opnieuw instellen: hetzelfde formulier, voorgevuld met
|
||
// wat er bij de installatie is gekozen.
|
||
async openReconfigure(stack) {
|
||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/config`);
|
||
if (!d.ok) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||
if (!this.netList.length) await this.loadNetworks();
|
||
await this.loadConnectApps();
|
||
this.install = {
|
||
mode: 'edit', stack: d.source, instance: stack, repo_id: '',
|
||
format: 'boilerplate', loading: false,
|
||
envVars: [], images: [], envOverrides: {}, imageTags: {},
|
||
fields: d.fields || [], values: { ...(d.values || {}) }, preview_files: {},
|
||
network: d.network || '', ip: d.ip || '', ips: { ...(d.ips || {}) },
|
||
services: [], connect_apps: !!d.connected,
|
||
step: 0, tab: 'compose', editing: false, composeText: '', envText: '',
|
||
// Bij wijzigen is de bron nodig om opnieuw te kunnen renderen.
|
||
repo_id: d.repo_id || '',
|
||
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
|
||
};
|
||
await this.loadAppNets(stack);
|
||
this.fieldInfo = null;
|
||
this.revealed = {};
|
||
this.modal = 'install';
|
||
},
|
||
async doReconfigure() {
|
||
const naam = this.install.instance;
|
||
if (!confirm(this.t('reconfigure_confirm', {stack: naam}))) return;
|
||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(naam)}/reconfigure`, {
|
||
values: this.install.values,
|
||
network: this.install.network,
|
||
ip: this.install.ip,
|
||
ips: this.install.ips || {},
|
||
connect_apps: this.install.connect_apps !== false,
|
||
app_networks: this.install.app_networks || [],
|
||
});
|
||
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('saved'), 'ok'); });
|
||
},
|
||
// Bestaande stack alsnog op het gedeelde netwerk zetten.
|
||
async connectStack(stack) {
|
||
if (!confirm(this.t('connect_stack_confirm', {stack, net: this.sharedNetwork}))) return;
|
||
const d = await this.rpc(`/api/stacks/${encodeURIComponent(stack)}/connect`, {connect: true});
|
||
if (!d.job_id) { this.toast(d.msg || this.t('error'), 'er'); return; }
|
||
this.termOpen = true;
|
||
this.pollJob(d.job_id, () => this.loadStacks());
|
||
},
|
||
|
||
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;
|
||
// Per container een adres; leeg laten betekent "één adres voor de stack".
|
||
if (Object.keys(this.install.ips || {}).length) body.ips = this.install.ips;
|
||
}
|
||
body.connect_apps = this.install.connect_apps !== false;
|
||
body.app_networks = this.install.app_networks || [];
|
||
if (this.install.format === 'boilerplate') body.values = this.install.values;
|
||
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
|
||
// Heb je het resultaat in de laatste stap zelf bewerkt, dan telt jouw
|
||
// versie in plaats van wat er uit het sjabloon zou rollen.
|
||
if (this.install.editing) {
|
||
body.compose_override = this.install.composeText;
|
||
body.env_override = this.install.envText;
|
||
}
|
||
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(meer) {
|
||
const f = this.auditFilter;
|
||
const offset = meer ? this.audit.length : 0;
|
||
const qs = new URLSearchParams({
|
||
q: f.q || '', src: f.src || '', action: f.action || '',
|
||
status: f.status || '', days: f.days || '',
|
||
limit: 100, offset,
|
||
});
|
||
const d = await this.rpc('/api/audit?' + qs);
|
||
this.audit = meer ? [...this.audit, ...(d.entries || [])] : (d.entries || []);
|
||
this.auditTotal = d.total ?? this.audit.length;
|
||
this.auditFacets = d.facets || {};
|
||
},
|
||
loadAuditDebounced() {
|
||
if (this.auditTimer) clearTimeout(this.auditTimer);
|
||
this.auditTimer = setTimeout(() => this.loadAudit(), 300);
|
||
},
|
||
async clearAudit() {
|
||
if (!confirm('Audit log wissen?')) return;
|
||
await this.rpc('/api/audit/clear', {});
|
||
this.loadAudit();
|
||
},
|
||
|
||
// Voorkeuren die over installaties heen gelden.
|
||
laadUiKeuzes() {
|
||
try {
|
||
this.showAdvanced = localStorage.getItem('su_advanced') === '1';
|
||
this.onePage = localStorage.getItem('su_onepage') === '1';
|
||
} catch (e) { /* privémodus */ }
|
||
},
|
||
async loadSettings() {
|
||
const d = await this.rpc('/api/settings'); this.settings = d || {};
|
||
await this.loadAppStore();
|
||
await this.loadUsers();
|
||
await this.loadNetworks();
|
||
await this.loadNotify();
|
||
await this.loadPangolin();
|
||
},
|
||
// Meteen opslaan. autoSave() wacht bewust even zodat typen niet elk teken
|
||
// een verzoek oplevert, maar de testknop moet de nieuwe waarden gebruiken.
|
||
async saveSettings() {
|
||
if (this.saveTimer) { clearTimeout(this.saveTimer); this.saveTimer = null; }
|
||
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);
|
||
},
|
||
autoSave() {
|
||
if (this.saveTimer) clearTimeout(this.saveTimer);
|
||
this.saveTimer = setTimeout(() => this.saveSettings(), 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',
|
||
appdata_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>
|