server-up/server-up/templates/index.html
bes-r eebbbc1534
All checks were successful
Deploy server-up (prod) / deploy (push) Successful in 3s
v0.4.0 - nieuw UI-ontwerp + SU_VERSION via build-arg/env (fix vdev)
2026-06-06 14:05:40 +02:00

1132 lines
71 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="nl" x-data="app()" x-init="init()" :class="theme==='dark' ? 'dark' : ''">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Server Up</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
<link rel="stylesheet" href="/static/fonts/mdi.min.css">
<script src="https://cdn.tailwindcss.com?plugins=forms,typography"></script>
<script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.3/dist/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>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', 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 { 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">
<!-- ░░ 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>
</div>
</div>
</aside>
<div x-show="sidebarOpen && isMobile" @click="sidebarOpen=false" class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-30" x-cloak></div>
<!-- ░░ Main column ░░ -->
<div class="h-screen flex flex-col transition-[padding] duration-200 ease-out" :class="(sidebarOpen && !isMobile) ? 'md:pl-[264px]' : 'pl-0'">
<!-- Topbar -->
<header class="h-16 px-4 sm:px-6 flex items-center gap-3 bg-bg/80 dark:bg-bg-dark/80 backdrop-blur border-b border-bd dark:border-bd-dark shrink-0 sticky top-0 z-20">
<button @click="toggleSidebar()" :title="sidebarOpen ? t('hide_menu') : t('show_menu')" class="btn-icon">
<span class="mdi text-xl" :class="sidebarOpen ? 'mdi-menu-open' : 'mdi-menu'"></span>
</button>
<h1 class="text-lg font-bold text-wh dark:text-wh-dark tracking-tight truncate" x-text="t(page === 'docker' ? 'images' : page)">Dashboard</h1>
<div class="ml-auto flex items-center gap-2">
<div class="hidden sm:flex items-center gap-1.5 px-3 h-10 rounded-xl bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark text-xs font-mono text-t2 dark:text-t2-dark">
<span class="mdi mdi-play-circle-outline text-ok"></span><span x-text="docker.running ?? ''"></span>
<span class="w-px h-4 bg-bd dark:bg-bd-dark mx-1"></span>
<span class="mdi mdi-layers-outline text-bl"></span><span x-text="docker.images ?? ''"></span>
</div>
<button @click="toggleTheme()" :title="t('theme')" class="btn-icon">
<span class="mdi text-xl" :class="theme==='dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"></span>
</button>
</div>
</header>
<!-- Scroll area -->
<main class="flex-1 overflow-y-auto">
<div class="p-4 sm:p-7 max-w-6xl mx-auto pb-48">
<!-- ══ Dashboard ══ -->
<section x-show="page === 'dashboard'" x-cloak class="anim">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-7">
<div class="stat-card">
<div class="stat-icon bg-ok/10 text-ok"><span class="mdi mdi-play-circle"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="docker.running ?? ''"></div><div class="stat-label" x-text="t('running')">Running</div></div>
</div>
<div class="stat-card">
<div class="stat-icon bg-dm/15 text-dm dark:text-dm-dark"><span class="mdi mdi-stop-circle-outline"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="(docker.containers ?? 0) - (docker.running ?? 0)"></div><div class="stat-label" x-text="t('stopped')">Stopped</div></div>
</div>
<button class="stat-card text-left hover:shadow-soft2 transition" @click="goto('docker')">
<div class="stat-icon bg-bl/10 text-bl"><span class="mdi mdi-layers-outline"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="docker.images ?? ''"></div><div class="stat-label" x-text="t('images')">Images</div></div>
</button>
<button class="stat-card text-left hover:shadow-soft2 transition" @click="goto('stacks')">
<div class="stat-icon bg-ac/10 text-ac dark:text-ac-dark"><span class="mdi mdi-folder-multiple-outline"></span></div>
<div class="min-w-0"><div class="stat-value" x-text="stacks.length"></div><div class="stat-label">Stacks</div></div>
</button>
</div>
<div class="flex items-center gap-3 mb-3">
<h2 class="text-sm font-bold text-wh dark:text-wh-dark uppercase tracking-wide" x-text="t('stacks')">Stacks</h2>
<button @click="loadDashboard()" class="btn-icon ml-auto w-9 h-9"><span class="mdi mdi-refresh"></span></button>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="stacks.length > 0">
<template x-for="s in stacks.slice(0, 6)" :key="s.name">
<button @click="goto('stacks')" class="card card-hover p-4 flex items-center gap-3 text-left">
<span class="w-2.5 h-2.5 rounded-full shrink-0" :class="s.running ? 'bg-ok shadow-[0_0_8px_#22c55e]' : 'bg-dm dark:bg-dm-dark'"></span>
<span class="font-semibold text-wh dark:text-wh-dark text-sm flex-1 truncate" x-text="s.name"></span>
<span class="badge" :class="s.running ? 'badge-ok' : 'badge-dim'" x-text="s.running ? t('running') : t('stopped')"></span>
</button>
</template>
</div>
<div x-show="stacks.length === 0" class="empty"><span class="mdi mdi-server-network"></span><p x-text="t('no_stacks')">Geen stacks</p></div>
</section>
<!-- ══ Stacks ══ -->
<section x-show="page === 'stacks'" x-cloak class="anim">
<div class="flex flex-wrap items-center gap-3 mb-5">
<div class="relative flex-1 min-w-[200px] max-w-sm">
<span class="mdi mdi-magnify absolute left-3.5 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark text-lg"></span>
<input x-model="stackQuery" :placeholder="t('search')+'…'" class="form-input !pl-10">
</div>
<button @click="loadStacks()" class="btn"><span class="mdi mdi-refresh"></span> <span x-text="t('refresh')">Vernieuwen</span></button>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4" x-show="filteredStacks.length > 0">
<template x-for="s in filteredStacks" :key="s.name">
<div class="card card-hover flex flex-col">
<div class="px-4 py-3.5 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<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>
<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.5" x-show="s.containers && s.containers.length">
<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>
</div>
<div class="flex flex-wrap gap-1.5 mt-auto" x-show="s.has_compose">
<button class="btn-sm flex-1 min-w-[84px]" :class="s.running ? 'btn-danger' : 'btn-primary'" @click="stackAction(s.name, s.running ? 'stop' : 'start')">
<span class="mdi" :class="s.running ? 'mdi-stop' : 'mdi-play'"></span>
<span x-text="s.running ? t('stop') : t('start')"></span>
</button>
<button class="btn-sm" :title="t('restart')" @click="stackAction(s.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm" :title="t('update')" @click="stackAction(s.name, 'update')"><span class="mdi mdi-update"></span></button>
<button class="btn-sm" title=".env" @click="openEditor(s.name, 'env')"><span class="mdi mdi-file-cog-outline"></span></button>
<button class="btn-sm" title="compose" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button>
<button class="btn-sm" :title="t('logs')" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
<button class="btn-sm" :title="t('backup')" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button>
<button class="btn-sm btn-danger ml-auto" :title="t('remove')" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</div>
</div>
</template>
</div>
<div x-show="filteredStacks.length === 0" class="empty"><span class="mdi mdi-server-network"></span><p x-text="t('no_stacks')">Geen stacks gevonden</p></div>
</section>
<!-- ══ App Store ══ -->
<section x-show="page === 'app_store'" x-cloak class="anim">
<div class="flex flex-wrap items-center gap-2 mb-5">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('app_store')">App Store</p>
<button @click="syncAllRepos()" class="btn"><span class="mdi mdi-source-pull"></span> <span x-text="t('pull')">Pull</span></button>
<button @click="modal='add-repo'" class="btn btn-primary"><span class="mdi mdi-plus"></span> <span x-text="t('add_repo')">Repository</span></button>
</div>
<div class="space-y-7">
<template x-for="repo in repos" :key="repo.id">
<div>
<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 (storeStacks[repo.id] || [])" :key="stack.dir">
<div class="card card-hover p-4 flex flex-col">
<div class="flex items-start gap-3 mb-3">
<template x-if="stack.logo_url">
<img :src="stack.logo_url" class="w-10 h-10 rounded-lg object-contain bg-s2 dark:bg-s2-dark p-1" loading="lazy" onerror="this.style.display='none'">
</template>
<template x-if="!stack.logo_url">
<div class="w-10 h-10 rounded-lg bg-s2 dark:bg-s2-dark grid place-items-center text-lg shrink-0" x-text="stack.icon || '📦'"></div>
</template>
<div class="flex-1 min-w-0">
<div class="font-semibold text-wh dark:text-wh-dark text-sm truncate" x-text="stack.name"></div>
<div class="text-[11px] text-t2 dark:text-t2-dark line-clamp-2 leading-snug" x-text="stack.description"></div>
</div>
<span x-show="stack.format === 'boilerplate'" class="badge badge-info shrink-0" title="ChristianLempa Boilerplate">BP</span>
</div>
<div class="flex flex-wrap gap-1 mb-3" x-show="stack.tags && stack.tags.length">
<template x-for="tag in (stack.tags || []).slice(0,3)">
<span class="text-[10px] font-mono px-2 py-0.5 rounded-md bg-s2 dark:bg-s2-dark text-t2 dark:text-t2-dark" x-text="tag"></span>
</template>
</div>
<div class="flex gap-2 mt-auto items-center">
<button @click="openInstall(stack, repo.id)" class="btn-sm btn-primary flex-1">
<span class="mdi mdi-download-outline"></span> <span x-text="t('install')">Installeren</span>
</button>
<template x-if="stack.instances && stack.instances.length">
<span class="text-[11px] font-semibold text-ok px-2 grid place-items-center" :title="stack.instances.join(', ')" x-text="'✓ '+stack.instances.length"></span>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
</section>
<!-- ══ Docker Images ══ -->
<section x-show="page === 'docker'" x-cloak class="anim">
<div class="flex items-center gap-2 mb-5">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('images')">Docker Images</p>
<button @click="loadImages()" class="btn-icon"><span class="mdi mdi-refresh"></span></button>
<button @click="pruneImages()" class="btn btn-danger"><span class="mdi mdi-broom"></span> <span x-text="t('prune')">Prune</span></button>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="bg-s2 dark:bg-s2-dark text-[10px] uppercase tracking-wider text-dm dark:text-dm-dark text-left">
<th class="px-4 py-3 font-bold">Repository</th><th class="px-4 py-3 font-bold">Tag</th>
<th class="px-4 py-3 font-bold">Size</th><th class="px-4 py-3 font-bold">Age</th><th class="px-4 py-3"></th>
</tr></thead>
<tbody>
<template x-for="img in images" :key="img.id">
<tr class="border-t border-bd dark:border-bd-dark hover:bg-s2/60 dark:hover:bg-s2-dark/60 transition">
<td class="px-4 py-2.5 font-mono text-tx dark:text-tx-dark" x-text="img.repo"></td>
<td class="px-4 py-2.5 font-mono text-ac dark:text-ac-dark" x-text="img.tag"></td>
<td class="px-4 py-2.5 font-mono text-t2 dark:text-t2-dark" x-text="img.size"></td>
<td class="px-4 py-2.5 font-mono text-t2 dark:text-t2-dark" x-text="img.age"></td>
<td class="px-4 py-2.5 text-right"><button @click="removeImage(img.id)" class="btn-sm btn-danger"><span class="mdi mdi-delete-outline"></span></button></td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="images.length === 0" class="empty !py-10"><span class="mdi mdi-layers-outline"></span><p x-text="t('no_images')">Geen images</p></div>
</div>
</section>
<!-- ══ Audit ══ -->
<section x-show="page === 'audit'" x-cloak class="anim">
<div class="flex items-center gap-2 mb-5">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('audit')">Audit Log</p>
<button @click="loadAudit()" class="btn-icon"><span class="mdi mdi-refresh"></span></button>
<button @click="clearAudit()" class="btn btn-danger"><span class="mdi mdi-delete-outline"></span> <span x-text="t('delete')">Wissen</span></button>
</div>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="bg-s2 dark:bg-s2-dark text-[10px] uppercase tracking-wider text-dm dark:text-dm-dark text-left">
<th class="px-4 py-3 font-bold">Tijd</th><th class="px-4 py-3 font-bold">Categorie</th>
<th class="px-4 py-3 font-bold">Actie</th><th class="px-4 py-3 font-bold">Status</th><th class="px-4 py-3 font-bold">Ref</th>
</tr></thead>
<tbody>
<template x-for="(e, i) in audit" :key="i">
<tr class="border-t border-bd dark:border-bd-dark hover:bg-s2/60 dark:hover:bg-s2-dark/60 transition">
<td class="px-4 py-2 font-mono text-t2 dark:text-t2-dark whitespace-nowrap" x-text="(e.ts || '').replace('T',' ').slice(0,19)"></td>
<td class="px-4 py-2" x-text="e.category"></td>
<td class="px-4 py-2" x-text="e.action"></td>
<td class="px-4 py-2"><span class="badge" :class="e.status==='ok' ? 'badge-ok' : 'badge-danger'" x-text="e.status"></span></td>
<td class="px-4 py-2 font-mono text-ac dark:text-ac-dark" x-text="e.ref || ''"></td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="audit.length === 0" class="empty !py-10"><span class="mdi mdi-clipboard-text-outline"></span><p x-text="t('no_results')">Geen resultaten</p></div>
</div>
</section>
<!-- ══ Settings ══ -->
<section x-show="page === 'settings'" x-cloak class="anim space-y-5">
<div class="flex items-center gap-3">
<p class="text-sm text-t2 dark:text-t2-dark flex-1" x-text="t('settings')">Instellingen</p>
<span x-show="saved" class="text-xs text-ok flex items-center gap-1.5 font-semibold"><span class="mdi mdi-check-circle"></span> <span x-text="t('auto_saved')">Opgeslagen</span></span>
</div>
<div class="card card-pad grid sm:grid-cols-2 gap-6">
<div>
<div class="form-label" x-text="t('theme')">Thema</div>
<div class="inline-flex rounded-xl bg-s2 dark:bg-s2-dark p-1 gap-1">
<button @click="setThemePref('system')" class="seg" :class="themePref==='system' ? 'seg-on' : ''"><span class="mdi mdi-monitor"></span> Auto</button>
<button @click="setThemePref('light')" class="seg" :class="themePref==='light' ? 'seg-on' : ''"><span class="mdi mdi-weather-sunny"></span> Light</button>
<button @click="setThemePref('dark')" class="seg" :class="themePref==='dark' ? 'seg-on' : ''"><span class="mdi mdi-weather-night"></span> Dark</button>
</div>
</div>
<div>
<div class="form-label" x-text="t('language')">Taal</div>
<select x-model="settings.LANGUAGE" @change="setLang(settings.LANGUAGE)" class="form-input !w-auto min-w-[180px]">
<template x-for="l in languages" :key="l.code"><option :value="l.code" x-text="(l.flag||'')+' '+l.name"></option></template>
</select>
</div>
</div>
<!-- Taalmodule (add-on) -->
<div class="card">
<div class="px-5 py-4 flex items-center gap-2.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-translate text-ac dark:text-ac-dark text-lg"></span>
<span class="font-bold text-wh dark:text-wh-dark text-sm" x-text="t('language_addon')">Taalmodule</span>
<span class="badge badge-info" x-text="languages.length"></span>
<button class="btn-sm btn-primary ml-auto" @click="openLangEditor(null)"><span class="mdi mdi-plus"></span> <span x-text="t('add_language')">Taal toevoegen</span></button>
</div>
<div class="p-5">
<p class="text-xs text-t2 dark:text-t2-dark mb-4" x-text="t('language_addon_hint')">Voeg extra talen toe als add-on.</p>
<div class="grid sm:grid-cols-2 gap-3">
<template x-for="l in languages" :key="l.code">
<div class="flex items-center gap-3 p-3 rounded-xl border border-bd dark:border-bd-dark bg-s2/50 dark:bg-s2-dark/50">
<span class="text-2xl shrink-0" x-text="l.flag || '🌐'"></span>
<div class="flex-1 min-w-0">
<div class="font-semibold text-wh dark:text-wh-dark text-sm truncate"><span x-text="l.name"></span> <span class="font-mono text-[11px] text-dm dark:text-dm-dark" x-text="'('+l.code+')'"></span></div>
<div class="text-[11px] text-dm dark:text-dm-dark"><span x-text="l.count"></span> <span x-text="t('strings_count')">strings</span><template x-if="l.builtin"><span class="ml-1.5 badge badge-dim" x-text="t('builtin')">ingebouwd</span></template></div>
</div>
<template x-if="!l.builtin"><button class="btn-icon w-9 h-9" :title="t('edit')" @click="openLangEditor(l.code)"><span class="mdi mdi-pencil-outline"></span></button></template>
<template x-if="!l.builtin"><button class="btn-icon w-9 h-9 !text-er hover:!border-er/40" :title="t('delete')" @click="deleteLang(l.code)"><span class="mdi mdi-delete-outline"></span></button></template>
<template x-if="l.builtin"><button class="btn-icon w-9 h-9" :title="t('duplicate')" @click="openLangEditor(l.code)"><span class="mdi mdi-content-copy"></span></button></template>
</div>
</template>
</div>
</div>
</div>
<details class="card" open>
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-folder-cog-outline text-ac dark:text-ac-dark text-lg"></span> <span x-text="t('paths')">Paden</span></summary>
<div class="px-5 pb-5 grid sm:grid-cols-3 gap-4">
<div><label class="form-label" x-text="t('library_dir')">Library map</label><input class="form-input" x-model="settings.LIBRARY_DIR" @change="autoSave()"></div>
<div><label class="form-label" x-text="t('data_dir')">Data map</label><input class="form-input" x-model="settings.DATA_DIR" @change="autoSave()"></div>
<div><label class="form-label" x-text="t('backup_dir')">Backup map</label><input class="form-input" x-model="settings.BACKUP_DIR" @change="autoSave()"></div>
</div>
</details>
<details class="card">
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-source-branch text-ac dark:text-ac-dark text-lg"></span> Git Repositories</summary>
<div class="px-5 pb-5 space-y-2">
<template x-for="r in repos" :key="r.id">
<div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0">
<span class="mdi mdi-source-branch text-dm dark:text-dm-dark"></span>
<span class="font-mono text-xs flex-1 truncate" x-text="r.name"></span>
<span class="text-[10px] text-t2 dark:text-t2-dark" x-text="r.subdir||''"></span>
<button @click="syncRepo(r.id)" class="btn-icon w-9 h-9"><span class="mdi mdi-refresh"></span></button>
<button @click="deleteRepo(r.id)" class="btn-icon w-9 h-9 !text-er hover:!border-er/40"><span class="mdi mdi-delete-outline"></span></button>
</div>
</template>
<button @click="modal='add-repo'" class="btn btn-primary mt-3"><span class="mdi mdi-plus"></span> <span x-text="t('add')">Toevoegen</span></button>
</div>
</details>
<details class="card">
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-restart text-ac dark:text-ac-dark text-lg"></span> <span x-text="t('container_restart')">Container herstarten</span></summary>
<div class="px-5 pb-5">
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('container_restart_hint')">Herstart de Server Up container.</p>
<button @click="restartSelf()" class="btn btn-danger"><span class="mdi mdi-restart-alert"></span> <span x-text="t('container_restart')">Container herstarten</span></button>
</div>
</details>
</section>
</div>
</main>
</div>
<!-- ░░ Terminal ░░ -->
<div class="fixed bottom-0 right-0 left-0 bg-[#0b1018] border-t border-black/40 transition-all duration-200 z-30"
:class="[(sidebarOpen && !isMobile) ? 'md:left-[264px]' : 'left-0', termOpen ? 'translate-y-0' : 'translate-y-[184px]']">
<div @click="termOpen = !termOpen" class="h-10 px-4 flex items-center gap-2.5 border-b border-white/5 cursor-pointer">
<div class="flex gap-1.5"><span class="w-3 h-3 rounded-full bg-[#ff5f57]"></span><span class="w-3 h-3 rounded-full bg-[#febc2e]"></span><span class="w-3 h-3 rounded-full bg-[#28c840]"></span></div>
<span class="text-xs font-mono text-slate-300 flex-1"><span class="mdi mdi-console"></span> Log</span>
<span class="badge" :class="job.status==='running'?'badge-info':job.status==='error'?'badge-danger':'badge-dim'" x-text="job.status==='running' ? t('busy') : (job.status==='error' ? t('error') : (job.status==='done' ? t('done') : t('ready')))"></span>
<span class="mdi text-slate-400 text-lg" :class="termOpen ? 'mdi-chevron-down' : 'mdi-chevron-up'"></span>
</div>
<div x-ref="termOut" class="h-44 overflow-y-auto px-4 py-3 font-mono text-xs leading-relaxed text-slate-200">
<template x-for="(line, i) in job.lines" :key="i">
<div class="flex gap-2" :class="'log-'+line.level">
<span class="w-3 text-center shrink-0" x-text="({ok:'✔',error:'✖',warn:'⚠',info:'',dim:'·',section:''})[line.level] || '·'"></span>
<span x-text="line.text"></span>
</div>
</template>
<div x-show="job.lines.length === 0" class="text-slate-500"><span x-text="t('ready')">gereed</span></div>
</div>
</div>
<!-- ░░ Toasts ░░ -->
<div class="fixed bottom-6 right-6 z-[70] flex flex-col gap-2 items-end pointer-events-none">
<template x-for="ts in toasts" :key="ts.id">
<div class="anim pointer-events-auto pl-3 pr-4 py-2.5 rounded-xl border bg-s1 dark:bg-s1-dark shadow-pop flex items-center gap-2.5 text-sm font-medium"
:class="({ok:'border-ok/30',er:'border-er/30',wr:'border-wr/30',in:'border-bl/30'})[ts.kind]">
<span class="mdi text-lg" :class="({ok:'mdi-check-circle text-ok',er:'mdi-alert-circle text-er',wr:'mdi-alert text-wr',in:'mdi-information text-bl'})[ts.kind]"></span>
<span class="text-tx dark:text-tx-dark" x-text="ts.msg"></span>
</div>
</template>
</div>
<!-- ░░ Modals ░░ -->
<div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-[60] grid place-items-center p-4">
<!-- Add repo -->
<div x-show="modal==='add-repo'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-store-plus-outline text-ac dark:text-ac-dark"></span> <span x-text="t('add_repo')">Repository toevoegen</span></div>
<div class="modal-body space-y-3">
<div><label class="form-label" x-text="t('repo_name')">Naam</label><input class="form-input" x-model="newRepo.name"></div>
<div><label class="form-label" x-text="t('repo_url')">Git URL</label><input class="form-input" x-model="newRepo.url" placeholder="https://github.com/..."></div>
<div class="grid grid-cols-2 gap-3">
<div><label class="form-label" x-text="t('repo_branch')">Branch</label><input class="form-input" x-model="newRepo.branch"></div>
<div><label class="form-label" x-text="t('repo_subdir')">Submap</label><input class="form-input" x-model="newRepo.subdir" placeholder="apps"></div>
</div>
<p class="text-[11px] text-t2 dark:text-t2-dark">Tip: voor de ChristianLempa Boilerplates gebruik je submap <code class="font-mono text-ac dark:text-ac-dark">compose</code>.</p>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="addRepo()"><span class="mdi mdi-plus"></span> <span x-text="t('add')">Toevoegen</span></button>
</div>
</div>
<!-- Install -->
<div x-show="modal==='install'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
<div class="modal-head">
<span class="mdi mdi-download-outline text-ac dark:text-ac-dark"></span>
<span x-text="t('install_stack')">Stack installeren</span>
<span x-show="install.format === 'boilerplate'" class="badge badge-info">Boilerplate</span>
</div>
<div class="modal-body overflow-y-auto space-y-3">
<div><label class="form-label">Stack</label><input class="form-input" :value="install.stack" readonly></div>
<div>
<label class="form-label" x-text="t('instance_name')">Instantie naam</label>
<input class="form-input" x-model="install.instance">
<p class="text-[11px] text-dm dark:text-dm-dark mt-1" x-text="t('instance_hint')">Wijzig voor meerdere installaties</p>
</div>
<div x-show="install.loading" class="text-center py-6 text-dm dark:text-dm-dark"><span class="mdi mdi-loading mdi-spin"></span> Configuratie laden…</div>
<!-- Boilerplate fields -->
<template x-if="install.format === 'boilerplate' && !install.loading">
<div class="space-y-3">
<template x-for="grp in groupedFields" :key="grp.name">
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden" :open="!grp.toggle || !!install.values[grp.toggle]">
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60">
<span x-text="grp.name"></span>
<template x-if="grp.toggle">
<label class="ml-auto inline-flex items-center cursor-pointer" @click.stop>
<input type="checkbox" :checked="!!install.values[grp.toggle]" @change="install.values[grp.toggle] = $event.target.checked" class="sr-only peer">
<span class="w-9 h-5 rounded-full bg-bd2 dark:bg-bd2-dark peer-checked:bg-ac transition relative after:content-[''] after:absolute after:top-0.5 after:left-0.5 after:w-4 after:h-4 after:rounded-full after:bg-white after:transition peer-checked:after:translate-x-4"></span>
</label>
</template>
</summary>
<div class="px-3.5 py-3 grid sm:grid-cols-2 gap-3" x-show="!grp.toggle || install.values[grp.toggle]">
<template x-for="f in grp.items" :key="f.name">
<div x-show="bpFieldVisible(f)" :class="f.type==='bool' ? 'sm:col-span-2' : ''">
<label class="form-label flex items-center gap-1"><span x-text="f.title"></span><span x-show="f.required" class="text-er">*</span></label>
<template x-if="f.type === 'enum'">
<select class="form-input" x-model="install.values[f.name]"><template x-for="opt in f.options" :key="opt"><option :value="opt" x-text="opt"></option></template></select>
</template>
<template x-if="f.type === 'bool'">
<label class="inline-flex items-center gap-2 cursor-pointer"><input type="checkbox" :checked="!!install.values[f.name]" @change="install.values[f.name] = $event.target.checked" class="rounded border-bd dark:border-bd-dark text-ac w-5 h-5"><span class="text-xs text-t2 dark:text-t2-dark" x-text="f.description"></span></label>
</template>
<template x-if="f.type === 'int'"><input type="number" class="form-input" x-model.number="install.values[f.name]" :placeholder="f.placeholder"></template>
<template x-if="f.type === 'str'"><input class="form-input" x-model="install.values[f.name]" :placeholder="f.placeholder"></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>
</details>
</template>
<details class="rounded-xl border border-bd dark:border-bd-dark overflow-hidden">
<summary class="px-3.5 py-2.5 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2 bg-s2/60 dark:bg-s2-dark/60"><span class="mdi mdi-eye-outline"></span> Preview</summary>
<div class="p-3"><pre class="bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto max-h-64" x-text="bpPreview()"></pre></div>
</details>
</div>
</template>
<!-- Legacy fields -->
<template x-if="install.format === 'compose' && !install.loading">
<div class="space-y-4">
<template x-if="install.images.length">
<div>
<div class="text-[10px] font-bold tracking-wider uppercase text-pu mb-2"><span class="mdi mdi-docker"></span> Image Tags</div>
<template x-for="img in install.images" :key="img.service">
<div class="flex gap-2 items-center mb-1.5">
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1 truncate" x-text="img.service+': '+img.repo"></span>
<input class="form-input !w-36" :placeholder="img.tag" x-model="install.imageTags[img.service]">
</div>
</template>
</div>
</template>
<template x-if="install.envVars.length">
<div>
<div class="text-[10px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-2"><span class="mdi mdi-file-cog-outline"></span> Omgevingsvariabelen</div>
<template x-for="ev in install.envVars" :key="ev.key">
<div class="flex gap-2 items-center mb-1.5">
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark flex-1 truncate" :title="ev.key" x-text="ev.key"></span>
<input class="form-input !w-44" :placeholder="ev.value" x-model="install.envOverrides[ev.key]">
</div>
</template>
</div>
</template>
</div>
</template>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="doInstall()"><span class="mdi mdi-rocket-launch"></span> <span x-text="t('install')">Installeren</span></button>
</div>
</div>
<!-- Remove confirm -->
<div x-show="modal==='remove'" class="modal max-w-md">
<div class="modal-head"><span class="mdi mdi-delete-alert-outline text-er"></span> <span x-text="t('remove_stack')">Verwijderen</span></div>
<div class="modal-body">
<p class="text-sm mb-4 text-t2 dark:text-t2-dark">Stack: <strong class="text-wh dark:text-wh-dark" x-text="install.stack"></strong></p>
<div class="flex flex-col gap-2">
<button class="btn" @click="doRemove('stack')"><span class="mdi mdi-stop-circle-outline"></span> <span x-text="t('remove_stack_only')">Stop + volumes</span></button>
<button class="btn btn-danger" @click="doRemove('all')"><span class="mdi mdi-delete-outline"></span> <span x-text="t('remove_all')">Verwijder alles</span></button>
</div>
</div>
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button></div>
</div>
<!-- Editor -->
<div x-show="modal==='editor'" class="modal max-w-2xl flex flex-col max-h-[88vh]">
<div class="modal-head"><span class="mdi" :class="editor.type==='env' ? 'mdi-file-cog-outline' : 'mdi-file-code-outline'"></span> <span x-text="editor.title"></span></div>
<div class="modal-body flex-1 flex"><textarea x-model="editor.content" class="flex-1 form-input !bg-[#0b1018] !text-slate-200 !border-black/30 font-mono text-xs min-h-[300px] leading-relaxed"></textarea></div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="saveEditor()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
</div>
</div>
<!-- Logs -->
<div x-show="modal==='logs'" class="modal max-w-3xl flex flex-col max-h-[88vh]">
<div class="modal-head"><span class="mdi mdi-text-box-outline"></span> <span x-text="logs.title"></span></div>
<div class="modal-body flex-1 overflow-hidden flex"><pre class="flex-1 bg-[#0b1018] text-slate-200 rounded-lg p-3 text-[11px] font-mono overflow-auto whitespace-pre-wrap break-all" x-text="logs.content"></pre></div>
<div class="modal-foot"><button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button></div>
</div>
<!-- Language editor -->
<div x-show="modal==='lang'" class="modal max-w-2xl flex flex-col max-h-[90vh]">
<div class="modal-head"><span class="mdi mdi-translate text-ac dark:text-ac-dark"></span> <span x-text="langEdit.isNew ? t('add_language') : t('edit_language')">Taal</span></div>
<div class="modal-body flex flex-col gap-3 overflow-hidden flex-1">
<div class="grid sm:grid-cols-3 gap-3">
<div><label class="form-label" x-text="t('lang_code')">Code</label><input class="form-input" x-model="langEdit.code" :readonly="!langEdit.isNew" placeholder="de, fr, pt-br"></div>
<div><label class="form-label" x-text="t('lang_name')">Naam</label><input class="form-input" x-model="langEdit.name" placeholder="Deutsch"></div>
<div><label class="form-label" x-text="t('lang_flag')">Vlag</label><input class="form-input" x-model="langEdit.flag" placeholder="🇩🇪" maxlength="8"></div>
</div>
<div class="relative">
<span class="mdi mdi-magnify absolute left-3.5 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark text-lg"></span>
<input x-model="langEdit.query" :placeholder="t('search_keys')" class="form-input !pl-10">
</div>
<div class="flex-1 overflow-y-auto -mx-1 px-1 space-y-2">
<template x-for="k in langKeysFiltered" :key="k">
<div class="grid sm:grid-cols-2 gap-2 items-center">
<div class="text-xs min-w-0"><div class="font-mono text-[11px] text-ac dark:text-ac-dark truncate" x-text="k"></div><div class="text-t2 dark:text-t2-dark text-[11px] truncate" :title="langEdit.ref[k]" x-text="langEdit.ref[k]"></div></div>
<input class="form-input" x-model="langEdit.values[k]" :placeholder="langEdit.ref[k]">
</div>
</template>
<p x-show="langKeysFiltered.length===0" class="text-center text-dm dark:text-dm-dark text-xs py-6" x-text="t('no_results')">Geen resultaten</p>
</div>
</div>
<div class="modal-foot">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
<button class="btn btn-primary" @click="saveLang()"><span class="mdi mdi-content-save"></span> <span x-text="t('save')">Opslaan</span></button>
</div>
</div>
</div>
<!-- ░░ First-run wizard ░░ -->
<div x-show="wizard.open" x-cloak class="fixed inset-0 bg-slate-900/80 backdrop-blur z-[80] grid place-items-center p-4">
<div class="bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full max-w-xl shadow-pop overflow-hidden">
<div class="h-1.5 bg-s2 dark:bg-s2-dark"><div class="h-full bg-gradient-to-r from-ac to-ac2 transition-all" :style="`width:${(wizard.step+1)/(wizard.steps.length||1)*100}%`"></div></div>
<div class="p-7 min-h-[270px]">
<h2 class="text-2xl font-extrabold text-wh dark:text-wh-dark mb-1 tracking-tight" x-text="wizard.steps[wizard.step]?.title || ''"></h2>
<p class="text-sm text-t2 dark:text-t2-dark mb-6" x-text="wizard.steps[wizard.step]?.subtitle || ''"></p>
<div x-show="wizard.step === 0">
<p class="text-sm">Doorloop deze korte wizard om Server Up te configureren.</p>
<ul class="text-sm mt-4 space-y-2 text-t2 dark:text-t2-dark">
<li class="flex gap-2"><span class="mdi mdi-check-circle text-ok"></span> Beheer Docker stacks via één UI</li>
<li class="flex gap-2"><span class="mdi mdi-check-circle text-ok"></span> Installeer apps uit je eigen repo of de Boilerplates</li>
<li class="flex gap-2"><span class="mdi mdi-check-circle text-ok"></span> Modules toevoegen voor MQTT, Home Assistant, etc.</li>
</ul>
</div>
<div x-show="wizard.step === 1" class="space-y-3">
<div><label class="form-label">Library map</label><input class="form-input" x-model="wizard.LIBRARY_DIR"></div>
<div><label class="form-label">Data map</label><input class="form-input" x-model="wizard.DATA_DIR"></div>
<div><label class="form-label">Backup map</label><input class="form-input" x-model="wizard.BACKUP_DIR"></div>
</div>
<div x-show="wizard.step === 2">
<p class="text-sm mb-3">Server Up start met deze repos. Je kunt later meer toevoegen via Instellingen → Git Repositories.</p>
<ul class="text-sm space-y-2.5">
<li class="flex items-start gap-2"><span class="mdi mdi-check-circle text-ok mt-0.5"></span><span><strong>bes-r/server-up</strong> — eigen stacks &amp; modules <span class="text-dm dark:text-dm-dark">(submap: apps / modules)</span></span></li>
<li class="flex items-start gap-2"><span class="mdi mdi-check-circle text-ok mt-0.5"></span><span><strong>ChristianLempa/boilerplates-library</strong> — community templates <span class="text-dm dark:text-dm-dark">(submap: compose)</span></span></li>
</ul>
</div>
<div x-show="wizard.step === 3" class="space-y-3">
<div class="grid grid-cols-3 gap-2">
<button @click="wizard.git.choice = 'existing'" :class="wizard.git.choice === 'existing' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
<span class="mdi mdi-server text-2xl"></span><span class="font-semibold">Al een server</span><span class="text-[10px] text-center opacity-70">Gitea / Forgejo of andere git</span>
</button>
<button @click="wizard.git.choice = 'install'" :class="wizard.git.choice === 'install' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
<span class="mdi mdi-download-circle-outline text-2xl"></span><span class="font-semibold">Installeer Forgejo</span><span class="text-[10px] text-center opacity-70">Forgejo als stack</span>
</button>
<button @click="wizard.git.choice = 'skip'" :class="wizard.git.choice === 'skip' ? 'border-ac text-ac dark:text-ac-dark bg-ac/5' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'" class="flex flex-col items-center gap-1.5 rounded-xl border p-3 text-xs transition cursor-pointer hover:border-ac">
<span class="mdi mdi-skip-next-circle-outline text-2xl"></span><span class="font-semibold">Overslaan</span><span class="text-[10px] text-center opacity-70">Later toe te voegen</span>
</button>
</div>
<div x-show="wizard.git.choice === 'existing'" class="space-y-2 pt-1">
<div><label class="form-label">Repo URL</label><input class="form-input" x-model="wizard.git.url" placeholder="https://gitea.example.com/gebruiker/boilerplates.git"></div>
<div><label class="form-label">Token <span class="normal-case font-normal opacity-60">(optioneel)</span></label><input class="form-input" type="password" x-model="wizard.git.token" placeholder="glpat-…"></div>
<div><label class="form-label">Submap <span class="normal-case font-normal opacity-60">(optioneel)</span></label><input class="form-input" x-model="wizard.git.subdir" placeholder="apps"></div>
</div>
<div x-show="wizard.git.choice === 'install'" class="space-y-2 pt-1">
<div class="grid grid-cols-2 gap-2">
<div><label class="form-label">Web poort</label><input class="form-input" type="number" x-model.number="wizard.git.port_web" placeholder="3000"></div>
<div><label class="form-label">SSH poort</label><input class="form-input" type="number" x-model.number="wizard.git.port_ssh" placeholder="2222"></div>
</div>
<p class="text-[11px] text-t2 dark:text-t2-dark">Forgejo wordt geïnstalleerd en gestart. Daarna kun je er zelf repos in aanmaken.</p>
</div>
</div>
<div x-show="wizard.step === 4"><p class="text-sm">We synchroniseren nu de repos en zijn klaar voor gebruik.</p></div>
</div>
<div class="px-7 py-4 border-t border-bd dark:border-bd-dark flex justify-end gap-2">
<button class="btn" @click="wizard.step = Math.max(0, wizard.step-1)" :disabled="wizard.step===0">Terug</button>
<button class="btn btn-primary" @click="wizardNext()"><span x-text="wizard.step < wizard.steps.length-1 ? 'Volgende' : 'Klaar'"></span></button>
</div>
</div>
</div>
<!-- ░░ Component classes ░░ -->
<style type="text/tailwindcss">
@layer components {
.btn { @apply inline-flex items-center justify-center gap-2 px-4 h-11 rounded-xl border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-sm font-semibold shadow-soft hover:border-bd2 dark:hover:border-bd2-dark hover:bg-s2 dark:hover:bg-s2-dark active:scale-[.98] transition cursor-pointer; }
.btn-primary{ @apply !bg-ac !text-white !border-ac shadow-soft2 hover:!bg-ac2 hover:!border-ac2; }
.btn-danger { @apply !text-er !border-er/30 !bg-er/5 hover:!bg-er/10 hover:!border-er/40; }
.btn-icon { @apply w-10 h-10 grid place-items-center rounded-xl border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-t2 dark:text-t2-dark hover:text-ac dark:hover:text-ac-dark hover:border-bd2 dark:hover:border-bd2-dark active:scale-95 transition cursor-pointer shrink-0; }
.btn-sm { @apply inline-flex items-center justify-center gap-1.5 px-3 h-9 rounded-lg border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-xs font-semibold hover:bg-s2 dark:hover:bg-s2-dark hover:border-bd2 dark:hover:border-bd2-dark active:scale-95 cursor-pointer transition shrink-0; }
.seg { @apply inline-flex items-center gap-1.5 px-3 h-9 rounded-lg text-xs font-semibold text-t2 dark:text-t2-dark cursor-pointer transition; }
.seg-on { @apply bg-s1 dark:bg-s1-dark text-ac dark:text-ac-dark shadow-soft; }
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft; }
.card-hover { @apply hover:shadow-soft2 hover:-translate-y-0.5 transition duration-200; }
.card-pad { @apply p-5; }
.nav-link { @apply flex items-center gap-3 px-3.5 h-11 rounded-xl text-sm font-semibold text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark cursor-pointer transition mb-0.5; }
.nav-active { @apply bg-ac/10 !text-ac dark:!text-ac-dark; }
.stat-card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl shadow-soft p-4 sm:p-5 flex items-center gap-4; }
.stat-icon { @apply w-12 h-12 rounded-xl grid place-items-center text-2xl shrink-0; }
.stat-value { @apply text-2xl sm:text-3xl font-extrabold text-wh dark:text-wh-dark leading-none tracking-tight; }
.stat-label { @apply text-[11px] text-t2 dark:text-t2-dark mt-1.5 uppercase tracking-wider font-semibold truncate; }
.form-label { @apply block text-[11px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5; }
.form-input { @apply w-full bg-s1 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-xl px-3.5 h-11 text-sm text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-4 focus:ring-ac/10 transition placeholder:text-dm dark:placeholder:text-dm-dark; }
textarea.form-input { @apply h-auto py-2.5; }
.badge { @apply inline-flex items-center text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded-md; }
.badge-ok { @apply bg-ok/10 text-ok; }
.badge-warn { @apply bg-wr/10 text-wr; }
.badge-info { @apply bg-ac/10 text-ac dark:text-ac-dark; }
.badge-danger{@apply bg-er/10 text-er; }
.badge-dim { @apply bg-s2 dark:bg-s3-dark text-t2 dark:text-t2-dark; }
.empty { @apply text-center py-16 text-dm dark:text-dm-dark; }
.empty span { @apply text-5xl opacity-40 block mb-2; }
.empty p { @apply text-sm; }
.modal { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl w-full shadow-pop anim; }
.modal-head { @apply px-5 py-4 text-base font-extrabold text-wh dark:text-wh-dark flex items-center gap-2.5 border-b border-bd dark:border-bd-dark; }
.modal-body { @apply px-5 py-4; }
.modal-foot { @apply px-5 py-4 flex justify-end gap-2 border-t border-bd dark:border-bd-dark; }
}
</style>
<script>
function app() {
return {
version: '0.4.0',
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,
docker: { running: 0, containers: 0, images: 0 },
stacks: [],
stackQuery: '',
images: [],
audit: [],
repos: [],
storeStacks: {},
settings: {},
languages: [],
modules: [],
T: {},
job: { id: null, lines: [], status: 'idle', timer: null },
newRepo: { name:'', url:'', branch:'main', subdir:'apps' },
install: {
stack: '', repo_id: '', instance: '',
format: 'compose', loading: false,
envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {}
},
editor: { stack:'', type:'env', title:'', content:'' },
logs: { title:'', content:'' },
langEdit: { isNew:true, code:'', name:'', flag:'', keys:[], ref:{}, values:{}, query:'' },
wizard: { open:false, step:0, steps: [], LIBRARY_DIR:'', DATA_DIR:'', BACKUP_DIR:'',
git: { choice:'skip', url:'', token:'', subdir:'', port_web:3000, port_ssh:2222 } },
navItems: [
{id:'dashboard', icon:'mdi-view-dashboard-outline', label:'dashboard'},
{id:'stacks', icon:'mdi-server-network', label:'stacks'},
{id:'app_store', icon:'mdi-store-outline', label:'app_store'},
{id:'docker', icon:'mdi-docker', label:'images'},
{id:'audit', icon:'mdi-clipboard-text-outline', label:'audit'},
{id:'settings', icon:'mdi-cog-outline', label:'settings'},
],
async init() {
this.applyThemePref();
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (this.themePref === 'system') this.applyThemePref();
});
}
window.addEventListener('hashchange', () => this.page = location.hash.slice(1) || 'dashboard');
window.addEventListener('resize', () => {
const m = window.innerWidth < 768;
if (m !== this.isMobile) { this.isMobile = m; if (m) this.sidebarOpen = false; else this.sidebarOpen = localStorage.getItem('sidebar') !== 'closed'; }
});
await this.loadLangs();
await this.loadLang(localStorage.getItem('lang') || 'nl');
await this.checkWizard();
await this.loadDocker();
await this.loadModules();
await this.loadDashboard();
setInterval(() => { if (this.page==='dashboard') this.loadDocker(); }, 10000);
this.$watch('page', async (p) => {
if (p === 'stacks') this.loadStacks();
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(k) { return this.T[k] || k; },
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 fetch('/api/i18n/'+code, { method:'DELETE' }).then(r=>r.json()).catch(()=>({}));
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);
},
async rpc(url, body) {
try {
const o = body !== undefined ? { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) } : {};
return await (await fetch(url, o)).json();
} catch(e) { return { error: e.message }; }
},
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 || []); },
get filteredStacks() { const q = this.stackQuery.toLowerCase(); return q ? this.stacks.filter(s => (s.name||'').toLowerCase().includes(q)) : this.stacks; },
async stackAction(name, action) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/${action}`, {});
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
else if (d.error) this.toast('Fout: '+d.error, 'er');
},
confirmRemove(name) { this.install.stack = name; this.modal = 'remove'; },
async doRemove(mode) {
const name = this.install.stack;
this.modal = null;
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/remove`, { mode });
if (d.job_id) this.pollJob(d.job_id, () => this.loadStacks());
},
async openEditor(name, type) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/${type}`);
this.editor = { stack: name, type, title: name+' · '+type, content: d.content || '' };
this.modal = 'editor';
},
async saveEditor() {
const r = await fetch(`/api/stacks/${encodeURIComponent(this.editor.stack)}/${this.editor.type}`,
{ method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ content: this.editor.content }) });
if (r.ok) { this.toast('Opgeslagen', 'ok'); this.modal=null; }
else this.toast('Fout bij opslaan', 'er');
},
async openLogs(name) {
const d = await this.rpc(`/api/stacks/${encodeURIComponent(name)}/logs`);
this.logs = { title: name+' · logs', content: d.logs || '(geen output)' };
this.modal = 'logs';
},
async loadAppStore() {
const repos = await this.rpc('/api/repos');
this.repos = Array.isArray(repos) ? repos : [];
this.storeStacks = {};
for (const r of this.repos) {
const d = await this.rpc(`/api/repos/${r.id}/stacks`);
this.storeStacks[r.id] = d.stacks || [];
}
},
async syncRepo(rid) {
const d = await this.rpc(`/api/repos/${rid}/sync`, {});
if (d.job_id) this.pollJob(d.job_id, () => this.loadAppStore());
},
async syncAllRepos() { for (const r of this.repos) await this.syncRepo(r.id); },
async deleteRepo(rid) {
if (!confirm('Repository verwijderen?')) return;
await fetch(`/api/repos/${rid}`, { method:'DELETE' });
this.toast('Verwijderd', 'ok');
this.loadAppStore();
},
async addRepo() {
const r = await this.rpc('/api/repos', this.newRepo);
if (r.ok) { this.toast('Toegevoegd', 'ok'); this.modal=null; this.newRepo = { name:'', url:'', branch:'main', subdir:'apps' }; this.loadAppStore(); }
else this.toast(r.msg || 'Fout', 'er');
},
async openInstall(stack, rid) {
this.install = {
stack: stack.dir || stack.name, repo_id: rid,
instance: stack.dir || stack.name,
format: stack.format === 'boilerplate' ? 'boilerplate' : 'compose',
loading: true,
envVars: [], images: [], envOverrides: {}, imageTags: {},
fields: [], values: {}, preview_files: {}
};
this.modal = 'install';
const d = await this.rpc('/api/store/preview', { stack: this.install.stack, repo_id: rid });
this.install.loading = false;
if (!d.ok) { this.toast(d.msg || 'Preview faalde', 'er'); this.modal = null; return; }
this.install.format = d.format || 'compose';
if (d.format === 'boilerplate') {
this.install.fields = d.fields || [];
const init = {};
(d.fields || []).forEach(f => {
if (f.default !== null && f.default !== undefined) init[f.name] = f.default;
else if (f.type === 'bool') init[f.name] = false;
else init[f.name] = '';
});
if (init.service_name !== undefined && !init.service_name) init.service_name = this.install.instance;
this.install.values = init;
this.install.preview_files = d.preview_files || {};
} else {
this.install.envVars = d.env_vars || [];
this.install.images = d.images || [];
}
},
get groupedFields() {
const map = new Map();
for (const f of this.install.fields) {
const g = f.group || 'Algemeen';
if (!map.has(g)) map.set(g, { name: g, toggle: f.group_toggle || null, items: [] });
map.get(g).items.push(f);
}
return Array.from(map.values());
},
bpFieldVisible(f) {
for (const cond of (f.needs || [])) {
const m = cond.match(/^(\w+)\s*(==|!=|=)\s*(.+)$/);
if (!m) continue;
const k = m[1], op = m[2], raw = m[3];
const want = raw.replace(/['"]/g, '').trim().toLowerCase();
const haveRaw = this.install.values[k];
const have = String(haveRaw ?? '').toLowerCase();
const eq = have === want || (want === 'true' && haveRaw === true) || (want === 'false' && (haveRaw === false || haveRaw === '' || haveRaw === undefined));
if ((op === '==' || op === '=') && !eq) return false;
if (op === '!=' && eq) return false;
}
return true;
},
bpPreview() {
const f = this.install.preview_files || {};
return f['compose.yaml'] || f['docker-compose.yml'] || f['compose.yml'] || Object.values(f)[0] || '(geen preview)';
},
async doInstall() {
const body = {
stack: this.install.stack,
repo_id: this.install.repo_id,
instance: this.install.instance || this.install.stack,
};
if (this.install.format === 'boilerplate') body.values = this.install.values;
else { body.env_overrides = this.install.envOverrides; body.image_tags = this.install.imageTags; }
const d = await this.rpc('/api/store/install', body);
if (d.job_id) {
this.modal = null;
this.pollJob(d.job_id, () => { this.loadStacks(); this.toast('Installatie voltooid', 'ok'); });
} else { this.toast(d.msg || 'Fout', 'er'); }
},
async loadImages() { const d = await this.rpc('/api/docker/images'); this.images = d.images || []; },
async removeImage(id) {
if (!confirm('Image verwijderen?')) return;
const d = await this.rpc('/api/docker/images/remove', { id, force: true });
if (d.ok) { this.toast('Verwijderd', 'ok'); this.loadImages(); }
else this.toast(d.msg || 'Fout', 'er');
},
async pruneImages() {
if (!confirm('Alle ongebruikte images opruimen?')) return;
const d = await this.rpc('/api/docker/images/prune', {});
if (d.ok) { this.toast('Opgeruimd', 'ok'); this.loadImages(); }
else this.toast(d.msg || 'Fout', 'er');
},
async loadAudit() { const d = await this.rpc('/api/audit'); this.audit = d.entries || []; },
async clearAudit() {
if (!confirm('Audit log wissen?')) return;
await this.rpc('/api/audit/clear', {});
this.loadAudit();
},
async loadSettings() { const d = await this.rpc('/api/settings'); this.settings = d || {}; await this.loadAppStore(); },
autoSave() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(async () => {
const body = { ...this.settings, LANGUAGE: this.settings.LANGUAGE, THEME: this.theme };
await fetch('/api/settings', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
this.saved = true; setTimeout(() => this.saved = false, 1500);
}, 400);
},
async restartSelf() {
if (!confirm('Server Up container herstarten?')) return;
const d = await this.rpc('/api/docker/restart', {});
this.toast(d.msg || (d.ok ? 'Bezig...' : 'Fout'), d.ok ? 'in' : 'er');
},
async loadModules() { const d = await this.rpc('/api/modules'); this.modules = Array.isArray(d) ? d : []; },
async checkWizard() {
const d = await this.rpc('/api/wizard');
if (!d.done) {
this.wizard.steps = [
{ title: 'Welkom bij Server Up 🚀', subtitle: 'Beheer je Docker stacks via een eenvoudige web-UI.' },
{ title: 'Paden', subtitle: 'Waar moeten stacks en data komen?' },
{ title: 'Repositories', subtitle: 'Server Up start met een ingebouwde stacks-repo en de ChristianLempa Boilerplates.' },
{ title: 'Eigen Git Server', subtitle: 'Optioneel: gebruik een zelfgehoste git voor eigen boilerplates.' },
{ title: 'Klaar!', subtitle: 'We synchroniseren nu de repos en zijn klaar voor gebruik.' },
];
this.wizard.LIBRARY_DIR = d.defaults?.LIBRARY_DIR || '/opt/serverup/stacks';
this.wizard.DATA_DIR = d.defaults?.DATA_DIR || '/opt/serverup/appdata';
this.wizard.BACKUP_DIR = d.defaults?.BACKUP_DIR || '/opt/serverup/backups';
this.wizard.open = true;
}
},
async wizardNext() {
if (this.wizard.step < this.wizard.steps.length - 1) {
if (this.wizard.step === 1) {
await this.rpc('/api/wizard/paths', {
LIBRARY_DIR: this.wizard.LIBRARY_DIR,
DATA_DIR: this.wizard.DATA_DIR,
BACKUP_DIR: this.wizard.BACKUP_DIR,
});
}
if (this.wizard.step === 3) {
const g = this.wizard.git;
if (g.choice === 'existing' && g.url.trim()) {
await this.rpc('/api/wizard/git', { action: 'add', url: g.url.trim(), token: g.token.trim(), subdir: g.subdir.trim() });
} else if (g.choice === 'install') {
const d = await this.rpc('/api/wizard/git', {
action: 'install_forgejo',
data_dir: this.wizard.DATA_DIR,
port_web: g.port_web,
port_ssh: g.port_ssh,
});
if (d.job_id) this.pollJob(d.job_id);
}
}
this.wizard.step++;
} else {
const d = await this.rpc('/api/wizard/sync', {});
if (d.job_id) this.pollJob(d.job_id, async () => {
await this.rpc('/api/wizard/complete', {});
this.wizard.open = false;
this.loadDashboard();
});
}
},
}
}
</script>
</body>
</html>