server-up/app/templates/index.html
2026-05-09 13:55:29 +00:00

1026 lines
60 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:'#f1f3f6', dark:'#0c1015' },
s1: { DEFAULT:'#ffffff', dark:'#131920' },
s2: { DEFAULT:'#eaeef2', dark:'#1a2230' },
s3: { DEFAULT:'#dde2e8', dark:'#212b3a' },
bd: { DEFAULT:'#c4cdd6', dark:'#1e2735' },
bd2: { DEFAULT:'#9aa8b5', dark:'#2d3a4d' },
tx: { DEFAULT:'#1e2730', dark:'#b0bcc8' },
t2: { DEFAULT:'#4a5a6a', dark:'#6a7d90' },
dm: { DEFAULT:'#7a8a9a', dark:'#3a4d62' },
wh: { DEFAULT:'#111820', dark:'#eaf0f6' },
ac: { DEFAULT:'#0284c7', dark:'#22d3ee' },
ok: '#4ade80',
wr: '#fbbf24',
er: '#f87171',
bl: '#60a5fa',
pu: '#a78bfa',
},
fontFamily: {
sans: ['Nunito','system-ui','sans-serif'],
mono: ['"JetBrains Mono"','ui-monospace','monospace'],
},
}}
}
</script>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
body { font-family: 'Nunito', sans-serif; }
[x-cloak] { display: none !important; }
.mdi-spin { animation: mdis 1s linear infinite; display:inline-block; }
@keyframes mdis { to { transform: rotate(360deg); } }
::-webkit-scrollbar { width:6px; height:6px; }
::-webkit-scrollbar-thumb { background: rgb(154 168 181 / .4); border-radius: 9px; }
::-webkit-scrollbar-track { background: transparent; }
.dark ::-webkit-scrollbar-thumb { background: rgb(45 58 77 / .8); }
.log-ok { color:#4ade80 }
.log-error { color:#f87171 }
.log-warn { color:#fbbf24 }
.log-info { color:#60a5fa }
.log-dim { color:#6a7d90 }
.log-section { color:#22d3ee; font-weight:600 }
</style>
</head>
<body class="bg-bg dark:bg-bg-dark text-tx dark:text-tx-dark text-sm h-screen overflow-hidden">
<!-- Topbar -->
<header class="h-12 px-4 flex items-center gap-2 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark fixed inset-x-0 top-0 z-40">
<button @click="sidebarOpen = !sidebarOpen" class="w-8 h-8 grid place-items-center rounded-lg hover:bg-s2 dark:hover:bg-s2-dark"><span class="mdi mdi-menu text-lg"></span></button>
<span class="font-extrabold text-wh dark:text-wh-dark">Server<span class="text-ac dark:text-ac-dark">Up</span></span>
<span class="ml-2 text-[10px] font-mono text-t2 dark:text-t2-dark" x-text="'v'+version"></span>
<div class="flex-1"></div>
<div class="hidden sm:flex items-center gap-3 text-[11px] font-mono text-t2 dark:text-t2-dark">
<span class="flex items-center gap-1">
<span class="w-1.5 h-1.5 rounded-full" :class="docker.running>0 ? 'bg-ok shadow-[0_0_5px_#4ade80]' : 'bg-dm dark:bg-dm-dark'"></span>
<span class="mdi mdi-play-circle-outline"></span><span x-text="docker.running ?? ''"></span>
</span>
<span class="flex items-center gap-1"><span class="mdi mdi-layers-outline"></span><span x-text="docker.images ?? ''"></span></span>
</div>
<button @click="termOpen = !termOpen" class="px-2 py-1 rounded-md border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac flex items-center gap-1 text-xs"><span class="mdi mdi-console"></span></button>
<button @click="toggleTheme()" class="px-2 py-1 rounded-md hover:bg-s2 dark:hover:bg-s2-dark"><span class="mdi" :class="theme==='dark' ? 'mdi-weather-sunny' : 'mdi-weather-night'"></span></button>
</header>
<!-- Sidebar -->
<aside class="fixed top-12 bottom-0 left-0 w-56 bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark transition-transform z-30 overflow-y-auto"
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'">
<nav class="p-2 space-y-0.5">
<template x-for="item in navItems" :key="item.id">
<a href="#" @click.prevent="goto(item.id)"
class="flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-xs font-medium transition"
:class="page === item.id ? 'bg-ac/10 text-ac dark:text-ac-dark border border-ac/20' : 'text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark hover:text-tx dark:hover:text-tx-dark border border-transparent'">
<span class="mdi text-base w-5 text-center" :class="item.icon"></span>
<span x-text="t(item.label)"></span>
</a>
</template>
<template x-if="modules.length > 0">
<div class="pt-3 pb-1 px-3 text-[8px] font-bold tracking-widest 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="flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-xs font-medium transition text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark">
<span class="text-base w-5 text-center" x-text="m.icon || '🧩'"></span>
<span x-text="m.name"></span>
</a>
</template>
</nav>
</aside>
<div x-show="sidebarOpen" @click="sidebarOpen=false" class="md:hidden fixed inset-0 bg-black/40 z-20" x-cloak></div>
<!-- Main -->
<main class="md:ml-56 pt-12 h-screen overflow-y-auto">
<div class="p-5 pb-48">
<!-- Dashboard -->
<section x-show="page === 'dashboard'" x-cloak>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-bl to-blue-600 grid place-items-center"><span class="mdi mdi-view-dashboard text-white text-lg"></span></div>
<div>
<h1 class="text-lg font-extrabold text-wh dark:text-wh-dark" x-text="t('dashboard')">Dashboard</h1>
<p class="text-[10px] text-t2 dark:text-t2-dark" x-text="t('overview')">Overzicht</p>
</div>
<button @click="loadDashboard()" class="ml-auto btn-icon"><span class="mdi mdi-refresh"></span></button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
<div class="stat-card"><div class="stat-icon bg-ok/10 text-ok"><span class="mdi mdi-play-circle"></span></div><div><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/10 text-dm dark:text-dm-dark"><span class="mdi mdi-stop-circle-outline"></span></div><div><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>
<div class="stat-card cursor-pointer" @click="goto('docker')"><div class="stat-icon bg-bl/10 text-bl"><span class="mdi mdi-layers-outline"></span></div><div><div class="stat-value" x-text="docker.images ?? ''"></div><div class="stat-label" x-text="t('images')">Images</div></div></div>
<div class="stat-card cursor-pointer" @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><div class="stat-value" x-text="stacks.length"></div><div class="stat-label">Stacks</div></div></div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-3" x-show="stacks.length > 0">
<template x-for="s in stacks.slice(0, 6)" :key="s.name">
<div class="card" :class="s.running ? 'border-l-4 border-l-ok' : 'border-l-4 border-l-dm'">
<div class="px-3 py-2 flex items-center gap-2 border-b border-bd dark:border-bd-dark">
<span class="mdi" :class="s.running ? 'mdi-play-circle text-ok' : 'mdi-stop-circle-outline text-dm dark:text-dm-dark'"></span>
<span class="font-bold text-wh dark:text-wh-dark text-xs 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>
</div>
</div>
</template>
</div>
</section>
<!-- Stacks -->
<section x-show="page === 'stacks'" x-cloak>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-ac to-cyan-600 grid place-items-center"><span class="mdi mdi-server-network text-white text-lg"></span></div>
<h1 class="text-lg font-extrabold text-wh dark:text-wh-dark" x-text="t('stacks')">Stacks</h1>
<button @click="loadStacks()" class="ml-auto btn-icon"><span class="mdi mdi-refresh"></span></button>
</div>
<div class="mb-4 relative max-w-sm">
<span class="mdi mdi-magnify absolute left-3 top-1/2 -translate-y-1/2 text-dm dark:text-dm-dark"></span>
<input x-model="stackQuery" placeholder="Zoek..." class="w-full pl-9 pr-3 py-2 bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-md font-mono text-xs">
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-3" x-show="filteredStacks.length > 0">
<template x-for="s in filteredStacks" :key="s.name">
<div class="card" :class="s.running ? 'border-l-4 border-l-ok' : !s.has_compose ? 'border-l-4 border-l-wr opacity-60' : 'border-l-4 border-l-dm'">
<div class="px-3 py-2 flex items-center gap-2 border-b border-bd dark:border-bd-dark">
<span class="mdi" :class="!s.has_compose ? 'mdi-alert-outline text-wr' : s.running ? 'mdi-play-circle text-ok' : 'mdi-stop-circle-outline text-dm dark:text-dm-dark'"></span>
<span class="font-bold text-wh dark:text-wh-dark text-xs 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-3">
<div class="flex flex-wrap gap-1 mb-2" x-show="s.containers && s.containers.length">
<template x-for="c in s.containers || []" :key="c.name">
<span class="text-[10px] font-mono px-1.5 py-0.5 rounded border" :class="c.running ? 'border-ok/30 text-ok' : '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" x-show="s.has_compose">
<button class="btn-sm" :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" @click="stackAction(s.name, 'restart')"><span class="mdi mdi-restart"></span></button>
<button class="btn-sm" @click="stackAction(s.name, 'update')"><span class="mdi mdi-update"></span></button>
<button class="btn-sm" @click="openEditor(s.name, 'env')"><span class="mdi mdi-file-cog-outline"></span></button>
<button class="btn-sm" @click="openEditor(s.name, 'compose')"><span class="mdi mdi-file-code-outline"></span></button>
<button class="btn-sm" @click="openLogs(s.name)"><span class="mdi mdi-text-box-outline"></span></button>
<button class="btn-sm" @click="stackAction(s.name, 'backup')"><span class="mdi mdi-archive-arrow-down-outline"></span></button>
<button class="btn-sm btn-danger ml-auto" @click="confirmRemove(s.name)"><span class="mdi mdi-delete-outline"></span></button>
</div>
</div>
</div>
</template>
</div>
<div x-show="filteredStacks.length === 0" class="text-center py-12 text-dm dark:text-dm-dark">
<span class="mdi mdi-server-network text-5xl opacity-40 block"></span>
<p class="text-xs mt-2">Geen stacks gevonden</p>
</div>
</section>
<!-- App Store -->
<section x-show="page === 'app_store'" x-cloak>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-wr to-orange-600 grid place-items-center"><span class="mdi mdi-store text-white text-lg"></span></div>
<h1 class="text-lg font-extrabold text-wh dark:text-wh-dark" x-text="t('app_store')">App Store</h1>
<div class="ml-auto flex gap-2">
<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></button>
</div>
</div>
<div class="space-y-4">
<template x-for="repo in repos" :key="repo.id">
<div>
<div class="flex items-center gap-2 mb-2">
<span class="mdi mdi-source-branch text-ac dark:text-ac-dark"></span>
<span class="font-bold text-wh dark:text-wh-dark text-xs" x-text="repo.name"></span>
<span class="text-[10px] font-mono text-dm dark:text-dm-dark truncate" x-text="repo.url"></span>
<button @click="syncRepo(repo.id)" class="btn-sm ml-auto"><span class="mdi mdi-refresh"></span></button>
<button @click="deleteRepo(repo.id)" class="btn-sm btn-danger"><span class="mdi mdi-delete-outline"></span></button>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
<template x-for="stack in (storeStacks[repo.id] || [])" :key="stack.dir">
<div class="card p-3">
<div class="flex items-start gap-2 mb-2">
<template x-if="stack.logo_url">
<img :src="stack.logo_url" class="w-8 h-8 rounded object-contain" loading="lazy" onerror="this.style.display='none'">
</template>
<template x-if="!stack.logo_url">
<div class="w-8 h-8 rounded bg-s2 dark:bg-s2-dark grid place-items-center text-base" x-text="stack.icon || '📦'"></div>
</template>
<div class="flex-1 min-w-0">
<div class="font-bold text-wh dark:text-wh-dark text-xs truncate" x-text="stack.name"></div>
<div class="text-[10px] text-t2 dark:text-t2-dark line-clamp-2" 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-[9px] font-mono px-1.5 py-0.5 rounded bg-s2 dark:bg-s2-dark text-t2 dark:text-t2-dark" x-text="tag"></span>
</template>
</div>
<div class="flex gap-1">
<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-[9px] text-ok px-2 py-1 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>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-pu to-violet-600 grid place-items-center"><span class="mdi mdi-docker text-white text-lg"></span></div>
<h1 class="text-lg font-extrabold text-wh dark:text-wh-dark" x-text="t('images')">Docker Images</h1>
<div class="ml-auto flex gap-2">
<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>
<div class="card overflow-hidden">
<table class="w-full text-xs font-mono">
<thead><tr class="bg-s2 dark:bg-s2-dark text-[8px] uppercase tracking-wider text-dm dark:text-dm-dark">
<th class="text-left px-3 py-2">Repo</th>
<th class="text-left px-3 py-2">Tag</th>
<th class="text-left px-3 py-2">Size</th>
<th class="text-left px-3 py-2">Age</th>
<th class="text-right px-3 py-2"></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 dark:hover:bg-s2-dark">
<td class="px-3 py-1.5" x-text="img.repo"></td>
<td class="px-3 py-1.5 text-ac dark:text-ac-dark" x-text="img.tag"></td>
<td class="px-3 py-1.5" x-text="img.size"></td>
<td class="px-3 py-1.5 text-t2 dark:text-t2-dark" x-text="img.age"></td>
<td class="px-3 py-1.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>
</section>
<!-- Audit -->
<section x-show="page === 'audit'" x-cloak>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-ok to-green-600 grid place-items-center"><span class="mdi mdi-clipboard-text text-white text-lg"></span></div>
<h1 class="text-lg font-extrabold text-wh dark:text-wh-dark" x-text="t('audit')">Audit Log</h1>
<button @click="loadAudit()" class="ml-auto btn-icon"><span class="mdi mdi-refresh"></span></button>
<button @click="clearAudit()" class="btn btn-danger"><span class="mdi mdi-delete-outline"></span></button>
</div>
<div class="card overflow-hidden">
<table class="w-full text-xs font-mono">
<thead><tr class="bg-s2 dark:bg-s2-dark text-[8px] uppercase tracking-wider text-dm dark:text-dm-dark">
<th class="text-left px-3 py-2">Tijd</th>
<th class="text-left px-3 py-2">Categorie</th>
<th class="text-left px-3 py-2">Actie</th>
<th class="text-left px-3 py-2">Status</th>
<th class="text-left px-3 py-2">Ref</th>
</tr></thead>
<tbody>
<template x-for="(e, i) in audit" :key="i">
<tr class="border-t border-bd dark:border-bd-dark">
<td class="px-3 py-1 text-t2 dark:text-t2-dark" x-text="(e.ts || '').replace('T',' ').slice(0,19)"></td>
<td class="px-3 py-1" x-text="e.category"></td>
<td class="px-3 py-1" x-text="e.action"></td>
<td class="px-3 py-1" :class="e.status==='ok' ? 'text-ok' : 'text-er'" x-text="e.status"></td>
<td class="px-3 py-1 text-ac dark:text-ac-dark" x-text="e.ref || ''"></td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
<!-- Settings -->
<section x-show="page === 'settings'" x-cloak>
<div class="flex items-center gap-3 mb-5">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-bl to-blue-600 grid place-items-center"><span class="mdi mdi-cog text-white text-lg"></span></div>
<h1 class="text-lg font-extrabold text-wh dark:text-wh-dark" x-text="t('settings')">Instellingen</h1>
<span x-show="saved" class="ml-auto text-[10px] text-ok flex items-center gap-1"><span class="mdi mdi-check-circle-outline"></span> <span x-text="t('auto_saved')">Opgeslagen</span></span>
</div>
<div class="card p-4 mb-3 flex flex-wrap gap-6">
<div>
<div class="text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1" x-text="t('theme')">Thema</div>
<div class="flex gap-1">
<button @click="theme='dark';autoSave()" :class="theme==='dark' ? 'btn-primary' : ''" class="btn">🌑 Dark</button>
<button @click="theme='light';autoSave()" :class="theme==='light' ? 'btn-primary' : ''" class="btn">☀️ Light</button>
</div>
</div>
<div>
<div class="text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1" x-text="t('language')">Taal</div>
<select x-model="settings.LANGUAGE" @change="setLang(settings.LANGUAGE)" class="bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-md px-2 py-1 text-xs">
<template x-for="l in languages" :key="l.code"><option :value="l.code" x-text="(l.flag||'')+' '+l.name"></option></template>
</select>
</div>
</div>
<details class="card mb-3" open>
<summary class="px-4 py-3 cursor-pointer font-semibold text-wh dark:text-wh-dark text-xs flex items-center gap-2"><span class="mdi mdi-folder-cog-outline text-ac dark:text-ac-dark"></span> <span x-text="t('paths')">Paden</span></summary>
<div class="px-4 pb-4 grid sm:grid-cols-3 gap-3">
<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 mb-3">
<summary class="px-4 py-3 cursor-pointer font-semibold text-wh dark:text-wh-dark text-xs flex items-center gap-2"><span class="mdi mdi-source-branch text-ac dark:text-ac-dark"></span> Git Repositories</summary>
<div class="px-4 pb-4 space-y-2">
<template x-for="r in repos" :key="r.id">
<div class="flex items-center gap-2 py-1.5 border-b border-bd dark:border-bd-dark">
<span class="mdi mdi-source-branch text-dm dark:text-dm-dark"></span>
<span class="font-mono text-[11px] flex-1 truncate" x-text="r.name"></span>
<span class="text-[9px] text-t2 dark:text-t2-dark" x-text="r.subdir||''"></span>
<button @click="syncRepo(r.id)" class="btn-sm"><span class="mdi mdi-refresh"></span></button>
<button @click="deleteRepo(r.id)" class="btn-sm btn-danger"><span class="mdi mdi-delete-outline"></span></button>
</div>
</template>
<button @click="modal='add-repo'" class="btn btn-primary mt-2"><span class="mdi mdi-plus"></span> Toevoegen</button>
</div>
</details>
<details class="card mb-3">
<summary class="px-4 py-3 cursor-pointer font-semibold text-wh dark:text-wh-dark text-xs flex items-center gap-2"><span class="mdi mdi-restart text-ac dark:text-ac-dark"></span> <span x-text="t('container_restart')">Container herstarten</span></summary>
<div class="px-4 pb-4">
<p class="text-xs text-t2 dark:text-t2-dark mb-2" 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>
<!-- Terminal -->
<div class="fixed bottom-0 right-0 left-0 md:left-56 bg-bg-dark/95 border-t border-bd dark:border-bd-dark transition-transform z-30"
:class="termOpen ? 'translate-y-0' : 'translate-y-[180px]'">
<div @click="termOpen = !termOpen" class="h-9 px-3 flex items-center gap-2 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark cursor-pointer">
<div class="flex gap-1.5"><span class="w-2 h-2 rounded-full bg-[#ff5f57]"></span><span class="w-2 h-2 rounded-full bg-[#febc2e]"></span><span class="w-2 h-2 rounded-full bg-[#28c840]"></span></div>
<span class="text-[11px] font-mono text-t2 dark:text-t2-dark 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>
</div>
<div x-ref="termOut" class="h-44 overflow-y-auto px-3 py-2 font-mono text-[11px] leading-relaxed text-tx-dark">
<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>
</div>
<!-- Toasts -->
<div class="fixed bottom-24 right-4 z-40 flex flex-col gap-2 items-end pointer-events-none">
<template x-for="ts in toasts" :key="ts.id">
<div class="pointer-events-auto px-3 py-2 rounded-lg border bg-s1 dark:bg-s1-dark shadow-lg flex items-center gap-2 text-xs"
:class="({ok:'border-ok/30 text-ok',er:'border-er/30 text-er',wr:'border-wr/30 text-wr',in:'border-bl/30 text-bl'})[ts.kind]">
<span class="mdi" :class="({ok:'mdi-check-circle',er:'mdi-alert-circle',wr:'mdi-alert',in:'mdi-information'})[ts.kind]"></span>
<span x-text="ts.msg"></span>
</div>
</template>
</div>
<!-- Modals -->
<div x-show="modal" @click.self="modal=null" x-cloak class="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 grid place-items-center p-4">
<!-- Add repo -->
<div x-show="modal==='add-repo'" class="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-xl p-5 w-full max-w-md max-h-[85vh] overflow-y-auto">
<h3 class="text-base font-extrabold text-wh dark:text-wh-dark mb-4 flex items-center gap-2"><span class="mdi mdi-store-plus-outline text-ac dark:text-ac-dark"></span> <span x-text="t('add_repo')">Repository toevoegen</span></h3>
<div class="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-2">
<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-[10px] text-t2 dark:text-t2-dark">Tip: voor de ChristianLempa Boilerplates gebruik je submap <code class="font-mono text-ac">compose</code>.</p>
</div>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-bd dark:border-bd-dark">
<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="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-xl p-5 w-full max-w-2xl max-h-[85vh] overflow-y-auto">
<h3 class="text-base font-extrabold text-wh dark:text-wh-dark mb-4 flex items-center gap-2">
<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>
</h3>
<div class="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-[9px] 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-4 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="card" :open="!grp.toggle || !!install.values[grp.toggle]">
<summary class="px-3 py-2 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2">
<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-8 h-4 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-3 after:h-3 after:rounded-full
after:bg-white after:transition peer-checked:after:translate-x-4"></span>
</label>
</template>
</summary>
<div class="px-3 pb-3 grid sm:grid-cols-2 gap-2"
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">
<span class="text-[11px] 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-[9px] text-dm dark:text-dm-dark mt-0.5" x-text="f.description"></p>
</div>
</template>
</div>
</details>
</template>
<details class="card">
<summary class="px-3 py-2 cursor-pointer font-bold text-xs text-wh dark:text-wh-dark flex items-center gap-2"><span class="mdi mdi-eye-outline"></span> Preview</summary>
<div class="px-3 pb-3">
<pre class="bg-bg dark:bg-bg-dark border border-bd dark:border-bd-dark rounded p-2 text-[10px] 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-3">
<template x-if="install.images.length">
<div>
<div class="text-[9px] font-bold tracking-wider uppercase text-pu mb-1"><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">
<span class="text-[10px] font-mono text-t2 dark:text-t2-dark flex-1 truncate" x-text="img.service+': '+img.repo"></span>
<input class="form-input w-32" :placeholder="img.tag" x-model="install.imageTags[img.service]">
</div>
</template>
</div>
</template>
<template x-if="install.envVars.length">
<div>
<div class="text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1"><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">
<span class="text-[10px] font-mono text-t2 dark:text-t2-dark flex-1 truncate" :title="ev.key" x-text="ev.key"></span>
<input class="form-input w-40" :placeholder="ev.value" x-model="install.envOverrides[ev.key]">
</div>
</template>
</div>
</template>
</div>
</template>
</div>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-bd dark:border-bd-dark">
<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="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-xl p-5 w-full max-w-md">
<h3 class="text-base font-extrabold text-wh dark:text-wh-dark mb-3 flex items-center gap-2"><span class="mdi mdi-delete-alert-outline text-er"></span> <span x-text="t('remove_stack')">Verwijderen</span></h3>
<p class="text-xs mb-4">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 class="flex justify-end gap-2 mt-4 pt-3 border-t border-bd dark:border-bd-dark">
<button class="btn" @click="modal=null" x-text="t('cancel')">Annuleren</button>
</div>
</div>
<!-- Editor -->
<div x-show="modal==='editor'" class="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-xl p-5 w-full max-w-2xl max-h-[85vh] flex flex-col">
<h3 class="text-base font-extrabold text-wh dark:text-wh-dark mb-3 flex items-center gap-2"><span class="mdi" :class="editor.type==='env' ? 'mdi-file-cog-outline' : 'mdi-file-code-outline'"></span> <span x-text="editor.title"></span></h3>
<textarea x-model="editor.content" class="flex-1 form-input font-mono text-[11px] min-h-[280px]"></textarea>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-bd dark:border-bd-dark">
<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="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-xl p-5 w-full max-w-3xl max-h-[85vh] flex flex-col">
<h3 class="text-base font-extrabold text-wh dark:text-wh-dark mb-3 flex items-center gap-2"><span class="mdi mdi-text-box-outline"></span> <span x-text="logs.title"></span></h3>
<pre class="flex-1 bg-bg dark:bg-bg-dark border border-bd dark:border-bd-dark rounded p-2 text-[10px] font-mono overflow-auto max-h-[60vh] whitespace-pre-wrap break-all" x-text="logs.content"></pre>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-bd dark:border-bd-dark">
<button class="btn" @click="modal=null" x-text="t('close')">Sluiten</button>
</div>
</div>
</div>
<!-- First-run wizard -->
<div x-show="wizard.open" x-cloak class="fixed inset-0 bg-black/85 backdrop-blur z-[60] grid place-items-center p-4">
<div class="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-xl w-full max-w-xl shadow-2xl overflow-hidden">
<div class="h-1 bg-bd dark:bg-bd-dark"><div class="h-1 bg-gradient-to-r from-ac to-bl transition-all" :style="`width:${(wizard.step+1)/(wizard.steps.length||1)*100}%`"></div></div>
<div class="p-6 min-h-[260px]">
<h2 class="text-xl font-extrabold text-wh dark:text-wh-dark mb-1" x-text="wizard.steps[wizard.step]?.title || ''"></h2>
<p class="text-xs text-t2 dark:text-t2-dark mb-5" x-text="wizard.steps[wizard.step]?.subtitle || ''"></p>
<div x-show="wizard.step === 0">
<p class="text-xs">Doorloop deze korte wizard om Server Up te configureren.</p>
<ul class="text-xs mt-3 space-y-1 text-t2 dark:text-t2-dark">
<li>✔ Beheer Docker stacks via één UI</li>
<li>✔ Installeer apps uit je eigen repo of de Boilerplates</li>
<li>✔ 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-xs mb-3">Server Up start met deze repos. Je kunt later meer toevoegen via Instellingen → Git Repositories.</p>
<ul class="text-xs space-y-2">
<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>
<!-- Stap 3: Eigen git server -->
<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' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'"
class="flex flex-col items-center gap-1.5 rounded-lg border p-3 text-xs transition cursor-pointer hover:border-ac hover:text-ac">
<span class="mdi mdi-server text-xl"></span>
<span class="font-semibold">Al een server</span>
<span class="text-[9px] text-center opacity-70">Gitea / Forgejo of andere zelfgehoste git</span>
</button>
<button
@click="wizard.git.choice = 'install'"
:class="wizard.git.choice === 'install' ? 'border-ac text-ac' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'"
class="flex flex-col items-center gap-1.5 rounded-lg border p-3 text-xs transition cursor-pointer hover:border-ac hover:text-ac">
<span class="mdi mdi-download-circle-outline text-xl"></span>
<span class="font-semibold">Installeer Forgejo</span>
<span class="text-[9px] text-center opacity-70">Forgejo wordt als stack opgezet</span>
</button>
<button
@click="wizard.git.choice = 'skip'"
:class="wizard.git.choice === 'skip' ? 'border-ac text-ac' : 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'"
class="flex flex-col items-center gap-1.5 rounded-lg border p-3 text-xs transition cursor-pointer hover:border-ac hover:text-ac">
<span class="mdi mdi-skip-next-circle-outline text-xl"></span>
<span class="font-semibold">Overslaan</span>
<span class="text-[9px] text-center opacity-70">Altijd later toe te voegen</span>
</button>
</div>
<!-- Bestaande git invulvelden -->
<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, voor private repos)</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>
<!-- Forgejo install opties -->
<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-[10px] text-t2 dark:text-t2-dark">Forgejo wordt geïnstalleerd en gestart. Daarna kun je er zelf repos in aanmaken voor eigen boilerplates.</p>
</div>
</div>
<!-- Stap 4: Klaar -->
<div x-show="wizard.step === 4">
<p class="text-xs">We synchroniseren nu de repos en zijn klaar voor gebruik.</p>
</div>
</div>
<div class="px-6 py-3 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>
<!-- Tailwind component classes -->
<style type="text/tailwindcss">
@layer components {
.btn { @apply inline-flex items-center gap-1 px-3 py-1.5 rounded-md border border-bd dark:border-bd-dark bg-s1 dark:bg-s1-dark text-tx dark:text-tx-dark text-xs font-medium hover:border-ac hover:text-ac dark:hover:text-ac-dark transition cursor-pointer; }
.btn-primary { @apply !bg-ac !text-black !border-ac hover:!brightness-110 font-semibold; }
.btn-danger { @apply !text-er !border-er/30 hover:!bg-er/10; }
.btn-icon { @apply w-8 h-8 grid place-items-center rounded-md border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac transition cursor-pointer; }
.btn-sm { @apply inline-flex items-center gap-1 px-2 py-1 rounded border border-bd dark:border-bd-dark text-tx dark:text-tx-dark text-[10px] hover:border-ac hover:text-ac dark:hover:text-ac-dark cursor-pointer transition; }
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-xl overflow-hidden; }
.stat-card { @apply card p-3 flex items-center gap-3; }
.stat-icon { @apply w-9 h-9 rounded-lg grid place-items-center text-base; }
.stat-value { @apply text-xl font-extrabold text-wh dark:text-wh-dark leading-none; }
.stat-label { @apply text-[9px] text-t2 dark:text-t2-dark mt-0.5 uppercase tracking-wider; }
.form-label { @apply block text-[9px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1; }
.form-input { @apply w-full bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-md px-3 py-1.5 text-xs font-mono text-tx dark:text-tx-dark outline-none focus:border-ac focus:bg-s1 dark:focus:bg-s1-dark transition; }
.badge { @apply text-[8px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded-full; }
.badge-ok { @apply bg-ok/10 text-ok; }
.badge-warn { @apply bg-wr/10 text-wr; }
.badge-info { @apply bg-bl/10 text-bl; }
.badge-danger{ @apply bg-er/10 text-er; }
.badge-dim { @apply bg-s2 dark:bg-s2-dark text-t2 dark:text-t2-dark; }
}
</style>
<script>
function app() {
return {
version: '0.3.0',
page: location.hash.slice(1) || 'dashboard',
sidebarOpen: window.innerWidth >= 768,
termOpen: false,
theme: localStorage.getItem('theme') || 'dark',
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:'' },
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() {
window.addEventListener('hashchange', () => this.page = location.hash.slice(1) || 'dashboard');
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();
});
},
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); },
toggleTheme() { this.theme = this.theme === 'dark' ? 'light' : 'dark'; localStorage.setItem('theme', this.theme); this.autoSave(); },
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 (window.innerWidth < 768) this.sidebarOpen = false;
},
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>