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