All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 15m26s
Het scherm Instellingen wijzigen van een bestaande stack is hetzelfde formulier als bij het installeren, maar openReconfigure() zette de poortcontrole niet in gang. De knoppen en de handmatige controle werkten daar wel; alleen de melding bij het openen bleef weg. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q9eqpADJSRs49SoGGr4NAy
3981 lines
237 KiB
HTML
3981 lines
237 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="paginaTitel">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>
|
||
<!-- Zelfde menu als in de store; de rij van twintig categorieknoppen
|
||
die hier stond, paste op een telefoon niet. -->
|
||
<div class="relative" x-show="stackCategories.length"
|
||
@keydown.escape.window="stackFilterOpen = false">
|
||
<button type="button" class="btn !px-3" @click="stackFilterOpen = !stackFilterOpen"
|
||
:class="stackCat ? '!border-ac !text-ac dark:!text-ac-dark !bg-ac/5' : ''">
|
||
<span class="mdi mdi-tune-variant"></span>
|
||
<span x-text="stackCat ? cat(stackCat).name : t('filter')">Filter</span>
|
||
<span class="mdi text-xs transition-transform"
|
||
:class="stackFilterOpen ? 'mdi-chevron-down rotate-180' : 'mdi-chevron-down'"></span>
|
||
</button>
|
||
<div x-show="stackFilterOpen" x-cloak @click.outside="stackFilterOpen = 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"
|
||
class="card shadow-pop absolute left-0 z-40 mt-2 w-[min(92vw,24rem)] origin-top p-3">
|
||
<div class="grid grid-cols-2 gap-1.5">
|
||
<button type="button" @click="stackCat = ''"
|
||
class="flex items-center gap-2 px-2.5 py-1.5 rounded-xl border text-[11px] font-semibold transition col-span-2"
|
||
: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 dark:hover:border-bd2-dark'">
|
||
<span class="mdi mdi-select-all"></span>
|
||
<span class="flex-1 text-left" x-text="t('all')">Alle</span>
|
||
<span class="opacity-60 tabular-nums" x-text="stacks.length"></span>
|
||
</button>
|
||
<template x-for="c in stackCategories" :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="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 dark:hover:border-bd2-dark'"
|
||
@click="stackCat = (stackCat === c.id ? '' : c.id); stackFilterOpen = false">
|
||
<span class="mdi shrink-0" :class="c.icon"
|
||
:style="stackCat === 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>
|
||
</div>
|
||
<button @click="loadStacks()" class="btn-icon ml-auto" :title="t('refresh')"><span class="mdi mdi-refresh"></span></button>
|
||
<button @click="checkStackUpdates()" class="btn" :disabled="checkingUpd">
|
||
<span class="mdi" :class="checkingUpd ? 'mdi-loading mdi-spin' : 'mdi-package-up'"></span>
|
||
<span class="hidden sm:inline" x-text="t('check_updates')">Updates controleren</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="filteredStacks.length > 0">
|
||
<template x-for="s in filteredStacks" :key="s.name">
|
||
<div class="card card-hover flex flex-col 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>
|
||
<!-- Iets wat deze stack nodig heeft draait niet. Klikken brengt
|
||
je naar de store om het alsnog te installeren. -->
|
||
<button type="button" x-show="(s.depends_missing || []).length"
|
||
class="badge badge-warn inline-flex items-center gap-1 cursor-pointer"
|
||
:title="(s.depends_missing || []).map(d => d.app + ': ' + d.reason).join('\n')"
|
||
@click="goto('app_store'); storeQuery = (s.depends_missing[0] || {}).app || ''">
|
||
<span class="mdi mdi-link-variant-off text-[11px]"></span>
|
||
<span x-text="(s.depends_missing || []).map(d => d.app).join(', ')"></span>
|
||
</button>
|
||
<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>
|
||
<!-- Twaalf icoonknoppen naast elkaar werkt op een breed scherm,
|
||
maar vult op een telefoon drie rijen met plaatjes zonder
|
||
naam. Daar blijven starten, logs en een menu over. -->
|
||
<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('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
|
||
|
||
<!-- Vanaf sm alles uitgeklapt -->
|
||
<button class="btn-sm hidden sm:inline-flex" :title="t('update')" @click="stackAction(s.name, 'update')"><span class="mdi mdi-update"></span></button>
|
||
<button class="btn-sm hidden sm:inline-flex" title=".env" @click="openEditor(s.name, 'env')"><span class="mdi mdi-file-cog-outline"></span></button>
|
||
<button class="btn-sm hidden sm:inline-flex" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button>
|
||
<button class="btn-sm hidden sm:inline-flex" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button>
|
||
<button class="btn-sm hidden sm:inline-flex" :title="t('credentials')" @click="openCredentials(s.name)"><span class="mdi mdi-account-key-outline"></span></button>
|
||
<button class="btn-sm hidden sm:inline-flex" :title="t('backups')" @click="openBackups(s.name)"><span class="mdi mdi-history"></span></button>
|
||
<button class="btn-sm hidden sm:inline-flex" :title="t('stack_settings')" @click="openReconfigure(s.name)"
|
||
:disabled="!s.editable"><span class="mdi mdi-tune"></span></button>
|
||
<button class="btn-sm hidden sm:inline-flex" :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 hidden sm:inline-flex sm:ml-auto" :title="t('remove')" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button>
|
||
|
||
<!-- Op de telefoon: de rest onder één knop, mét namen -->
|
||
<div class="relative sm:hidden ml-auto" @keydown.escape.window="stackMenu = ''">
|
||
<button class="btn-sm" @click="stackMenu = (stackMenu === s.name ? '' : s.name)"
|
||
:title="t('more')">
|
||
<span class="mdi mdi-dots-horizontal"></span>
|
||
</button>
|
||
<div x-show="stackMenu === s.name" x-cloak @click.outside="stackMenu = ''"
|
||
x-transition:enter="transition ease-out duration-150"
|
||
x-transition:enter-start="opacity-0 scale-95"
|
||
x-transition:enter-end="opacity-100 scale-100"
|
||
class="card shadow-pop absolute right-0 bottom-full mb-2 z-30 w-52 p-1.5 origin-bottom-right">
|
||
<template x-for="a in stackMenuItems(s)" :key="a.id">
|
||
<button class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-xs text-left transition"
|
||
:class="a.gevaar ? 'text-er hover:bg-er/10' : 'text-tx dark:text-tx-dark hover:bg-s2 dark:hover:bg-s2-dark'"
|
||
:disabled="a.uit" :style="a.uit ? 'opacity:.4' : ''"
|
||
@click="stackMenu = ''; a.doe()">
|
||
<span class="mdi text-base w-5 text-center" :class="a.icon"></span>
|
||
<span x-text="a.label"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</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.debounce.200ms="storeQuery" @input="storeLimiet = 60"
|
||
: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); storeLimiet = 60">
|
||
<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); storeLimiet = 60">
|
||
<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 zichtbareStore(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>
|
||
<!-- Draait deze app al? Dat stond eerder als een klein
|
||
vinkje naast de knop, waar niemand het zag. -->
|
||
<template x-if="(stack.instances || []).length">
|
||
<div class="flex flex-wrap items-center gap-1.5 mb-3">
|
||
<span class="badge badge-ok inline-flex items-center gap-1">
|
||
<span class="mdi mdi-check-circle text-[11px]"></span>
|
||
<span x-text="t('installed')">Geïnstalleerd</span>
|
||
</span>
|
||
<template x-for="inst in stack.instances" :key="inst">
|
||
<button type="button" @click.stop="gaNaarStack(inst)"
|
||
class="text-[10px] font-mono px-2 py-0.5 rounded-md border border-ok/30 bg-ok/5 text-ok hover:bg-ok/10 transition inline-flex items-center gap-1"
|
||
:title="t('open_stack')">
|
||
<span x-text="inst"></span>
|
||
<span class="mdi mdi-arrow-right text-[11px]"></span>
|
||
</button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<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 flex-1"
|
||
:class="(stack.instances || []).length ? '' : 'btn-primary'">
|
||
<span class="mdi mdi-download-outline"></span>
|
||
<span x-text="(stack.instances || []).length ? t('install_again') : t('install')">Installeren</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Niet alles tegelijk: 258 kaarten in de DOM is traag en niemand
|
||
scrolt eroverheen. -->
|
||
<div x-show="storeVerborgen > 0" class="flex justify-center pt-1">
|
||
<button type="button" class="btn" @click="meerTonen()">
|
||
<span class="mdi mdi-chevron-down"></span>
|
||
<span x-text="t('show_more', {n: storeVerborgen})">Meer tonen</span>
|
||
</button>
|
||
</div>
|
||
</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="auditNaam('cat', 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="auditNaam('act', 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" x-text="t('col_time')">Tijd</th>
|
||
<th class="px-4 py-3 font-bold" x-text="t('col_category')">Categorie</th>
|
||
<th class="px-4 py-3 font-bold" x-text="t('col_action')">Actie</th>
|
||
<th class="px-4 py-3 font-bold" x-text="t('col_status')">Status</th>
|
||
<th class="px-4 py-3 font-bold" x-text="t('col_ref')">Betreft</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="auditNaam('cat', e.category)" :title="e.category"></td>
|
||
<td class="px-4 py-2" x-text="auditNaam('act', e.action)" :title="e.action"></td>
|
||
<td class="px-4 py-2"><span class="badge" :class="e.status==='ok' ? 'badge-ok' : 'badge-danger'" x-text="e.status === 'ok' ? t('ok') : t('failed')"></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="flex items-center gap-2.5 cursor-pointer">
|
||
<input type="checkbox" :checked="!!settings.BACKUP_APPDATA" @change="settings.BACKUP_APPDATA=$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_appdata')"></span>
|
||
</label>
|
||
<p class="text-[11px] text-dm dark:text-dm-dark mt-1 ml-8" x-text="t('backup_appdata_hint')"></p>
|
||
</div>
|
||
<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-0 sm: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 h-full max-h-full rounded-none
|
||
sm:h-auto sm:max-h-[88vh] sm:rounded-2xl">
|
||
<div class="modal-head">
|
||
<span class="mdi mdi-download-outline text-ac dark:text-ac-dark shrink-0"></span>
|
||
<span class="min-w-0 truncate" x-text="install.mode === 'edit' ? t('stack_settings') : t('install_stack')">Stack installeren</span>
|
||
<span x-show="install.format === 'boilerplate'" class="badge badge-info hidden sm:inline-flex">Boilerplate</span>
|
||
<!-- Op een telefoon vult deze modal het scherm, dus er is geen
|
||
achtergrond meer om op te tikken. Zonder deze knop kom je er niet uit. -->
|
||
<button class="btn-icon ml-auto shrink-0" @click="modal=null" :title="t('close')">
|
||
<span class="mdi mdi-close"></span>
|
||
</button>
|
||
</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>
|
||
<!-- Poortveld: knoppen om te stappen, en een
|
||
controle of hij vrij is. De backend kon dat al
|
||
(/api/ports/check, kijkt naar Docker én naar wat
|
||
er rechtstreeks op de host luistert), maar het
|
||
formulier vroeg het nooit. -->
|
||
<template x-if="!f.connect && f.type === 'int' && isPoortVeld(f)">
|
||
<div>
|
||
<div class="flex">
|
||
<button type="button" class="stepper rounded-l-xl border-r-0"
|
||
@click="stapPoort(f, -1)" :title="t('lower')">
|
||
<span class="mdi mdi-minus"></span>
|
||
</button>
|
||
<input type="number" inputmode="numeric"
|
||
class="form-input !rounded-none text-center"
|
||
x-model.number="install.values[f.name]"
|
||
@input.debounce.400ms="checkPoort(f)"
|
||
:placeholder="f.placeholder">
|
||
<button type="button" class="stepper border-l-0"
|
||
@click="stapPoort(f, 1)" :title="t('raise')">
|
||
<span class="mdi mdi-plus"></span>
|
||
</button>
|
||
<button type="button" class="stepper rounded-r-xl border-l-0"
|
||
@click="checkPoort(f)" :title="t('check_availability')">
|
||
<span class="mdi" :class="poortCheck[f.name] && poortCheck[f.name].bezig ? 'mdi-loading mdi-spin' : 'mdi-magnify'"></span>
|
||
</button>
|
||
</div>
|
||
<template x-if="poortCheck[f.name] && !poortCheck[f.name].bezig">
|
||
<p class="text-[11px] mt-1 flex items-start gap-1.5"
|
||
:class="poortCheck[f.name].free ? 'text-ok' : 'text-wr'">
|
||
<span class="mdi shrink-0"
|
||
:class="poortCheck[f.name].free ? 'mdi-check-circle-outline' : 'mdi-alert-circle-outline'"></span>
|
||
<span class="min-w-0">
|
||
<span x-text="poortStatus(f)"></span>
|
||
<button type="button" x-show="!poortCheck[f.name].free"
|
||
class="underline ml-1"
|
||
@click="install.values[f.name] = poortCheck[f.name].suggested; checkPoort(f)"
|
||
x-text="t('use_free_port', {port: poortCheck[f.name].suggested})"></button>
|
||
</span>
|
||
</p>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<template x-if="!f.connect && f.type === 'int' && !isPoortVeld(f)"><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.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 hidden sm:inline-flex" @click="modal=null" x-text="t('cancel')">Annuleren</button>
|
||
<button class="btn flex-1 sm:flex-none" 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 flex-1 sm:flex-none" 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 flex-1 sm:flex-none" 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="wizardStapId() === 'welkom'">
|
||
<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="wizardStapId() === 'paden'" 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="wizardStapId() === 'repos'">
|
||
<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="wizardStapId() === 'git'" 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>
|
||
<!-- Bereikbaar van buitenaf. Server Up kan zelf publieke URL's aanmaken
|
||
in Pangolin; deze stap zet die koppeling klaar, of installeert
|
||
Pangolin hier. -->
|
||
<div x-show="wizardStapId() === 'pangolin'" class="space-y-3">
|
||
<div class="grid grid-cols-3 gap-2">
|
||
<button @click="wizard.pangolin.choice = 'existing'"
|
||
:class="wizard.pangolin.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-lan-connect text-xl"></span>
|
||
<span x-text="t('wizard_pangolin_existing')">Ik heb er al een</span>
|
||
</button>
|
||
<button @click="wizard.pangolin.choice = 'install'"
|
||
:class="wizard.pangolin.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-outline text-xl"></span>
|
||
<span x-text="t('wizard_pangolin_install')">Hier installeren</span>
|
||
</button>
|
||
<button @click="wizard.pangolin.choice = 'skip'"
|
||
:class="wizard.pangolin.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-close text-xl"></span>
|
||
<span x-text="t('skip')">Overslaan</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div x-show="wizard.pangolin.choice === 'existing'" class="space-y-2 pt-1">
|
||
<div><label class="form-label" x-text="t('pangolin_url')">Adres</label>
|
||
<input class="form-input" x-model="wizard.pangolin.url" placeholder="https://pangolin.example.com"></div>
|
||
<div><label class="form-label" x-text="t('pangolin_api_key')">API-sleutel</label>
|
||
<input class="form-input" type="password" x-model="wizard.pangolin.api_key"></div>
|
||
<div><label class="form-label" x-text="t('pangolin_org')">Organisatie</label>
|
||
<input class="form-input" x-model="wizard.pangolin.org_id" placeholder="mijn-org"></div>
|
||
<label class="inline-flex items-center gap-2 cursor-pointer pt-1">
|
||
<input type="checkbox" x-model="wizard.pangolin.newt"
|
||
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="t('wizard_newt_too')"></span>
|
||
</label>
|
||
<div x-show="wizard.pangolin.newt" class="grid sm:grid-cols-2 gap-2">
|
||
<div><label class="form-label">Site-id</label>
|
||
<input class="form-input font-mono !text-xs" x-model="wizard.pangolin.newt_id"></div>
|
||
<div><label class="form-label">Sitesleutel</label>
|
||
<input class="form-input font-mono !text-xs" type="password" x-model="wizard.pangolin.newt_secret"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-show="wizard.pangolin.choice === 'install'" class="space-y-2 pt-1">
|
||
<div><label class="form-label" x-text="t('base_domain')">Basisdomein</label>
|
||
<input class="form-input" x-model="wizard.pangolin.base_domain" placeholder="example.com"></div>
|
||
<div><label class="form-label" x-text="t('dashboard_domain')">Dashboarddomein</label>
|
||
<input class="form-input" x-model="wizard.pangolin.dashboard_domain" placeholder="pangolin.example.com"></div>
|
||
<div><label class="form-label" x-text="t('admin_email')">E-mailadres</label>
|
||
<input class="form-input" x-model="wizard.pangolin.admin_email" placeholder="admin@example.com"></div>
|
||
<p class="text-[11px] text-t2 dark:text-t2-dark" x-text="t('wizard_pangolin_hint')"></p>
|
||
</div>
|
||
</div>
|
||
|
||
<div x-show="wizardStapId() === 'klaar'"><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; }
|
||
.stepper { @apply w-10 h-11 grid place-items-center shrink-0 border border-bd dark:border-bd-dark bg-s2 dark:bg-s2-dark text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark hover:bg-s3 dark:hover:bg-s3-dark active:scale-95 transition cursor-pointer; }
|
||
.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-4 sm: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-4 sm:px-5 py-4; }
|
||
.modal-foot { @apply px-4 sm:px-5 py-3 sm:py-4 flex flex-wrap 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: '', stackMenu: '', stackFilterOpen: false,
|
||
poortCheck: {},
|
||
// Niet reactief nodig: puur een geheugensteun voor filteredStore().
|
||
_storeCache: { sleutel: null, lijsten: {} },
|
||
storeLimiet: 60,
|
||
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:'',
|
||
pangolin: { choice:'skip', url:'', api_key:'', org_id:'',
|
||
newt:false, newt_id:'', newt_secret:'',
|
||
base_domain:'', dashboard_domain:'', admin_email:'' },
|
||
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.
|
||
// Het auditlog bewaart ruwe id's ('store', 'reconfigure'). Die horen niet
|
||
// in beeld: vertaal ze waar we ze kennen, en val terug op de ruwe waarde
|
||
// zodat een module met een eigen categorie niets kapotmaakt.
|
||
auditNaam(soort, waarde) {
|
||
if (!waarde) return '';
|
||
return this.T[`audit_${soort}_${waarde}`] || waarde;
|
||
},
|
||
// De titel bovenaan. 'docker' heet 'images' in de vertalingen, en een
|
||
// modulepagina heet 'mod:<id>' — die toonde letterlijk 'mod:mijnmodule'.
|
||
get paginaTitel() {
|
||
if (this.page.startsWith('mod:')) {
|
||
const m = this.modules.find(x => 'mod:' + x.id === this.page);
|
||
return m ? m.name : this.page.slice(4);
|
||
}
|
||
return this.t(this.page === 'docker' ? 'images' : this.page);
|
||
},
|
||
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.
|
||
//
|
||
// Dit wordt per repo minstens drie keer per reactieve tick aangeroepen: in
|
||
// de x-show, in de x-for en via storeResultaat in het filtermenu. Met 258
|
||
// apps betekende dat bij elke toetsaanslag drie keer de hele lijst langs,
|
||
// met een toLowerCase() per veld per app. Vandaar de cache: de uitkomst
|
||
// hangt alleen van de filterstand af, dus die berekenen we één keer.
|
||
filteredStore(repoId) {
|
||
const sleutel = [repoId, this.storeQuery.trim().toLowerCase(),
|
||
this.storeCat, this.storeTag].join(' |