Files
jbergner 07824a56a7
All checks were successful
release-tag / release-image (push) Successful in 1m40s
Update
2026-08-02 20:05:31 +02:00

97 lines
13 KiB
HTML

{{define "page"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="csrf-token" content="{{.CSRF}}">
<title>{{.Title}} · ENV Controller</title>
<style>
:root{color-scheme:light dark;--bg:#0b1020;--panel:#121a2d;--panel2:#18233b;--text:#edf2ff;--muted:#9fb0cf;--line:#2a395a;--accent:#79a7ff;--good:#52d273;--warn:#ffc857;--bad:#ff6b7a;--shadow:0 16px 40px rgba(0,0,0,.24)}
*{box-sizing:border-box}body{margin:0;background:linear-gradient(145deg,#09101f,#11182a 48%,#0b1020);color:var(--text);font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}.wrap{max-width:1500px;margin:auto;padding:24px}.top{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:20px}.brand h1{font-size:26px;margin:0}.brand p{color:var(--muted);margin:4px 0 0}.nav{display:flex;gap:10px;flex-wrap:wrap}.button,button,select,input,textarea{font:inherit}.button,button{border:1px solid var(--line);border-radius:10px;background:var(--panel2);color:var(--text);padding:9px 13px;cursor:pointer;font-weight:650}.button:hover,button:hover{border-color:var(--accent);text-decoration:none}.primary{background:var(--accent);color:#071126;border-color:transparent}.danger{border-color:var(--bad)}.panel{background:rgba(18,26,45,.94);border:1px solid var(--line);border-radius:16px;padding:18px;box-shadow:var(--shadow);margin-bottom:18px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px}.project-card{display:block;background:var(--panel2);border:1px solid var(--line);border-radius:14px;padding:16px;color:var(--text)}.project-card:hover{border-color:var(--accent);text-decoration:none}.stats{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}.badge{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--line);border-radius:999px;padding:3px 9px;font-size:12px;color:var(--muted)}.badge.good{border-color:color-mix(in srgb,var(--good),transparent 45%);color:var(--good)}.badge.warn{border-color:color-mix(in srgb,var(--warn),transparent 45%);color:var(--warn)}.badge.bad{border-color:color-mix(in srgb,var(--bad),transparent 45%);color:var(--bad)}.flash{padding:12px 14px;border-radius:12px;margin-bottom:18px;border:1px solid var(--line)}.flash.success{border-color:var(--good)}.flash.error{border-color:var(--bad)}.flash.info{border-color:var(--accent)}.summary{display:flex;gap:10px;flex-wrap:wrap}.path{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);overflow-wrap:anywhere}.toolbar{display:flex;justify-content:space-between;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:14px}.search{min-width:300px}.field-list{display:grid;gap:10px}.field{display:grid;grid-template-columns:minmax(260px,.75fr) minmax(320px,1.4fr);gap:14px;padding:13px;border:1px solid var(--line);border-radius:12px;background:rgba(24,35,59,.7)}.field.missing{opacity:.72;border-style:dashed}.field .key{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-weight:700;overflow-wrap:anywhere}.field .desc{color:var(--muted);font-size:13px;margin-top:5px}.field input,.field textarea,.field select,.restore-select,.action-select,.search{width:100%;border:1px solid var(--line);border-radius:9px;background:#0d1528;color:var(--text);padding:9px 10px}.field textarea{min-height:96px;resize:vertical}.secret{filter:blur(5px);transition:filter .15s}.secret:focus,.secret.revealed{filter:none}.actions-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:15px}.target-picker{display:grid;gap:8px;margin-top:14px}.target-row{display:grid;grid-template-columns:auto minmax(180px,1fr) 130px;gap:10px;align-items:center;padding:9px 10px;border:1px solid var(--line);border-radius:10px}.status-dot{width:9px;height:9px;border-radius:50%;display:inline-block;background:var(--muted);margin-right:6px}.status-dot.running{background:var(--good)}.status-dot.exited,.status-dot.dead{background:var(--bad)}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:10px;border-bottom:1px solid var(--line);vertical-align:top}th{color:var(--muted);font-size:12px;text-transform:uppercase;letter-spacing:.05em}.split{display:grid;grid-template-columns:1.25fr .75fr;gap:18px}@media(max-width:980px){.split{grid-template-columns:1fr}.field{grid-template-columns:1fr}.wrap{padding:14px}.search{min-width:100%}}details summary{cursor:pointer;font-weight:700}.muted{color:var(--muted)}.small{font-size:12px}.warning-list{margin:8px 0 0;padding-left:20px}.footer{color:var(--muted);font-size:12px;text-align:center;padding:18px}.inline-form{display:inline}.hidden{display:none!important}
</style>
</head>
<body>
<div class="wrap">
<header class="top"><div class="brand"><h1>ENV Controller</h1><p>Versionierte Konfiguration und kontrollierte Container-Aktionen</p></div><nav class="nav"><a class="button" href="/">Projekte</a>{{if .Project}}<a class="button" href="/project/{{.Project.Config.ID}}">Neu laden</a>{{end}}</nav></header>
{{if .Flash}}<div class="flash {{.FlashKind}}">{{.Flash}}</div>{{end}}
{{if .Project}}
<section class="panel">
<h2 style="margin-top:0">{{.Project.Config.Title}}</h2>
<div class="summary"><span class="badge">ENV: <span class="path">{{.Project.Config.EnvFile}}</span></span><span class="badge">Vorlage: <span class="path">{{.Project.Config.ExampleFile}}</span></span><span class="badge {{if .Project.Missing}}warn{{else}}good{{end}}">{{len .Project.Missing}} fehlend</span><span class="badge {{if .Project.Extra}}warn{{else}}good{{end}}">{{len .Project.Extra}} zusätzlich</span><span class="badge">{{len .Project.Backups}} Sicherungen</span></div>
{{if .Project.Duplicates}}<div class="flash error" style="margin-top:14px"><strong>Doppelte Schlüssel:</strong> {{join .Project.Duplicates ", "}}. Beim Speichern wird nur der jeweils letzte wirksame Eintrag aktualisiert.</div>{{end}}
</section>
<div class="split">
<main>
<section class="panel">
<div class="toolbar"><div><h2 style="margin:0">Konfiguration bearbeiten</h2><div class="muted small">Geheime Werte werden erst beim Fokus vom Server geladen und beim Verlassen wieder verdeckt.</div></div><input id="fieldSearch" class="search" type="search" placeholder="Schlüssel oder Beschreibung filtern"></div>
<form method="post" action="/project/{{.Project.Config.ID}}/save" data-confirm="Konfiguration speichern? Vorher wird automatisch ein Backup erstellt.">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="field-list" id="fieldList">
{{range .Project.Fields}}
<div class="field {{if .Missing}}missing{{end}}" data-search="{{.Key}} {{.Description}}">
<div><div class="key">{{.Key}}</div><div class="stats">{{if .Missing}}<span class="badge warn">fehlt in .env</span>{{end}}{{if .Extra}}<span class="badge warn">nur lokal</span>{{end}}{{if .Duplicate}}<span class="badge bad">doppelt</span>{{end}}{{if .Secret}}<span class="badge">Secret</span>{{end}}</div>{{if .Description}}<div class="desc">{{.Description}}</div>{{end}}</div>
<div>
{{if .Missing}}
<input value="{{.Value}}" disabled title="Über den Import-Button aus .env.example übernehmen">
{{else if .Secret}}
<input class="secret" type="password" autocomplete="new-password" name="v.{{.Key}}" value="{{.Value}}" data-secret-key="{{.Key}}" data-reveal-url="/project/{{$.Project.Config.ID}}/reveal">
{{else if .Boolean}}
<select name="v.{{.Key}}"><option value="true" {{if eq .Value "true"}}selected{{end}}>true</option><option value="false" {{if eq .Value "false"}}selected{{end}}>false</option></select>
{{else if .Long}}
<textarea name="v.{{.Key}}">{{.Value}}</textarea>
{{else}}
<input name="v.{{.Key}}" value="{{.Value}}">
{{end}}
</div></div>
{{end}}
</div>
{{template "targetPicker" .Project}}
<div class="actions-row"><button class="primary" type="submit">Speichern{{if .Project.Targets}} und Auswahl anwenden{{end}}</button><span class="muted small">Backup wird vor dem Schreiben erstellt.</span></div>
</form>
</section>
</main>
<aside>
<section class="panel">
<h2 style="margin-top:0">Vorlagenabgleich</h2>
{{if .Project.Missing}}<p><strong>{{len .Project.Missing}} neue Schlüssel</strong> sind in <code>.env.example</code> vorhanden.</p><ul class="warning-list">{{range .Project.Missing}}<li><code>{{.}}</code></li>{{end}}</ul>
<form method="post" action="/project/{{.Project.Config.ID}}/import" data-confirm="Fehlende Einträge importieren? Vorher wird automatisch ein Backup erstellt."><input type="hidden" name="csrf" value="{{.CSRF}}">{{template "targetPicker" .Project}}<div class="actions-row"><button class="primary" type="submit">Neue Einträge importieren</button></div></form>
{{else}}<p class="muted">Die .env enthält alle Schlüssel aus der Vorlage.</p>{{end}}
{{if .Project.Extra}}<details><summary>{{len .Project.Extra}} zusätzliche lokale Schlüssel</summary><ul>{{range .Project.Extra}}<li><code>{{.}}</code></li>{{end}}</ul></details>{{end}}
</section>
<section class="panel">
<h2 style="margin-top:0">Container-Steuerung</h2>
<p class="muted small">Nur konfigurierte Namen und erlaubte Aktionen können ausgeführt werden. Recreate verwendet Compose-Metadaten oder explizite Compose-Pfade.</p>
<form method="post" action="/project/{{.Project.Config.ID}}/containers" data-confirm="Ausgewählte Container-Aktionen jetzt ausführen?"><input type="hidden" name="csrf" value="{{.CSRF}}">{{template "targetPicker" .Project}}<div class="actions-row"><button type="submit">Ausführen</button></div></form>
</section>
<section class="panel">
<h2 style="margin-top:0">Sicherungen</h2>
{{if .Project.Backups}}
<form method="post" action="/project/{{.Project.Config.ID}}/restore" data-confirm="Ausgewählte Sicherung wiederherstellen? Der aktuelle Stand wird vorher erneut gesichert."><input type="hidden" name="csrf" value="{{.CSRF}}"><select class="restore-select" name="backup" required>{{range .Project.Backups}}<option value="{{.Name}}">{{.CreatedAt.Format "02.01.2006 15:04:05"}} · {{.Reason}} · {{humanBytes .Size}}</option>{{end}}</select>{{template "targetPicker" .Project}}<div class="actions-row"><button class="danger" type="submit">Wiederherstellen</button></div></form>
<div style="overflow:auto;margin-top:15px"><table><thead><tr><th>Zeit</th><th>Grund</th><th>Datei</th></tr></thead><tbody>{{range .Project.Backups}}<tr><td>{{.CreatedAt.Format "02.01.06 15:04"}}</td><td>{{.Reason}}</td><td><a href="/project/{{$.Project.Config.ID}}/backup/{{.Name}}">Download</a><div class="small muted">{{slice .SHA256 0 12}}</div></td></tr>{{end}}</tbody></table></div>
{{else}}<p class="muted">Noch keine Sicherungen vorhanden.</p>{{end}}
</section>
</aside>
</div>
{{else}}
<section class="panel"><h2 style="margin-top:0">Konfigurationsprojekte</h2><div class="grid">{{range .Projects}}<a class="project-card" href="/project/{{.ID}}"><strong>{{.Title}}</strong>{{if .Error}}<p class="badge bad">{{.Error}}</p>{{else}}<div class="stats"><span class="badge {{if .Missing}}warn{{else}}good{{end}}">{{.Missing}} fehlend</span><span class="badge {{if .Extra}}warn{{else}}good{{end}}">{{.Extra}} zusätzlich</span><span class="badge">{{.Backups}} Backups</span></div>{{end}}</a>{{end}}</div></section>
{{end}}
<div class="footer">ENV Controller · Änderungen wirken in laufenden Containern erst nach Restart/Recreate entsprechend der jeweiligen Anwendung.</div>
</div>
<script>
const csrf=document.querySelector('meta[name="csrf-token"]').content;
document.querySelectorAll('form[data-confirm]').forEach(f=>f.addEventListener('submit',e=>{if(!confirm(f.dataset.confirm))e.preventDefault()}));
const search=document.getElementById('fieldSearch');if(search){search.addEventListener('input',()=>{const q=search.value.toLowerCase();document.querySelectorAll('#fieldList .field').forEach(el=>el.classList.toggle('hidden',!el.dataset.search.toLowerCase().includes(q)))})}
document.querySelectorAll('input[data-secret-key]').forEach(input=>{
let loaded=false;
input.addEventListener('focus',async()=>{input.type='text';input.classList.add('revealed');if(loaded)return;try{const body=new URLSearchParams({csrf,key:input.dataset.secretKey});const res=await fetch(input.dataset.revealUrl,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body,credentials:'same-origin'});if(!res.ok)throw new Error(await res.text());const data=await res.json();input.value=data.value;loaded=true}catch(err){input.blur();alert('Secret konnte nicht geladen werden: '+err.message)}});
input.addEventListener('blur',()=>{input.type='password';input.classList.remove('revealed')});
});
</script>
</body></html>
{{end}}
{{define "targetPicker"}}
{{if .Targets}}<div class="target-picker">{{range .Targets}}{{$current := .Target.DefaultAction}}<label class="target-row"><input type="checkbox" name="target" value="{{.Target.ContainerName}}" {{if .Target.ApplyByDefault}}checked{{end}}><span><span class="status-dot {{.Status.State}}"></span><strong>{{if .Target.DisplayName}}{{.Target.DisplayName}}{{else}}{{.Target.ContainerName}}{{end}}</strong><div class="small muted"><code>{{.Target.ContainerName}}</code> · {{if .Status.Error}}{{.Status.Error}}{{else}}{{.Status.State}}{{if .Status.Health}} / {{.Status.Health}}{{end}}{{end}}</div></span><select class="action-select" name="action.{{.Target.ContainerName}}">{{range .Target.AllowedActions}}<option value="{{.}}" {{if eq . $current}}selected{{end}}>{{.}}</option>{{end}}</select></label>{{end}}</div>{{end}}
{{end}}