1156 lines
68 KiB
HTML
1156 lines
68 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="nl" x-data="app()" x-init="init()" :class="theme==='dark' ? 'dark' : ''">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Server Up</title>
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🚀</text></svg>">
|
||
<link rel="stylesheet" href="/static/fonts/mdi.min.css">
|
||
<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-[15px] h-screen overflow-hidden">
|
||
|
||
<!-- Topbar -->
|
||
<header class="h-16 px-3 sm:px-5 flex items-center gap-2 sm:gap-3 bg-s1 dark:bg-s1-dark border-b border-bd dark:border-bd-dark fixed inset-x-0 top-0 z-40">
|
||
<button @click="toggleSidebar()" :title="sidebarOpen ? t('hide_menu') : t('show_menu')" class="w-11 h-11 grid place-items-center rounded-xl hover:bg-s2 dark:hover:bg-s2-dark active:scale-95 transition"><span class="mdi text-2xl" :class="sidebarOpen ? 'mdi-backburger' : 'mdi-menu'"></span></button>
|
||
<span class="font-extrabold text-lg text-wh dark:text-wh-dark">Server<span class="text-ac dark:text-ac-dark">Up</span></span>
|
||
<span class="text-[11px] 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-4 text-xs font-mono text-t2 dark:text-t2-dark">
|
||
<span class="flex items-center gap-1.5">
|
||
<span class="w-2 h-2 rounded-full" :class="docker.running>0 ? 'bg-ok shadow-[0_0_6px_#4ade80]' : 'bg-dm dark:bg-dm-dark'"></span>
|
||
<span class="mdi mdi-play-circle-outline text-base"></span><span x-text="docker.running ?? '–'"></span>
|
||
</span>
|
||
<span class="flex items-center gap-1.5"><span class="mdi mdi-layers-outline text-base"></span><span x-text="docker.images ?? '–'"></span></span>
|
||
</div>
|
||
<button @click="termOpen = !termOpen" :title="t('log')" class="btn-icon"><span class="mdi mdi-console text-xl"></span></button>
|
||
<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>
|
||
</header>
|
||
|
||
<!-- Sidebar -->
|
||
<aside class="fixed top-16 bottom-0 left-0 w-72 max-w-[85vw] bg-s1 dark:bg-s1-dark border-r border-bd dark:border-bd-dark transition-transform duration-200 ease-out z-30 overflow-y-auto"
|
||
:class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'">
|
||
<nav class="p-3 space-y-1.5">
|
||
<template x-for="item in navItems" :key="item.id">
|
||
<a href="#" @click.prevent="goto(item.id)"
|
||
class="flex items-center gap-3 px-4 py-3.5 rounded-xl cursor-pointer text-sm font-semibold transition"
|
||
:class="page === item.id ? 'bg-ac/10 text-ac dark:text-ac-dark border border-ac/30 shadow-sm' : '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-xl w-6 text-center" :class="item.icon"></span>
|
||
<span x-text="t(item.label)"></span>
|
||
</a>
|
||
</template>
|
||
<template x-if="modules.length > 0">
|
||
<div class="pt-4 pb-1 px-4 text-[10px] 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-3 px-4 py-3.5 rounded-xl cursor-pointer text-sm font-semibold transition text-t2 dark:text-t2-dark hover:bg-s2 dark:hover:bg-s2-dark">
|
||
<span class="text-xl w-6 text-center" x-text="m.icon || '🧩'"></span>
|
||
<span x-text="m.name"></span>
|
||
</a>
|
||
</template>
|
||
</nav>
|
||
</aside>
|
||
<div x-show="sidebarOpen && isMobile" @click="sidebarOpen=false" class="fixed inset-0 top-16 bg-black/50 backdrop-blur-sm z-20" x-cloak></div>
|
||
|
||
<!-- Main -->
|
||
<main class="pt-16 h-screen overflow-y-auto transition-[margin] duration-200 ease-out" :class="(sidebarOpen && !isMobile) ? 'md:ml-72' : 'ml-0'">
|
||
<div class="p-4 sm:p-6 max-w-6xl mx-auto 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-[10px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5" x-text="t('language')">Taal</div>
|
||
<select x-model="settings.LANGUAGE" @change="setLang(settings.LANGUAGE)" class="form-input !w-auto min-w-[160px]">
|
||
<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) -->
|
||
<details class="card mb-3" id="lang-addon">
|
||
<summary class="px-4 py-3.5 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2">
|
||
<span class="mdi mdi-translate text-ac dark:text-ac-dark text-lg"></span>
|
||
<span x-text="t('language_addon')">Taalmodule</span>
|
||
<span class="badge badge-info ml-1" x-text="languages.length"></span>
|
||
<span class="ml-auto flex gap-2" @click.stop>
|
||
<button class="btn-sm" @click="openLangEditor(null)"><span class="mdi mdi-plus"></span> <span x-text="t('add_language')">Taal toevoegen</span></button>
|
||
</span>
|
||
</summary>
|
||
<div class="px-4 pb-4">
|
||
<p class="text-xs text-t2 dark:text-t2-dark mb-3" x-text="t('language_addon_hint')">Voeg extra talen toe als add-on. Ingebouwde talen kunnen niet bewerkt of verwijderd worden.</p>
|
||
<div class="space-y-2">
|
||
<template x-for="l in languages" :key="l.code">
|
||
<div class="flex items-center gap-3 p-3 rounded-xl bg-s2 dark:bg-s2-dark">
|
||
<span class="text-2xl" 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-t2 dark:text-t2-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-2 badge badge-dim" x-text="t('builtin')">ingebouwd</span></template></div>
|
||
</div>
|
||
<template x-if="!l.builtin">
|
||
<button class="btn-sm" @click="openLangEditor(l.code)"><span class="mdi mdi-pencil-outline"></span> <span x-text="t('edit')">Bewerken</span></button>
|
||
</template>
|
||
<template x-if="!l.builtin">
|
||
<button class="btn-sm btn-danger" @click="deleteLang(l.code)"><span class="mdi mdi-delete-outline"></span></button>
|
||
</template>
|
||
<template x-if="l.builtin">
|
||
<button class="btn-sm" @click="openLangEditor(l.code)"><span class="mdi mdi-content-copy"></span> <span x-text="t('duplicate')">Dupliceren</span></button>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
|
||
<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 bg-bg-dark/95 border-t border-bd dark:border-bd-dark transition-all duration-200 z-30"
|
||
:class="[(sidebarOpen && !isMobile) ? 'md:left-72' : 'left-0', 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>
|
||
|
||
<!-- Language editor -->
|
||
<div x-show="modal==='lang'" class="bg-s1 dark:bg-s1-dark border border-bd2 dark:border-bd2-dark rounded-2xl p-5 w-full max-w-2xl max-h-[88vh] flex flex-col">
|
||
<h3 class="text-lg font-extrabold text-wh dark:text-wh-dark mb-4 flex items-center gap-2">
|
||
<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>
|
||
</h3>
|
||
<div class="grid sm:grid-cols-3 gap-3 mb-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 mb-3">
|
||
<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="langEdit.query" :placeholder="t('search_keys')" class="form-input pl-9">
|
||
</div>
|
||
<div class="flex-1 overflow-y-auto -mx-1 px-1 space-y-2" style="max-height:48vh">
|
||
<template x-for="k in langKeysFiltered" :key="k">
|
||
<div class="grid sm:grid-cols-2 gap-2 items-center">
|
||
<div class="text-xs">
|
||
<div class="font-mono text-[11px] text-ac dark:text-ac-dark" 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 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="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-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 & 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 justify-center gap-1.5 px-4 py-2.5 min-h-[44px] 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 hover:border-ac hover:text-ac dark:hover:text-ac-dark active:scale-[.97] transition cursor-pointer; }
|
||
.btn-primary { @apply !bg-ac !text-black !border-ac hover:!brightness-110 shadow-sm shadow-ac/20 font-bold; }
|
||
.btn-danger { @apply !text-er !border-er/30 hover:!bg-er/10; }
|
||
.btn-icon { @apply w-11 h-11 grid place-items-center rounded-xl border border-bd dark:border-bd-dark text-t2 dark:text-t2-dark hover:text-ac hover:border-ac active:scale-95 transition cursor-pointer; }
|
||
.btn-sm { @apply inline-flex items-center justify-center gap-1.5 px-3 py-2 min-h-[38px] rounded-lg border border-bd dark:border-bd-dark text-tx dark:text-tx-dark text-xs font-semibold hover:border-ac hover:text-ac dark:hover:text-ac-dark active:scale-95 cursor-pointer transition; }
|
||
.card { @apply bg-s1 dark:bg-s1-dark border border-bd dark:border-bd-dark rounded-2xl overflow-hidden shadow-sm shadow-black/[0.03] dark:shadow-black/20; }
|
||
.stat-card { @apply card p-4 flex items-center gap-4; }
|
||
.stat-icon { @apply w-12 h-12 rounded-xl grid place-items-center text-xl; }
|
||
.stat-value { @apply text-2xl font-extrabold text-wh dark:text-wh-dark leading-none; }
|
||
.stat-label { @apply text-[10px] text-t2 dark:text-t2-dark mt-1 uppercase tracking-wider font-semibold; }
|
||
.form-label { @apply block text-[10px] font-bold tracking-wider uppercase text-dm dark:text-dm-dark mb-1.5; }
|
||
.form-input { @apply w-full bg-s2 dark:bg-s2-dark border border-bd dark:border-bd-dark rounded-lg px-3 py-2.5 min-h-[44px] text-sm font-mono text-tx dark:text-tx-dark outline-none focus:border-ac focus:ring-2 focus:ring-ac/20 focus:bg-s1 dark:focus:bg-s1-dark transition; }
|
||
.badge { @apply text-[9px] font-bold uppercase tracking-wider px-2 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.1',
|
||
page: location.hash.slice(1) || 'dashboard',
|
||
isMobile: window.innerWidth < 768,
|
||
sidebarOpen: window.innerWidth >= 768 && localStorage.getItem('sidebar') !== 'closed',
|
||
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:'' },
|
||
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() {
|
||
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();
|
||
});
|
||
},
|
||
|
||
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) {
|
||
// Dupliceren: leeg code, gebruik bestaande strings als startpunt
|
||
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'); }
|
||
},
|
||
|
||
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 (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>
|