@@ -1,4 +1,4 @@
|
||||
# Dockwatch v9.5.1
|
||||
# Dockwatch v9.5.3
|
||||
|
||||
> Go module: `git.send.nrw/sendnrw/dockwatch`
|
||||
|
||||
@@ -6,6 +6,16 @@ Dockwatch is a single-binary Go control plane for Docker Compose, Docker resourc
|
||||
|
||||
The same binary runs as `standalone`, `master` or `agent`. SQLite uses `modernc.org/sqlite`, so the application itself builds with `CGO_ENABLED=0`.
|
||||
|
||||
## v9.5.3 deutsche Weboberfläche
|
||||
|
||||
- die Weboberfläche ist nun durchgängig deutsch beschriftet; interne API-/Compose-Werte bleiben unverändert
|
||||
- Navigation, Compose-Designer, Monitoring, Dienste, Statusseiten, Docker-Ressourcen, Git, Benachrichtigungen und Host-Sicherheit wurden sprachlich vereinheitlicht
|
||||
- Status- und Rollenwerte werden im UI deutsch dargestellt, ohne die API-Werte (`up`, `down`, `admin`, `operator` usw.) zu verändern
|
||||
- die öffentliche Statusseite ist ebenfalls deutsch
|
||||
- häufige serverseitige Diagnosemeldungen werden für die Anzeige im WebUI verständlich ins Deutsche übertragen
|
||||
- statische Assets verwenden `v=9.5.3` zur sicheren Cache-Aktualisierung
|
||||
|
||||
|
||||
## v9.5.1 UI / Compose editor fixes
|
||||
|
||||
- Compose scalar edits no longer re-render the complete visual tree after every keystroke, so text/port fields keep focus while typing. Structural changes (add/remove/type changes) still re-parse the YAML AST.
|
||||
|
||||
@@ -546,7 +546,7 @@ type publicStatusJSONResponse struct {
|
||||
func (s *Server) publicStatusJSON(w http.ResponseWriter, r *http.Request) {
|
||||
v, e := s.monitors.PublicStatusPage(r.Context(), r.PathValue("slug"))
|
||||
if e != nil {
|
||||
http.Error(w, "status page not found", 404)
|
||||
http.Error(w, "Statusseite nicht gefunden", 404)
|
||||
return
|
||||
}
|
||||
out := publicStatusJSONResponse{Name: v.Name, Slug: v.Slug, Description: v.Description, Status: v.Status, Services: []publicServiceJSON{}}
|
||||
@@ -560,7 +560,20 @@ func (s *Server) publicStatusJSON(w http.ResponseWriter, r *http.Request) {
|
||||
jsonOut(w, 200, out)
|
||||
}
|
||||
|
||||
var publicStatusTemplate = template.Must(template.New("status").Funcs(template.FuncMap{"upper": strings.ToUpper}).Parse(`<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="30"><title>{{.Name}} · Status</title><style>:root{color-scheme:dark}body{margin:0;background:#0b0f14;color:#e8edf3;font:15px system-ui,-apple-system,Segoe UI,sans-serif}.wrap{max-width:900px;margin:0 auto;padding:54px 20px}.brand{color:#8b98a8;font-size:13px;margin-bottom:28px}h1{font-size:34px;margin:0 0 8px}.desc{color:#9aa8b7;margin-bottom:22px;white-space:pre-wrap}.summary,.service{background:#121820;border:1px solid #222c38;border-radius:14px;padding:18px 20px;margin:12px 0}.summary{display:flex;align-items:center;justify-content:space-between;gap:18px}.summary strong{font-size:17px}.dot{width:11px;height:11px;border-radius:50%;display:inline-block;flex:0 0 auto}.up{background:#26c281}.down{background:#ef5b5b}.maintenance{background:#f0b849}.pending,.paused,.unknown{background:#8290a2}.service h2{font-size:17px;margin:0}.row{display:flex;align-items:center;justify-content:space-between;gap:15px}.meta{color:#8b98a8;font-size:13px;margin-top:5px}.probe{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;padding:10px 0;border-top:1px solid #1d2631;margin-top:10px}.probe+.probe{margin-top:0}.badge{font-size:12px;text-transform:uppercase;font-weight:700;letter-spacing:.04em}.footer{color:#647181;font-size:12px;text-align:center;margin-top:32px}@media(max-width:600px){.wrap{padding:30px 14px}h1{font-size:28px}.summary{align-items:flex-start;flex-direction:column}.probe{grid-template-columns:auto 1fr}.probe .meta{grid-column:2}}</style></head><body><main class="wrap"><div class="brand">Dockwatch Public Status</div><h1>{{.Name}}</h1><div class="desc">{{.Description}}</div><section class="summary"><div><strong>{{if eq .Status "up"}}All published services operational{{else if eq .Status "down"}}Service disruption detected{{else if eq .Status "maintenance"}}Maintenance in progress{{else}}Status being evaluated{{end}}</strong><div class="meta">This page refreshes automatically every 30 seconds.</div></div><div class="row"><span class="dot {{.Status}}"></span><span class="badge">{{upper .Status}}</span></div></section>{{range .Services}}<section class="service"><div class="row"><div><h2>{{.Name}}</h2><div class="meta">{{.Description}}</div></div><div class="row"><span class="dot {{.Status}}"></span><span class="badge">{{upper .Status}}</span></div></div>{{range .Monitors}}<div class="probe"><span class="dot {{.Status}}"></span><span>{{.Name}}</span><span class="meta">{{printf "%.2f" .Uptime24h}}% / 24h{{if .LastLatencyMS}} · {{.LastLatencyMS}} ms{{end}}</span></div>{{end}}</section>{{else}}<section class="service">No public services configured.</section>{{end}}<div class="footer">Only explicitly published service status is shown · powered by Dockwatch</div></main></body></html>`))
|
||||
var publicStatusTemplate = template.Must(template.New("status").Funcs(template.FuncMap{"upper": strings.ToUpper, "statusDE": func(v string) string {
|
||||
switch strings.ToLower(v) {
|
||||
case "up":
|
||||
return "Verfügbar"
|
||||
case "down":
|
||||
return "Ausgefallen"
|
||||
case "maintenance":
|
||||
return "Wartung"
|
||||
case "paused":
|
||||
return "Pausiert"
|
||||
default:
|
||||
return "Unbekannt"
|
||||
}
|
||||
}}).Parse(`<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="refresh" content="30"><title>{{.Name}} · Status</title><style>:root{color-scheme:dark}body{margin:0;background:#0b0f14;color:#e8edf3;font:15px system-ui,-apple-system,Segoe UI,sans-serif}.wrap{max-width:900px;margin:0 auto;padding:54px 20px}.brand{color:#8b98a8;font-size:13px;margin-bottom:28px}h1{font-size:34px;margin:0 0 8px}.desc{color:#9aa8b7;margin-bottom:22px;white-space:pre-wrap}.summary,.service{background:#121820;border:1px solid #222c38;border-radius:14px;padding:18px 20px;margin:12px 0}.summary{display:flex;align-items:center;justify-content:space-between;gap:18px}.summary strong{font-size:17px}.dot{width:11px;height:11px;border-radius:50%;display:inline-block;flex:0 0 auto}.up{background:#26c281}.down{background:#ef5b5b}.maintenance{background:#f0b849}.pending,.paused,.unknown{background:#8290a2}.service h2{font-size:17px;margin:0}.row{display:flex;align-items:center;justify-content:space-between;gap:15px}.meta{color:#8b98a8;font-size:13px;margin-top:5px}.probe{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px;padding:10px 0;border-top:1px solid #1d2631;margin-top:10px}.probe+.probe{margin-top:0}.badge{font-size:12px;text-transform:uppercase;font-weight:700;letter-spacing:.04em}.footer{color:#647181;font-size:12px;text-align:center;margin-top:32px}@media(max-width:600px){.wrap{padding:30px 14px}h1{font-size:28px}.summary{align-items:flex-start;flex-direction:column}.probe{grid-template-columns:auto 1fr}.probe .meta{grid-column:2}}</style></head><body><main class="wrap"><div class="brand">Dockwatch · Öffentlicher Status</div><h1>{{.Name}}</h1><div class="desc">{{.Description}}</div><section class="summary"><div><strong>{{if eq .Status "up"}}Alle veröffentlichten Dienste sind verfügbar{{else if eq .Status "down"}}Dienststörung erkannt{{else if eq .Status "maintenance"}}Wartung läuft{{else}}Status wird ermittelt{{end}}</strong><div class="meta">Diese Seite wird automatisch alle 30 Sekunden aktualisiert.</div></div><div class="row"><span class="dot {{.Status}}"></span><span class="badge">{{statusDE .Status}}</span></div></section>{{range .Services}}<section class="service"><div class="row"><div><h2>{{.Name}}</h2><div class="meta">{{.Description}}</div></div><div class="row"><span class="dot {{.Status}}"></span><span class="badge">{{statusDE .Status}}</span></div></div>{{range .Monitors}}<div class="probe"><span class="dot {{.Status}}"></span><span>{{.Name}}</span><span class="meta">{{printf "%.2f" .Uptime24h}}% / 24h{{if .LastLatencyMS}} · {{.LastLatencyMS}} ms{{end}}</span></div>{{end}}</section>{{else}}<section class="service">Keine öffentlichen Dienste konfiguriert.</section>{{end}}<div class="footer">Es werden nur ausdrücklich veröffentlichte Dienststatus angezeigt · bereitgestellt von Dockwatch</div></main></body></html>`))
|
||||
|
||||
func (s *Server) publicStatusPage(w http.ResponseWriter, r *http.Request) {
|
||||
v, e := s.monitors.PublicStatusPage(r.Context(), r.PathValue("slug"))
|
||||
|
||||
+137
-123
@@ -2,17 +2,29 @@ const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||||
const asArray=v=>Array.isArray(v)?v:[];
|
||||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const state={me:{role:'viewer'},system:null,nodes:[],nodeHealth:{},stacks:[],monitors:[],services:[],node:0,view:'dashboard',stack:null,monitor:null,checks:[],dirty:false,draftKey:'',logStream:null,logBuffer:'',logPaused:false,logAutoScroll:true,refreshing:false};
|
||||
function uiMessage(msg){let s=String(msg??'').trim();if(!s)return s;const exact={
|
||||
'host security service unavailable':'Host-Sicherheitsdienst ist nicht verfügbar.',
|
||||
'another firewall frontend is active (ufw/firewalld); Dockwatch refuses to apply a competing managed ruleset':'Ein anderes Firewall-Frontend (UFW/firewalld) ist aktiv; Dockwatch wendet keinen konkurrierenden Regelsatz an.',
|
||||
'HOST_ROOT does not match the configured host PID root; mount /:/host and use pid: host':'HOST_ROOT stimmt nicht mit dem Root des konfigurierten Host-PID überein; mounte /:/host und verwende pid: host.',
|
||||
'Dockwatch appears to be PID 1; enable pid: host for host command execution':'Dockwatch scheint selbst PID 1 zu sein; aktiviere pid: host für Host-Befehle.',
|
||||
'cross-site request rejected':'Seitenübergreifende Anfrage abgelehnt.',
|
||||
'origin rejected':'Herkunft der Anfrage abgelehnt.',
|
||||
'not found':'Nicht gefunden.',
|
||||
'permission denied':'Zugriff verweigert.'};
|
||||
if(exact[s])return exact[s];s=s.replace(/^agent returned (\d+) Bad Request:\s*/i,'Agent antwortete mit $1 (Ungültige Anfrage): ');s=s.replace(/^agent returned (\d+) (.+?):\s*/i,'Agent antwortete mit $1 $2: ');s=s.replace(/\bcheck failed\b/gi,'Prüfung fehlgeschlagen').replace(/\bnicht erreichbar\b/gi,'nicht erreichbar').replace(/\bhost access unavailable\b/gi,'Host-Zugriff nicht verfügbar');return s}
|
||||
async function api(path,opt={}){let r;try{r=await fetch(path,{headers:{'Content-Type':'application/json',...(opt.headers||{})},...opt})}catch(e){if(e?.name!=='AbortError')setApiState(false,e.message);throw e}setApiState(true);if(r.status===401){location='/auth/login';throw Error('Nicht angemeldet')}const txt=await r.text();if(!r.ok)throw Error(txt||r.statusText);if(!txt)return null;try{return JSON.parse(txt)}catch{throw Error('Ungültige JSON-Antwort vom Server')}}
|
||||
function setApiState(ok,msg=''){const e=$('#apiState');if(!e)return;e.classList.toggle('offline',!ok);e.title=ok?'API erreichbar':('API nicht erreichbar'+(msg?': '+msg:''));const t=e.querySelector('span');if(t)t.textContent=ok?'API':'Offline'}
|
||||
function applyPreferences(){const theme=localStorage.getItem('dockwatch:theme')||'dark';document.documentElement.dataset.theme=theme;document.body.classList.toggle('sidebar-collapsed',localStorage.getItem('dockwatch:sidebar')==='collapsed'&&innerWidth>860);const t=$('#themeToggle');if(t)t.textContent=theme==='light'?'☾':'☀'}
|
||||
function toggleTheme(){const next=(document.documentElement.dataset.theme||'dark')==='dark'?'light':'dark';localStorage.setItem('dockwatch:theme',next);applyPreferences()}
|
||||
function teardownInteractive(){stopLiveLogs();closeTerminal()}
|
||||
function setBusy(btn,on,label=''){if(!btn)return;btn.disabled=!!on;btn.classList.toggle('busy',!!on);if(on){if(!btn.dataset.oldText)btn.dataset.oldText=btn.textContent;if(label)btn.textContent=label}else if(btn.dataset.oldText){btn.textContent=btn.dataset.oldText;delete btn.dataset.oldText}}
|
||||
function toast(msg){const e=$('#toast');e.textContent=msg;e.style.display='block';clearTimeout(e._t);e._t=setTimeout(()=>e.style.display='none',4200)}
|
||||
function toast(msg){const e=$('#toast');e.textContent=uiMessage(msg);e.style.display='block';clearTimeout(e._t);e._t=setTimeout(()=>e.style.display='none',4200)}
|
||||
function roleOK(min='operator'){return state.me.role==='admin'||(min==='operator'&&state.me.role==='operator')}
|
||||
function qnode(){return state.node?`?node_id=${state.node}`:''}
|
||||
function joinQ(base,extra){return base+(base.includes('?')?'&':'?')+extra}
|
||||
function badge(s){return `<span class="status ${esc(s||'unknown')}">${esc(s||'unknown')}</span>`}
|
||||
function statusLabel(s){return ({running:'Läuft',up:'Verfügbar',down:'Ausgefallen',maintenance:'Wartung',paused:'Pausiert',stopped:'Gestoppt',exited:'Beendet',created:'Erstellt',restarting:'Startet neu',new:'Neu',unknown:'Unbekannt',healthy:'Gesund',unhealthy:'Nicht gesund'}[String(s||'unknown').toLowerCase()]||String(s||'Unbekannt'))}
|
||||
function roleLabel(r){return ({admin:'Administrator',operator:'Operator',viewer:'Betrachter'}[r]||r||'Betrachter')}
|
||||
function badge(s){return `<span class="status ${esc(s||'unknown')}">${esc(statusLabel(s))}</span>`}
|
||||
function setDirty(v){state.dirty=v;$('#dirtyTop').hidden=!v;window.onbeforeunload=v?()=>true:null}
|
||||
function draftKey(name='new'){return `dockwatch:draft:${state.node}:${name||'new'}`}
|
||||
function saveDraft(){if(!state.dirty)return;const c=$('#composeText'),e=$('#envText'),n=$('#stackName');if(!c||!n)return;const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value,content:r.querySelector('.secContent').value}));const env_files=collectManaged('.envfilerow');const configs=collectManaged('.configrow');localStorage.setItem(draftKey(n.value||'new'),JSON.stringify({name:n.value,compose:c.value,env:e?.value||'',secrets,env_files,configs,ts:Date.now()}))}
|
||||
@@ -21,33 +33,33 @@ function clearDraft(name){localStorage.removeItem(draftKey(name));localStorage.r
|
||||
function fmtTime(ts){if(!ts)return'—';return new Date(ts*1000).toLocaleString()}
|
||||
function fmtAgo(ts){if(!ts)return'nie';const s=Math.max(0,Math.floor(Date.now()/1000-ts));if(s<60)return`${s}s`;if(s<3600)return`${Math.floor(s/60)}m`;if(s<86400)return`${Math.floor(s/3600)}h`;return`${Math.floor(s/86400)}d`}
|
||||
function setCrumb(t){$('#crumb').textContent=t}
|
||||
function nodeName(){return state.node?(state.nodes.find(n=>n.id===state.node)?.name||'Remote'):'Local Docker'}
|
||||
function nodeName(){return state.node?(state.nodes.find(n=>n.id===state.node)?.name||'Remote-Host'):'Lokaler Docker-Host'}
|
||||
async function init(){applyPreferences();state.me=await api('/api/me');const [sysR,nodesR]=await Promise.allSettled([api('/api/system'),api('/api/nodes')]);state.system=sysR.status==='fulfilled'?sysR.value:null;state.nodes=nodesR.status==='fulfilled'&&Array.isArray(nodesR.value)?nodesR.value:[];renderUser();renderNodePicker();wireShell();await refreshData(true);navigate('dashboard')}
|
||||
function renderUser(){const name=state.me.name||state.me.email||'User';$('#userName').textContent=name;$('#userRole').textContent=state.me.role;$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"],#nav button[data-view="security"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')}
|
||||
function renderNodePicker(){const p=$('#globalNode');p.innerHTML=`<option value="0">Local Docker</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}}
|
||||
function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)}
|
||||
function renderUser(){const name=state.me.name||state.me.email||'Benutzer';$('#userName').textContent=name;$('#userRole').textContent=roleLabel(state.me.role);$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"],#nav button[data-view="security"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')}
|
||||
function renderNodePicker(){const p=$('#globalNode');p.innerHTML=`<option value="0">Lokaler Docker-Host</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · deaktiviert'}</option>`).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}}
|
||||
function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser ist offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)}
|
||||
function navigate(v){if(state.dirty&&state.view==='stacks'&&v!=='stacks'&&!confirm('Ungespeicherte Stack-Änderungen verlassen?'))return;if(v!==state.view)teardownInteractive();state.view=v;document.body.classList.remove('sidebar-mobile-open');$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));render()}
|
||||
async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('Monitors: '+moR.reason.message);if(svR.status==='fulfilled')state.services=Array.isArray(svR.value)?svR.value:[];else errs.push('Services: '+svR.reason.message);$('#navStackCount').textContent=state.stacks.length;$('#navMonitorCount').textContent=state.monitors.length;if(force||!state.dirty){if(state.stack?.name){const n=state.stacks.find(x=>x.name===state.stack.name);if(!n&&stR.status==='fulfilled')state.stack=null}if(state.monitor&&moR.status==='fulfilled')state.monitor=state.monitors.find(m=>m.id===state.monitor.id)||state.monitor}if(errs.length&&force)toast(errs.join(' · '))}finally{state.refreshing=false}}
|
||||
async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('Monitore: '+moR.reason.message);if(svR.status==='fulfilled')state.services=Array.isArray(svR.value)?svR.value:[];else errs.push('Dienste: '+svR.reason.message);$('#navStackCount').textContent=state.stacks.length;$('#navMonitorCount').textContent=state.monitors.length;if(force||!state.dirty){if(state.stack?.name){const n=state.stacks.find(x=>x.name===state.stack.name);if(!n&&stR.status==='fulfilled')state.stack=null}if(state.monitor&&moR.status==='fulfilled')state.monitor=state.monitors.find(m=>m.id===state.monitor.id)||state.monitor}if(errs.length&&force)toast(errs.join(' · '))}finally{state.refreshing=false}}
|
||||
function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,security:renderSecurity,activity:renderActivity}[state.view]||renderDashboard)()}
|
||||
function pageHead(title,sub,actions=''){return `<div class="pagehead"><div><h1>${esc(title)}</h1><p>${esc(sub)}</p></div><div class="toolbar">${actions}</div></div>`}
|
||||
function renderDashboard(){setCrumb('Dashboard');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Dashboard',nodeName()+' · Docker & Uptime overview','<span class="keyboardHint"><kbd>Ctrl</kbd>+<kbd>S</kbd> speichert Stacks</span><button class="btn" id="dashRefresh">↻ Refresh</button>')}<div class="stats"><div class="stat"><small>Compose stacks</small><strong>${state.stacks.length}</strong><div class="trend"><span class="green">${running} running</span></div></div><div class="stat"><small>Monitors up</small><strong>${up}</strong><div class="trend">${state.monitors.length} configured</div></div><div class="stat"><small>Monitor incidents</small><strong class="${down?'red':''}">${down}</strong><div class="trend">current probe failures</div></div><div class="stat"><small>Services</small><strong class="${svcDown?'red':''}">${svcUp}/${state.services.length}</strong><div class="trend">${svcDown} degraded</div></div></div><div class="twocol"><div class="panel"><div class="panelhead"><h2>Compose stacks</h2><button class="btn tiny" id="goStacks">View all</button></div>${stackTable(state.stacks.slice(0,8))}</div><div class="panel"><div class="panelhead"><h2>Uptime monitors</h2><button class="btn tiny" id="goMons">View all</button></div>${monitorTable(state.monitors.slice(0,8))}</div></div>`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))}
|
||||
function stackTable(items){if(!items.length)return'<div class="empty">No compose stacks found.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Services</th><th>Images</th></tr></thead><tbody>${items.map(s=>`<tr class="${roleOK()?'clickrow':''}" data-openstack="${esc(s.name)}"><td><div class="namecell"><span class="cube">▱</span><b>${esc(s.name)}</b></div></td><td>${badge(s.status)}</td><td>${(s.services||[]).length}</td><td class="muted">${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}</td></tr>`).join('')}</tbody></table>`}
|
||||
function monitorTable(items){if(!items.length)return'<div class="empty">No monitors configured.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Type</th><th>Uptime 24h</th></tr></thead><tbody>${items.map(m=>`<tr class="clickrow" data-openmon="${m.id}"><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td><span class="tag">${esc(m.type.toUpperCase())}</span></td><td>${Number(m.uptime_24h||0).toFixed(2)}%</td></tr>`).join('')}</tbody></table>`}
|
||||
function renderStacks(){setCrumb(`Docker / Compose Stacks / ${nodeName()}`);const actions=roleOK()?'<button class="btn primary" id="newStack">+ New stack</button>':'';$('#content').innerHTML=`${pageHead('Compose stacks','Edit, deploy and operate multi-container applications.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="stackSearch" placeholder="Filter stacks…"><button class="btn tiny" id="stackRefresh">↻</button></div><div id="stackList">${renderStackList(state.stacks)}</div></div><div id="stackDetail" class="detail">${state.stack?stackDetailHTML(state.stack):'<div class="empty"><div class="big">▱</div>Select a stack or create a new one.</div>'}</div></div>`;$('#newStack')?.addEventListener('click',newStack);$('#stackRefresh').onclick=async()=>{if(state.dirty)return toast('Editor contains unsaved changes.');await refreshData(true);renderStacks()};$('#stackSearch').oninput=e=>$('#stackList').innerHTML=renderStackList(state.stacks.filter(s=>s.name.toLowerCase().includes(e.target.value.toLowerCase())));wireStackList();if(state.stack)wireStackDetail()}
|
||||
function renderStackList(items){return items.map(s=>`<div class="stackitem ${state.stack?.name===s.name?'active':''}" data-stack="${esc(s.name)}"><div class="itemtop"><b>${esc(s.name)}</b>${badge(s.status)}</div><div class="itemsub">${(s.services||[]).length} services · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'not deployed')}</div></div>`).join('')||'<div class="empty">No stacks</div>'}
|
||||
function renderDashboard(){setCrumb('Übersicht');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Übersicht',nodeName()+' · Docker- und Uptime-Übersicht','<span class="keyboardHint"><kbd>Ctrl</kbd>+<kbd>S</kbd> speichert Stacks</span><button class="btn" id="dashRefresh">↻ Aktualisieren</button>')}<div class="stats"><div class="stat"><small>Compose-Stacks</small><strong>${state.stacks.length}</strong><div class="trend"><span class="green">${running} aktiv</span></div></div><div class="stat"><small>Monitore verfügbar</small><strong>${up}</strong><div class="trend">${state.monitors.length} konfiguriert</div></div><div class="stat"><small>Monitor-Störungen</small><strong class="${down?'red':''}">${down}</strong><div class="trend">aktuelle Prüfungsfehler</div></div><div class="stat"><small>Dienste</small><strong class="${svcDown?'red':''}">${svcUp}/${state.services.length}</strong><div class="trend">${svcDown} gestört</div></div></div><div class="twocol"><div class="panel"><div class="panelhead"><h2>Compose-Stacks</h2><button class="btn tiny" id="goStacks">Alle anzeigen</button></div>${stackTable(state.stacks.slice(0,8))}</div><div class="panel"><div class="panelhead"><h2>Uptime-Monitore</h2><button class="btn tiny" id="goMons">Alle anzeigen</button></div>${monitorTable(state.monitors.slice(0,8))}</div></div>`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))}
|
||||
function stackTable(items){if(!items.length)return'<div class="empty">Keine Compose-Stacks gefunden.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Dienste</th><th>Images</th></tr></thead><tbody>${items.map(s=>`<tr class="${roleOK()?'clickrow':''}" data-openstack="${esc(s.name)}"><td><div class="namecell"><span class="cube">▱</span><b>${esc(s.name)}</b></div></td><td>${badge(s.status)}</td><td>${(s.services||[]).length}</td><td class="muted">${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}</td></tr>`).join('')}</tbody></table>`}
|
||||
function monitorTable(items){if(!items.length)return'<div class="empty">Keine Monitore konfiguriert.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Typ</th><th>Uptime 24 h</th></tr></thead><tbody>${items.map(m=>`<tr class="clickrow" data-openmon="${m.id}"><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td><span class="tag">${esc(m.type.toUpperCase())}</span></td><td>${Number(m.uptime_24h||0).toFixed(2)}%</td></tr>`).join('')}</tbody></table>`}
|
||||
function renderStacks(){setCrumb(`Docker / Compose-Stacks / ${nodeName()}`);const actions=roleOK()?'<button class="btn primary" id="newStack">+ Neuer Stack</button>':'';$('#content').innerHTML=`${pageHead('Compose-Stacks','Mehrcontainer-Anwendungen bearbeiten, bereitstellen und betreiben.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="stackSearch" placeholder="Stacks filtern…"><button class="btn tiny" id="stackRefresh">↻</button></div><div id="stackList">${renderStackList(state.stacks)}</div></div><div id="stackDetail" class="detail">${state.stack?stackDetailHTML(state.stack):'<div class="empty"><div class="big">▱</div>Wähle einen Stack aus oder lege einen neuen an.</div>'}</div></div>`;$('#newStack')?.addEventListener('click',newStack);$('#stackRefresh').onclick=async()=>{if(state.dirty)return toast('Der Editor enthält ungespeicherte Änderungen.');await refreshData(true);renderStacks()};$('#stackSearch').oninput=e=>$('#stackList').innerHTML=renderStackList(state.stacks.filter(s=>s.name.toLowerCase().includes(e.target.value.toLowerCase())));wireStackList();if(state.stack)wireStackDetail()}
|
||||
function renderStackList(items){return items.map(s=>`<div class="stackitem ${state.stack?.name===s.name?'active':''}" data-stack="${esc(s.name)}"><div class="itemtop"><b>${esc(s.name)}</b>${badge(s.status)}</div><div class="itemsub">${(s.services||[]).length} Dienste · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'nicht bereitgestellt')}</div></div>`).join('')||'<div class="empty">Keine Stacks</div>'}
|
||||
function wireStackList(){$$('[data-stack]').forEach(x=>x.onclick=()=>openStack(x.dataset.stack))}
|
||||
async function openStack(name){if(!roleOK())return toast('Stack configuration and logs require Operator access.');if(state.dirty&&state.stack?.name!==name&&!confirm('Ungespeicherte Änderungen verwerfen?'))return;if(state.stack?.name!==name)teardownInteractive();try{state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);setDirty(false);state.view='stacks';renderStacks()}catch(e){toast(e.message)}}
|
||||
async function openStack(name){if(!roleOK())return toast('Stack-Konfiguration und Logs erfordern Operator-Rechte.');if(state.dirty&&state.stack?.name!==name&&!confirm('Ungespeicherte Änderungen verwerfen?'))return;if(state.stack?.name!==name)teardownInteractive();try{state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);setDirty(false);state.view='stacks';renderStacks()}catch(e){toast(e.message)}}
|
||||
function newStack(){if(state.dirty&&!confirm('Aktuellen Entwurf verwerfen?'))return;teardownInteractive();const draft=localStorage.getItem(draftKey('new'));let st={name:'',status:'new',services:[],compose:`services:
|
||||
app:
|
||||
image: nginx:alpine
|
||||
restart: unless-stopped
|
||||
`,env:'',secrets:[],env_files:[],configs:[]};if(draft){try{const d=JSON.parse(draft);if(confirm('Gespeicherten lokalen Stack-Entwurf wiederherstellen?'))st={...st,...d}}catch{}}state.stack=st;setDirty(false);renderStacks()}
|
||||
function stackDetailHTML(st){const sv=st.services||[];return `<div class="detailhead"><div class="detailtitle"><span class="cube">▱</span><div><h2>${esc(st.name||'New compose stack')}</h2><small>${st.name?esc(nodeName()):'Draft · not deployed'}</small></div>${badge(st.status||'new')}</div><div class="actions">${st.name?`<button class="btn success" data-act="up">▶ Deploy</button><button class="btn" data-act="pull">↓ Pull</button><button class="btn" data-act="update">↻ Update</button><button class="btn" data-act="restart">⟳ Restart</button><button class="btn" data-act="stop">■ Stop</button>`:''}<button class="btn primary" id="saveStack">Save</button></div></div><div class="tabs"><button class="active" data-tab="compose">Compose</button><button data-tab="env">.env</button><button data-tab="envfiles">Env files</button><button data-tab="secrets">Secrets</button><button data-tab="configs">Configs</button><button data-tab="services">Containers <span class="tag">${sv.length}</span></button><button data-tab="permissions">Permissions</button><button data-tab="graph">Graph</button><button data-tab="updates">Updates</button><button data-tab="logs">Live logs</button><button data-tab="console">Terminal</button><button data-tab="danger">Danger zone</button></div><div class="tabbody"><div id="tab-compose">${composeTab(st)}</div><div id="tab-env" hidden>${envTab(st)}</div><div id="tab-envfiles" hidden>${managedFilesTab('envfile',st.env_files||[],'Additional env files','Use these from Compose as <code>env_file: ./envs/app.env</code>.')}</div><div id="tab-secrets" hidden>${secretsTab(st)}</div><div id="tab-configs" hidden>${managedFilesTab('config',st.configs||[],'Compose configs','Use these from Compose as <code>configs: ... file: ./configs/name</code>.')}</div><div id="tab-services" hidden>${servicesTab(st)}</div><div id="tab-permissions" hidden>${permissionsTab()}</div><div id="tab-graph" hidden>${graphTab()}</div><div id="tab-updates" hidden>${updatesTab()}</div><div id="tab-logs" hidden>${logsTab()}</div><div id="tab-console" hidden>${consoleTab(sv)}</div><div id="tab-danger" hidden>${dangerTab(st)}</div><pre id="actionOut" class="terminal" style="min-height:90px;display:none;margin-top:10px"></pre></div>`}
|
||||
function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'<div class="draftnotice"><span>A local unsaved draft exists for this stack.</span><span><button class="btn tiny" id="restoreDraft">Restore</button> <button class="btn tiny" id="discardDraft">Discard</button></span></div>':''}<div class="field" style="margin-bottom:8px"><label>Stack name</label><input id="stackName" value="${esc(st.name)}" ${st.name?'disabled':''} placeholder="my-stack"></div><div class="compose-livebar"><div><b>Full Compose Designer</b><span id="composeSyncState" class="muted"> parsing…</span></div><div class="actions"><button class="btn tiny" id="composeExpandAll">Expand all</button><button class="btn tiny" id="composeCollapseAll">Collapse all</button></div></div><div class="compose-livegrid compose-fullgrid"><div class="codebox"><div class="editorlabel">compose.yaml <span class="muted">Source of truth</span></div><textarea id="composeText" spellcheck="false">${esc(st.compose||'')}</textarea></div><div class="visualcompose"><div class="editorlabel">Visual editor <span class="muted">all fields · AST patch mode</span></div><div class="compose-sections" id="composeSections"></div><div id="composeVisual"><div class="empty">Parsing Compose…</div></div></div></div><div class="compose-help"><span>Every present Compose value is editable as string, number, boolean, null, map or array.</span><span>Current spec fields are suggested; x-* and future fields remain editable too.</span><span>Invalid YAML pauses visual sync without replacing your source.</span></div>`}
|
||||
function stackDetailHTML(st){const sv=st.services||[];return `<div class="detailhead"><div class="detailtitle"><span class="cube">▱</span><div><h2>${esc(st.name||'Neuer Compose-Stack')}</h2><small>${st.name?esc(nodeName()):'Entwurf · nicht bereitgestellt'}</small></div>${badge(st.status||'new')}</div><div class="actions">${st.name?`<button class="btn success" data-act="up">▶ Bereitstellen</button><button class="btn" data-act="pull">↓ Images laden</button><button class="btn" data-act="update">↻ Aktualisieren</button><button class="btn" data-act="restart">⟳ Neustart</button><button class="btn" data-act="stop">■ Stoppen</button>`:''}<button class="btn primary" id="saveStack">Speichern</button></div></div><div class="tabs"><button class="active" data-tab="compose">Compose</button><button data-tab="env">.env</button><button data-tab="envfiles">Env-Dateien</button><button data-tab="secrets">Secrets</button><button data-tab="configs">Configs</button><button data-tab="services">Container <span class="tag">${sv.length}</span></button><button data-tab="permissions">Berechtigungen</button><button data-tab="graph">Graph</button><button data-tab="updates">Updates</button><button data-tab="logs">Live-Logs</button><button data-tab="console">Terminal</button><button data-tab="danger">Gefahrenbereich</button></div><div class="tabbody"><div id="tab-compose">${composeTab(st)}</div><div id="tab-env" hidden>${envTab(st)}</div><div id="tab-envfiles" hidden>${managedFilesTab('envfile',st.env_files||[],'Zusätzliche Env-Dateien','In Compose verwenden als <code>env_file: ./envs/app.env</code>.')}</div><div id="tab-secrets" hidden>${secretsTab(st)}</div><div id="tab-configs" hidden>${managedFilesTab('config',st.configs||[],'Compose-Configs','In Compose verwenden als <code>configs: ... file: ./configs/name</code>.')}</div><div id="tab-services" hidden>${servicesTab(st)}</div><div id="tab-permissions" hidden>${permissionsTab()}</div><div id="tab-graph" hidden>${graphTab()}</div><div id="tab-updates" hidden>${updatesTab()}</div><div id="tab-logs" hidden>${logsTab()}</div><div id="tab-console" hidden>${consoleTab(sv)}</div><div id="tab-danger" hidden>${dangerTab(st)}</div><pre id="actionOut" class="terminal" style="min-height:90px;display:none;margin-top:10px"></pre></div>`}
|
||||
function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'<div class="draftnotice"><span>Für diesen Stack existiert ein lokaler ungespeicherter Entwurf.</span><span><button class="btn tiny" id="restoreDraft">Wiederherstellen</button> <button class="btn tiny" id="discardDraft">Verwerfen</button></span></div>':''}<div class="field" style="margin-bottom:8px"><label>Stack-Name</label><input id="stackName" value="${esc(st.name)}" ${st.name?'disabled':''} placeholder="my-stack"></div><div class="compose-livebar"><div><b>Vollständiger Compose-Designer</b><span id="composeSyncState" class="muted"> wird analysiert…</span></div><div class="actions"><button class="btn tiny" id="composeExpandAll">Alle aufklappen</button><button class="btn tiny" id="composeCollapseAll">Alle einklappen</button></div></div><div class="compose-livegrid compose-fullgrid"><div class="codebox"><div class="editorlabel">compose.yaml <span class="muted">Verbindliche Quelle</span></div><textarea id="composeText" spellcheck="false">${esc(st.compose||'')}</textarea></div><div class="visualcompose"><div class="editorlabel">Visueller Editor <span class="muted">alle Felder · AST-Patch-Modus</span></div><div class="compose-sections" id="composeSections"></div><div id="composeVisual"><div class="empty">Compose wird analysiert…</div></div></div></div><div class="compose-help"><span>Jeder vorhandene Compose-Wert kann als String, Zahl, Boolean, Null, Map oder Array bearbeitet werden.</span><span>Felder der aktuellen Spezifikation werden vorgeschlagen; x-* und zukünftige Felder bleiben ebenfalls bearbeitbar.</span><span>Ungültiges YAML pausiert die visuelle Synchronisierung, ohne die Quelle zu ersetzen.</span></div>`}
|
||||
|
||||
const COMPOSE_SERVICE_FIELDS=['annotations','attach','build','blkio_config','cpu_count','cpu_percent','cpu_shares','cpu_period','cpu_quota','cpu_rt_runtime','cpu_rt_period','cpus','cpuset','cap_add','cap_drop','cgroup','cgroup_parent','command','configs','container_name','credential_spec','depends_on','deploy','develop','device_cgroup_rules','devices','dns','dns_opt','dns_search','domainname','driver_opts','entrypoint','env_file','environment','expose','extends','external_links','extra_hosts','gpus','group_add','healthcheck','hostname','image','init','ipc','isolation','labels','label_file','links','logging','mac_address','mem_limit','mem_reservation','mem_swappiness','memswap_limit','models','network_mode','networks','oom_kill_disable','oom_score_adj','pid','pids_limit','platform','ports','post_start','pre_start','pre_stop','privileged','profiles','provider','pull_policy','read_only','restart','runtime','scale','secrets','security_opt','shm_size','stdin_open','stop_grace_period','stop_signal','storage_opt','sysctls','tmpfs','tty','ulimits','use_api_socket','user','userns_mode','uts','volumes','volumes_from','working_dir'];
|
||||
const COMPOSE_TOP_FIELDS=['name','include','services','models','networks','volumes','secrets','configs','version'];
|
||||
const COMPOSE_FIELD_HINTS={image:'Container image',build:'Build configuration',command:'Override image command',entrypoint:'Override image entrypoint',environment:'Environment variables',env_file:'Environment files',ports:'Published ports',expose:'Exposed container ports',volumes:'Mounts and named volumes',networks:'Network attachments',depends_on:'Service dependencies',healthcheck:'Container health check',deploy:'Deployment constraints and resources',develop:'Compose watch/development settings',secrets:'Granted secrets',configs:'Granted configs',logging:'Logging driver/options',restart:'Container restart policy',pull_policy:'Image pull policy',provider:'External provider configuration',post_start:'Post-start lifecycle hooks',pre_start:'Pre-start lifecycle hooks',pre_stop:'Pre-stop lifecycle hooks',models:'AI model attachments'};
|
||||
const COMPOSE_FIELD_HINTS={image:'Container-Image',build:'Build-Konfiguration',command:'Image-Befehl überschreiben',entrypoint:'Image-Entrypoint überschreiben',environment:'Umgebungsvariablen',env_file:'Umgebungsdateien',ports:'Veröffentlichte Ports',expose:'Freigegebene Container-Ports',volumes:'Mounts und benannte Volumes',networks:'Netzwerkanbindungen',depends_on:'Dienstabhängigkeiten',healthcheck:'Container-Healthcheck',deploy:'Bereitstellungsregeln und Ressourcen',develop:'Compose-Watch-/Entwicklungseinstellungen',secrets:'Bereitgestellte Secrets',configs:'Bereitgestellte Configs',logging:'Logging-Treiber/-Optionen',restart:'Container-Neustartrichtlinie',pull_policy:'Image-Pull-Richtlinie',provider:'Externe Provider-Konfiguration',post_start:'Post-Start-Lifecycle-Hooks',pre_start:'Pre-Start-Lifecycle-Hooks',pre_stop:'Pre-Stop-Lifecycle-Hooks',models:'KI-Modellanbindungen'};
|
||||
const COMPOSE_CHILD_FIELDS={build:['context','dockerfile','dockerfile_inline','entitlements','args','ssh','labels','cache_from','cache_to','no_cache','no_cache_filter','additional_contexts','network','provenance','sbom','pull','target','shm_size','extra_hosts','isolation','privileged','secrets','tags','ulimits','platforms'],healthcheck:['test','interval','timeout','retries','start_period','start_interval','disable'],logging:['driver','options'],deploy:['mode','endpoint_mode','replicas','labels','rollback_config','update_config','resources','restart_policy','placement'],resources:['limits','reservations'],limits:['cpus','memory','pids'],reservations:['cpus','memory','generic_resources','devices'],restart_policy:['condition','delay','max_attempts','window'],update_config:['parallelism','delay','failure_action','monitor','max_failure_ratio','order'],rollback_config:['parallelism','delay','failure_action','monitor','max_failure_ratio','order'],placement:['constraints','preferences','max_replicas_per_node'],develop:['watch'],watch:['path','action','target','ignore','exec','initial_sync'],credential_spec:['config','file','registry'],extends:['file','service'],provider:['type','options'],ports:['name','mode','host_ip','target','published','protocol','app_protocol'],volumes:['type','source','target','read_only','consistency','bind','volume','tmpfs','image'],bind:['propagation','create_host_path','selinux','recursive'],volume:['nocopy','subpath'],tmpfs:['size','mode'],image:['subpath'],depends_on:['restart','required','condition'],networks:['aliases','interface_name','ipv4_address','ipv6_address','link_local_ips','mac_address','driver_opts','priority','gw_priority'],secrets:['source','target','uid','gid','mode'],configs:['source','target','uid','gid','mode'],ipam:['driver','config','options'],network:['attachable','driver','driver_opts','enable_ipv4','enable_ipv6','external','ipam','internal','labels','name'],config:['file','environment','content','external','name'],secret:['file','environment','external','name'],model:['model','context_size','runtime_flags'],blkio_config:['device_read_bps','device_read_iops','device_write_bps','device_write_iops','weight','weight_device'],ulimits:['soft','hard'],post_start:['command','user','privileged','working_dir','environment'],pre_start:['command','user','privileged','working_dir','environment'],pre_stop:['command','user','privileged','working_dir','environment']};
|
||||
function composeSuggestions(path,opt={}){if(opt.serviceRoot)return COMPOSE_SERVICE_FIELDS;const clean=path.filter(x=>!/^\d+$/.test(String(x)));const last=clean[clean.length-1]||'';if(COMPOSE_CHILD_FIELDS[last])return COMPOSE_CHILD_FIELDS[last];if(clean.length===1&&clean[0]==='networks')return COMPOSE_CHILD_FIELDS.network;if(clean.length===1&&clean[0]==='volumes')return ['driver','driver_opts','external','labels','name'];if(clean.length===1&&clean[0]==='configs')return COMPOSE_CHILD_FIELDS.config;if(clean.length===1&&clean[0]==='secrets')return COMPOSE_CHILD_FIELDS.secret;if(clean.length===1&&clean[0]==='models')return COMPOSE_CHILD_FIELDS.model;return []}
|
||||
|
||||
@@ -58,137 +70,139 @@ function pathFromAttr(v){try{return JSON.parse(decodeURIComponent(v))}catch{retu
|
||||
function typeOfValue(v){if(v===null)return'null';if(Array.isArray(v))return'array';if(typeof v==='object')return'map';return typeof v}
|
||||
function cloneJSON(v){return v===undefined?undefined:JSON.parse(JSON.stringify(v))}
|
||||
function composeSetLocal(path,val,del=false){if(!composeVisualModel)return;let cur=composeVisualModel;for(let i=0;i<path.length-1;i++){const k=path[i];if(Array.isArray(cur))cur=cur[Number(k)];else cur=cur[k];if(cur==null)return}const k=path[path.length-1];if(del){if(Array.isArray(cur))cur.splice(Number(k),1);else delete cur[k]}else{if(Array.isArray(cur))cur[Number(k)]=val;else cur[k]=val}}
|
||||
async function parseComposeVisual(){const box=$('#composeVisual'),ta=$('#composeText'),status=$('#composeSyncState');if(!box||!ta)return;const seq=++composeParseSeq;status.className='muted';status.textContent=' parsing…';try{const r=await api('/api/compose/parse',{method:'POST',body:JSON.stringify({compose:ta.value})});if(seq!==composeParseSeq)return;composeVisualModel=r.value||{};status.className='green';status.textContent=' YAML ↔ Designer synchronized';renderComposeSections();renderComposeVisual()}catch(e){if(seq!==composeParseSeq)return;status.className='red';status.textContent=' Visual sync paused';box.innerHTML=`<div class="compose-parseerror"><b>Visual editor paused</b><p>${esc(e.message)}</p><p>Your YAML source is untouched. Fix the syntax and synchronization resumes automatically.</p></div>`}}
|
||||
function renderComposeSections(){const host=$('#composeSections');if(!host)return;const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),missing=[...new Set(usages.filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];const volumeLabel=`Volumes <span class="tag ${missing.length?'warn':''}">${usages.length}${missing.length?` · ${missing.length}!`:''}</span>`;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Models'],['include','Include']];host.innerHTML=items.map(([k,l])=>`<button class="${composeSection===k?'active':''}" data-csection="${k}">${l}</button>`).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})}
|
||||
async function parseComposeVisual(){const box=$('#composeVisual'),ta=$('#composeText'),status=$('#composeSyncState');if(!box||!ta)return;const seq=++composeParseSeq;status.className='muted';status.textContent=' wird analysiert…';try{const r=await api('/api/compose/parse',{method:'POST',body:JSON.stringify({compose:ta.value})});if(seq!==composeParseSeq)return;composeVisualModel=r.value||{};status.className='green';status.textContent=' YAML ↔ Designer synchronisiert';renderComposeSections();renderComposeVisual()}catch(e){if(seq!==composeParseSeq)return;status.className='red';status.textContent=' Visuelle Synchronisierung pausiert';box.innerHTML=`<div class="compose-parseerror"><b>Visueller Editor pausiert</b><p>${esc(uiMessage(e.message))}</p><p>Die YAML-Quelle bleibt unverändert. Korrigiere die Syntax; anschließend wird automatisch weiter synchronisiert.</p></div>`}}
|
||||
function renderComposeSections(){const host=$('#composeSections');if(!host)return;const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),missing=[...new Set(usages.filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];const volumeLabel=`Volumes <span class="tag ${missing.length?'warn':''}">${usages.length}${missing.length?` · ${missing.length}!`:''}</span>`;const items=[['project','Projekt'],['services','Dienste'],['networks','Netzwerke'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Modelle'],['include','Include']];host.innerHTML=items.map(([k,l])=>`<button class="${composeSection===k?'active':''}" data-csection="${k}">${l}</button>`).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})}
|
||||
function sectionValue(){const m=composeVisualModel||{};if(composeSection==='project'){const x={};for(const k of Object.keys(m))if(!['services','networks','volumes','configs','secrets','models','include'].includes(k))x[k]=m[k];return x}return m[composeSection]??(composeSection==='include'?[]:{})}
|
||||
function sectionPath(){return composeSection==='project'?[]:[composeSection]}
|
||||
function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const scrollTop=box.scrollTop,v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(composeSection==='volumes'){body=composeVolumeSection(v,base)}else if(['networks','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'<div class="empty">Nothing configured in this section.</div>';wireComposeTree();wireComposeVolumeReconcile();box.scrollTop=Math.min(scrollTop,Math.max(0,box.scrollHeight-box.clientHeight))}
|
||||
function composeNamedObjectEditor(v,path,label,suggestions){if(!v||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,label);const rows=Object.entries(v).map(([k,val])=>`<details class="compose-object compose-named" open><summary><span><b>${esc(k)}</b> <span class="tag">${esc(label)}</span></span><span class="summary-actions"><button class="iconbtn" data-cdelete="${pathAttr([...path,k])}" title="Remove">×</button></span></summary><div class="compose-object-body">${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}</div></details>`).join('');return `${rows}<div class="compose-addrow"><input data-cnewname="${pathAttr(path)}" placeholder="New ${esc(label)} name"><button class="btn tiny" data-caddnamed="${pathAttr(path)}" data-ctype="map">+ ${esc(label)}</button></div>`}
|
||||
function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const scrollTop=box.scrollTop,v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(composeSection==='volumes'){body=composeVolumeSection(v,base)}else if(['networks','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'<div class="empty">In diesem Bereich ist nichts konfiguriert.</div>';wireComposeTree();wireComposeVolumeReconcile();box.scrollTop=Math.min(scrollTop,Math.max(0,box.scrollHeight-box.clientHeight))}
|
||||
function composeObjectLabel(v){return ({service:'Dienst',volume:'Volume',network:'Netzwerk',config:'Config',secret:'Secret',model:'Modell',include:'Einbindung'}[v]||v)}
|
||||
function composeTypeLabel(v){return ({string:'Zeichenkette',number:'Zahl',boolean:'Boolean',map:'Objekt',array:'Array',null:'Null'}[v]||v)}
|
||||
function composeNamedObjectEditor(v,path,label,suggestions){if(!v||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,label);const displayLabel=composeObjectLabel(label),rows=Object.entries(v).map(([k,val])=>`<details class="compose-object compose-named" open><summary><span><b>${esc(k)}</b> <span class="tag">${esc(displayLabel)}</span></span><span class="summary-actions"><button class="iconbtn" data-cdelete="${pathAttr([...path,k])}" title="Entfernen">×</button></span></summary><div class="compose-object-body">${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}</div></details>`).join('');return `${rows}<div class="compose-addrow"><input data-cnewname="${pathAttr(path)}" placeholder="Neuer ${esc(displayLabel)}-Name"><button class="btn tiny" data-caddnamed="${pathAttr(path)}" data-ctype="map">+ ${esc(displayLabel)}</button></div>`}
|
||||
function splitComposeMountShort(raw){const s=String(raw??''),cuts=[];let brace=0;for(let i=0;i<s.length;i++){if(s[i]==='$'&&s[i+1]==='{'){brace++;i++;continue}if(s[i]==='}'&&brace>0){brace--;continue}if(s[i]===':'&&brace===0)cuts.push(i)}if(cuts.length&&cuts[0]===1&&/^[A-Za-z]$/.test(s[0])&&/[\\/]/.test(s[2]||''))cuts.shift();if(!cuts.length)return{source:'',target:s,options:'',raw:s};const a=cuts[0],b=cuts[1];return{source:s.slice(0,a),target:b===undefined?s.slice(a+1):s.slice(a+1,b),options:b===undefined?'':s.slice(b+1),raw:s}}
|
||||
function classifyComposeMountSource(source,type=''){if(!source)return String(type||'').toLowerCase()||'anonymous';const s=String(source),t=String(type||'').toLowerCase();if(s.includes('$'))return'dynamic';if(t)return t;if(s.startsWith('/')||s.startsWith('./')||s.startsWith('../')||s.startsWith('~/')||/^[A-Za-z]:[\\/]/.test(s)||s.includes('/'))return'bind';return'volume'}
|
||||
function composeVolumeUsages(model=composeVisualModel||{}){const out=[];for(const [service,cfg] of Object.entries(model.services||{})){const mounts=Array.isArray(cfg?.volumes)?cfg.volumes:[];mounts.forEach((m,index)=>{if(typeof m==='string'){const x=splitComposeMountShort(m),kind=classifyComposeMountSource(x.source);out.push({service,index,kind,source:x.source,target:x.target,readOnly:(x.options||'').split(',').includes('ro'),raw:m})}else if(m&&typeof m==='object'){const kind=classifyComposeMountSource(m.source,m.type);out.push({service,index,kind,source:m.source||'',target:m.target||'',readOnly:!!m.read_only,raw:m})}})}return out}
|
||||
function composeVolumeSection(v,path){const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),named=usages.filter(x=>x.kind==='volume'&&x.source),missing=[...new Set(named.map(x=>x.source).filter(x=>!declared.has(x)))],usedNames=new Set(named.map(x=>x.source)),unused=[...declared].filter(x=>!usedNames.has(x)),binds=usages.filter(x=>x.kind==='bind'),other=usages.filter(x=>!['volume','bind'].includes(x.kind));const status=missing.length?`<span class="tag warn">${missing.length} missing declaration${missing.length===1?'':'s'}</span>`:'<span class="tag oktag">named volumes aligned</span>';const rows=usages.map(x=>`<tr><td><b>${esc(x.service)}</b></td><td><span class="tag">${esc(x.kind)}</span></td><td><code>${esc(x.source||'—')}</code></td><td><code>${esc(x.target||'—')}</code></td><td>${x.readOnly?'read-only':'read-write'}</td><td>${x.kind==='volume'&&x.source?(declared.has(x.source)?'<span class="green">declared</span>':`<span class="amber">missing</span> <button class="btn tiny" data-vdeclare="${esc(x.source)}">Declare</button>`):x.kind==='bind'?'<span class="muted">no top-level declaration</span>':'<span class="muted">not applicable</span>'}</td></tr>`).join('');return `<div class="volume-reconcile"><div class="panelhead"><div><h3>Volume reconciliation</h3><p class="muted">Service mounts are compared live with top-level <code>volumes:</code>. Bind mounts are shown here but never turned into named-volume declarations.</p></div><div class="actions">${status}${missing.length?'<button class="btn tiny primary" id="syncVolumeDeclarations">+ Add missing declarations</button>':''}</div></div>${rows?`<div class="tablewrap"><table class="table compact"><thead><tr><th>Service</th><th>Type</th><th>Source</th><th>Target</th><th>Access</th><th>Top-level</th></tr></thead><tbody>${rows}</tbody></table></div>`:'<div class="empty small">No service mounts are currently configured.</div>'}${unused.length?`<div class="volume-unused"><b>Declared but currently unused:</b> ${unused.map(n=>`<span class="tag">${esc(n)} <button class="tagx" data-vremove="${esc(n)}" title="Remove unused declaration">×</button></span>`).join(' ')}</div>`:''}${binds.length?`<div class="muted volume-note">${binds.length} bind mount${binds.length===1?'':'s'} detected. Bind sources belong to the host filesystem and therefore do not appear as top-level named volumes.</div>`:''}${other.length?`<div class="muted volume-note">${other.length} anonymous/dynamic/special mount${other.length===1?'':'s'} preserved without automatic declaration changes.</div>`:''}</div><div class="compose-subsection"><div class="editorlabel">Top-level volumes <span class="muted">Compose declarations</span></div>${composeNamedObjectEditor(v,path,'volume',[])}</div>`}
|
||||
function wireComposeVolumeReconcile(){if(composeSection!=='volumes')return;$$('[data-vdeclare]').forEach(b=>b.onclick=()=>queueComposePatch(['volumes',b.dataset.vdeclare],{}));$('#syncVolumeDeclarations')?.addEventListener('click',()=>{const declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),names=[...new Set(composeVolumeUsages().filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];if(!names.length)return toast('Named volumes are already aligned.');names.forEach(n=>queueComposePatch(['volumes',n],{}));toast(`${names.length} volume declaration${names.length===1?'':'s'} queued.`)});$$('[data-vremove]').forEach(b=>b.onclick=()=>{const n=b.dataset.vremove;if(confirm(`Remove unused top-level volume declaration ${n}? This does not delete a Docker volume.`))queueComposePatch(['volumes',n],null,true)})}
|
||||
function composeVolumeSection(v,path){const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),named=usages.filter(x=>x.kind==='volume'&&x.source),missing=[...new Set(named.map(x=>x.source).filter(x=>!declared.has(x)))],usedNames=new Set(named.map(x=>x.source)),unused=[...declared].filter(x=>!usedNames.has(x)),binds=usages.filter(x=>x.kind==='bind'),other=usages.filter(x=>!['volume','bind'].includes(x.kind));const status=missing.length?`<span class="tag warn">${missing.length} fehlende Deklaration${missing.length===1?'':'en'}</span>`:'<span class="tag oktag">benannte Volumes abgeglichen</span>';const rows=usages.map(x=>`<tr><td><b>${esc(x.service)}</b></td><td><span class="tag">${esc(x.kind)}</span></td><td><code>${esc(x.source||'—')}</code></td><td><code>${esc(x.target||'—')}</code></td><td>${x.readOnly?'schreibgeschützt':'beschreibbar'}</td><td>${x.kind==='volume'&&x.source?(declared.has(x.source)?'<span class="green">deklariert</span>':`<span class="amber">fehlt</span> <button class="btn tiny" data-vdeclare="${esc(x.source)}">Deklarieren</button>`):x.kind==='bind'?'<span class="muted">keine Top-Level-Deklaration</span>':'<span class="muted">nicht erforderlich</span>'}</td></tr>`).join('');return `<div class="volume-reconcile"><div class="panelhead"><div><h3>Volume-Abgleich</h3><p class="muted">Dienst-Mounts werden live mit Top-Level-<code>volumes:</code> verglichen. Bind-Mounts werden hier angezeigt, aber niemals in Named-Volume-Deklarationen umgewandelt.</p></div><div class="actions">${status}${missing.length?'<button class="btn tiny primary" id="syncVolumeDeclarations">+ Fehlende Deklarationen ergänzen</button>':''}</div></div>${rows?`<div class="tablewrap"><table class="table compact"><thead><tr><th>Dienst</th><th>Typ</th><th>Quelle</th><th>Ziel</th><th>Zugriff</th><th>Top-Level</th></tr></thead><tbody>${rows}</tbody></table></div>`:'<div class="empty small">Aktuell sind keine Dienst-Mounts konfiguriert.</div>'}${unused.length?`<div class="volume-unused"><b>Deklariert, aber aktuell ungenutzt:</b> ${unused.map(n=>`<span class="tag">${esc(n)} <button class="tagx" data-vremove="${esc(n)}" title="Ungenutzte Deklaration entfernen">×</button></span>`).join(' ')}</div>`:''}${binds.length?`<div class="muted volume-note">${binds.length} Bind-Mount${binds.length===1?'':'s'} erkannt. Bind-Quellen gehören zum Host-Dateisystem und erscheinen deshalb nicht als benannte Top-Level-Volumes.</div>`:''}${other.length?`<div class="muted volume-note">${other.length} anonyme/dynamische/spezielle Mount${other.length===1?'':'s'} ohne automatische Änderung der Deklarationen beibehalten.</div>`:''}</div><div class="compose-subsection"><div class="editorlabel">Top-Level-Volumes <span class="muted">Compose-Deklarationen</span></div>${composeNamedObjectEditor(v,path,'volume',[])}</div>`}
|
||||
function wireComposeVolumeReconcile(){if(composeSection!=='volumes')return;$$('[data-vdeclare]').forEach(b=>b.onclick=()=>queueComposePatch(['volumes',b.dataset.vdeclare],{}));$('#syncVolumeDeclarations')?.addEventListener('click',()=>{const declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),names=[...new Set(composeVolumeUsages().filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];if(!names.length)return toast('Benannte Volumes sind bereits abgeglichen.');names.forEach(n=>queueComposePatch(['volumes',n],{}));toast(`${names.length} Volume-Deklaration${names.length===1?'':'en'} vorgemerkt.`)});$$('[data-vremove]').forEach(b=>b.onclick=()=>{const n=b.dataset.vremove;if(confirm(`Ungenutzte Top-Level-Volume-Deklaration ${n} entfernen? Das Docker-Volume selbst wird nicht gelöscht.`))queueComposePatch(['volumes',n],null,true)})}
|
||||
|
||||
function composeMapEditor(v,path,opt={}){if(v===null||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,'value');const entries=Object.entries(v);const serviceRoot=!!opt.serviceRoot;const suggestions=opt.rootProject?COMPOSE_TOP_FIELDS:composeSuggestions(path,opt);const listId='candidates-'+Math.abs(pathKey(path).split('').reduce((a,c)=>((a<<5)-a+c.charCodeAt(0))|0,0));return `<div class="compose-map">${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}</div><div class="compose-addrow"><input ${suggestions.length?`list="${listId}"`:''} data-cnewkey="${pathAttr(path)}" placeholder="Add field/key"><select data-cnewtype="${pathAttr(path)}"><option value="string">string</option><option value="map">object</option><option value="array">array</option><option value="boolean">boolean</option><option value="number">number</option><option value="null">null</option></select><button class="btn tiny" data-caddkey="${pathAttr(path)}">+ Field</button>${suggestions.length?`<datalist id="${listId}">${suggestions.map(x=>`<option value="${esc(x)}"></option>`).join('')}</datalist>`:''}</div>`}
|
||||
function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `<div class="compose-fieldrow ${complex?'complex':''}"><div class="compose-fieldhead"><div><code>${esc(k)}</code>${hint?`<small>${esc(hint)}</small>`:''}${k.startsWith('x-')?'<span class="tag">extension</span>':''}</div><div class="summary-actions"><span class="typepill">${t}</span><button class="iconbtn" data-cdelete="${pathAttr(path)}" title="Remove">×</button></div></div>${composeValueEditor(val,path,k,{serviceRoot:false})}</div>`}
|
||||
function composeValueEditor(v,path,label,opt={}){const t=typeOfValue(v),pa=pathAttr(path);if(t==='map')return composeMapEditor(v,path,opt);if(t==='array')return `<div class="compose-array">${v.map((x,i)=>`<div class="compose-arrayitem"><div class="arrayindex">#${i+1}<button class="iconbtn" data-cdelete="${pathAttr([...path,String(i)])}" title="Remove">×</button></div>${composeValueEditor(x,[...path,String(i)],label)}</div>`).join('')}<div class="compose-addrow"><select data-carraytype="${pa}"><option value="string">string</option><option value="map">object</option><option value="array">array</option><option value="boolean">boolean</option><option value="number">number</option><option value="null">null</option></select><button class="btn tiny" data-carrayadd="${pa}">+ Item</button></div></div>`;if(t==='boolean')return `<div class="compose-scalar"><select data-cscalar="${pa}" data-ctype="boolean"><option value="true" ${v?'selected':''}>true</option><option value="false" ${!v?'selected':''}>false</option></select>${typeSwitcher(path,t)}</div>`;if(t==='null')return `<div class="compose-scalar"><span class="nullvalue">null</span>${typeSwitcher(path,t)}</div>`;if(t==='number')return `<div class="compose-scalar"><input data-cscalar="${pa}" data-ctype="number" type="number" step="any" value="${esc(v)}">${typeSwitcher(path,t)}</div>`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `<div class="compose-scalar">${multiline?`<textarea data-cscalar="${pa}" data-ctype="string">${esc(v??'')}</textarea>`:`<input data-cscalar="${pa}" data-ctype="string" value="${esc(v??'')}">`}${typeSwitcher(path,'string')}</div>`}
|
||||
function typeSwitcher(path,t){return `<select class="typeswitch" data-ctypeswitch="${pathAttr(path)}"><option value="string" ${t==='string'?'selected':''}>string</option><option value="number" ${t==='number'?'selected':''}>number</option><option value="boolean" ${t==='boolean'?'selected':''}>boolean</option><option value="map" ${t==='map'?'selected':''}>object</option><option value="array" ${t==='array'?'selected':''}>array</option><option value="null" ${t==='null'?'selected':''}>null</option></select>`}
|
||||
function composeMapEditor(v,path,opt={}){if(v===null||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,'value');const entries=Object.entries(v);const serviceRoot=!!opt.serviceRoot;const suggestions=opt.rootProject?COMPOSE_TOP_FIELDS:composeSuggestions(path,opt);const listId='candidates-'+Math.abs(pathKey(path).split('').reduce((a,c)=>((a<<5)-a+c.charCodeAt(0))|0,0));return `<div class="compose-map">${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}</div><div class="compose-addrow"><input ${suggestions.length?`list="${listId}"`:''} data-cnewkey="${pathAttr(path)}" placeholder="Add field/key"><select data-cnewtype="${pathAttr(path)}"><option value="string">Zeichenkette</option><option value="map">Objekt</option><option value="array">Array</option><option value="boolean">Boolean</option><option value="number">Zahl</option><option value="null">Null</option></select><button class="btn tiny" data-caddkey="${pathAttr(path)}">+ Feld</button>${suggestions.length?`<datalist id="${listId}">${suggestions.map(x=>`<option value="${esc(x)}"></option>`).join('')}</datalist>`:''}</div>`}
|
||||
function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `<div class="compose-fieldrow ${complex?'complex':''}"><div class="compose-fieldhead"><div><code>${esc(k)}</code>${hint?`<small>${esc(hint)}</small>`:''}${k.startsWith('x-')?'<span class="tag">Erweiterung</span>':''}</div><div class="summary-actions"><span class="typepill">${esc(composeTypeLabel(t))}</span><button class="iconbtn" data-cdelete="${pathAttr(path)}" title="Entfernen">×</button></div></div>${composeValueEditor(val,path,k,{serviceRoot:false})}</div>`}
|
||||
function composeValueEditor(v,path,label,opt={}){const t=typeOfValue(v),pa=pathAttr(path);if(t==='map')return composeMapEditor(v,path,opt);if(t==='array')return `<div class="compose-array">${v.map((x,i)=>`<div class="compose-arrayitem"><div class="arrayindex">#${i+1}<button class="iconbtn" data-cdelete="${pathAttr([...path,String(i)])}" title="Entfernen">×</button></div>${composeValueEditor(x,[...path,String(i)],label)}</div>`).join('')}<div class="compose-addrow"><select data-carraytype="${pa}"><option value="string">Zeichenkette</option><option value="map">Objekt</option><option value="array">Array</option><option value="boolean">Boolean</option><option value="number">Zahl</option><option value="null">Null</option></select><button class="btn tiny" data-carrayadd="${pa}">+ Eintrag</button></div></div>`;if(t==='boolean')return `<div class="compose-scalar"><select data-cscalar="${pa}" data-ctype="boolean"><option value="true" ${v?'selected':''}>true</option><option value="false" ${!v?'selected':''}>false</option></select>${typeSwitcher(path,t)}</div>`;if(t==='null')return `<div class="compose-scalar"><span class="nullvalue">null</span>${typeSwitcher(path,t)}</div>`;if(t==='number')return `<div class="compose-scalar"><input data-cscalar="${pa}" data-ctype="number" type="number" step="any" value="${esc(v)}">${typeSwitcher(path,t)}</div>`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `<div class="compose-scalar">${multiline?`<textarea data-cscalar="${pa}" data-ctype="string">${esc(v??'')}</textarea>`:`<input data-cscalar="${pa}" data-ctype="string" value="${esc(v??'')}">`}${typeSwitcher(path,'string')}</div>`}
|
||||
function typeSwitcher(path,t){return `<select class="typeswitch" data-ctypeswitch="${pathAttr(path)}"><option value="string" ${t==='string'?'selected':''}>Zeichenkette</option><option value="number" ${t==='number'?'selected':''}>Zahl</option><option value="boolean" ${t==='boolean'?'selected':''}>Boolean</option><option value="map" ${t==='map'?'selected':''}>Objekt</option><option value="array" ${t==='array'?'selected':''}>Array</option><option value="null" ${t==='null'?'selected':''}>Null</option></select>`}
|
||||
function newValueForType(t){return t==='map'?{}:t==='array'?[]:t==='boolean'?false:t==='number'?0:t==='null'?null:''}
|
||||
function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v,false,{refresh:false})};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Enter a field name.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Enter a name.');queueComposePatch([...p,k],{})})}
|
||||
function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v,false,{refresh:false})};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Bitte einen Feldnamen eingeben.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Bitte einen Namen eingeben.');queueComposePatch([...p,k],{})})}
|
||||
function debounceFn(fn,ms){let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
|
||||
function composePatchPathKey(path){return JSON.stringify(path.map(String))}
|
||||
function queueComposePatch(path,value,del=false,opt={}){const item={path,value,delete:del,refresh:opt.refresh!==false},key=composePatchPathKey(path);if(!del){const i=composePatchQueue.findIndex(x=>!x.delete&&composePatchPathKey(x.path)===key);if(i>=0)composePatchQueue[i]=item;else composePatchQueue.push(item)}else composePatchQueue.push(item);void processComposePatchQueue()}
|
||||
function markComposeSynced(){const status=$('#composeSyncState');if(status){status.className='green';status.textContent=' YAML ↔ Designer synchronized'}renderComposeSections()}
|
||||
async function processComposePatchQueue(){if(composePatchBusy)return;composePatchBusy=true;try{while(composePatchQueue.length){const p=composePatchQueue.shift();try{const ta=$('#composeText');if(!ta){composePatchQueue=[];break}const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();if(p.refresh)await parseComposeVisual();else markComposeSynced()}catch(e){toast('Compose patch failed: '+e.message);composePatchQueue=[];await parseComposeVisual();break}}}finally{composePatchBusy=false}}
|
||||
function markComposeSynced(){const status=$('#composeSyncState');if(status){status.className='green';status.textContent=' YAML ↔ Designer synchronisiert'}renderComposeSections()}
|
||||
async function processComposePatchQueue(){if(composePatchBusy)return;composePatchBusy=true;try{while(composePatchQueue.length){const p=composePatchQueue.shift();try{const ta=$('#composeText');if(!ta){composePatchQueue=[];break}const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();if(p.refresh)await parseComposeVisual();else markComposeSynced()}catch(e){toast('Compose-Patch fehlgeschlagen: '+e.message);composePatchQueue=[];await parseComposeVisual();break}}}finally{composePatchBusy=false}}
|
||||
function addComposeService(){composeSection='services';queueComposePatch(['services','service'+(((composeVisualModel?.services&&Object.keys(composeVisualModel.services).length)||0)+1)],{image:'nginx:alpine'})}
|
||||
function envTab(st){return `<div class="field"><label>.env · Compose variable substitution</label><textarea id="envText" class="terminal" style="min-height:500px" spellcheck="false" placeholder="POSTGRES_TAG=16-alpine\nAPP_PORT=8080">${esc(st.env||'')}</textarea></div>`}
|
||||
function secretsTab(st){return `<div class="notice" style="margin-bottom:10px">Secret values are written with mode 0600. Use Compose <code>secrets:</code> with <code>file: ./secrets/name</code>. Existing values are visible only to operators who can edit the stack.</div><div id="secretList">${(st.secrets||[]).map(secretRow).join('')}</div><button class="btn" id="addSecret">+ Add secret file</button>`}
|
||||
function secretRow(s={}){return `<div class="secretrow"><input class="secName" placeholder="db_password" value="${esc(s.name||'')}"><input class="secContent" type="password" placeholder="secret value" value="${esc(s.content||'')}"><button class="btn danger removeSecret">Remove</button></div>`}
|
||||
function managedFilesTab(kind,files,title,help){return `<div class="notice" style="margin-bottom:10px"><b>${title}</b> · ${help} Managed files are validated together with the stack.</div><div id="${kind}List">${files.map(f=>managedFileRow(kind,f)).join('')}</div><button class="btn" id="add${kind}">+ Add file</button>`}
|
||||
function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `<div class="secretrow ${cls}"><input class="managedName" placeholder="${kind==='envfile'?'app.env':'nginx.conf'}" value="${esc(f.name||'')}"><textarea class="managedContent" placeholder="file contents">${esc(f.content||'')}</textarea><button class="btn danger removeManaged">Remove</button></div>`}
|
||||
function envTab(st){return `<div class="field"><label>.env · Compose-Variablenersetzung</label><textarea id="envText" class="terminal" style="min-height:500px" spellcheck="false" placeholder="POSTGRES_TAG=16-alpine\nAPP_PORT=8080">${esc(st.env||'')}</textarea></div>`}
|
||||
function secretsTab(st){return `<div class="notice" style="margin-bottom:10px">Secret-Werte werden mit Modus 0600 geschrieben. Verwende in Compose <code>secrets:</code> mit <code>file: ./secrets/name</code>. Vorhandene Werte sind nur für Operatoren sichtbar, die den Stack bearbeiten dürfen.</div><div id="secretList">${(st.secrets||[]).map(secretRow).join('')}</div><button class="btn" id="addSecret">+ Secret-Datei hinzufügen</button>`}
|
||||
function secretRow(s={}){return `<div class="secretrow"><input class="secName" placeholder="db_password" value="${esc(s.name||'')}"><input class="secContent" type="password" placeholder="secret value" value="${esc(s.content||'')}"><button class="btn danger removeSecret">Entfernen</button></div>`}
|
||||
function managedFilesTab(kind,files,title,help){return `<div class="notice" style="margin-bottom:10px"><b>${title}</b> · ${help} Verwaltete Dateien werden gemeinsam mit dem Stack validiert.</div><div id="${kind}List">${files.map(f=>managedFileRow(kind,f)).join('')}</div><button class="btn" id="add${kind}">+ Datei hinzufügen</button>`}
|
||||
function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `<div class="secretrow ${cls}"><input class="managedName" placeholder="${kind==='envfile'?'app.env':'nginx.conf'}" value="${esc(f.name||'')}"><textarea class="managedContent" placeholder="Dateiinhalt">${esc(f.content||'')}</textarea><button class="btn danger removeManaged">Entfernen</button></div>`}
|
||||
function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.managedName').value.trim(),content:r.querySelector('.managedContent').value})).filter(f=>f.name)}
|
||||
function formatServicePorts(raw){const text=String(raw||'').trim();if(!text)return'—';try{const p=JSON.parse(text);if(Array.isArray(p)){const items=p.map(x=>{if(!x||typeof x!=='object')return String(x??'');const proto=x.Protocol||x.protocol||'tcp',target=x.TargetPort??x.target_port??x.target,pub=x.PublishedPort??x.published_port??x.published,url=x.URL??x.url??'';if(pub!==undefined&&pub!==null&&String(pub)!==''){const host=url?(String(url).includes(':')&&!String(url).startsWith('[')?`[${url}]`:String(url)):'';return `${host?host+':':''}${pub} → ${target??'?'}${proto?'/'+proto:''}`}return target!==undefined?`${target}${proto?'/'+proto:''}`:''}).filter(Boolean);if(items.length)return items.join(' · ')}}catch{}return text}
|
||||
function svcMetaValue(value,opt={}){const full=String(value||'—'),shown=opt.ports?formatServicePorts(full):full;return `<code class="svcvalue" title="${esc(full)}">${esc(shown)}</code>`}
|
||||
function servicesTab(st){const sv=st.services||[];if(!sv.length)return'<div class="empty">This stack has no running containers yet.</div>';return `<div class="servicecards">${sv.map(v=>`<div class="servicecard"><div class="itemtop"><b>${esc(v.service||v.name)}</b>${badge(v.state||v.status)}</div><div class="svcmeta"><span class="muted">Image</span>${svcMetaValue(v.image)}<span class="muted">Ports</span>${svcMetaValue(v.ports,{ports:true})}<span class="muted">Command</span>${svcMetaValue(v.command)}</div></div>`).join('')}</div>`}
|
||||
function logsTab(){return `<div class="consolebar"><button class="btn" id="loadLogs">Load 500</button><button class="btn success" id="liveLogs">▶ Follow</button><button class="btn" id="pauseLogs">Ⅱ Pause</button><button class="btn danger" id="stopLogs">■ Stop</button><input id="logFilter" placeholder="Filter visible logs…"><span class="spacer"></span><label class="switch"><input id="logAutoScroll" type="checkbox" checked> Auto-scroll</label><button class="btn" id="downloadLogs">Download</button><span id="logState" class="logstate">idle</span></div><pre id="logBox" class="terminal">Select “Load” or “Follow”.</pre>`}
|
||||
function graphTab(){return `<div class="consolebar"><button class="btn primary" id="loadGraph">↻ Build dependency graph</button></div><div id="graphBox" class="graphbox"><div class="empty">Load the normalized Compose dependency graph.</div></div>`}
|
||||
function permissionsTab(){return `<div class="consolebar"><button class="btn primary" id="loadPermissions">Analyze bind mounts</button><span class="muted">Checks expected application UID/GID, host ownership, mode bits and ACL hints.</span></div><div id="permissionsBox"><div class="empty">Run the analysis to review writable bind mounts for every created service.</div></div>`}
|
||||
function updatesTab(){return `<div class="consolebar"><button class="btn primary" id="checkUpdates">↻ Check registry updates</button></div><div id="updateBox"><div class="empty">Compare installed image digests with registry manifests.</div></div>`}
|
||||
function consoleTab(sv){return `<div class="notice" style="margin-bottom:8px">Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.</div><div class="consolebar"><select id="execService">${sv.map(v=>`<option>${esc(v.service||v.name)}</option>`).join('')}</select><select id="execShell"><option value="sh">sh</option><option value="bash">bash</option><option value="ash">ash</option></select><button class="btn primary" id="openTerminal">Connect</button><button class="btn danger" id="closeTerminal">Disconnect</button></div><div id="execOut" class="terminal interactive" tabindex="0">Terminal disconnected.
|
||||
function servicesTab(st){const sv=st.services||[];if(!sv.length)return'<div class="empty">Für diesen Stack laufen noch keine Container.</div>';return `<div class="servicecards">${sv.map(v=>`<div class="servicecard"><div class="itemtop"><b>${esc(v.service||v.name)}</b>${badge(v.state||v.status)}</div><div class="svcmeta"><span class="muted">Image</span>${svcMetaValue(v.image)}<span class="muted">Ports</span>${svcMetaValue(v.ports,{ports:true})}<span class="muted">Befehl</span>${svcMetaValue(v.command)}</div></div>`).join('')}</div>`}
|
||||
function logsTab(){return `<div class="consolebar"><button class="btn" id="loadLogs">500 laden</button><button class="btn success" id="liveLogs">▶ Folgen</button><button class="btn" id="pauseLogs">Ⅱ Pausieren</button><button class="btn danger" id="stopLogs">■ Stoppen</button><input id="logFilter" placeholder="Sichtbare Logs filtern…"><span class="spacer"></span><label class="switch"><input id="logAutoScroll" type="checkbox" checked> Automatisch scrollen</label><button class="btn" id="downloadLogs">Herunterladen</button><span id="logState" class="logstate">inaktiv</span></div><pre id="logBox" class="terminal">„Laden“ oder „Folgen“ auswählen.</pre>`}
|
||||
function graphTab(){return `<div class="consolebar"><button class="btn primary" id="loadGraph">↻ Abhängigkeitsgraph erstellen</button></div><div id="graphBox" class="graphbox"><div class="empty">Normalisierten Compose-Abhängigkeitsgraph laden.</div></div>`}
|
||||
function permissionsTab(){return `<div class="consolebar"><button class="btn primary" id="loadPermissions">Bind-Mounts analysieren</button><span class="muted">Prüft erwartete Anwendungs-UID/GID, Host-Besitz, Modus-Bits und ACL-Hinweise.</span></div><div id="permissionsBox"><div class="empty">Analyse starten, um beschreibbare Bind-Mounts aller erzeugten Dienste zu prüfen.</div></div>`}
|
||||
function updatesTab(){return `<div class="consolebar"><button class="btn primary" id="checkUpdates">↻ Registry-Updates prüfen</button></div><div id="updateBox"><div class="empty">Installierte Image-Digests mit Registry-Manifesten vergleichen.</div></div>`}
|
||||
function consoleTab(sv){return `<div class="notice" style="margin-bottom:8px">Interaktives Docker-Exec-Terminal mit echter PTY/WebSocket-Sitzung. In das Terminal klicken und normal tippen.</div><div class="consolebar"><select id="execService">${sv.map(v=>`<option>${esc(v.service||v.name)}</option>`).join('')}</select><select id="execShell"><option value="sh">sh</option><option value="bash">bash</option><option value="ash">ash</option></select><button class="btn primary" id="openTerminal">Verbinden</button><button class="btn danger" id="closeTerminal">Trennen</button></div><div id="execOut" class="terminal interactive" tabindex="0">Terminal disconnected.
|
||||
</div>`}
|
||||
function dangerTab(st){return st.name?`<div class="notice"><b>Safe delete is the default.</b> It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.</div><div class="toolbar" style="margin-top:10px"><button class="btn danger" data-act="down">Compose down</button><button class="btn" data-act="recreate">Force recreate</button><button class="btn danger" id="deleteStack">Delete definition</button><button class="btn danger" id="purgeStack">Purge entire folder…</button></div>`:'<div class="empty">Save the stack first.</div>'}
|
||||
function dangerTab(st){return st.name?`<div class="notice"><b>Sicheres Löschen ist der Standard.</b> Es werden nur compose.yaml, .env und von Dockwatch verwaltete Secret-/Env-/Config-Ordner entfernt. Unabhängige Bind-Mount-Daten neben dem Stack bleiben erhalten.</div><div class="toolbar" style="margin-top:10px"><button class="btn danger" data-act="down">Compose herunterfahren</button><button class="btn" data-act="recreate">Neu erstellen erzwingen</button><button class="btn danger" id="deleteStack">Definition löschen</button><button class="btn danger" id="purgeStack">Gesamten Ordner endgültig löschen…</button></div>`:'<div class="empty">Speichere zuerst den Stack.</div>'}
|
||||
function wireStackDetail(){const root=$('#stackDetail');root.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x===b));['compose','env','envfiles','secrets','configs','services','permissions','graph','updates','logs','console','danger'].forEach(t=>{const e=$(`#tab-${t}`);if(e)e.hidden=t!==b.dataset.tab})});const mark=()=>{setDirty(true);saveDraft()};let composeTimer;root.addEventListener('input',e=>{if(e.target.matches('#composeText,#envText,#stackName,.secName,.secContent,.managedName,.managedContent'))mark();if(e.target.matches('#composeText')){clearTimeout(composeTimer);composeTimer=setTimeout(parseComposeVisual,220)}});$('#addComposeService')?.addEventListener('click',addComposeService);parseComposeVisual();$('#composeExpandAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=true)});$('#composeCollapseAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=false)});$('#addSecret')?.addEventListener('click',()=>{$('#secretList').insertAdjacentHTML('beforeend',secretRow());wireSecretRemovers();mark()});$('#addenvfile')?.addEventListener('click',()=>{$('#envfileList').insertAdjacentHTML('beforeend',managedFileRow('envfile'));wireManagedRemovers();mark()});$('#addconfig')?.addEventListener('click',()=>{$('#configList').insertAdjacentHTML('beforeend',managedFileRow('config'));wireManagedRemovers();mark()});wireSecretRemovers();wireManagedRemovers();$('#saveStack').onclick=saveStack;$$('[data-act]').forEach(b=>b.onclick=()=>stackAction(b.dataset.act));$('#loadLogs')?.addEventListener('click',loadLogs);$('#liveLogs')?.addEventListener('click',startLiveLogs);$('#stopLogs')?.addEventListener('click',stopLiveLogs);$('#pauseLogs')?.addEventListener('click',togglePauseLogs);$('#downloadLogs')?.addEventListener('click',downloadLogs);$('#logAutoScroll')?.addEventListener('change',e=>state.logAutoScroll=e.target.checked);$('#logFilter')?.addEventListener('input',filterLogs);$('#openTerminal')?.addEventListener('click',openTerminal);$('#closeTerminal')?.addEventListener('click',closeTerminal);$('#loadGraph')?.addEventListener('click',loadGraph);$('#loadPermissions')?.addEventListener('click',loadStackPermissions);$('#checkUpdates')?.addEventListener('click',loadImageUpdates);$('#deleteStack')?.addEventListener('click',()=>deleteStack(false));$('#purgeStack')?.addEventListener('click',()=>deleteStack(true));$('#restoreDraft')?.addEventListener('click',restoreDraft);$('#discardDraft')?.addEventListener('click',()=>{clearDraft(state.stack.name);renderStacks()})}
|
||||
function wireSecretRemovers(){$$('.removeSecret').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})}
|
||||
function wireManagedRemovers(){$$('.removeManaged').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})}
|
||||
function restoreDraft(){try{const d=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('Draft could not be restored.')}}
|
||||
async function saveStack(){const name=$('#stackName')?.value.trim();if(!name)return toast('Stack name is required.');const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value.trim(),content:r.querySelector('.secContent').value})).filter(s=>s.name);const env_files=collectManaged('.envfilerow'),configs=collectManaged('.configrow');try{await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`,{method:'PUT',body:JSON.stringify({compose:$('#composeText').value,env:$('#envText')?.value||state.stack.env||'',secrets,env_files,configs})});clearDraft(name);setDirty(false);toast('Stack saved and Compose validated.');state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);await refreshData(false);renderStacks()}catch(e){toast(e.message)}}
|
||||
function restoreDraft(){try{const d=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('Entwurf konnte nicht wiederhergestellt werden.')}}
|
||||
async function saveStack(){const name=$('#stackName')?.value.trim();if(!name)return toast('Stack-Name ist erforderlich.');const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value.trim(),content:r.querySelector('.secContent').value})).filter(s=>s.name);const env_files=collectManaged('.envfilerow'),configs=collectManaged('.configrow');try{await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`,{method:'PUT',body:JSON.stringify({compose:$('#composeText').value,env:$('#envText')?.value||state.stack.env||'',secrets,env_files,configs})});clearDraft(name);setDirty(false);toast('Stack gespeichert und Compose validiert.');state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);await refreshData(false);renderStacks()}catch(e){toast(e.message)}}
|
||||
async function stackAction(action){if(!state.stack?.name)return;try{const r=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/actions/${action}${qnode()}`,{method:'POST'});const out=$('#actionOut');out.style.display='block';out.textContent=r.output||'OK';toast(`${action} completed`);await refreshData(false);state.stack=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}${qnode()}`);renderStacks()}catch(e){toast(e.message)}}
|
||||
async function deleteStack(purge=false){const name=state.stack?.name;if(!name)return;if(purge){const typed=prompt(`FULL PURGE deletes the entire stack folder, including unrelated files or bind-mount data.\n\nType ${name} to continue:`);if(typed!==name)return}else if(!confirm(`Delete the Dockwatch-managed definition for ${name}? Containers are not automatically removed and unrelated files are preserved.`))return;try{await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}${state.node?'&':'?'}purge=${purge?'true':'false'}`,{method:'DELETE'});state.stack=null;setDirty(false);clearDraft(name);await refreshData(true);renderStacks();toast(purge?'Stack folder purged.':'Stack definition deleted safely.')}catch(e){toast(e.message)}}
|
||||
async function deleteStack(purge=false){const name=state.stack?.name;if(!name)return;if(purge){const typed=prompt(`VOLLSTÄNDIGES LÖSCHEN entfernt den gesamten Stack-Ordner einschließlich unabhängiger Dateien und Bind-Mount-Daten.\n\nGib ${name} ein, um fortzufahren:`);if(typed!==name)return}else if(!confirm(`Die von Dockwatch verwaltete Definition für ${name} löschen? Container werden nicht automatisch entfernt; unabhängige Dateien bleiben erhalten.`))return;try{await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}${state.node?'&':'?'}purge=${purge?'true':'false'}`,{method:'DELETE'});state.stack=null;setDirty(false);clearDraft(name);await refreshData(true);renderStacks();toast(purge?'Stack-Ordner vollständig gelöscht.':'Stack-Definition sicher gelöscht.')}catch(e){toast(e.message)}}
|
||||
async function loadLogs(){try{const r=await api(joinQ(`/api/stacks/${encodeURIComponent(state.stack.name)}/logs${qnode()}`,'tail=500'));state.logBuffer=r.output||'';state.logPaused=false;renderLogBuffer();setLogState('loaded')}catch(e){toast(e.message)}}
|
||||
function startLiveLogs(){stopLiveLogs();const url=joinQ(`/api/stacks/${encodeURIComponent(state.stack.name)}/logs${qnode()}`,'tail=200&live=true');state.logBuffer='';state.logPaused=false;state.logStream=new EventSource(url);setLogState('live');state.logStream.onmessage=e=>{let line;try{line=JSON.parse(e.data)}catch{line=e.data}state.logBuffer+=(line+'\n');if(state.logBuffer.length>1048576)state.logBuffer=state.logBuffer.slice(-1048576);if(!state.logPaused)renderLogBuffer()};state.logStream.onerror=()=>setLogState('reconnecting')}
|
||||
function stopLiveLogs(){state.logStream?.close();state.logStream=null;setLogState('stopped')}
|
||||
function filterLogs(){renderLogBuffer()}
|
||||
function renderLogBuffer(){const b=$('#logBox');if(!b)return;const q=$('#logFilter')?.value.toLowerCase()||'',raw=state.logBuffer||'';b.textContent=q?raw.split('\n').filter(l=>l.toLowerCase().includes(q)).join('\n'):raw;if(state.logAutoScroll)b.scrollTop=b.scrollHeight}
|
||||
function setLogState(v){const e=$('#logState');if(!e)return;e.textContent=v;e.className='logstate '+(v==='live'?'live':v==='reconnecting'?'reconnecting':v==='paused'?'paused':'')}
|
||||
function togglePauseLogs(){state.logPaused=!state.logPaused;const b=$('#pauseLogs');if(b)b.textContent=state.logPaused?'▶ Resume':'Ⅱ Pause';setLogState(state.logPaused?'paused':(state.logStream?'live':'loaded'));if(!state.logPaused)renderLogBuffer()}
|
||||
function togglePauseLogs(){state.logPaused=!state.logPaused;const b=$('#pauseLogs');if(b)b.textContent=state.logPaused?'▶ Fortsetzen':'Ⅱ Pausieren';setLogState(state.logPaused?'paused':(state.logStream?'live':'loaded'));if(!state.logPaused)renderLogBuffer()}
|
||||
function downloadLogs(){const blob=new Blob([state.logBuffer||''],{type:'text/plain;charset=utf-8'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${state.stack?.name||'dockwatch'}-${new Date().toISOString().replace(/[:.]/g,'-')}.log`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)}
|
||||
async function runExec(){const service=$('#execService').value,command=$('#execCmd').value;if(!service||!command)return;$('#execOut').textContent=`$ ${command}\n`;try{const r=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/exec${qnode()}`,{method:'POST',body:JSON.stringify({service,command})});$('#execOut').textContent+=r.output||''}catch(e){$('#execOut').textContent+=`ERROR: ${e.message}`}}
|
||||
async function runExec(){const service=$('#execService').value,command=$('#execCmd').value;if(!service||!command)return;$('#execOut').textContent=`$ ${command}\n`;try{const r=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/exec${qnode()}`,{method:'POST',body:JSON.stringify({service,command})});$('#execOut').textContent+=r.output||''}catch(e){$('#execOut').textContent+=`FEHLER: ${uiMessage(e.message)}`}}
|
||||
|
||||
function renderMonitors(){setCrumb('Observability / Probes');const actions=roleOK()?'<button class="btn primary" id="newMonitor">+ New monitor</button>':'';$('#content').innerHTML=`${pageHead('Probes','Uptime, latency, Docker state and maintenance across your environments.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="monSearch" placeholder="Filter monitors…"><button class="btn tiny" id="monRefresh">↻</button></div><div id="monList">${monitorListHTML(state.monitors)}</div></div><div id="monDetail" class="detail">${state.monitor?monitorDetailHTML(state.monitor,state.checks):'<div class="empty"><div class="big">♡</div>Select a monitor to inspect uptime and latency.</div>'}</div></div>`;$('#newMonitor')?.addEventListener('click',()=>monitorModal());$('#monSearch').oninput=e=>$('#monList').innerHTML=monitorListHTML(state.monitors.filter(m=>(m.name+' '+m.target).toLowerCase().includes(e.target.value.toLowerCase())));$('#monRefresh').onclick=()=>refreshData(true).then(renderMonitors);wireMonitorList();if(state.monitor)wireMonitorDetail()}
|
||||
function monitorListHTML(ms){return ms.map(m=>`<div class="monitoritem ${state.monitor?.id===m.id?'active':''}" data-mon="${m.id}"><div class="itemtop"><b>${esc(m.name)}</b>${badge(m.status)}</div><div class="itemsub">${esc(m.type.toUpperCase())} · ${esc(m.target)}</div><div class="hb">${heartbeatBars([],m.status,24)}</div></div>`).join('')||'<div class="empty">No monitors</div>'}
|
||||
function renderMonitors(){setCrumb('Überwachung / Monitore');const actions=roleOK()?'<button class="btn primary" id="newMonitor">+ Neuer Monitor</button>':'';$('#content').innerHTML=`${pageHead('Monitore','Uptime, Latenz, Docker-Status und Wartung über alle Umgebungen hinweg.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="monSearch" placeholder="Monitore filtern…"><button class="btn tiny" id="monRefresh">↻</button></div><div id="monList">${monitorListHTML(state.monitors)}</div></div><div id="monDetail" class="detail">${state.monitor?monitorDetailHTML(state.monitor,state.checks):'<div class="empty"><div class="big">♡</div>Wähle einen Monitor aus, um Uptime und Latenz anzuzeigen.</div>'}</div></div>`;$('#newMonitor')?.addEventListener('click',()=>monitorModal());$('#monSearch').oninput=e=>$('#monList').innerHTML=monitorListHTML(state.monitors.filter(m=>(m.name+' '+m.target).toLowerCase().includes(e.target.value.toLowerCase())));$('#monRefresh').onclick=()=>refreshData(true).then(renderMonitors);wireMonitorList();if(state.monitor)wireMonitorDetail()}
|
||||
function monitorListHTML(ms){return ms.map(m=>`<div class="monitoritem ${state.monitor?.id===m.id?'active':''}" data-mon="${m.id}"><div class="itemtop"><b>${esc(m.name)}</b>${badge(m.status)}</div><div class="itemsub">${esc(m.type.toUpperCase())} · ${esc(m.target)}</div><div class="hb">${heartbeatBars([],m.status,24)}</div></div>`).join('')||'<div class="empty">Keine Monitore</div>'}
|
||||
function wireMonitorList(){$$('[data-mon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.mon)))}
|
||||
async function openMonitor(id){try{const [m,c]=await Promise.all([api(`/api/monitors/${id}`),api(`/api/monitors/${id}/checks?limit=80`)]);state.monitor=m;state.checks=Array.isArray(c)?c:[];state.view='monitors';renderMonitors()}catch(e){toast(e.message)}}
|
||||
function heartbeatBars(checks,status,n=48){if(!checks?.length)return Array.from({length:n},()=>`<span class="${status==='up'?'up':status==='down'?'down':status==='maintenance'?'maintenance':'paused'}"></span>`).join('');const a=[...checks].reverse().slice(-n);return Array.from({length:n-a.length},()=>'<span></span>').join('')+a.map(c=>`<span class="${c.ok?'up':'down'}" title="${esc(fmtTime(c.checked_at))} · ${c.latency_ms}ms"></span>`).join('')}
|
||||
function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'<div class="empty">No heartbeat data yet.</div>';const max=Math.max(1,...a.map(x=>x.latency_ms)),pts=a.map((x,i)=>`${(i/(Math.max(1,a.length-1))*100).toFixed(2)},${(95-(x.latency_ms/max)*80).toFixed(2)}`).join(' ');return `<svg viewBox="0 0 100 100" preserveAspectRatio="none"><polyline points="${pts}" fill="none" stroke="#7c6cf7" stroke-width="1.3" vector-effect="non-scaling-stroke"/><line x1="0" x2="100" y1="95" y2="95" stroke="#273042" stroke-width=".5"/></svg>`}
|
||||
function monitorDetailHTML(m,c){const avg=c?.length?Math.round(c.reduce((a,x)=>a+x.latency_ms,0)/c.length):0,ok=c?.filter(x=>x.ok).length||0,ratio=c?.length?ok/c.length*100:0;return `<div class="detailhead"><div class="detailtitle"><span class="cube">♡</span><div><h2>${esc(m.name)}</h2><small>${esc(m.target)}</small></div>${badge(m.status)}</div><div class="actions">${roleOK()?`<button class="btn primary" id="checkMon">↻ Check now</button><button class="btn" id="editMon">Edit</button>${m.status==='paused'?'<button class="btn success" id="resumeMon">Resume</button>':'<button class="btn" id="pauseMon">Pause</button>'}${m.status==='maintenance'?'<button class="btn" id="clearMaint">End maintenance</button>':'<button class="btn" id="maintMon">Maintenance</button>'}`:''}</div></div><div class="monitorhero"><div><span class="muted">24 hour uptime</span><div class="uptimebig ${m.status==='down'?'red':'green'}">${Number(m.uptime_24h||0).toFixed(3)}%</div></div><div>${badge(m.status)}</div></div><div class="tabbody"><div class="hb">${heartbeatBars(c,m.status,64)}</div><div class="monitorstats"><div class="mini"><small>Last heartbeat</small><b>${fmtAgo(m.last_checked_at)}</b></div><div class="mini"><small>Last latency</small><b>${m.last_latency_ms||0} ms</b></div><div class="mini"><small>Average latency</small><b>${avg} ms</b></div><div class="mini"><small>Recent success</small><b>${ratio.toFixed(1)}%</b></div><div class="mini"><small>Interval</small><b>${m.interval_seconds}s</b></div></div>${m.last_message?`<div class="notice ${m.status==='down'?'red':''}" style="margin-bottom:10px"><b>Last result:</b> ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}</div>`:''}<div class="panel"><div class="panelhead"><h2>Response time</h2><span class="muted">last ${c.length} heartbeats</span></div><div class="tabbody"><div class="chart">${latencyChart(c)}</div></div></div>${m.status==='maintenance'?`<div class="notice" style="margin-top:10px">Maintenance active${m.maintenance_until?` until ${esc(fmtTime(m.maintenance_until))}`:' until manually ended'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.</div>`:''}<div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Configuration</h2></div><table class="table"><tbody><tr><td class="muted">Type</td><td>${esc(m.type.toUpperCase())}</td><td class="muted">Method</td><td>${esc(m.method||'GET')}</td></tr><tr><td class="muted">Expected status</td><td>${m.expected_min}–${m.expected_max}</td><td class="muted">Timeout</td><td>${m.timeout_ms} ms</td></tr><tr><td class="muted">Keyword</td><td>${esc(m.keyword||'—')}</td><td class="muted">Probe environment</td><td>${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / local'}</td></tr></tbody></table></div>${roleOK()?'<div class="toolbar" style="margin-top:10px"><button class="btn danger" id="deleteMon">Delete monitor</button></div>':''}</div>`}
|
||||
function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'<div class="empty">Noch keine Heartbeat-Daten vorhanden.</div>';const max=Math.max(1,...a.map(x=>x.latency_ms)),pts=a.map((x,i)=>`${(i/(Math.max(1,a.length-1))*100).toFixed(2)},${(95-(x.latency_ms/max)*80).toFixed(2)}`).join(' ');return `<svg viewBox="0 0 100 100" preserveAspectRatio="none"><polyline points="${pts}" fill="none" stroke="#7c6cf7" stroke-width="1.3" vector-effect="non-scaling-stroke"/><line x1="0" x2="100" y1="95" y2="95" stroke="#273042" stroke-width=".5"/></svg>`}
|
||||
function monitorDetailHTML(m,c){const avg=c?.length?Math.round(c.reduce((a,x)=>a+x.latency_ms,0)/c.length):0,ok=c?.filter(x=>x.ok).length||0,ratio=c?.length?ok/c.length*100:0;return `<div class="detailhead"><div class="detailtitle"><span class="cube">♡</span><div><h2>${esc(m.name)}</h2><small>${esc(m.target)}</small></div>${badge(m.status)}</div><div class="actions">${roleOK()?`<button class="btn primary" id="checkMon">↻ Jetzt prüfen</button><button class="btn" id="editMon">Bearbeiten</button>${m.status==='paused'?'<button class="btn success" id="resumeMon">Fortsetzen</button>':'<button class="btn" id="pauseMon">Pausieren</button>'}${m.status==='maintenance'?'<button class="btn" id="clearMaint">Wartung beenden</button>':'<button class="btn" id="maintMon">Wartung</button>'}`:''}</div></div><div class="monitorhero"><div><span class="muted">Uptime 24 h</span><div class="uptimebig ${m.status==='down'?'red':'green'}">${Number(m.uptime_24h||0).toFixed(3)}%</div></div><div>${badge(m.status)}</div></div><div class="tabbody"><div class="hb">${heartbeatBars(c,m.status,64)}</div><div class="monitorstats"><div class="mini"><small>Letzter Heartbeat</small><b>${fmtAgo(m.last_checked_at)}</b></div><div class="mini"><small>Letzte Latenz</small><b>${m.last_latency_ms||0} ms</b></div><div class="mini"><small>Durchschnittliche Latenz</small><b>${avg} ms</b></div><div class="mini"><small>Letzte Erfolge</small><b>${ratio.toFixed(1)}%</b></div><div class="mini"><small>Intervall</small><b>${m.interval_seconds}s</b></div></div>${m.last_message?`<div class="notice ${m.status==='down'?'red':''}" style="margin-bottom:10px"><b>Letztes Ergebnis:</b> ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}</div>`:''}<div class="panel"><div class="panelhead"><h2>Antwortzeit</h2><span class="muted">letzte ${c.length} Heartbeats</span></div><div class="tabbody"><div class="chart">${latencyChart(c)}</div></div></div>${m.status==='maintenance'?`<div class="notice" style="margin-top:10px">Wartung aktiv${m.maintenance_until?` bis ${esc(fmtTime(m.maintenance_until))}`:' bis zur manuellen Beendigung'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.</div>`:''}<div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Konfiguration</h2></div><table class="table"><tbody><tr><td class="muted">Typ</td><td>${esc(m.type.toUpperCase())}</td><td class="muted">Methode</td><td>${esc(m.method||'GET')}</td></tr><tr><td class="muted">Erwarteter Status</td><td>${m.expected_min}–${m.expected_max}</td><td class="muted">Timeout</td><td>${m.timeout_ms} ms</td></tr><tr><td class="muted">Schlüsselwort</td><td>${esc(m.keyword||'—')}</td><td class="muted">Prüfumgebung</td><td>${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / lokal'}</td></tr></tbody></table></div>${roleOK()?'<div class="toolbar" style="margin-top:10px"><button class="btn danger" id="deleteMon">Monitor löschen</button></div>':''}</div>`}
|
||||
function wireMonitorDetail(){$('#checkMon')?.addEventListener('click',checkMonitorNow);$('#editMon')?.addEventListener('click',()=>monitorModal(state.monitor));$('#pauseMon')?.addEventListener('click',()=>monitorAction('pause'));$('#resumeMon')?.addEventListener('click',()=>monitorAction('resume'));$('#maintMon')?.addEventListener('click',()=>maintenanceModal(state.monitor));$('#clearMaint')?.addEventListener('click',()=>clearMaintenance(state.monitor.id));$('#deleteMon')?.addEventListener('click',deleteMonitor)}
|
||||
async function checkMonitorNow(){const b=$('#checkMon');setBusy(b,true,'Checking');try{const c=await api(`/api/monitors/${state.monitor.id}/check`,{method:'POST'});toast(c.ok?`Check OK · ${c.latency_ms} ms`:`Check failed · ${c.message}`);await refreshData(false);await openMonitor(state.monitor.id)}catch(e){toast(e.message)}finally{setBusy(b,false)}}
|
||||
function monitorModal(m=null){const isEdit=!!m;modal(`<div class="modalhead"><h2>${isEdit?'Edit monitor':'New monitor'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="mfName" value="${esc(m?.name||'')}"></div><div class="field"><label>Monitor type</label><select id="mfType"><option value="http">HTTP(S)</option><option value="tcp">TCP port</option><option value="dns">DNS hostname</option><option value="docker">Docker container</option></select></div><div class="field full"><label>URL / target / container name or ID</label><input id="mfTarget" list="dockerTargets" value="${esc(m?.target||'')}" placeholder="https://example.com, host:port or container-name"><datalist id="dockerTargets"></datalist></div><div class="field"><label>Service group</label><select id="mfService"><option value="">Ungrouped</option>${state.services.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label>Probe environment</label><select id="mfNode"><option value="">Master / local</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}</select></div><div class="field"><label>Interval (seconds)</label><input id="mfInterval" type="number" min="10" max="86400" step="1" value="${m?.interval_seconds||60}"></div><div class="field"><label>Timeout (ms)</label><input id="mfTimeout" type="number" value="${m?.timeout_ms||5000}"></div><div class="field"><label>HTTP method</label><select id="mfMethod">${['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].map(v=>`<option>${v}</option>`).join('')}</select></div><div class="field"><label>Expected HTTP status</label><div style="display:flex;gap:5px"><input id="mfMin" type="number" value="${m?.expected_min||200}" style="width:50%"><input id="mfMax" type="number" value="${m?.expected_max||399}" style="width:50%"></div></div><div class="field full"><label>Keyword assertion (optional)</label><input id="mfKeyword" value="${esc(m?.keyword||'')}" placeholder="Text that must occur in the response"></div><div class="field full"><label>Request headers as JSON</label><textarea id="mfHeaders" spellcheck="false">${esc(m?.headers_json||'{}')}</textarea></div><div class="field full"><label>Request body</label><textarea id="mfBody" spellcheck="false">${esc(m?.body||'')}</textarea></div><label class="switch"><input id="mfInvert" type="checkbox" ${m?.invert_keyword?'checked':''}> Invert keyword match</label><label class="switch"><input id="mfTLS" type="checkbox" ${m?.ignore_tls?'checked':''}> Ignore TLS certificate errors</label><label class="switch"><input id="mfHealthy" type="checkbox" ${m?.require_healthy?'checked':''}> Docker: require Healthy condition</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveMonitor">${isEdit?'Save changes':'Create monitor'}</button></div>`);$('#mfType').value=m?.type||'http';$('#mfNode').value=m?.node_id||'';$('#mfService').value=m?.service_id||'';$('#mfInterval').value=String(m?.interval_seconds||60);$('#mfMethod').value=m?.method||'GET';const syncFields=()=>{const t=$('#mfType').value,isHTTP=t==='http',isDocker=t==='docker';['mfMethod','mfMin','mfMax','mfKeyword','mfHeaders','mfBody'].forEach(id=>{const e=$('#'+id);if(e?.closest('.field'))e.closest('.field').style.display=isHTTP?'':'none'});['mfInvert','mfTLS'].forEach(id=>{const e=$('#'+id);if(e?.closest('.switch'))e.closest('.switch').style.display=isHTTP?'':'none'});const he=$('#mfHealthy');if(he?.closest('.switch'))he.closest('.switch').style.display=isDocker?'':'none';const target=$('#mfTarget');if(target)target.placeholder=isHTTP?'https://example.com':t==='tcp'?'host:port':t==='dns'?'example.com':'container-name-or-id'};const loadDockerTargets=async()=>{syncFields();if($('#mfType').value!=='docker')return;try{const nid=$('#mfNode').value;const rows=await api('/api/docker/containers'+(nid?`?node_id=${nid}`:''));$('#dockerTargets').innerHTML=asArray(rows).map(x=>`<option value="${esc(x.Names||x.Name||x.ID||x.name||x.id||'')}"></option>`).join('')}catch{}};$('#mfType').onchange=loadDockerTargets;$('#mfNode').onchange=loadDockerTargets;loadDockerTargets();$('#saveMonitor').onclick=()=>saveMonitor(m?.id,m?.enabled??true)}
|
||||
async function saveMonitor(id,currentEnabled=true){const isEdit=id!==undefined&&id!==null;const enabled=isEdit?Boolean(currentEnabled):true;const body={name:$('#mfName').value,type:$('#mfType').value,target:$('#mfTarget').value,node_id:$('#mfNode').value?Number($('#mfNode').value):null,service_id:$('#mfService').value?Number($('#mfService').value):null,interval_seconds:Number($('#mfInterval').value),timeout_ms:Number($('#mfTimeout').value),expected_min:Number($('#mfMin').value),expected_max:Number($('#mfMax').value),method:$('#mfMethod').value,headers_json:$('#mfHeaders').value||'{}',body:$('#mfBody').value,keyword:$('#mfKeyword').value,invert_keyword:$('#mfInvert').checked,ignore_tls:$('#mfTLS').checked,require_healthy:$('#mfHealthy').checked,enabled};try{const m=await api(id?`/api/monitors/${id}`:'/api/monitors',{method:id?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);state.monitor=m;state.checks=id?await api(`/api/monitors/${id}/checks?limit=80`):[];renderMonitors();toast(id?'Monitor updated.':'Monitor created.')}catch(e){toast(e.message)}}
|
||||
async function monitorAction(a){try{await api(`/api/monitors/${state.monitor.id}/${a}`,{method:'POST'});await openMonitor(state.monitor.id);toast(a==='pause'?'Monitor paused.':'Monitor resumed.')}catch(e){toast(e.message)}}
|
||||
function maintenanceModal(m){modal(`<div class="modalhead"><h2>Maintenance · ${esc(m.name)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice" style="margin-bottom:10px">Checks are suppressed during maintenance and the monitor is shown as maintenance instead of down.</div><div class="fieldgrid"><div class="field"><label>End mode</label><select id="maintMode"><option value="manual">Until manually ended</option><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="24h">24 hours</option><option value="custom">Custom date/time</option></select></div><div class="field"><label>Custom end</label><input id="maintUntil" type="datetime-local"></div><div class="field full"><label>Note</label><input id="maintNote" placeholder="Planned deployment / provider maintenance"></div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="startMaint">Start maintenance</button></div>`);$('#startMaint').onclick=()=>startMaintenance(m.id)}
|
||||
async function startMaintenance(id){const mode=$('#maintMode').value;let until=null;if(mode==='custom'){const v=$('#maintUntil').value;if(v)until=Math.floor(new Date(v).getTime()/1000)}else if(mode!=='manual'){until=Math.floor(Date.now()/1000)+Number(mode.replace('h',''))*3600}try{await api(`/api/monitors/${id}/maintenance`,{method:'POST',body:JSON.stringify({until,note:$('#maintNote').value})});closeModal();await refreshData(true);await openMonitor(id);toast('Maintenance started.')}catch(e){toast(e.message)}}
|
||||
async function clearMaintenance(id){await api(`/api/monitors/${id}/maintenance`,{method:'DELETE'});await refreshData(true);await openMonitor(id);toast('Maintenance ended.')}
|
||||
async function checkMonitorNow(){const b=$('#checkMon');setBusy(b,true,'Prüfe…');try{const c=await api(`/api/monitors/${state.monitor.id}/check`,{method:'POST'});toast(c.ok?`Prüfung erfolgreich · ${c.latency_ms} ms`:`Prüfung fehlgeschlagen · ${c.message}`);await refreshData(false);await openMonitor(state.monitor.id)}catch(e){toast(e.message)}finally{setBusy(b,false)}}
|
||||
function monitorModal(m=null){const isEdit=!!m;modal(`<div class="modalhead"><h2>${isEdit?'Monitor bearbeiten':'Neuer Monitor'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="mfName" value="${esc(m?.name||'')}"></div><div class="field"><label>Monitor-Typ</label><select id="mfType"><option value="http">HTTP(S)</option><option value="tcp">TCP-Port</option><option value="dns">DNS-Hostname</option><option value="docker">Docker-Container</option></select></div><div class="field full"><label>URL / Ziel / Containername oder ID</label><input id="mfTarget" list="dockerTargets" value="${esc(m?.target||'')}" placeholder="https://beispiel.de, host:port oder container-name"><datalist id="dockerTargets"></datalist></div><div class="field"><label>Dienstgruppe</label><select id="mfService"><option value="">Nicht gruppiert</option>${state.services.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label>Prüfumgebung</label><select id="mfNode"><option value="">Master / lokal</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · deaktiviert'}</option>`).join('')}</select></div><div class="field"><label>Intervall (Sekunden)</label><input id="mfInterval" type="number" min="10" max="86400" step="1" value="${m?.interval_seconds||60}"></div><div class="field"><label>Timeout (ms)</label><input id="mfTimeout" type="number" value="${m?.timeout_ms||5000}"></div><div class="field"><label>HTTP-Methode</label><select id="mfMethod">${['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].map(v=>`<option>${v}</option>`).join('')}</select></div><div class="field"><label>Erwarteter HTTP-Status</label><div style="display:flex;gap:5px"><input id="mfMin" type="number" value="${m?.expected_min||200}" style="width:50%"><input id="mfMax" type="number" value="${m?.expected_max||399}" style="width:50%"></div></div><div class="field full"><label>Schlüsselwortprüfung (optional)</label><input id="mfKeyword" value="${esc(m?.keyword||'')}" placeholder="Text, der in der Antwort vorkommen muss"></div><div class="field full"><label>Anfrage-Header als JSON</label><textarea id="mfHeaders" spellcheck="false">${esc(m?.headers_json||'{}')}</textarea></div><div class="field full"><label>Anfrage-Body</label><textarea id="mfBody" spellcheck="false">${esc(m?.body||'')}</textarea></div><label class="switch"><input id="mfInvert" type="checkbox" ${m?.invert_keyword?'checked':''}> Schlüsselwortprüfung umkehren</label><label class="switch"><input id="mfTLS" type="checkbox" ${m?.ignore_tls?'checked':''}> TLS-Zertifikatsfehler ignorieren</label><label class="switch"><input id="mfHealthy" type="checkbox" ${m?.require_healthy?'checked':''}> Docker: Healthy-Status erforderlich</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="saveMonitor">${isEdit?'Änderungen speichern':'Monitor anlegen'}</button></div>`);$('#mfType').value=m?.type||'http';$('#mfNode').value=m?.node_id||'';$('#mfService').value=m?.service_id||'';$('#mfInterval').value=String(m?.interval_seconds||60);$('#mfMethod').value=m?.method||'GET';const syncFields=()=>{const t=$('#mfType').value,isHTTP=t==='http',isDocker=t==='docker';['mfMethod','mfMin','mfMax','mfKeyword','mfHeaders','mfBody'].forEach(id=>{const e=$('#'+id);if(e?.closest('.field'))e.closest('.field').style.display=isHTTP?'':'none'});['mfInvert','mfTLS'].forEach(id=>{const e=$('#'+id);if(e?.closest('.switch'))e.closest('.switch').style.display=isHTTP?'':'none'});const he=$('#mfHealthy');if(he?.closest('.switch'))he.closest('.switch').style.display=isDocker?'':'none';const target=$('#mfTarget');if(target)target.placeholder=isHTTP?'https://beispiel.de':t==='tcp'?'host:port':t==='dns'?'beispiel.de':'container-name-oder-id'};const loadDockerTargets=async()=>{syncFields();if($('#mfType').value!=='docker')return;try{const nid=$('#mfNode').value;const rows=await api('/api/docker/containers'+(nid?`?node_id=${nid}`:''));$('#dockerTargets').innerHTML=asArray(rows).map(x=>`<option value="${esc(x.Names||x.Name||x.ID||x.name||x.id||'')}"></option>`).join('')}catch{}};$('#mfType').onchange=loadDockerTargets;$('#mfNode').onchange=loadDockerTargets;loadDockerTargets();$('#saveMonitor').onclick=()=>saveMonitor(m?.id,m?.enabled??true)}
|
||||
async function saveMonitor(id,currentEnabled=true){const isEdit=id!==undefined&&id!==null;const enabled=isEdit?Boolean(currentEnabled):true;const body={name:$('#mfName').value,type:$('#mfType').value,target:$('#mfTarget').value,node_id:$('#mfNode').value?Number($('#mfNode').value):null,service_id:$('#mfService').value?Number($('#mfService').value):null,interval_seconds:Number($('#mfInterval').value),timeout_ms:Number($('#mfTimeout').value),expected_min:Number($('#mfMin').value),expected_max:Number($('#mfMax').value),method:$('#mfMethod').value,headers_json:$('#mfHeaders').value||'{}',body:$('#mfBody').value,keyword:$('#mfKeyword').value,invert_keyword:$('#mfInvert').checked,ignore_tls:$('#mfTLS').checked,require_healthy:$('#mfHealthy').checked,enabled};try{const m=await api(id?`/api/monitors/${id}`:'/api/monitors',{method:id?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);state.monitor=m;state.checks=id?await api(`/api/monitors/${id}/checks?limit=80`):[];renderMonitors();toast(id?'Monitor aktualisiert.':'Monitor angelegt.')}catch(e){toast(e.message)}}
|
||||
async function monitorAction(a){try{await api(`/api/monitors/${state.monitor.id}/${a}`,{method:'POST'});await openMonitor(state.monitor.id);toast(a==='pause'?'Monitor pausiert.':'Monitor fortgesetzt.')}catch(e){toast(e.message)}}
|
||||
function maintenanceModal(m){modal(`<div class="modalhead"><h2>Maintenance · ${esc(m.name)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice" style="margin-bottom:10px">Während der Wartung werden Prüfalarme unterdrückt; der Monitor wird als Wartung statt als ausgefallen angezeigt.</div><div class="fieldgrid"><div class="field"><label>Endmodus</label><select id="maintMode"><option value="manual">Bis zur manuellen Beendigung</option><option value="1h">1 Stunde</option><option value="4h">4 Stunden</option><option value="24h">24 Stunden</option><option value="custom">Benutzerdefiniertes Datum/Uhrzeit</option></select></div><div class="field"><label>Benutzerdefiniertes Ende</label><input id="maintUntil" type="datetime-local"></div><div class="field full"><label>Hinweis</label><input id="maintNote" placeholder="Planned deployment / provider maintenance"></div></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="startMaint">Wartung starten</button></div>`);$('#startMaint').onclick=()=>startMaintenance(m.id)}
|
||||
async function startMaintenance(id){const mode=$('#maintMode').value;let until=null;if(mode==='custom'){const v=$('#maintUntil').value;if(v)until=Math.floor(new Date(v).getTime()/1000)}else if(mode!=='manual'){until=Math.floor(Date.now()/1000)+Number(mode.replace('h',''))*3600}try{await api(`/api/monitors/${id}/maintenance`,{method:'POST',body:JSON.stringify({until,note:$('#maintNote').value})});closeModal();await refreshData(true);await openMonitor(id);toast('Wartung gestartet.')}catch(e){toast(e.message)}}
|
||||
async function clearMaintenance(id){await api(`/api/monitors/${id}/maintenance`,{method:'DELETE'});await refreshData(true);await openMonitor(id);toast('Wartung beendet.')}
|
||||
async function deleteMonitor(){if(!confirm(`Delete monitor ${state.monitor.name} and its heartbeat history?`))return;await api(`/api/monitors/${state.monitor.id}`,{method:'DELETE'});state.monitor=null;state.checks=[];await refreshData(true);renderMonitors()}
|
||||
|
||||
async function renderServices(){setCrumb('Observability / Services');const actions=roleOK()?'<button class="btn primary" id="newService">+ New service</button>':'';$('#content').innerHTML=`${pageHead('Services','Group probes into user-facing services. One failed probe makes the whole service fail.',actions)}<div class="panel" id="servicePanel">${state.services.length?`<table class="table"><thead><tr><th>Service</th><th>Status</th><th>Probes</th><th></th></tr></thead><tbody>${state.services.map(g=>`<tr><td><b>${esc(g.name)}</b><div class="muted">${esc(g.description||'')}</div></td><td>${badge(g.status)}</td><td>${(g.monitors||[]).length}<div class="muted">${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'No probes assigned'}</div></td><td class="right">${roleOK()?`<button class="btn tiny" data-sedit="${g.id}">Edit</button> <button class="btn tiny danger" data-sdel="${g.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No services configured. Create a service and assign probes to it.</div>'}</div>`;$('#newService')?.addEventListener('click',()=>serviceModal());$$('[data-sedit]').forEach(b=>b.onclick=()=>serviceModal(state.services.find(x=>x.id===Number(b.dataset.sedit))));$$('[data-sdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete service? Probes become ungrouped.')){await api('/api/services/'+b.dataset.sdel,{method:'DELETE'});await refreshData(true);renderServices()}})}
|
||||
function serviceModal(x=null){const selected=new Set((x?.monitors||[]).map(m=>m.id));modal(`<div class="modalhead"><h2>${x?'Edit':'New'} service</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Name</label><input id="svcName" value="${esc(x?.name||'')}"></div><div class="field"><label>Description</label><textarea id="svcDesc">${esc(x?.description||'')}</textarea></div><div class="field"><label>Assigned probes</label><div class="checklist serviceProbeList">${state.monitors.map(m=>`<label class="switch"><input class="svcProbe" type="checkbox" value="${m.id}" ${selected.has(m.id)?'checked':''}> <span>${esc(m.name)} · ${esc(m.type.toUpperCase())}</span> ${badge(m.status)}</label>`).join('')||'<span class="muted">No probes configured.</span>'}</div></div><div class="notice">A probe can belong to one service. Assigning it here moves it from a previous service. One DOWN probe makes this service DOWN.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="svcSave">Save</button></div>`);$('#svcSave').onclick=async()=>{const body={name:$('#svcName').value,description:$('#svcDesc').value,monitor_ids:$$('.svcProbe:checked').map(e=>Number(e.value))};try{await api(x?`/api/services/${x.id}`:'/api/services',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);renderServices()}catch(e){toast(e.message)}}}
|
||||
async function renderStatusPages(){setCrumb('Observability / Status Pages');$('#content').innerHTML=`${pageHead('Public status pages','Publish only selected services and their aggregate status.',state.me.role==='admin'?'<button class="btn primary" id="newStatusPage">+ New status page</button>':'')}<div class="panel" id="statusPagePanel"><div class="empty">Loading status pages…</div></div>`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Public URL</th><th>Services</th><th>Enabled</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b><div class="muted">${esc(x.description||'')}</div></td><td><a href="/status/${encodeURIComponent(x.slug)}" target="_blank">/status/${esc(x.slug)}</a></td><td>${(x.service_ids||[]).length}</td><td>${x.enabled?'<span class="green">Public</span>':'Disabled'}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-pedit="${x.id}">Edit</button> <button class="btn tiny danger" data-pdel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No public status pages configured.</div>';$$('[data-pedit]').forEach(b=>b.onclick=()=>statusPageModal(rows.find(x=>x.id===Number(b.dataset.pedit))));$$('[data-pdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete status page?')){await api('/api/status-pages/'+b.dataset.pdel,{method:'DELETE'});renderStatusPages()}})}catch(e){$('#statusPagePanel').innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`<div class="modalhead"><h2>${x?'Edit':'New'} public status page</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="pgName" value="${esc(x?.name||'')}"></div><div class="field"><label>Slug</label><input id="pgSlug" value="${esc(x?.slug||'')}" placeholder="public-status"></div><div class="field full"><label>Description</label><textarea id="pgDesc">${esc(x?.description||'')}</textarea></div><div class="field full"><label>Published services</label><div class="checklist">${state.services.map(g=>`<label class="switch"><input class="pgSvc" type="checkbox" value="${g.id}" ${selected.has(g.id)?'checked':''}> ${esc(g.name)} ${badge(g.status)}</label>`).join('')||'<span class="muted">Create services first.</span>'}</div></div><label class="switch"><input id="pgEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Publicly accessible</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="pgSave">Save</button></div>`);$('#pgSave').onclick=async()=>{const body={name:$('#pgName').value,slug:$('#pgSlug').value,description:$('#pgDesc').value,enabled:$('#pgEnabled').checked,service_ids:$$('.pgSvc:checked').map(e=>Number(e.value))};try{await api(x?`/api/status-pages/${x.id}`:'/api/status-pages',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();renderStatusPages()}catch(e){toast(e.message)}}}
|
||||
async function renderServices(){setCrumb('Überwachung / Dienste');const actions=roleOK()?'<button class="btn primary" id="newService">+ Neuer Dienst</button>':'';$('#content').innerHTML=`${pageHead('Dienste','Monitore zu sichtbaren Diensten gruppieren. Eine fehlgeschlagene Prüfung setzt den gesamten Dienst auf gestört.',actions)}<div class="panel" id="servicePanel">${state.services.length?`<table class="table"><thead><tr><th>Dienst</th><th>Status</th><th>Monitore</th><th></th></tr></thead><tbody>${state.services.map(g=>`<tr><td><b>${esc(g.name)}</b><div class="muted">${esc(g.description||'')}</div></td><td>${badge(g.status)}</td><td>${(g.monitors||[]).length}<div class="muted">${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'Keine Monitore zugeordnet'}</div></td><td class="right">${roleOK()?`<button class="btn tiny" data-sedit="${g.id}">Bearbeiten</button> <button class="btn tiny danger" data-sdel="${g.id}">Löschen</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine Dienste konfiguriert. Lege einen Dienst an und ordne ihm Monitore zu.</div>'}</div>`;$('#newService')?.addEventListener('click',()=>serviceModal());$$('[data-sedit]').forEach(b=>b.onclick=()=>serviceModal(state.services.find(x=>x.id===Number(b.dataset.sedit))));$$('[data-sdel]').forEach(b=>b.onclick=async()=>{if(confirm('Dienst löschen? Die zugehörigen Monitore sind anschließend nicht mehr gruppiert.')){await api('/api/services/'+b.dataset.sdel,{method:'DELETE'});await refreshData(true);renderServices()}})}
|
||||
function serviceModal(x=null){const selected=new Set((x?.monitors||[]).map(m=>m.id));modal(`<div class="modalhead"><h2>${x?'Dienst bearbeiten':'Neuer Dienst'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Name</label><input id="svcName" value="${esc(x?.name||'')}"></div><div class="field"><label>Beschreibung</label><textarea id="svcDesc">${esc(x?.description||'')}</textarea></div><div class="field"><label>Zugeordnete Monitore</label><div class="checklist serviceProbeList">${state.monitors.map(m=>`<label class="switch"><input class="svcProbe" type="checkbox" value="${m.id}" ${selected.has(m.id)?'checked':''}> <span>${esc(m.name)} · ${esc(m.type.toUpperCase())}</span> ${badge(m.status)}</label>`).join('')||'<span class="muted">Keine Monitore konfiguriert.</span>'}</div></div><div class="notice">Ein Monitor kann genau einem Dienst angehören. Eine Zuordnung hier verschiebt ihn aus dem bisherigen Dienst. Eine fehlgeschlagene Prüfung setzt den Dienst auf gestört.</div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="svcSave">Speichern</button></div>`);$('#svcSave').onclick=async()=>{const body={name:$('#svcName').value,description:$('#svcDesc').value,monitor_ids:$$('.svcProbe:checked').map(e=>Number(e.value))};try{await api(x?`/api/services/${x.id}`:'/api/services',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);renderServices()}catch(e){toast(e.message)}}}
|
||||
async function renderStatusPages(){setCrumb('Überwachung / Statusseiten');$('#content').innerHTML=`${pageHead('Öffentliche Statusseiten','Nur ausgewählte Dienste und deren aggregierten Status veröffentlichen.',state.me.role==='admin'?'<button class="btn primary" id="newStatusPage">+ Neue Statusseite</button>':'')}<div class="panel" id="statusPagePanel"><div class="empty">Statusseiten werden geladen…</div></div>`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Öffentliche URL</th><th>Dienste</th><th>Aktiviert</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b><div class="muted">${esc(x.description||'')}</div></td><td><a href="/status/${encodeURIComponent(x.slug)}" target="_blank">/status/${esc(x.slug)}</a></td><td>${(x.service_ids||[]).length}</td><td>${x.enabled?'<span class="green">Öffentlich</span>':'Deaktiviert'}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-pedit="${x.id}">Bearbeiten</button> <button class="btn tiny danger" data-pdel="${x.id}">Löschen</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine öffentlichen Statusseiten konfiguriert.</div>';$$('[data-pedit]').forEach(b=>b.onclick=()=>statusPageModal(rows.find(x=>x.id===Number(b.dataset.pedit))));$$('[data-pdel]').forEach(b=>b.onclick=async()=>{if(confirm('Statusseite löschen?')){await api('/api/status-pages/'+b.dataset.pdel,{method:'DELETE'});renderStatusPages()}})}catch(e){$('#statusPagePanel').innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
|
||||
function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`<div class="modalhead"><h2>${x?'Statusseite bearbeiten':'Neue Statusseite'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="pgName" value="${esc(x?.name||'')}"></div><div class="field"><label>Slug</label><input id="pgSlug" value="${esc(x?.slug||'')}" placeholder="public-status"></div><div class="field full"><label>Beschreibung</label><textarea id="pgDesc">${esc(x?.description||'')}</textarea></div><div class="field full"><label>Veröffentlichte Dienste</label><div class="checklist">${state.services.map(g=>`<label class="switch"><input class="pgSvc" type="checkbox" value="${g.id}" ${selected.has(g.id)?'checked':''}> ${esc(g.name)} ${badge(g.status)}</label>`).join('')||'<span class="muted">Lege zuerst Dienste an.</span>'}</div></div><label class="switch"><input id="pgEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Öffentlich erreichbar</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="pgSave">Speichern</button></div>`);$('#pgSave').onclick=async()=>{const body={name:$('#pgName').value,slug:$('#pgSlug').value,description:$('#pgDesc').value,enabled:$('#pgEnabled').checked,service_ids:$$('.pgSvc:checked').map(e=>Number(e.value))};try{await api(x?`/api/status-pages/${x.id}`:'/api/status-pages',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();renderStatusPages()}catch(e){toast(e.message)}}}
|
||||
|
||||
function renderMaintenance(){setCrumb('Observability / Maintenance');const ms=state.monitors.filter(m=>m.status==='maintenance');$('#content').innerHTML=`${pageHead('Maintenance','Suppress monitoring alerts during planned work.')}<div class="panel"><div class="panelhead"><h2>Active maintenance windows</h2></div>${ms.length?`<table class="table"><thead><tr><th>Monitor</th><th>Target</th><th>Until</th><th>Note</th><th></th></tr></thead><tbody>${ms.map(m=>`<tr><td><b>${esc(m.name)}</b></td><td class="muted">${esc(m.target)}</td><td>${m.maintenance_until?fmtTime(m.maintenance_until):'Manual end'}</td><td>${esc(m.maintenance_note||'—')}</td><td><button class="btn tiny" data-endmaint="${m.id}">End</button></td></tr>`).join('')}</tbody></table>`:'<div class="empty">No active maintenance windows.</div>'}</div><div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Start maintenance</h2></div><table class="table"><tbody>${state.monitors.filter(m=>m.status!=='maintenance').map(m=>`<tr><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td style="text-align:right"><button class="btn tiny" data-startmaint="${m.id}">Schedule</button></td></tr>`).join('')}</tbody></table></div>`;$$('[data-endmaint]').forEach(b=>b.onclick=()=>clearMaintenance(Number(b.dataset.endmaint)).then(renderMaintenance));$$('[data-startmaint]').forEach(b=>b.onclick=()=>maintenanceModal(state.monitors.find(m=>m.id===Number(b.dataset.startmaint))))}
|
||||
function renderNodes(){setCrumb('System / Environments');const local=`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>Local Docker</b><div class="muted">Docker socket</div></div></div></td><td>${badge('up')}<div class="healthDetail">${esc(state.system?.build?.version||'local')}</div></td><td>Local</td><td>—</td></tr>`;$('#content').innerHTML=`${pageHead('Environments','Master and remote agents managed from one control plane.',state.me.role==='admin'?'<button class="btn primary" id="addNode">+ Add environment</button>':'')}<div class="panel"><table class="table"><thead><tr><th>Name</th><th>Status</th><th>Connection</th><th>Actions</th></tr></thead><tbody>${local}${state.nodes.map(n=>`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>${esc(n.name)}</b><div class="muted">${esc(n.base_url)}</div></div></div></td><td id="nodeHealth-${n.id}">${n.enabled?'<span class="status unknown">Checking…</span>':badge('paused')}</td><td>${n.enabled?'Bearer agent':'Disabled'}</td><td>${state.me.role==='admin'?`<button class="btn tiny" data-editnode="${n.id}">Edit</button> <button class="btn tiny danger" data-delnode="${n.id}">Remove</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$('#addNode')?.addEventListener('click',()=>nodeModal());$$('[data-editnode]').forEach(b=>b.onclick=()=>nodeModal(state.nodes.find(n=>n.id===Number(b.dataset.editnode))));$$('[data-delnode]').forEach(b=>b.onclick=async()=>{if(confirm('Remove this environment?')){await api('/api/nodes/'+b.dataset.delnode,{method:'DELETE'});const x=await api('/api/nodes');state.nodes=Array.isArray(x)?x:[];renderNodePicker();renderNodes()}});loadNodeHealth()}
|
||||
function nodeModal(x=null){modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} remote agent</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nodeName" value="${esc(x?.name||'')}" placeholder="server-2"></div><div class="field full"><label>Agent base URL</label><input id="nodeURL" value="${esc(x?.base_url||'')}" placeholder="https://server-2.example.com"></div><div class="field full"><label>Agent token ${x?'<span class="muted">(leer lassen = beibehalten)</span>':''}</label><input id="nodeToken" type="password"></div>${x?`<label class="switch"><input id="nodeEnabled" type="checkbox" ${x.enabled?'checked':''}> Agent enabled</label>`:''}</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveNode">${x?'Save':'Add environment'}</button></div>`);$('#saveNode').onclick=async()=>{try{const body={name:$('#nodeName').value,base_url:$('#nodeURL').value,token:$('#nodeToken').value};if(x)body.enabled=$('#nodeEnabled').checked;await api(x?`/api/nodes/${x.id}`:'/api/nodes',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();const rows=await api('/api/nodes');state.nodes=Array.isArray(rows)?rows:[];renderNodePicker();renderNodes()}catch(e){toast(e.message)}}}
|
||||
async function loadNodeHealth(){await Promise.all(state.nodes.filter(n=>n.enabled).map(async n=>{const el=$(`#nodeHealth-${n.id}`);if(!el)return;try{const h=await api(`/api/nodes/${n.id}/health`);state.nodeHealth[n.id]=h;el.innerHTML=`${badge('up')}<div class="healthDetail">v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}</div>`}catch(e){el.innerHTML=`${badge('down')}<div class="healthDetail" title="${esc(e.message)}">unreachable</div>`}}))}
|
||||
function resourceTitle(k){return({containers:'Containers',images:'Images',volumes:'Volumes',networks:'Networks'})[k]||k}
|
||||
function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='containers')create='<button class="btn" id="identityAudit">Identity audit</button>';if(k==='images')create='<button class="btn primary" id="resourceCreate">↓ Pull image</button><button class="btn" id="registryLogin">Registry login</button><button class="btn" id="registryLogout">Logout</button>';if(k==='volumes')create='<button class="btn primary" id="resourceCreate">+ Create volume</button>';if(k==='networks')create='<button class="btn primary" id="resourceCreate">+ Create network</button>'}const prune=roleOK()&&k!=='containers'?'<button class="btn danger" id="resourcePrune">Prune unused</button>':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}<button class="btn" id="invRefresh">↻ Refresh</button>`)}<div class="panel"><div class="resourcebar"><input id="inventorySearch" placeholder="Filter ${n.toLowerCase()}…"><span id="inventoryCount" class="muted"></span></div><div id="inventoryPanel"><div class="empty">Loading ${n.toLowerCase()}…</div></div></div>`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)}
|
||||
function renderMaintenance(){setCrumb('Überwachung / Wartung');const ms=state.monitors.filter(m=>m.status==='maintenance');$('#content').innerHTML=`${pageHead('Wartung','Monitoring-Alarme während geplanter Arbeiten unterdrücken.')}<div class="panel"><div class="panelhead"><h2>Aktive Wartungsfenster</h2></div>${ms.length?`<table class="table"><thead><tr><th>Monitor</th><th>Ziel</th><th>Bis</th><th>Hinweis</th><th></th></tr></thead><tbody>${ms.map(m=>`<tr><td><b>${esc(m.name)}</b></td><td class="muted">${esc(m.target)}</td><td>${m.maintenance_until?fmtTime(m.maintenance_until):'Manuelles Ende'}</td><td>${esc(m.maintenance_note||'—')}</td><td><button class="btn tiny" data-endmaint="${m.id}">Beenden</button></td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine aktiven Wartungsfenster.</div>'}</div><div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Wartung starten</h2></div><table class="table"><tbody>${state.monitors.filter(m=>m.status!=='maintenance').map(m=>`<tr><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td style="text-align:right"><button class="btn tiny" data-startmaint="${m.id}">Zeitplan</button></td></tr>`).join('')}</tbody></table></div>`;$$('[data-endmaint]').forEach(b=>b.onclick=()=>clearMaintenance(Number(b.dataset.endmaint)).then(renderMaintenance));$$('[data-startmaint]').forEach(b=>b.onclick=()=>maintenanceModal(state.monitors.find(m=>m.id===Number(b.dataset.startmaint))))}
|
||||
function renderNodes(){setCrumb('System / Umgebungen');const local=`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>Lokaler Docker-Host</b><div class="muted">Docker-Socket</div></div></div></td><td>${badge('up')}<div class="healthDetail">${esc(state.system?.build?.version||'lokal')}</div></td><td>Lokal</td><td>—</td></tr>`;$('#content').innerHTML=`${pageHead('Umgebungen','Master und Remote-Agenten über eine zentrale Oberfläche verwalten.',state.me.role==='admin'?'<button class="btn primary" id="addNode">+ Umgebung hinzufügen</button>':'')}<div class="panel"><table class="table"><thead><tr><th>Name</th><th>Status</th><th>Verbindung</th><th>Aktionen</th></tr></thead><tbody>${local}${state.nodes.map(n=>`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>${esc(n.name)}</b><div class="muted">${esc(n.base_url)}</div></div></div></td><td id="nodeHealth-${n.id}">${n.enabled?'<span class="status unknown">Wird geprüft…</span>':badge('paused')}</td><td>${n.enabled?'Bearer-Agent':'Deaktiviert'}</td><td>${state.me.role==='admin'?`<button class="btn tiny" data-editnode="${n.id}">Bearbeiten</button> <button class="btn tiny danger" data-delnode="${n.id}">Entfernen</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$('#addNode')?.addEventListener('click',()=>nodeModal());$$('[data-editnode]').forEach(b=>b.onclick=()=>nodeModal(state.nodes.find(n=>n.id===Number(b.dataset.editnode))));$$('[data-delnode]').forEach(b=>b.onclick=async()=>{if(confirm('Diese Umgebung entfernen?')){await api('/api/nodes/'+b.dataset.delnode,{method:'DELETE'});const x=await api('/api/nodes');state.nodes=Array.isArray(x)?x:[];renderNodePicker();renderNodes()}});loadNodeHealth()}
|
||||
function nodeModal(x=null){modal(`<div class="modalhead"><h2>${x?'Remote-Agent bearbeiten':'Remote-Agent hinzufügen'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nodeName" value="${esc(x?.name||'')}" placeholder="server-2"></div><div class="field full"><label>Agent-Basis-URL</label><input id="nodeURL" value="${esc(x?.base_url||'')}" placeholder="https://server-2.example.com"></div><div class="field full"><label>Agent-Token ${x?'<span class="muted">(leer lassen = beibehalten)</span>':''}</label><input id="nodeToken" type="password"></div>${x?`<label class="switch"><input id="nodeEnabled" type="checkbox" ${x.enabled?'checked':''}> Agent aktiviert</label>`:''}</div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="saveNode">${x?'Speichern':'Umgebung hinzufügen'}</button></div>`);$('#saveNode').onclick=async()=>{try{const body={name:$('#nodeName').value,base_url:$('#nodeURL').value,token:$('#nodeToken').value};if(x)body.enabled=$('#nodeEnabled').checked;await api(x?`/api/nodes/${x.id}`:'/api/nodes',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();const rows=await api('/api/nodes');state.nodes=Array.isArray(rows)?rows:[];renderNodePicker();renderNodes()}catch(e){toast(e.message)}}}
|
||||
async function loadNodeHealth(){await Promise.all(state.nodes.filter(n=>n.enabled).map(async n=>{const el=$(`#nodeHealth-${n.id}`);if(!el)return;try{const h=await api(`/api/nodes/${n.id}/health`);state.nodeHealth[n.id]=h;el.innerHTML=`${badge('up')}<div class="healthDetail">v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}</div>`}catch(e){el.innerHTML=`${badge('down')}<div class="healthDetail" title="${esc(uiMessage(e.message))}">nicht erreichbar</div>`}}))}
|
||||
function resourceTitle(k){return({containers:'Container',images:'Images',volumes:'Volumes',networks:'Netzwerke'})[k]||k}
|
||||
function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='containers')create='<button class="btn" id="identityAudit">Identitätsprüfung</button>';if(k==='images')create='<button class="btn primary" id="resourceCreate">↓ Image laden</button><button class="btn" id="registryLogin">Registry-Anmeldung</button><button class="btn" id="registryLogout">Abmelden</button>';if(k==='volumes')create='<button class="btn primary" id="resourceCreate">+ Volume erstellen</button>';if(k==='networks')create='<button class="btn primary" id="resourceCreate">+ Netzwerk erstellen</button>'}const prune=roleOK()&&k!=='containers'?'<button class="btn danger" id="resourcePrune">Ungenutzte bereinigen</button>':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}<button class="btn" id="invRefresh">↻ Aktualisieren</button>`)}<div class="panel"><div class="resourcebar"><input id="inventorySearch" placeholder="${n} filtern…"><span id="inventoryCount" class="muted"></span></div><div id="inventoryPanel"><div class="empty">${n} werden geladen…</div></div></div>`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)}
|
||||
function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''}
|
||||
async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
|
||||
function inventoryLabelSummary(raw){const full=typeof raw==='string'?raw:raw&&typeof raw==='object'?Object.entries(raw).map(([k,v])=>`${k}=${v}`).join(','):String(raw||'');if(!full)return'—';const parts=full.split(',').map(x=>x.trim()).filter(Boolean),pretty=parts.slice(0,3).map(x=>x.replace(/^com\.docker\.compose\./,'compose.'));return pretty.join(' · ')+(parts.length>3?` · +${parts.length-3}`:'')}
|
||||
function inventoryCell(kind,key,value,first=false){const labelRaw=key==='Labels'&&value&&typeof value==='object'?Object.entries(value).map(([k,v])=>`${k}=${v}`).join(','):null,raw=value===undefined||value===null||value===''?'—':labelRaw??String(value);if(first)return `<div class="namecell"><span class="cube">⬡</span><b title="${esc(raw)}">${esc(raw)}</b></div>`;if(key==='Labels')return `<span class="inventory-ellipsis inventory-labels" title="${esc(raw)}">${esc(inventoryLabelSummary(value))}</span>`;if(['Mountpoint','Ports','Status','ID'].includes(key))return `<span class="inventory-ellipsis" title="${esc(raw)}">${esc(raw)}</span>`;return esc(raw)}
|
||||
function renderInventoryRows(kind,all){const p=$('#inventoryPanel');if(!p)return;const q=$('#inventorySearch')?.value.toLowerCase()||'',rows=q?all.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):all;const count=$('#inventoryCount');if(count)count.textContent=`${rows.length} / ${all.length}`;if(!rows.length){p.innerHTML='<div class="empty">No matching items found.</div>';return}const defs={containers:[['Names','Name'],['Image','Image'],['State','State'],['Status','Status'],['Ports','Ports']],images:[['Repository','Repository'],['Tag','Tag'],['ID','Image ID'],['Size','Size'],['CreatedSince','Created']],volumes:[['Name','Name'],['Driver','Driver'],['Mountpoint','Mountpoint'],['Labels','Labels']],networks:[['Name','Name'],['Driver','Driver'],['Scope','Scope'],['IPv6','IPv6'],['Internal','Internal']]};const cols=defs[kind]||Object.keys(rows[0]).slice(0,5).map(k=>[k,k]);p.innerHTML=`<div class="inventory-tablewrap"><table class="table inventory-table inventory-${esc(kind)}"><thead><tr>${cols.map(c=>`<th data-col="${esc(c[0])}">${esc(c[1])}</th>`).join('')}<th class="right inventory-actions-cell">Actions</th></tr></thead><tbody>${rows.map(r=>{const idx=all.indexOf(r);return `<tr>${cols.map((c,i)=>`<td data-col="${esc(c[0])}" class="${i?'muted':''}">${inventoryCell(kind,c[0],r[c[0]],i===0)}</td>`).join('')}<td class="right inventory-actions-cell"><div class="inventory-actions">${resourceActions(kind,r,idx)}</div></td></tr>`}).join('')}</tbody></table></div>`;wireResourceRows(kind)}
|
||||
function renderInventoryRows(kind,all){const p=$('#inventoryPanel');if(!p)return;const q=$('#inventorySearch')?.value.toLowerCase()||'',rows=q?all.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):all;const count=$('#inventoryCount');if(count)count.textContent=`${rows.length} / ${all.length}`;if(!rows.length){p.innerHTML='<div class="empty">Keine passenden Einträge gefunden.</div>';return}const defs={containers:[['Names','Name'],['Image','Image'],['State','Zustand'],['Status','Status'],['Ports','Ports']],images:[['Repository','Repository'],['Tag','Tag'],['ID','Image ID'],['Size','Größe'],['CreatedSince','Erstellt']],volumes:[['Name','Name'],['Driver','Treiber'],['Mountpoint','Einhängepunkt'],['Labels','Labels']],networks:[['Name','Name'],['Driver','Treiber'],['Scope','Geltungsbereich'],['IPv6','IPv6'],['Internal','Intern']]};const cols=defs[kind]||Object.keys(rows[0]).slice(0,5).map(k=>[k,k]);p.innerHTML=`<div class="inventory-tablewrap"><table class="table inventory-table inventory-${esc(kind)}"><thead><tr>${cols.map(c=>`<th data-col="${esc(c[0])}">${esc(c[1])}</th>`).join('')}<th class="right inventory-actions-cell">Aktionen</th></tr></thead><tbody>${rows.map(r=>{const idx=all.indexOf(r);return `<tr>${cols.map((c,i)=>`<td data-col="${esc(c[0])}" class="${i?'muted':''}">${inventoryCell(kind,c[0],r[c[0]],i===0)}</td>`).join('')}<td class="right inventory-actions-cell"><div class="inventory-actions">${resourceActions(kind,r,idx)}</div></td></tr>`}).join('')}</tbody></table></div>`;wireResourceRows(kind)}
|
||||
|
||||
function resourceActions(kind,r,idx){const inspect=roleOK()?`<button class="btn tiny" data-inspect="${idx}">Inspect</button>`:'';if(kind==='containers')return `${inspect}${roleOK()?` <button class="btn tiny" data-identity="${idx}">Identity</button> <button class="btn tiny" data-ract="start" data-row="${idx}">Start</button> <button class="btn tiny" data-ract="restart" data-row="${idx}">Restart</button> <button class="btn tiny" data-ract="stop" data-row="${idx}">Stop</button> <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`:''}`;if(!roleOK())return'';return `${inspect} <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`}
|
||||
function resourceActions(kind,r,idx){const inspect=roleOK()?`<button class="btn tiny" data-inspect="${idx}">Inspizieren</button>`:'';if(kind==='containers')return `${inspect}${roleOK()?` <button class="btn tiny" data-identity="${idx}">Identität</button> <button class="btn tiny" data-ract="start" data-row="${idx}">Starten</button> <button class="btn tiny" data-ract="restart" data-row="${idx}">Neustart</button> <button class="btn tiny" data-ract="stop" data-row="${idx}">Stoppen</button> <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Entfernen</button>`:''}`;if(!roleOK())return'';return `${inspect} <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Entfernen</button>`}
|
||||
function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]));$$('[data-identity]').forEach(b=>b.onclick=()=>inspectContainerIdentity(window.__inventoryRows[Number(b.dataset.identity)]))}
|
||||
async function dockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action} ${kind}? This can delete Docker resources.`))return;try{const r=await api(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);await loadInventory(kind)}catch(e){toast(e.message)}}
|
||||
|
||||
function registryLoginModal(){modal(`<div class="modalhead"><h2>Registry login</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div><div class="field"><label>Username</label><input id="regUser"></div><div class="field"><label>Password / token</label><input id="regPass" type="password"></div></div><div class="notice" style="margin-top:10px">Credentials are written by Docker CLI to the persistent Docker config on this environment. The password is passed through stdin, not a command-line argument.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="regSave">Login</button></div>`);$('#regSave').onclick=async()=>{const body={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)return toast('Registry, username and password required.');closeModal();await dockerResourceAction('images','login',body)}}
|
||||
function registryLogoutModal(){modal(`<div class="modalhead"><h2>Registry logout</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn danger" id="regSave">Logout</button></div>`);$('#regSave').onclick=async()=>{const registry=$('#regHost').value.trim();if(!registry)return toast('Registry required.');closeModal();await dockerResourceAction('images','logout',{registry})}}
|
||||
function registryLoginModal(){modal(`<div class="modalhead"><h2>Registry-Anmeldung</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div><div class="field"><label>Benutzername</label><input id="regUser"></div><div class="field"><label>Passwort / Token</label><input id="regPass" type="password"></div></div><div class="notice" style="margin-top:10px">Anmeldedaten werden von der Docker-CLI in die persistente Docker-Konfiguration dieser Umgebung geschrieben. Das Passwort wird über stdin und nicht als Kommandozeilenargument übergeben.</div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="regSave">Anmelden</button></div>`);$('#regSave').onclick=async()=>{const body={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)return toast('Registry, username and password required.');closeModal();await dockerResourceAction('images','login',body)}}
|
||||
function registryLogoutModal(){modal(`<div class="modalhead"><h2>Registry-Abmeldung</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn danger" id="regSave">Abmelden</button></div>`);$('#regSave').onclick=async()=>{const registry=$('#regHost').value.trim();if(!registry)return toast('Registry required.');closeModal();await dockerResourceAction('images','logout',{registry})}}
|
||||
|
||||
function resourceCreateModal(kind){if(kind==='images'){modal(`<div class="modalhead"><h2>Pull image</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Image reference</label><input id="resName" placeholder="nginx:alpine"></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="resSave">Pull</button></div>`);$('#resSave').onclick=async()=>{const name=$('#resName').value.trim();if(!name)return toast('Image reference required.');closeModal();await dockerResourceAction('images','pull',{name})};return}const isNet=kind==='networks';modal(`<div class="modalhead"><h2>Create ${isNet?'network':'volume'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="resName" placeholder="${isNet?'frontend':'app-data'}"></div><div class="field"><label>Driver</label><input id="resDriver" placeholder="${isNet?'bridge':'local'}"></div>${isNet?'<label class="switch"><input id="resInternal" type="checkbox"> Internal network</label><label class="switch"><input id="resAttachable" type="checkbox"> Attachable</label>':''}<div class="field full"><label>Labels (key=value, one per line)</label><textarea id="resLabels" spellcheck="false"></textarea></div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="resSave">Create</button></div>`);$('#resSave').onclick=async()=>{const labels={};($('#resLabels').value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).forEach(x=>{const i=x.indexOf('=');if(i<0)labels[x]='';else labels[x.slice(0,i).trim()]=x.slice(i+1).trim()});const body={name:$('#resName').value.trim(),driver:$('#resDriver').value.trim(),labels};if(isNet){body.internal=$('#resInternal').checked;body.attachable=$('#resAttachable').checked}if(!body.name)return toast('Name required.');closeModal();await dockerResourceAction(kind,'create',body)}}
|
||||
async function containerIdentityAudit(){const rows=asArray(window.__inventoryRows);if(!rows.length)return toast('No containers to inspect.');const btn=$('#identityAudit');setBusy(btn,true,'Scanning…');try{const results=[];for(let i=0;i<rows.length;i+=4){const batch=rows.slice(i,i+4);results.push(...await Promise.allSettled(batch.map(r=>{const id=r.ID||r.Names||r.Name;return api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`).then(v=>({row:r,report:v}))})))}const ok=results.filter(x=>x.status==='fulfilled').map(x=>x.value),failed=results.filter(x=>x.status==='rejected');const rootCount=ok.filter(x=>x.report.runs_as_root===true).length,nonRoot=ok.filter(x=>x.report.runs_as_root===false).length,unknown=ok.length-rootCount-nonRoot;modal(`<div class="modalhead"><h2>Container identity audit · ${esc(nodeName())}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="stats"><div class="stat"><small>Containers checked</small><strong>${ok.length}</strong></div><div class="stat"><small>Root PID 1</small><strong class="${rootCount?'amber':''}">${rootCount}</strong></div><div class="stat"><small>Non-root PID 1</small><strong class="green">${nonRoot}</strong></div><div class="stat"><small>Unknown / failed</small><strong>${unknown+failed.length}</strong></div></div><div class="notice" style="margin-top:12px">“Root” means the effective UID of PID 1 where Dockwatch could read it. This is a review signal, not proof that the application can safely be converted to non-root.</div><div class="tablewrap" style="margin-top:12px"><table class="table"><thead><tr><th>Container</th><th>UID:GID</th><th>Assessment</th><th>Host account</th><th>Bind mounts</th></tr></thead><tbody>${ok.map(x=>{const d=x.report;return `<tr><td><b>${esc(d.container_name||x.row.Names||x.row.ID)}</b><div class="muted">${esc(d.image||'')}</div></td><td class="mono"><div>PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}</div><div class="muted">bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</div></td><td>${esc(identityAssessmentLabel(d.root_assessment))}</td><td>${d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)}</span>`:(d.host_access?.available&&d.bind_uid>0?'<span class="warn">missing</span>':'—')}</td><td>${asArray(d.bind_mounts).length}</td></tr>`}).join('')}${failed.map((x,i)=>`<tr><td colspan="5" class="red">Check ${i+1} failed: ${esc(x.reason?.message||x.reason)}</td></tr>`).join('')}</tbody></table></div></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}catch(e){toast(e.message)}finally{setBusy(btn,false)}}
|
||||
function resourceCreateModal(kind){if(kind==='images'){modal(`<div class="modalhead"><h2>Image laden</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Image-Referenz</label><input id="resName" placeholder="nginx:alpine"></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="resSave">Laden</button></div>`);$('#resSave').onclick=async()=>{const name=$('#resName').value.trim();if(!name)return toast('Image-Referenz erforderlich.');closeModal();await dockerResourceAction('images','pull',{name})};return}const isNet=kind==='networks';modal(`<div class="modalhead"><h2>${isNet?'Netzwerk erstellen':'Volume erstellen'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="resName" placeholder="${isNet?'frontend':'app-data'}"></div><div class="field"><label>Treiber</label><input id="resDriver" placeholder="${isNet?'bridge':'lokal'}"></div>${isNet?'<label class="switch"><input id="resInternal" type="checkbox"> Internes Netzwerk</label><label class="switch"><input id="resAttachable" type="checkbox"> Anbindbar</label>':''}<div class="field full"><label>Labels (key=value, eines pro Zeile)</label><textarea id="resLabels" spellcheck="false"></textarea></div></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="resSave">Erstellen</button></div>`);$('#resSave').onclick=async()=>{const labels={};($('#resLabels').value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).forEach(x=>{const i=x.indexOf('=');if(i<0)labels[x]='';else labels[x.slice(0,i).trim()]=x.slice(i+1).trim()});const body={name:$('#resName').value.trim(),driver:$('#resDriver').value.trim(),labels};if(isNet){body.internal=$('#resInternal').checked;body.attachable=$('#resAttachable').checked}if(!body.name)return toast('Name ist erforderlich.');closeModal();await dockerResourceAction(kind,'create',body)}}
|
||||
async function containerIdentityAudit(){const rows=asArray(window.__inventoryRows);if(!rows.length)return toast('Keine Container zum Prüfen vorhanden.');const btn=$('#identityAudit');setBusy(btn,true,'Wird geprüft…');try{const results=[];for(let i=0;i<rows.length;i+=4){const batch=rows.slice(i,i+4);results.push(...await Promise.allSettled(batch.map(r=>{const id=r.ID||r.Names||r.Name;return api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`).then(v=>({row:r,report:v}))})))}const ok=results.filter(x=>x.status==='fulfilled').map(x=>x.value),failed=results.filter(x=>x.status==='rejected');const rootCount=ok.filter(x=>x.report.runs_as_root===true).length,nonRoot=ok.filter(x=>x.report.runs_as_root===false).length,unknown=ok.length-rootCount-nonRoot;modal(`<div class="modalhead"><h2>Container-Identitätsprüfung · ${esc(nodeName())}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="stats"><div class="stat"><small>Geprüfte Container</small><strong>${ok.length}</strong></div><div class="stat"><small>PID 1 als Root</small><strong class="${rootCount?'amber':''}">${rootCount}</strong></div><div class="stat"><small>PID 1 ohne Root</small><strong class="green">${nonRoot}</strong></div><div class="stat"><small>Unbekannt / fehlgeschlagen</small><strong>${unknown+failed.length}</strong></div></div><div class="notice" style="margin-top:12px">„Root“ bezeichnet die effektive UID von PID 1, sofern Dockwatch sie lesen konnte. Das ist ein Prüfsignal, kein Beweis dafür, dass die Anwendung sicher auf Non-Root umgestellt werden kann.</div><div class="tablewrap" style="margin-top:12px"><table class="table"><thead><tr><th>Container</th><th>UID:GID</th><th>Bewertung</th><th>Host-Benutzer</th><th>Bind-Mounts</th></tr></thead><tbody>${ok.map(x=>{const d=x.report;return `<tr><td><b>${esc(d.container_name||x.row.Names||x.row.ID)}</b><div class="muted">${esc(d.image||'')}</div></td><td class="mono"><div>PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}</div><div class="muted">bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</div></td><td>${esc(identityAssessmentLabel(d.root_assessment))}</td><td>${d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)}</span>`:(d.host_access?.available&&d.bind_uid>0?'<span class="warn">fehlt</span>':'—')}</td><td>${asArray(d.bind_mounts).length}</td></tr>`}).join('')}${failed.map((x,i)=>`<tr><td colspan="5" class="red">Prüfung ${i+1} fehlgeschlagen: ${esc(uiMessage(x.reason?.message||x.reason))}</td></tr>`).join('')}</tbody></table></div></div><div class="modalfoot"><button class="btn" data-close>Schließen</button></div>`)}catch(e){toast(e.message)}finally{setBusy(btn,false)}}
|
||||
function hostAccountSuggestion(name){let n=String(name||'container').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'');if(!/^[a-z_]/.test(n))n='c-'+n;return('dockwatch-'+n).slice(0,31).replace(/-+$/,'')||'dockwatch-app'}
|
||||
function identityAssessmentLabel(v){return ({'non-root':'Non-root','root-high-privilege':'Root + privileged','root-review-docker-socket':'Root · Docker socket','root-review-devices':'Root · devices','root-not-obviously-required':'Root · review recommended','unknown':'Unknown'})[v]||v||'Unknown'}
|
||||
function writeState(v){return v===true?'<span class="green">writable</span>':v===false?'<span class="red">not writable</span>':'<span class="muted">unknown</span>'}
|
||||
function identityAssessmentLabel(v){return ({'non-root':'Ohne Root','root-high-privilege':'Root + privilegiert','root-review-docker-socket':'Root · Docker-Socket','root-review-devices':'Root · Gerätezugriff','root-not-obviously-required':'Root · Prüfung empfohlen','unknown':'Unbekannt'})[v]||v||'Unbekannt'}
|
||||
function writeState(v){return v===true?'<span class="green">beschreibbar</span>':v===false?'<span class="red">nicht beschreibbar</span>':'<span class="muted">unbekannt</span>'}
|
||||
async function inspectContainerIdentity(r){
|
||||
const id=r.ID||r.Names||r.Name;if(!id)return;
|
||||
try{
|
||||
const d=await api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`),uid=d.effective_uid??'—',gid=d.effective_gid??'—';
|
||||
const root=d.runs_as_root===true?'Yes':d.runs_as_root===false?'No':'Unknown',reasons=asArray(d.root_reasons).map(x=>`<li>${esc(x)}</li>`).join(''),recs=asArray(d.recommendations).map(x=>`<li>${esc(x)}</li>`).join(''),binds=asArray(d.bind_mounts),host=d.host_access||{};
|
||||
window.__identityBindRows=binds.map(x=>({container:id,mount:x}));
|
||||
const bindTable=binds.length?`<div class="field"><label>Bind mount permissions · expected ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')} (${esc(d.bind_identity_source||'unknown')})</label><div class="tablewrap"><table class="table"><thead><tr><th>Host path</th><th>Container path</th><th>Mode / owner</th><th>Write access</th><th></th></tr></thead><tbody>${binds.map((x,i)=>`<tr><td class="mono">${esc(x.source)}</td><td class="mono">${esc(x.destination)} ${x.read_only?'<span class="tag">ro</span>':'<span class="tag">rw</span>'}</td><td><code>${esc(x.mode||'—')}</code> · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'<div class="warn">ACL detected</div>':''}</td><td>${x.read_only?'<span class="muted">read-only</span>':writeState(x.static_writable)}<div class="muted">${esc(x.writable_reason||x.ownership_note||'')}</div></td><td class="right">${!x.read_only?`<button class="btn tiny" data-bindpreview="${i}">Analyze / repair</button>`:''}</td></tr>`).join('')}</tbody></table></div></div>`:'<div class="notice">No bind mounts detected.</div>';
|
||||
const bindHost=d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})</span>`:(host.available&&d.bind_uid>0?'<span class="warn">No matching host user</span>':'—');
|
||||
const bindTable=binds.length?`<div class="field"><label>Bind mount permissions · expected ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')} (${esc(d.bind_identity_source||'unknown')})</label><div class="tablewrap"><table class="table"><thead><tr><th>Host-Pfad</th><th>Container-Pfad</th><th>Modus / Besitzer</th><th>Schreibzugriff</th><th></th></tr></thead><tbody>${binds.map((x,i)=>`<tr><td class="mono">${esc(x.source)}</td><td class="mono">${esc(x.destination)} ${x.read_only?'<span class="tag">ro</span>':'<span class="tag">rw</span>'}</td><td><code>${esc(x.mode||'—')}</code> · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'<div class="warn">ACL erkannt</div>':''}</td><td>${x.read_only?'<span class="muted">schreibgeschützt</span>':writeState(x.static_writable)}<div class="muted">${esc(x.writable_reason||x.ownership_note||'')}</div></td><td class="right">${!x.read_only?`<button class="btn tiny" data-bindpreview="${i}">Analysieren / reparieren</button>`:''}</td></tr>`).join('')}</tbody></table></div></div>`:'<div class="notice">Keine Bind-Mounts erkannt.</div>';
|
||||
const bindHost=d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})</span>`:(host.available&&d.bind_uid>0?'<span class="warn">Kein passender Host-Benutzer</span>':'—');
|
||||
const canCreate=state.me.role==='admin'&&host.available&&host.management_enabled&&d.host_id_mapping!=='remapped'&&Number.isInteger(d.bind_uid)&&d.bind_uid>0&&!d.bind_host_user;
|
||||
modal(`<div class="modalhead"><h2>Identity & bind permissions · ${esc(d.container_name||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Process identity and storage identity are evaluated separately.</b> PID 1 may run as root while an image writes application data with PUID/PGID. Dockwatch never changes Compose <code>user:</code> automatically.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>PID 1 UID:GID</small><strong>${esc(uid)}:${esc(gid)}</strong></div><div class="stat"><small>Bind UID:GID</small><strong>${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</strong></div><div class="stat"><small>Runs as root</small><strong>${esc(root)}</strong></div><div class="stat"><small>Assessment</small><strong>${esc(identityAssessmentLabel(d.root_assessment))}</strong></div></div><div class="twocol" style="margin-top:12px"><div class="panel inset"><h3>Why</h3><ul class="compactList">${reasons||'<li>No additional reasons.</li>'}</ul></div><div class="panel inset"><h3>Recommendations</h3><ul class="compactList">${recs||'<li>No recommendations.</li>'}</ul></div></div><div class="panel inset" style="margin-top:12px"><div class="row"><div><h3>Host identity</h3><div class="muted">${esc(host.message||'Host access unavailable')}</div></div><div>${bindHost}</div></div>${host.configured?`<div class="meta" style="margin-top:8px">Host root: <code>${esc(host.root||'')}</code> · user creation ${host.management_enabled?'enabled':'off'} · permission repair ${host.permission_management_enabled?'enabled':'off'} · UID mapping ${esc(d.host_id_mapping||'unknown')}</div>`:''}</div>${bindTable}</div><div class="modalfoot">${canCreate?'<button class="btn" id="createMatchingHostUser">Create matching host account…</button>':''}<button class="btn" data-close>Close</button></div>`);
|
||||
modal(`<div class="modalhead"><h2>Identität & Bind-Berechtigungen · ${esc(d.container_name||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Prozess- und Speicheridentität werden getrennt bewertet.</b> PID 1 kann als root laufen, während ein Image Anwendungsdaten mit PUID/PGID schreibt. Dockwatch ändert Compose <code>user:</code> niemals automatisch.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>PID 1 UID:GID</small><strong>${esc(uid)}:${esc(gid)}</strong></div><div class="stat"><small>Bind-UID:GID</small><strong>${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</strong></div><div class="stat"><small>Läuft als root</small><strong>${esc(root)}</strong></div><div class="stat"><small>Bewertung</small><strong>${esc(identityAssessmentLabel(d.root_assessment))}</strong></div></div><div class="twocol" style="margin-top:12px"><div class="panel inset"><h3>Begründung</h3><ul class="compactList">${reasons||'<li>Keine zusätzlichen Gründe.</li>'}</ul></div><div class="panel inset"><h3>Empfehlungen</h3><ul class="compactList">${recs||'<li>Keine Empfehlungen.</li>'}</ul></div></div><div class="panel inset" style="margin-top:12px"><div class="row"><div><h3>Host-Identität</h3><div class="muted">${esc(uiMessage(host.message||'Host-Zugriff nicht verfügbar'))}</div></div><div>${bindHost}</div></div>${host.configured?`<div class="meta" style="margin-top:8px">Host-Root: <code>${esc(host.root||'')}</code> · Benutzeranlage ${host.management_enabled?'aktiviert':'aus'} · Berechtigungsreparatur ${host.permission_management_enabled?'aktiviert':'aus'} · UID-Zuordnung ${esc(d.host_id_mapping||'unbekannt')}</div>`:''}</div>${bindTable}</div><div class="modalfoot">${canCreate?'<button class="btn" id="createMatchingHostUser">Passenden Host-Benutzer anlegen…</button>':''}<button class="btn" data-close>Schließen</button></div>`);
|
||||
$$('[data-bindpreview]').forEach(b=>b.onclick=()=>{const x=window.__identityBindRows[Number(b.dataset.bindpreview)];bindPermissionModal(x.container,x.mount.destination)});
|
||||
if(canCreate)$('#createMatchingHostUser').onclick=()=>hostUserModal(id,d);
|
||||
}catch(e){toast(e.message)}
|
||||
}
|
||||
function hostUserModal(containerID,d){const suggested=hostAccountSuggestion(d.container_name);modal(`<div class="modalhead"><h2>Create matching host account</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Host change:</b> Dockwatch re-checks the container and derives the bind UID/GID server-side. Numeric IDs cannot be chosen by the browser.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>Container</small><strong>${esc(d.container_name)}</strong></div><div class="stat"><small>Bind UID:GID</small><strong>${esc(d.bind_uid)}:${esc(d.bind_gid)}</strong></div><div class="stat"><small>Source</small><strong>${esc(d.bind_identity_source||'container identity')}</strong></div></div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Host username</label><input id="hostUsername" value="${esc(suggested)}"></div><div class="field"><label>Host group name</label><input id="hostGroup" value="${esc(suggested)}"></div><label class="switch"><input id="hostHome" type="checkbox"> Create home directory</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn danger" id="confirmHostUser">Create on host</button></div>`);$('#confirmHostUser').onclick=async()=>{if(!confirm(`Create a local account on ${nodeName()} matching bind identity ${d.bind_uid}:${d.bind_gid}?`))return;const btn=$('#confirmHostUser');setBusy(btn,true,'Creating…');try{const out=await api(`/api/host/users${qnode()}`,{method:'POST',body:JSON.stringify({container_id:containerID,username:$('#hostUsername').value.trim(),group_name:$('#hostGroup').value.trim(),create_home:$('#hostHome').checked})});toast(out.message||'Host account created');closeModal();await inspectContainerIdentity({ID:containerID})}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function hostUserModal(containerID,d){const suggested=hostAccountSuggestion(d.container_name);modal(`<div class="modalhead"><h2>Passenden Host-Benutzer anlegen</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Host-Änderung:</b> Dockwatch prüft den Container erneut und leitet die Bind-UID/GID serverseitig ab. Numerische IDs können nicht im Browser vorgegeben werden.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>Container</small><strong>${esc(d.container_name)}</strong></div><div class="stat"><small>Bind-UID:GID</small><strong>${esc(d.bind_uid)}:${esc(d.bind_gid)}</strong></div><div class="stat"><small>Quelle</small><strong>${esc(d.bind_identity_source||'Container-Identität')}</strong></div></div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Host-Benutzername</label><input id="hostUsername" value="${esc(suggested)}"></div><div class="field"><label>Host-Gruppenname</label><input id="hostGroup" value="${esc(suggested)}"></div><label class="switch"><input id="hostHome" type="checkbox"> Home-Verzeichnis anlegen</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn danger" id="confirmHostUser">Auf Host anlegen</button></div>`);$('#confirmHostUser').onclick=async()=>{if(!confirm(`Lokalen Account auf ${nodeName()} passend zur Bind-Identität ${d.bind_uid}:${d.bind_gid} anlegen?`))return;const btn=$('#confirmHostUser');setBusy(btn,true,'Wird angelegt…');try{const out=await api(`/api/host/users${qnode()}`,{method:'POST',body:JSON.stringify({container_id:containerID,username:$('#hostUsername').value.trim(),group_name:$('#hostGroup').value.trim(),create_home:$('#hostHome').checked})});toast(out.message||'Host-Account angelegt.');closeModal();await inspectContainerIdentity({ID:containerID})}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
async function bindPermissionModal(containerID,destination,recursive=false){
|
||||
try{const p=await api(`/api/docker/containers/${encodeURIComponent(containerID)}/bind-permissions/preview${qnode()}`,{method:'POST',body:JSON.stringify({destination,recursive})});showBindPermissionPreview(p)}catch(e){toast(e.message)}
|
||||
}
|
||||
function showBindPermissionPreview(p){
|
||||
const canRepair=state.me.role==='admin'&&p.can_repair&&!p.scan_truncated,owner=`${p.owner_uid??'—'}:${p.owner_gid??'—'}`,expected=`${p.expected_uid??'—'}:${p.expected_gid??'—'}`,canCreateUser=state.me.role==='admin'&&p.host_access?.management_enabled&&p.expected_uid>0&&!p.host_user;
|
||||
modal(`<div class="modalhead"><h2>Bind permission review</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Mount</label><div class="mono">${esc(p.source)} → ${esc(p.destination)}</div></div><div class="field"><label>Expected identity</label><b>${esc(expected)}</b><div class="muted">${esc(p.identity_source||'')}</div></div><div class="field"><label>Current owner / mode</label><b>${esc(owner)} · ${esc(p.mode||'—')}</b></div><div class="field"><label>Host account for expected UID</label>${p.host_user?`<span class="green">${esc(p.host_user.name)}</span>`:'<span class="warn">missing</span>'}<div class="muted">${p.host_group?`group ${esc(p.host_group.name)}`:'numeric GID only'}</div></div><div class="field"><label>Static write check</label>${writeState(p.static_writable)}<div class="muted">${esc(p.writable_reason||'')}</div></div><div class="field"><label>Runtime write check</label>${writeState(p.runtime_writable)}<div class="muted">${esc(p.runtime_note||'')}</div></div><div class="field"><label>Ownership scan</label><b>${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} differ</b><div class="muted">${esc(p.files_scanned||0)} files · ${esc(p.directories_scanned||0)} dirs · ${esc(p.symlinks_skipped||0)} symlinks · ${esc(p.cross_filesystem_skipped||0)} nested filesystems skipped</div></div><div class="field"><label>ACL</label>${p.acl_detected?'<span class="warn">extended ACL detected</span>':'<span class="green">no extended ACL detected</span>'}<div class="muted">${esc(p.acl_note||'')}</div></div></div><div class="notice ${p.blocked_reason?'dangerNotice':''}" style="margin-top:12px">${p.blocked_reason?`<b>Automatic repair unavailable:</b> ${esc(p.blocked_reason)}<br>`:''}${esc(p.recommendation||'')}</div><div class="panel inset" style="margin-top:12px"><h3>Re-scan / repair scope</h3><label class="switch"><input id="bindRecursive" type="checkbox" ${p.recursive?'checked':''}> Recursively inspect/chown files below this bind root</label><div class="field" style="margin-top:10px"><label>Optional top-level chmod</label><input id="bindMode" placeholder="e.g. 750 or 775"><div class="muted">Never applied automatically and never recursively. Leave empty to preserve mode bits.</div></div><label class="switch"><input id="bindFixOwner" type="checkbox" checked> Fix owner/group to ${esc(expected)}</label></div></div><div class="modalfoot"><button class="btn" id="rescanBind">Re-scan</button>${canCreateUser?'<button class="btn" id="bindCreateUser">Create host account…</button>':''}${canRepair?'<button class="btn danger" id="repairBind">Repair & verify</button>':''}<button class="btn" data-close>Close</button></div>`);
|
||||
modal(`<div class="modalhead"><h2>Bind-Berechtigungsprüfung</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Mount</label><div class="mono">${esc(p.source)} → ${esc(p.destination)}</div></div><div class="field"><label>Erwartete Identität</label><b>${esc(expected)}</b><div class="muted">${esc(p.identity_source||'')}</div></div><div class="field"><label>Aktueller Besitzer / Modus</label><b>${esc(owner)} · ${esc(p.mode||'—')}</b></div><div class="field"><label>Host-Benutzer für erwartete UID</label>${p.host_user?`<span class="green">${esc(p.host_user.name)}</span>`:'<span class="warn">fehlt</span>'}<div class="muted">${p.host_group?`Gruppe ${esc(p.host_group.name)}`:'nur numerische GID'}</div></div><div class="field"><label>Statische Schreibprüfung</label>${writeState(p.static_writable)}<div class="muted">${esc(uiMessage(p.writable_reason||''))}</div></div><div class="field"><label>Laufzeit-Schreibprüfung</label>${writeState(p.runtime_writable)}<div class="muted">${esc(uiMessage(p.runtime_note||''))}</div></div><div class="field"><label>Besitzprüfung</label><b>${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} abweichend</b><div class="muted">${esc(p.files_scanned||0)} Dateien · ${esc(p.directories_scanned||0)} Verzeichnisse · ${esc(p.symlinks_skipped||0)} Symlinks übersprungen · ${esc(p.cross_filesystem_skipped||0)} eingebundene Dateisysteme übersprungen</div></div><div class="field"><label>ACL</label>${p.acl_detected?'<span class="warn">Erweiterte ACL erkannt</span>':'<span class="green">Keine erweiterte ACL erkannt</span>'}<div class="muted">${esc(uiMessage(p.acl_note||''))}</div></div></div><div class="notice ${p.blocked_reason?'dangerNotice':''}" style="margin-top:12px">${p.blocked_reason?`<b>Automatische Reparatur nicht verfügbar:</b> ${esc(uiMessage(p.blocked_reason))}<br>`:''}${esc(uiMessage(p.recommendation||''))}</div><div class="panel inset" style="margin-top:12px"><h3>Erneut prüfen / Reparaturumfang</h3><label class="switch"><input id="bindRecursive" type="checkbox" ${p.recursive?'checked':''}> Dateien unter diesem Bind-Root rekursiv prüfen/chownen</label><div class="field" style="margin-top:10px"><label>Optionaler chmod des Top-Level-Verzeichnisses</label><input id="bindMode" placeholder="z. B. 750 oder 775"><div class="muted">Wird nie automatisch und nie rekursiv angewendet. Leer lassen, um Modus-Bits beizubehalten.</div></div><label class="switch"><input id="bindFixOwner" type="checkbox" checked> Besitzer/Gruppe auf ${esc(expected)} korrigieren</label></div></div><div class="modalfoot"><button class="btn" id="rescanBind">Erneut prüfen</button>${canCreateUser?'<button class="btn" id="bindCreateUser">Host-Benutzer anlegen…</button>':''}${canRepair?'<button class="btn danger" id="repairBind">Reparieren & prüfen</button>':''}<button class="btn" data-close>Schließen</button></div>`);
|
||||
$('#rescanBind').onclick=()=>bindPermissionModal(p.container_id,p.destination,$('#bindRecursive').checked);
|
||||
if(canCreateUser)$('#bindCreateUser').onclick=()=>hostUserModal(p.container_id,{container_name:p.container_name,bind_uid:p.expected_uid,bind_gid:p.expected_gid,bind_identity_source:p.identity_source,bind_host_user:p.host_user});
|
||||
if(canRepair)$('#repairBind').onclick=async()=>{const recursive=$('#bindRecursive').checked,fix=$('#bindFixOwner').checked,mode=$('#bindMode').value.trim();if(recursive&&!p.recursive)return toast('Run a recursive re-scan first so the affected file count is known.');const what=recursive?`${p.entries_ownership_mismatch} entries recursively`:'the bind root only';if(!confirm(`Repair ${what} on ${nodeName()} to ${expected}${mode?` and set top-level mode ${mode}`:''}?`))return;const btn=$('#repairBind');setBusy(btn,true,'Repairing…');try{const out=await api(`/api/host/bind-permissions/repair${qnode()}`,{method:'POST',body:JSON.stringify({container_id:p.container_id,destination:p.destination,recursive,fix_ownership:fix,mode})});toast(out.message||'Bind mount repaired');showBindPermissionPreview(out.after);if(state.stack?.name)setTimeout(loadStackPermissions,0)}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
}
|
||||
async function loadStackPermissions(){const box=$('#permissionsBox');if(!box||!state.stack?.name)return;box.innerHTML='<div class="empty">Inspecting containers and bind mounts…</div>';try{const d=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/bind-permissions${qnode()}`),rows=[];asArray(d.containers).forEach(c=>{if(c.error){rows.push({service:c.service,error:c.error});return}asArray(c.report?.bind_mounts).forEach(m=>rows.push({service:c.service,container:c.container_id,report:c.report,mount:m}))});window.__stackBindRows=rows;if(!rows.length){box.innerHTML='<div class="empty">No created containers or bind mounts found.</div>';return}box.innerHTML=`<div class="tablewrap"><table class="table"><thead><tr><th>Service</th><th>Host → container</th><th>Expected</th><th>Owner / mode</th><th>Write</th><th></th></tr></thead><tbody>${rows.map((x,i)=>x.error?`<tr><td><b>${esc(x.service)}</b></td><td colspan="5" class="red">${esc(x.error)}</td></tr>`:`<tr><td><b>${esc(x.service||x.report?.container_name)}</b></td><td><div class="mono">${esc(x.mount.source)}</div><div class="muted mono">→ ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'}</div></td><td class="mono">${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')}<div class="muted">${esc(x.report.bind_identity_source||'')}</div></td><td class="mono">${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')}</td><td>${x.mount.read_only?'<span class="muted">read-only</span>':writeState(x.mount.static_writable)}</td><td class="right">${!x.mount.read_only?`<button class="btn tiny" data-stackbind="${i}">Analyze / repair</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
async function inspectDockerResource(kind,r){const id=kind==='containers'?(r.ID||r.Names||r.Name):kind==='images'?(r.ID||([r.Repository,r.Tag].filter(Boolean).join(':'))):r.Name;if(!id)return;try{const d=await api(`/api/docker/${kind}/${encodeURIComponent(id)}/inspect${qnode()}`),i=d.inspect||{},st=d.stats||{};modal(`<div class="modalhead"><h2>${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${kind==='containers'?`<div class="stats"><div class="stat"><small>CPU</small><strong>${esc(st.CPUPerc||'—')}</strong></div><div class="stat"><small>Memory</small><strong>${esc(st.MemUsage||'—')}</strong></div><div class="stat"><small>Network I/O</small><strong>${esc(st.NetIO||'—')}</strong></div><div class="stat"><small>Block I/O</small><strong>${esc(st.BlockIO||'—')}</strong></div></div>`:''}<div class="field"><label>Inspect JSON <span class="muted">(operator/admin only; may contain sensitive environment values)</span></label><pre class="terminal" style="max-height:55vh">${esc(JSON.stringify(i,null,2))}</pre></div></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}catch(e){toast(e.message)}}
|
||||
function showOutput(title,text){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><pre class="terminal" style="max-height:60vh">${esc(text||'OK')}</pre></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}
|
||||
async function loadStackPermissions(){const box=$('#permissionsBox');if(!box||!state.stack?.name)return;box.innerHTML='<div class="empty">Container und Bind-Mounts werden geprüft…</div>';try{const d=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/bind-permissions${qnode()}`),rows=[];asArray(d.containers).forEach(c=>{if(c.error){rows.push({service:c.service,error:c.error});return}asArray(c.report?.bind_mounts).forEach(m=>rows.push({service:c.service,container:c.container_id,report:c.report,mount:m}))});window.__stackBindRows=rows;if(!rows.length){box.innerHTML='<div class="empty">Keine erzeugten Container oder Bind-Mounts gefunden.</div>';return}box.innerHTML=`<div class="tablewrap"><table class="table"><thead><tr><th>Dienst</th><th>Host → Container</th><th>Erwartet</th><th>Besitzer / Modus</th><th>Schreiben</th><th></th></tr></thead><tbody>${rows.map((x,i)=>x.error?`<tr><td><b>${esc(x.service)}</b></td><td colspan="5" class="red">${esc(x.error)}</td></tr>`:`<tr><td><b>${esc(x.service||x.report?.container_name)}</b></td><td><div class="mono">${esc(x.mount.source)}</div><div class="muted mono">→ ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'}</div></td><td class="mono">${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')}<div class="muted">${esc(x.report.bind_identity_source||'')}</div></td><td class="mono">${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')}</td><td>${x.mount.read_only?'<span class="muted">schreibgeschützt</span>':writeState(x.mount.static_writable)}</td><td class="right">${!x.mount.read_only?`<button class="btn tiny" data-stackbind="${i}">Analysieren / reparieren</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
|
||||
async function inspectDockerResource(kind,r){const id=kind==='containers'?(r.ID||r.Names||r.Name):kind==='images'?(r.ID||([r.Repository,r.Tag].filter(Boolean).join(':'))):r.Name;if(!id)return;try{const d=await api(`/api/docker/${kind}/${encodeURIComponent(id)}/inspect${qnode()}`),i=d.inspect||{},st=d.stats||{};modal(`<div class="modalhead"><h2>${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${kind==='containers'?`<div class="stats"><div class="stat"><small>CPU</small><strong>${esc(st.CPUPerc||'—')}</strong></div><div class="stat"><small>Arbeitsspeicher</small><strong>${esc(st.MemUsage||'—')}</strong></div><div class="stat"><small>Netzwerk-I/O</small><strong>${esc(st.NetIO||'—')}</strong></div><div class="stat"><small>Block-I/O</small><strong>${esc(st.BlockIO||'—')}</strong></div></div>`:''}<div class="field"><label>Inspect-JSON <span class="muted">(nur Operator/Admin; kann sensible Umgebungswerte enthalten)</span></label><pre class="terminal" style="max-height:55vh">${esc(JSON.stringify(i,null,2))}</pre></div></div><div class="modalfoot"><button class="btn" data-close>Schließen</button></div>`)}catch(e){toast(e.message)}}
|
||||
function showOutput(title,text){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><pre class="terminal" style="max-height:60vh">${esc(text||'OK')}</pre></div><div class="modalfoot"><button class="btn" data-close>Schließen</button></div>`)}
|
||||
|
||||
let terminalWS=null;
|
||||
function wsURL(path){const proto=location.protocol==='https:'?'wss:':'ws:';return `${proto}//${location.host}${path}`}
|
||||
@@ -196,73 +210,73 @@ function stripANSI(s){return String(s||'').replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g,'
|
||||
function closeTerminal(){const had=!!terminalWS;if(terminalWS){terminalWS.close();terminalWS=null}const out=$('#execOut');if(had&&out)out.textContent+='\n[disconnected]\n'}
|
||||
function terminalKey(e){if(!terminalWS||terminalWS.readyState!==WebSocket.OPEN)return;let d='';if(e.ctrlKey&&e.key.length===1){const c=e.key.toUpperCase().charCodeAt(0);if(c>=64&&c<=95)d=String.fromCharCode(c-64)}else{const map={Enter:'\r',Backspace:'\x7f',Tab:'\t',Escape:'\x1b',ArrowUp:'\x1b[A',ArrowDown:'\x1b[B',ArrowRight:'\x1b[C',ArrowLeft:'\x1b[D',Home:'\x1b[H',End:'\x1b[F',Delete:'\x1b[3~',PageUp:'\x1b[5~',PageDown:'\x1b[6~'};d=map[e.key]||(e.key.length===1&&!e.metaKey&&!e.altKey?e.key:'')}if(d){e.preventDefault();terminalWS.send(JSON.stringify({type:'input',data:d}))}}
|
||||
function terminalResize(){if(!terminalWS||terminalWS.readyState!==WebSocket.OPEN)return;const el=$('#execOut');if(!el)return;const cols=Math.max(40,Math.floor(el.clientWidth/7.2)),rows=Math.max(12,Math.floor(el.clientHeight/17));terminalWS.send(JSON.stringify({type:'resize',cols,rows}))}
|
||||
function openTerminal(){closeTerminal();if(!state.stack?.name)return toast('Save the stack first.');const service=$('#execService')?.value;if(!service)return toast('No service selected.');const shell=$('#execShell')?.value||'sh';const q=new URLSearchParams({service,shell});if(state.node)q.set('node_id',String(state.node));const out=$('#execOut');out.textContent=`Connecting to ${service}...\n`;terminalWS=new WebSocket(wsURL(`/api/stacks/${encodeURIComponent(state.stack.name)}/terminal?${q}`));terminalWS.onopen=()=>{out.textContent='';out.focus();terminalResize()};terminalWS.onmessage=e=>{try{const m=JSON.parse(e.data);if(m.type==='output'){out.textContent+=stripANSI(m.data);out.scrollTop=out.scrollHeight}else if(m.type==='error'){out.textContent+=`\n[error] ${m.data}\n`}else if(m.type==='exit'){out.textContent+='\n[session ended]\n'}}catch{out.textContent+=stripANSI(e.data)}};terminalWS.onerror=()=>toast('Terminal websocket failed.');terminalWS.onclose=()=>{terminalWS=null};out.onkeydown=terminalKey;out.onpaste=e=>{if(!terminalWS)return;e.preventDefault();terminalWS.send(JSON.stringify({type:'input',data:e.clipboardData.getData('text')}))};}
|
||||
async function loadGraph(){if(!state.stack?.name)return;const box=$('#graphBox');box.innerHTML='<div class="empty">Resolving Compose model…</div>';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='<div class="empty">No graph nodes.</div>';return}const services=nodes.filter(n=>n.kind==='service'),resources=nodes.filter(n=>n.kind!=='service');const h=Math.max(360,Math.max(services.length,resources.length)*76+50),w=Math.max(760,box.clientWidth||900);const pos={};services.forEach((n,i)=>pos[n.id]={x:140,y:55+i*76});resources.forEach((n,i)=>pos[n.id]={x:w-160,y:55+i*76});const lines=edges.map(e=>{const a=pos[e.from],b=pos[e.to];if(!a||!b)return'';return `<path d="M${a.x+100},${a.y} C${a.x+240},${a.y} ${b.x-240},${b.y} ${b.x-100},${b.y}" class="edge ${esc(e.kind)}"/><text x="${(a.x+b.x)/2}" y="${(a.y+b.y)/2-4}" class="edgelabel">${esc(e.kind)}</text>`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `<g class="gnode ${esc(n.kind)}"><rect x="${p.x-100}" y="${p.y-24}" width="200" height="48" rx="7"/><circle cx="${p.x-80}" cy="${p.y}" r="5" class="${n.running?'ok':''}"/><text x="${p.x-67}" y="${p.y-3}" class="glabel">${esc(n.label)}</text><text x="${p.x-67}" y="${p.y+12}" class="gsub">${esc(n.image||n.kind)}</text></g>`}).join('');box.innerHTML=`<svg viewBox="0 0 ${w} ${h}" role="img">${lines}${ns}</svg>`}
|
||||
async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='<div class="empty">Checking registry manifests…</div>';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`<table class="table"><thead><tr><th>Service</th><th>Image</th><th>Local digest</th><th>Remote digest</th><th>Status</th></tr></thead><tbody>${rows.map(r=>`<tr><td><b>${esc(r.service)}</b></td><td>${esc(r.image)}</td><td class="digest">${esc(r.local_digest||'—')}</td><td class="digest">${esc(r.remote_digest||'—')}</td><td>${r.error?`<span class="red" title="${esc(r.error)}">check failed</span>`:r.update?'<span class="amber">Update available</span>':'<span class="green">Current</span>'}</td></tr>`).join('')}</tbody></table>`}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function openTerminal(){closeTerminal();if(!state.stack?.name)return toast('Speichere den Stack zuerst.');const service=$('#execService')?.value;if(!service)return toast('Kein Dienst ausgewählt.');const shell=$('#execShell')?.value||'sh';const q=new URLSearchParams({service,shell});if(state.node)q.set('node_id',String(state.node));const out=$('#execOut');out.textContent=`Verbindung zu ${service} wird hergestellt…\n`;terminalWS=new WebSocket(wsURL(`/api/stacks/${encodeURIComponent(state.stack.name)}/terminal?${q}`));terminalWS.onopen=()=>{out.textContent='';out.focus();terminalResize()};terminalWS.onmessage=e=>{try{const m=JSON.parse(e.data);if(m.type==='output'){out.textContent+=stripANSI(m.data);out.scrollTop=out.scrollHeight}else if(m.type==='error'){out.textContent+=`\n[Fehler] ${m.data}\n`}else if(m.type==='exit'){out.textContent+='\n[Sitzung beendet]\n'}}catch{out.textContent+=stripANSI(e.data)}};terminalWS.onerror=()=>toast('Terminal-WebSocket fehlgeschlagen.');terminalWS.onclose=()=>{terminalWS=null};out.onkeydown=terminalKey;out.onpaste=e=>{if(!terminalWS)return;e.preventDefault();terminalWS.send(JSON.stringify({type:'input',data:e.clipboardData.getData('text')}))};}
|
||||
async function loadGraph(){if(!state.stack?.name)return;const box=$('#graphBox');box.innerHTML='<div class="empty">Compose-Modell wird aufgelöst…</div>';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
|
||||
function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='<div class="empty">Keine Graph-Knoten.</div>';return}const services=nodes.filter(n=>n.kind==='service'),resources=nodes.filter(n=>n.kind!=='service');const h=Math.max(360,Math.max(services.length,resources.length)*76+50),w=Math.max(760,box.clientWidth||900);const pos={};services.forEach((n,i)=>pos[n.id]={x:140,y:55+i*76});resources.forEach((n,i)=>pos[n.id]={x:w-160,y:55+i*76});const lines=edges.map(e=>{const a=pos[e.from],b=pos[e.to];if(!a||!b)return'';return `<path d="M${a.x+100},${a.y} C${a.x+240},${a.y} ${b.x-240},${b.y} ${b.x-100},${b.y}" class="edge ${esc(e.kind)}"/><text x="${(a.x+b.x)/2}" y="${(a.y+b.y)/2-4}" class="edgelabel">${esc(e.kind)}</text>`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `<g class="gnode ${esc(n.kind)}"><rect x="${p.x-100}" y="${p.y-24}" width="200" height="48" rx="7"/><circle cx="${p.x-80}" cy="${p.y}" r="5" class="${n.running?'ok':''}"/><text x="${p.x-67}" y="${p.y-3}" class="glabel">${esc(n.label)}</text><text x="${p.x-67}" y="${p.y+12}" class="gsub">${esc(n.image||n.kind)}</text></g>`}).join('');box.innerHTML=`<svg viewBox="0 0 ${w} ${h}" role="img">${lines}${ns}</svg>`}
|
||||
async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='<div class="empty">Registry-Manifeste werden geprüft…</div>';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`<table class="table"><thead><tr><th>Dienst</th><th>Image</th><th>Lokaler Digest</th><th>Remote-Digest</th><th>Status</th></tr></thead><tbody>${rows.map(r=>`<tr><td><b>${esc(r.service)}</b></td><td>${esc(r.image)}</td><td class="digest">${esc(r.local_digest||'—')}</td><td class="digest">${esc(r.remote_digest||'—')}</td><td>${r.error?`<span class="red" title="${esc(r.error)}">Prüfung fehlgeschlagen</span>`:r.update?'<span class="amber">Update verfügbar</span>':'<span class="green">Aktuell</span>'}</td></tr>`).join('')}</tbody></table>`}catch(e){box.innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
|
||||
|
||||
async function renderActivity(){setCrumb('System / Activity');$('#content').innerHTML=`${pageHead('Activity','Persistent audit trail for changes, deployments and monitor transitions.','<input class="search" id="actFilter" placeholder="Filter action…"><button class="btn" id="actRefresh">↻ Refresh</button>')}<div class="panel" id="activityPanel"><div class="empty">Loading audit events…</div></div>`;const load=async()=>{try{const q=$('#actFilter')?.value.trim()||'',rows=asArray(await api('/api/activity?limit=200'+(q?'&action='+encodeURIComponent(q):'')));$('#activityPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Resource</th><th>Status</th><th>Details</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${fmtTime(x.created_at)}</td><td><b>${esc(x.actor)}</b></td><td><span class="tag">${esc(x.action)}</span></td><td>${esc(x.resource||'—')}</td><td>${x.status>=200&&x.status<300?'<span class="green">'+x.status+'</span>':'<span class="red">'+x.status+'</span>'}</td><td class="muted">${esc(JSON.stringify(x.detail||{}))}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No matching audit events.</div>'}catch(e){$('#activityPanel').innerHTML=`<div class="empty red">${esc(e.message)}</div>`}};$('#actRefresh').onclick=load;let t;$('#actFilter').oninput=()=>{clearTimeout(t);t=setTimeout(load,300)};load()}
|
||||
async function renderActivity(){setCrumb('System / Aktivitäten');$('#content').innerHTML=`${pageHead('Aktivitäten','Persistentes Audit-Protokoll für Änderungen, Bereitstellungen und Monitor-Statuswechsel.','<input class="search" id="actFilter" placeholder="Aktion filtern…"><button class="btn" id="actRefresh">↻ Aktualisieren</button>')}<div class="panel" id="activityPanel"><div class="empty">Audit-Ereignisse werden geladen…</div></div>`;const load=async()=>{try{const q=$('#actFilter')?.value.trim()||'',rows=asArray(await api('/api/activity?limit=200'+(q?'&action='+encodeURIComponent(q):'')));$('#activityPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Zeit</th><th>Akteur</th><th>Aktion</th><th>Ressource</th><th>Status</th><th>Details</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${fmtTime(x.created_at)}</td><td><b>${esc(x.actor)}</b></td><td><span class="tag">${esc(x.action)}</span></td><td>${esc(x.resource||'—')}</td><td>${x.status>=200&&x.status<300?'<span class="green">'+x.status+'</span>':'<span class="red">'+x.status+'</span>'}</td><td class="muted">${esc(JSON.stringify(x.detail||{}))}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine passenden Audit-Ereignisse.</div>'}catch(e){$('#activityPanel').innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}};$('#actRefresh').onclick=load;let t;$('#actFilter').oninput=()=>{clearTimeout(t);t=setTimeout(load,300)};load()}
|
||||
|
||||
async function renderGit(){setCrumb('Docker / Git Stacks');$('#content').innerHTML=`${pageHead('Git stacks','Synchronize Compose stacks from Git and deploy them from signed webhooks.',roleOK()?'<button class="btn primary" id="addGit">+ Add Git source</button>':'')}<div class="panel" id="gitPanel"><div class="empty">Loading Git sources…</div></div>`;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Stack</th><th>Environment</th><th>Repository</th><th>Branch</th><th>Commit</th><th>Last sync</th><th>Auto deploy</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.stack_name)}</b>${x.last_error?`<div class="red">${esc(x.last_error)}</div>`:''}</td><td>${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote'):'Local Docker')}</td><td>${esc(x.repo_url)}</td><td><span class="tag">${esc(x.branch)}</span></td><td class="digest">${esc((x.last_commit||'—').slice(0,12))}</td><td>${x.last_sync_at?fmtTime(x.last_sync_at):'Never'}</td><td>${x.auto_deploy?'<span class="green">Yes</span>':'No'}</td><td class="right">${roleOK()?`<button class="btn tiny" data-gsync="${x.id}">Sync</button> <button class="btn tiny" data-gedit="${x.id}">Edit</button> <button class="btn tiny danger" data-gdel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No Git sources configured.</div>';window.__gitRows=rows;$$('[data-gsync]').forEach(b=>b.onclick=()=>gitSync(Number(b.dataset.gsync)));$$('[data-gedit]').forEach(b=>b.onclick=()=>gitModal(rows.find(x=>x.id===Number(b.dataset.gedit))));$$('[data-gdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete Git source configuration?')){await api('/api/git-sources/'+b.dataset.gdel,{method:'DELETE'});renderGit()}})}catch(e){$('#gitPanel').innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function gitModal(x=null){modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} Git source</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Stack name</label><input id="gitStack" value="${esc(x?.stack_name||'')}"></div><div class="field"><label>Environment</label><select id="gitNode"><option value="0">Local Docker</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}</select></div><div class="field"><label>Branch</label><input id="gitBranch" value="${esc(x?.branch||'main')}"></div><div class="field full"><label>Repository URL</label><input id="gitRepo" value="${esc(x?.repo_url||'')}" placeholder="https://github.com/org/repo.git or ssh://..."></div><div class="field"><label>Workdir</label><input id="gitWorkdir" value="${esc(x?.workdir||'.')}"></div><div class="field"><label>Compose file</label><input id="gitCompose" value="${esc(x?.compose_file||'compose.yaml')}"></div><label class="switch"><input id="gitAuto" type="checkbox" ${x?.auto_deploy?'checked':''}> Deploy after sync/webhook</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveGit">Save</button></div>`);$('#gitNode').value=String(x?.node_id||state.node||0);$('#saveGit').onclick=async()=>{const body={node_id:Number($('#gitNode').value)||null,stack_name:$('#gitStack').value,repo_url:$('#gitRepo').value,branch:$('#gitBranch').value,workdir:$('#gitWorkdir').value,compose_file:$('#gitCompose').value,auto_deploy:$('#gitAuto').checked};try{const r=await api(x?`/api/git-sources/${x.id}`:'/api/git-sources',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();if(!x)showGitSecret(r);renderGit()}catch(e){toast(e.message)}}}
|
||||
function showGitSecret(r){modal(`<div class="modalhead"><h2>Webhook created</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Copy this secret now. It is stored encrypted and will not be shown again.</div><div class="field" style="margin-top:10px"><label>Webhook URL</label><input readonly value="${esc(r.webhook_url)}"></div><div class="field" style="margin-top:10px"><label>Webhook secret</label><input readonly value="${esc(r.webhook_secret)}"></div><p class="muted">GitHub: use the secret for X-Hub-Signature-256. GitLab: send it as X-Gitlab-Token. Generic hooks may use X-Webhook-Token.</p></div><div class="modalfoot"><button class="btn primary" data-close>Done</button></div>`)}
|
||||
async function gitSync(id){try{toast('Git sync started…');await api(`/api/git-sources/${id}/sync`,{method:'POST'});await refreshData(true);renderGit();toast('Git stack synchronized.')}catch(e){toast(e.message);renderGit()}}
|
||||
async function renderGit(){setCrumb('Docker / Git-Stacks');$('#content').innerHTML=`${pageHead('Git-Stacks','Compose-Stacks aus Git synchronisieren und über signierte Webhooks bereitstellen.',roleOK()?'<button class="btn primary" id="addGit">+ Git-Quelle hinzufügen</button>':'')}<div class="panel" id="gitPanel"><div class="empty">Git-Quellen werden geladen…</div></div>`;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Stack</th><th>Umgebung</th><th>Repository</th><th>Branch</th><th>Commit</th><th>Letzte Synchronisierung</th><th>Automatisch bereitstellen</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.stack_name)}</b>${x.last_error?`<div class="red">${esc(x.last_error)}</div>`:''}</td><td>${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote-Host'):'Lokaler Docker-Host')}</td><td>${esc(x.repo_url)}</td><td><span class="tag">${esc(x.branch)}</span></td><td class="digest">${esc((x.last_commit||'—').slice(0,12))}</td><td>${x.last_sync_at?fmtTime(x.last_sync_at):'Nie'}</td><td>${x.auto_deploy?'<span class="green">Ja</span>':'Nein'}</td><td class="right">${roleOK()?`<button class="btn tiny" data-gsync="${x.id}">Synchronisieren</button> <button class="btn tiny" data-gedit="${x.id}">Bearbeiten</button> <button class="btn tiny danger" data-gdel="${x.id}">Löschen</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine Git-Quellen konfiguriert.</div>';window.__gitRows=rows;$$('[data-gsync]').forEach(b=>b.onclick=()=>gitSync(Number(b.dataset.gsync)));$$('[data-gedit]').forEach(b=>b.onclick=()=>gitModal(rows.find(x=>x.id===Number(b.dataset.gedit))));$$('[data-gdel]').forEach(b=>b.onclick=async()=>{if(confirm('Git-Quellenkonfiguration löschen?')){await api('/api/git-sources/'+b.dataset.gdel,{method:'DELETE'});renderGit()}})}catch(e){$('#gitPanel').innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
|
||||
function gitModal(x=null){modal(`<div class="modalhead"><h2>${x?'Git-Quelle bearbeiten':'Git-Quelle hinzufügen'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Stack-Name</label><input id="gitStack" value="${esc(x?.stack_name||'')}"></div><div class="field"><label>Umgebung</label><select id="gitNode"><option value="0">Lokaler Docker-Host</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · deaktiviert'}</option>`).join('')}</select></div><div class="field"><label>Branch</label><input id="gitBranch" value="${esc(x?.branch||'main')}"></div><div class="field full"><label>Repository-URL</label><input id="gitRepo" value="${esc(x?.repo_url||'')}" placeholder="https://github.com/org/repo.git oder ssh://..."></div><div class="field"><label>Arbeitsverzeichnis</label><input id="gitWorkdir" value="${esc(x?.workdir||'.')}"></div><div class="field"><label>Compose-Datei</label><input id="gitCompose" value="${esc(x?.compose_file||'compose.yaml')}"></div><label class="switch"><input id="gitAuto" type="checkbox" ${x?.auto_deploy?'checked':''}> Nach Sync/Webhook bereitstellen</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="saveGit">Speichern</button></div>`);$('#gitNode').value=String(x?.node_id||state.node||0);$('#saveGit').onclick=async()=>{const body={node_id:Number($('#gitNode').value)||null,stack_name:$('#gitStack').value,repo_url:$('#gitRepo').value,branch:$('#gitBranch').value,workdir:$('#gitWorkdir').value,compose_file:$('#gitCompose').value,auto_deploy:$('#gitAuto').checked};try{const r=await api(x?`/api/git-sources/${x.id}`:'/api/git-sources',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();if(!x)showGitSecret(r);renderGit()}catch(e){toast(e.message)}}}
|
||||
function showGitSecret(r){modal(`<div class="modalhead"><h2>Webhook erstellt</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dieses Secret jetzt kopieren. Es wird verschlüsselt gespeichert und nicht erneut angezeigt.</div><div class="field" style="margin-top:10px"><label>Webhook-URL</label><input readonly value="${esc(r.webhook_url)}"></div><div class="field" style="margin-top:10px"><label>Webhook-Secret</label><input readonly value="${esc(r.webhook_secret)}"></div><p class="muted">GitHub: Secret für X-Hub-Signature-256 verwenden. GitLab: als X-Gitlab-Token senden. Generische Hooks können X-Webhook-Token verwenden.</p></div><div class="modalfoot"><button class="btn primary" data-close>Fertig</button></div>`)}
|
||||
async function gitSync(id){try{toast('Git-Synchronisierung gestartet…');await api(`/api/git-sources/${id}/sync`,{method:'POST'});await refreshData(true);renderGit();toast('Git-Stack synchronisiert.')}catch(e){toast(e.message);renderGit()}}
|
||||
|
||||
async function renderNotifications(){setCrumb('Observability / Notifications');$('#content').innerHTML=`${pageHead('Notifications','Send monitor state transitions to Webhook, ntfy, Gotify or SMTP.',state.me.role==='admin'?'<button class="btn primary" id="addNotify">+ Add provider</button>':'')}<div class="panel" id="notifyPanel"><div class="empty">Loading providers…</div></div>`;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Provider</th><th>Enabled</th><th>Configuration</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b></td><td><span class="tag">${esc(x.type)}</span></td><td>${x.enabled?'<span class="green">Enabled</span>':'Disabled'}</td><td class="muted">${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-ntest="${x.id}">Test</button> <button class="btn tiny" data-nedit="${x.id}">Edit</button> <button class="btn tiny danger" data-ndel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No notification providers configured.</div>';$$('[data-ntest]').forEach(b=>b.onclick=async()=>{try{await api(`/api/notifications/${b.dataset.ntest}/test`,{method:'POST'});toast('Test notification sent.')}catch(e){toast(e.message)}});$$('[data-nedit]').forEach(b=>b.onclick=()=>notificationModal(rows.find(x=>x.id===Number(b.dataset.nedit))));$$('[data-ndel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete notification provider?')){await api('/api/notifications/'+b.dataset.ndel,{method:'DELETE'});renderNotifications()}})}catch(e){$('#notifyPanel').innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
|
||||
function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`<div class="field"><label>${label}</label><input id="${id}" ${secret?'type="password"':''} value="${esc(c[key]||'')}" placeholder="${esc(ph)}"></div>`;if(type==='webhook')return f('nUrl','URL','url',false,'https://...')+f('nToken','Bearer token','bearer_token',true);if(type==='ntfy')return f('nServer','Server','server',false,'https://ntfy.sh')+f('nTopic','Topic','topic')+f('nToken','Access token','token',true);if(type==='gotify')return f('nServer','Server','server',false,'https://gotify.example.com')+f('nToken','App token','token',true);return f('nHost','SMTP host','host')+f('nPort','Port','port',false,'587')+`<div class="field"><label>Security</label><select id="nSecurity"><option value="starttls">STARTTLS</option><option value="tls">SSL/TLS</option><option value="none">None</option></select></div><label class="switch"><input id="nAuth" type="checkbox" ${c.auth==='true'||(!c.auth&&c.username)?'checked':''}> SMTP authentication</label>`+f('nUser','Username','username')+f('nPass','Password','password',true)+f('nFrom','From','from')+f('nTo','To','to',false,'ops@example.com')+`<label class="switch"><input id="nSkipVerify" type="checkbox" ${c.skip_verify==='true'?'checked':''}> Disable TLS certificate verification (unsafe)</label>`}
|
||||
function notificationModal(x=null){const type=x?.type||'webhook';modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} notification provider</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nName" value="${esc(x?.name||'')}"></div><div class="field"><label>Type</label><select id="nType"><option value="webhook">Webhook</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option><option value="smtp">SMTP</option></select></div><div id="nConfig" class="fieldgrid full" style="grid-column:1/-1">${notifyConfigFields(type,x?.config||{})}</div><label class="switch"><input id="nEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Enabled</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="nSave">Save</button></div>`);$('#nType').value=type;if(type==='smtp'&&$('#nSecurity'))$('#nSecurity').value=x?.config?.security||'starttls';$('#nType').onchange=()=>$('#nConfig').innerHTML=notifyConfigFields($('#nType').value,{});$('#nSave').onclick=async()=>{const t=$('#nType').value,c={};if(t==='webhook'){c.url=$('#nUrl').value;c.bearer_token=$('#nToken').value}else if(t==='ntfy'){c.server=$('#nServer').value;c.topic=$('#nTopic').value;c.token=$('#nToken').value}else if(t==='gotify'){c.server=$('#nServer').value;c.token=$('#nToken').value}else{c.host=$('#nHost').value;c.port=$('#nPort').value;c.security=$('#nSecurity').value;c.auth=String($('#nAuth').checked);c.username=$('#nUser').value;c.password=$('#nPass').value;c.from=$('#nFrom').value;c.to=$('#nTo').value;c.skip_verify=String($('#nSkipVerify').checked)}try{await api(x?`/api/notifications/${x.id}`:'/api/notifications',{method:x?'PUT':'POST',body:JSON.stringify({name:$('#nName').value,type:t,config:c,enabled:$('#nEnabled').checked})});closeModal();renderNotifications()}catch(e){toast(e.message)}}}
|
||||
async function renderNotifications(){setCrumb('Überwachung / Benachrichtigungen');$('#content').innerHTML=`${pageHead('Benachrichtigungen','Monitor-Statuswechsel per Webhook, ntfy, Gotify oder SMTP senden.',state.me.role==='admin'?'<button class="btn primary" id="addNotify">+ Provider hinzufügen</button>':'')}<div class="panel" id="notifyPanel"><div class="empty">Provider werden geladen…</div></div>`;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Provider</th><th>Aktiviert</th><th>Konfiguration</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b></td><td><span class="tag">${esc(x.type)}</span></td><td>${x.enabled?'<span class="green">Aktiviert</span>':'Deaktiviert'}</td><td class="muted">${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-ntest="${x.id}">Test</button> <button class="btn tiny" data-nedit="${x.id}">Bearbeiten</button> <button class="btn tiny danger" data-ndel="${x.id}">Löschen</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine Benachrichtigungs-Provider konfiguriert.</div>';$$('[data-ntest]').forEach(b=>b.onclick=async()=>{try{await api(`/api/notifications/${b.dataset.ntest}/test`,{method:'POST'});toast('Testbenachrichtigung gesendet.')}catch(e){toast(e.message)}});$$('[data-nedit]').forEach(b=>b.onclick=()=>notificationModal(rows.find(x=>x.id===Number(b.dataset.nedit))));$$('[data-ndel]').forEach(b=>b.onclick=async()=>{if(confirm('Benachrichtigungs-Provider löschen?')){await api('/api/notifications/'+b.dataset.ndel,{method:'DELETE'});renderNotifications()}})}catch(e){$('#notifyPanel').innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
|
||||
function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`<div class="field"><label>${label}</label><input id="${id}" ${secret?'type="password"':''} value="${esc(c[key]||'')}" placeholder="${esc(ph)}"></div>`;if(type==='webhook')return f('nUrl','URL','url',false,'https://...')+f('nToken','Bearer-Token','bearer_token',true);if(type==='ntfy')return f('nServer','Server','server',false,'https://ntfy.sh')+f('nTopic','Thema','topic')+f('nToken','Zugriffstoken','token',true);if(type==='gotify')return f('nServer','Server','server',false,'https://gotify.example.com')+f('nToken','App-Token','token',true);return f('nHost','SMTP-Host','host')+f('nPort','Port','port',false,'587')+`<div class="field"><label>Sicherheit</label><select id="nSecurity"><option value="starttls">STARTTLS</option><option value="tls">SSL/TLS</option><option value="none">Keine</option></select></div><label class="switch"><input id="nAuth" type="checkbox" ${c.auth==='true'||(!c.auth&&c.username)?'checked':''}> SMTP-Authentifizierung</label>`+f('nUser','Benutzername','username')+f('nPass','Passwort','password',true)+f('nFrom','Absender','from')+f('nTo','Empfänger','to',false,'ops@example.com')+`<label class="switch"><input id="nSkipVerify" type="checkbox" ${c.skip_verify==='true'?'checked':''}> TLS-Zertifikatsprüfung deaktivieren (unsicher)</label>`}
|
||||
function notificationModal(x=null){const type=x?.type||'webhook';modal(`<div class="modalhead"><h2>${x?'Benachrichtigungs-Provider bearbeiten':'Benachrichtigungs-Provider hinzufügen'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nName" value="${esc(x?.name||'')}"></div><div class="field"><label>Typ</label><select id="nType"><option value="webhook">Webhook</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option><option value="smtp">SMTP</option></select></div><div id="nConfig" class="fieldgrid full" style="grid-column:1/-1">${notifyConfigFields(type,x?.config||{})}</div><label class="switch"><input id="nEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Aktiviert</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="nSave">Speichern</button></div>`);$('#nType').value=type;if(type==='smtp'&&$('#nSecurity'))$('#nSecurity').value=x?.config?.security||'starttls';$('#nType').onchange=()=>$('#nConfig').innerHTML=notifyConfigFields($('#nType').value,{});$('#nSave').onclick=async()=>{const t=$('#nType').value,c={};if(t==='webhook'){c.url=$('#nUrl').value;c.bearer_token=$('#nToken').value}else if(t==='ntfy'){c.server=$('#nServer').value;c.topic=$('#nTopic').value;c.token=$('#nToken').value}else if(t==='gotify'){c.server=$('#nServer').value;c.token=$('#nToken').value}else{c.host=$('#nHost').value;c.port=$('#nPort').value;c.security=$('#nSecurity').value;c.auth=String($('#nAuth').checked);c.username=$('#nUser').value;c.password=$('#nPass').value;c.from=$('#nFrom').value;c.to=$('#nTo').value;c.skip_verify=String($('#nSkipVerify').checked)}try{await api(x?`/api/notifications/${x.id}`:'/api/notifications',{method:x?'PUT':'POST',body:JSON.stringify({name:$('#nName').value,type:t,config:c,enabled:$('#nEnabled').checked})});closeModal();renderNotifications()}catch(e){toast(e.message)}}}
|
||||
|
||||
|
||||
|
||||
async function renderSecurity(){
|
||||
setCrumb(`System / Host Security / ${nodeName()}`);
|
||||
if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('Host Security','Administrator access required.')}<div class="empty">Host security configuration is restricted to administrators.</div>`; return }
|
||||
$('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Security audit</button>')}<div class="panel"><div class="empty">Auditing host security posture…</div></div>`;
|
||||
setCrumb(`System / Host-Sicherheit / ${nodeName()}`);
|
||||
if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('Host-Sicherheit','Administratorrechte erforderlich.')}<div class="empty">Die Host-Sicherheitskonfiguration ist auf Administratoren beschränkt.</div>`; return }
|
||||
$('#content').innerHTML=`${pageHead('Host-Sicherheit',`Linux-Host härten, konfigurieren und warten · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Sicherheitsprüfung</button>')}<div class="panel"><div class="empty">Host-Sicherheitsstatus wird geprüft…</div></div>`;
|
||||
$('#securityRefresh').onclick=renderSecurity;
|
||||
try{
|
||||
const d=await api('/api/security/status'+qnode()),c=d.capabilities||{},os=d.os||{},managed=d.managed||{};
|
||||
window.__securityCaps=c;
|
||||
window.__firewallBackend=d.firewall_backend||{};
|
||||
const capClass=!c.enabled?'securityOff':c.allow_changes&&c.executor_available?'securityManage':'securityAudit';
|
||||
$('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Security audit</button>')}
|
||||
<div class="securityHero ${capClass}"><div><small>HOST SECURITY POSTURE</small><div class="securityScore">${Number(d.score||0)}<span>/100</span></div><p>${esc(os.pretty_name||os.name||'Host OS unknown')} · ${esc(d.package_manager||'package manager unknown')} · ${esc(d.init_system||'init unknown')}</p></div><div class="securityCaps">${securityCapabilityPills(c)}</div></div>
|
||||
${c.reason?`<div class="notice ${c.enabled?'':'warnNotice'}"><b>Capability:</b> ${esc(c.reason)}</div>`:''}
|
||||
$('#content').innerHTML=`${pageHead('Host-Sicherheit',`Linux-Host härten, konfigurieren und warten · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Sicherheitsprüfung</button>')}
|
||||
<div class="securityHero ${capClass}"><div><small>HOST-SICHERHEITSSTATUS</small><div class="securityScore">${Number(d.score||0)}<span>/100</span></div><p>${esc(os.pretty_name||os.name||'Host-Betriebssystem unbekannt')} · ${esc(d.package_manager||'Paketmanager unbekannt')} · ${esc(d.init_system||'Init-System unbekannt')}</p></div><div class="securityCaps">${securityCapabilityPills(c)}</div></div>
|
||||
${c.reason?`<div class="notice ${c.enabled?'':'warnNotice'}"><b>Berechtigungsmodus:</b> ${esc(c.reason)}</div>`:''}
|
||||
<div class="securityGrid">
|
||||
${securityComponentCard('firewall',`Firewall · ${esc((d.firewall_backend||{}).selected||'auto')}`,'Auto-detected native firewall management through UFW, firewalld or isolated nftables.',d.firewall,managed.firewall,d.conflicts)}
|
||||
${securityComponentCard('fail2ban','Fail2Ban','Rate-limit and ban repeated authentication failures using managed jail.d overrides.',d.fail2ban,managed.fail2ban)}
|
||||
${securityComponentCard('auditd','Linux Audit · auditd','Track changes to identity, SSH, sudo, Docker and selected host paths.',d.auditd,managed.auditd)}
|
||||
${securityComponentCard('firewall',`Firewall · ${esc((d.firewall_backend||{}).selected||'auto')}`,'Automatisch erkannte native Firewall-Verwaltung über UFW, firewalld oder isoliertes nftables.',d.firewall,managed.firewall,d.conflicts)}
|
||||
${securityComponentCard('fail2ban','Fail2Ban','Wiederholte Authentifizierungsfehler begrenzen und über verwaltete jail.d-Regeln sperren.',d.fail2ban,managed.fail2ban)}
|
||||
${securityComponentCard('auditd','Linux Audit · auditd','Änderungen an Identität, SSH, sudo, Docker und ausgewählten Host-Pfaden überwachen.',d.auditd,managed.auditd)}
|
||||
</div>
|
||||
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Security findings</h2><span class="muted">Dockwatch-managed posture only; not a substitute for a full host benchmark.</span></div>${securityFindings(d.findings||[])}</div>
|
||||
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Safety model</h2></div><div class="securitySafety"><div><b>Audit first</b><p>Inspection can be enabled independently from host mutations.</p></div><div><b>Explicit mutation opt-in</b><p>Configuration and package installation are separate capabilities.</p></div><div><b>Managed drop-ins</b><p>Fail2Ban and auditd use dedicated Dockwatch files; foreign configuration is preserved.</p></div><div><b>Audited changes</b><p>Every mutation passes the existing Dockwatch admin RBAC, origin guard and Activity audit trail.</p></div></div></div>`;
|
||||
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Sicherheitsbefunde</h2><span class="muted">Nur von Dockwatch verwalteter Sicherheitsstatus; kein Ersatz für einen vollständigen Host-Benchmark.</span></div>${securityFindings(d.findings||[])}</div>
|
||||
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Sicherheitsmodell</h2></div><div class="securitySafety"><div><b>Zuerst prüfen</b><p>Prüfungen können unabhängig von Host-Änderungen aktiviert werden.</p></div><div><b>Explizite Freigabe für Änderungen</b><p>Konfigurationsänderungen und Paketinstallation sind getrennte Berechtigungen.</p></div><div><b>Verwaltete Drop-ins</b><p>Fail2Ban und auditd verwenden eigene Dockwatch-Dateien; fremde Konfiguration bleibt erhalten.</p></div><div><b>Protokollierte Änderungen</b><p>Jede Änderung durchläuft Dockwatch-Admin-RBAC, Origin-Schutz und das Aktivitätsprotokoll.</p></div></div></div>`;
|
||||
$('#securityRefresh').onclick=renderSecurity;
|
||||
$$('[data-secinstall]').forEach(b=>b.onclick=()=>securityInstall(b.dataset.secinstall,b));
|
||||
$$('[data-secaction]').forEach(b=>b.onclick=()=>securityComponentAction(b.dataset.seccomponent,b.dataset.secaction,b));
|
||||
$('#configure-firewall')?.addEventListener('click',securityFirewallModal);
|
||||
$('#configure-fail2ban')?.addEventListener('click',securityFail2BanModal);
|
||||
$('#configure-auditd')?.addEventListener('click',securityAuditdModal);
|
||||
}catch(e){$('#content').innerHTML=`${pageHead('Host Security',nodeName())}<div class="empty red">${esc(e.message)}</div>`}
|
||||
}catch(e){$('#content').innerHTML=`${pageHead('Host-Sicherheit',nodeName())}<div class="empty red">${esc(uiMessage(e.message))}</div>`}
|
||||
}
|
||||
function securityCapabilityPills(c){return `<span class="tag ${c.host_root_available?'oktag':''}">host root ${c.host_root_available?'✓':'×'}</span><span class="tag ${c.target_verified?'oktag':''}">host namespace ${c.target_verified?'✓':'×'}</span><span class="tag ${c.allow_changes?'oktag':''}">${c.allow_changes?'manage':'audit only'}</span><span class="tag ${c.allow_package_management?'oktag':''}">packages ${c.allow_package_management?'enabled':'locked'}</span>`}
|
||||
function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host security changes are disabled for this environment"',pkgDisabled=packages?'':'disabled title="Host package management is disabled for this environment"',canConfigure=manage&&(key==='firewall'||installed);return `<section class="securityCard"><div class="securityCardHead"><div><span class="securityIcon">${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}</span><div><h2>${title}</h2><p>${esc(desc)}</p></div></div>${installed?(active?badge('up'):badge('paused')):badge('unknown')}</div><div class="securityFacts"><span><small>Installed</small><b>${installed?'Yes':'No'}</b></span><span><small>Runtime</small><b class="${active?'green':'muted'}">${active?'Active':'Inactive'}</b></span><span><small>Boot</small><b>${st.enabled?'Enabled':'—'}</b></span><span><small>Config</small><b class="${drift?'amber':''}">${managed?.configured?(drift?'Drift':'Managed'):'Not managed'}</b></span></div>${st.version?`<div class="securityVersion">${esc(st.version)}</div>`:''}${st.detail?`<pre class="securityDetail">${esc(st.detail)}</pre>`:''}${conflicts?.length?`<div class="notice dangerNotice"><b>Firewall conflict:</b> ${esc(conflicts.join(', '))}. Resolve competing active frontends before applying changes.</div>`:''}<div class="securityActions">${!installed?`<button class="btn primary" data-secinstall="${key}" ${pkgDisabled}>Install</button>`:''}<button class="btn" id="configure-${key}" ${!canConfigure?'disabled':''} ${!manage?'title="Host security changes are disabled for this environment"':''}>Configure</button>${installed?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="enable" ${manageDisabled}>Enable</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="disable" ${manageDisabled}>Disable</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="restart" ${manageDisabled}>Restart</button>${key!=='firewall'?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="reload" ${manageDisabled}>Reload</button>`:''}<button class="btn tiny" data-secinstall="${key}" ${pkgDisabled} title="Uses the host package manager to install the currently available package version">Upgrade</button>`:''}</div></section>`}
|
||||
function securityFindings(rows){if(!rows.length)return '<div class="empty">No findings.</div>';return `<div class="securityFindings">${rows.map(f=>`<div class="securityFinding sev-${esc(f.severity)}"><span>${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}</span><div><b>${esc(f.title)}</b><p>${esc(f.detail)}</p>${f.action?`<small>${esc(f.action)}</small>`:''}</div></div>`).join('')}</div>`}
|
||||
async function securityInstall(component,btn,provider=''){if(component==='firewall'&&!provider)provider=(window.__firewallBackend||{}).selected||'nftables';if(!confirm(`Install or upgrade ${component==='firewall'?provider:component} on ${nodeName()} using the host package manager?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall',provider})});toast(out.message||'Package operation complete');if(out.output)showOutput(`${component} package operation`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${action} ${component} on ${nodeName()}?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component} ${action} complete`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
function securityCapabilityPills(c){return `<span class="tag ${c.host_root_available?'oktag':''}">Host-Root ${c.host_root_available?'✓':'×'}</span><span class="tag ${c.target_verified?'oktag':''}">Host-Namespace ${c.target_verified?'✓':'×'}</span><span class="tag ${c.allow_changes?'oktag':''}">${c.allow_changes?'Verwaltung':'nur Prüfung'}</span><span class="tag ${c.allow_package_management?'oktag':''}">Pakete ${c.allow_package_management?'freigegeben':'gesperrt'}</span>`}
|
||||
function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host-Sicherheitsänderungen sind für diese Umgebung deaktiviert"',pkgDisabled=packages?'':'disabled title="Host-Paketverwaltung ist für diese Umgebung deaktiviert"',canConfigure=manage&&(key==='firewall'||installed);return `<section class="securityCard"><div class="securityCardHead"><div><span class="securityIcon">${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}</span><div><h2>${title}</h2><p>${esc(desc)}</p></div></div>${installed?(active?badge('up'):badge('paused')):badge('unknown')}</div><div class="securityFacts"><span><small>Installiert</small><b>${installed?'Ja':'Nein'}</b></span><span><small>Laufzeit</small><b class="${active?'green':'muted'}">${active?'Aktiv':'Inaktiv'}</b></span><span><small>Systemstart</small><b>${st.enabled?'Aktiviert':'—'}</b></span><span><small>Konfiguration</small><b class="${drift?'amber':''}">${managed?.configured?(drift?'Abweichung':'Verwaltet'):'Nicht verwaltet'}</b></span></div>${st.version?`<div class="securityVersion">${esc(st.version)}</div>`:''}${st.detail?`<pre class="securityDetail">${esc(st.detail)}</pre>`:''}${conflicts?.length?`<div class="notice dangerNotice"><b>Firewall-Konflikt:</b> ${esc(conflicts.join(', '))}. Aktive konkurrierende Frontends müssen vor Änderungen aufgelöst werden.</div>`:''}<div class="securityActions">${!installed?`<button class="btn primary" data-secinstall="${key}" ${pkgDisabled}>Installieren</button>`:''}<button class="btn" id="configure-${key}" ${!canConfigure?'disabled':''} ${!manage?'title="Host-Sicherheitsänderungen sind für diese Umgebung deaktiviert"':''}>Konfigurieren</button>${installed?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="enable" ${manageDisabled}>Aktivieren</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="disable" ${manageDisabled}>Deaktivieren</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="restart" ${manageDisabled}>Neustart</button>${key!=='firewall'?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="reload" ${manageDisabled}>Neu laden</button>`:''}<button class="btn tiny" data-secinstall="${key}" ${pkgDisabled} title="Verwendet den Host-Paketmanager, um die aktuell verfügbare Paketversion zu installieren">Aktualisieren</button>`:''}</div></section>`}
|
||||
function securityFindings(rows){if(!rows.length)return '<div class="empty">Keine Befunde.</div>';return `<div class="securityFindings">${rows.map(f=>`<div class="securityFinding sev-${esc(f.severity)}"><span>${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}</span><div><b>${esc(uiMessage(f.title))}</b><p>${esc(uiMessage(f.detail))}</p>${f.action?`<small>${esc(uiMessage(f.action))}</small>`:''}</div></div>`).join('')}</div>`}
|
||||
async function securityInstall(component,btn,provider=''){if(component==='firewall'&&!provider)provider=(window.__firewallBackend||{}).selected||'nftables';if(!confirm(`${component==='firewall'?provider:component} auf ${nodeName()} über den Host-Paketmanager installieren oder aktualisieren?`))return;setBusy(btn,true,'Wird ausgeführt…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall',provider})});toast(out.message||'Paketvorgang abgeschlossen.');if(out.output)showOutput(`${component} · Paketvorgang`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${component} auf ${nodeName()} wirklich ${action==='disable'?'deaktivieren':'stoppen'}?`))return;setBusy(btn,true,'Wird ausgeführt…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component}: Aktion abgeschlossen.`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
|
||||
async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode()),preview=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(p)});firewallEditor(preview.policy||p,preview)}catch(e){toast(e.message)}}
|
||||
function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>`<option value="${v}" ${current===v?'selected':''}>${v==='auto'?'Auto detect':v}</option>`).join('')}
|
||||
function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `<div class="panel inset fwRuntime"><div class="panelhead"><h3>Detected host firewall</h3><span class="tag">selected: ${esc(b.selected||'—')}</span></div><div class="securityFacts"><span><small>Available</small><b>${esc(available.join(', ')||'none')}</b></span><span><small>Active</small><b>${esc(active.join(', ')||'none')}</b></span><span><small>Default</small><b>${esc(r.default_inbound||'—')}</b></span><span><small>Zone</small><b>${esc(r.zone||b.default_zone||'—')}</b></span></div>${b.reason?`<div class="notice ${asArray(b.conflicts).length?'dangerNotice':'warnNotice'}">${esc(b.reason)}</div>`:''}<div class="field"><label>Existing native rules/state <span class="muted">· read-only · foreign rules are preserved</span></label><pre class="terminal fwExisting" style="max-height:230px">${esc(r.raw||'No runtime rules reported.')}</pre></div></div>`}
|
||||
function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`<div class="modalhead"><h2>Host firewall · ${esc(selected)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Provider model:</b> Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs <code>ufw reset</code> or <code>nft flush ruleset</code>.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Firewall provider</label><select id="fwProvider">${firewallProviderOptions(p.provider||'auto')}</select></div><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Enable Dockwatch-managed rules</label><label class="switch"><input id="fwManageDefault" type="checkbox" ${p.manage_default?'checked':''} ${selected==='nftables'?'disabled':''}> Manage provider default inbound ${selected==='nftables'?'<span class="muted">(native policy is always local to Dockwatch table)</span>':''}</label><div class="field"><label>Default inbound</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div>${selected==='firewalld'?`<div class="field"><label>firewalld zone</label><input id="fwZone" value="${esc(p.zone||preview.runtime?.zone||backend.default_zone||'public')}" placeholder="public"></div>`:''}<label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''} ${selected!=='nftables'?'disabled':''}> Allow ICMP / IPv6 ICMP ${selected!=='nftables'?'<span class="muted">(kept native by this provider)</span>':''}</label><div class="field full"><label>Trusted CIDRs · one per line</label><textarea id="fwTrusted" placeholder="192.0.2.0/24\n2001:db8::/32">${esc(asArray(p.trusted_cidrs).join('\n'))}</textarea></div></div>${firewallRuntimeHTML(preview)}<div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Dockwatch-managed port rules</h3><button class="btn tiny" id="fwAddRule">+ Rule</button></div><div id="fwRules">${rules.map(firewallRuleRow).join('')}</div></div><div class="notice dangerNotice" style="margin-top:12px"><b>Lockout protection:</b> Apply starts a 90-second rollback timer. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.</div></div><div class="modalfoot">${!installed&&selected!=='auto'?`<button class="btn" id="fwInstall" ${packages?'':'disabled'}>Install ${esc(selected)}</button>`:''}<button class="btn" data-close>Cancel</button><button class="btn" id="fwPreview">Preview ${esc(selected)}</button><button class="btn danger" id="fwApply" ${preview.can_apply===false?'disabled':''}>Apply with rollback</button></div>`,'modal-wide modal-firewall');$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function firewallRuleRow(r={}){return `<div class="fwRule"><select class="fwAction"><option value="accept" ${r.action==='accept'||!r.action?'selected':''}>ALLOW</option><option value="drop" ${r.action==='drop'?'selected':''}>DENY</option><option value="reject" ${r.action==='reject'?'selected':''}>REJECT</option><option value="limit" ${r.action==='limit'?'selected':''}>LIMIT</option></select><select class="fwProto"><option value="tcp" ${r.protocol!=='udp'?'selected':''}>TCP</option><option value="udp" ${r.protocol==='udp'?'selected':''}>UDP</option></select><input class="fwPort" placeholder="22 or 8000-8100" value="${esc(r.port||'')}"><input class="fwSource" placeholder="Source CIDR · optional" value="${esc(r.source||'')}"><input class="fwComment" placeholder="Comment" value="${esc(r.comment||'')}"><button class="iconbtn fwUp" title="Move up">↑</button><button class="iconbtn fwDown" title="Move down">↓</button><button class="iconbtn fwRemove" title="Remove">×</button></div>`}
|
||||
function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>`<option value="${v}" ${current===v?'selected':''}>${v==='auto'?'Automatisch erkennen':v}</option>`).join('')}
|
||||
function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `<div class="panel inset fwRuntime"><div class="panelhead"><h3>Erkannte Host-Firewall</h3><span class="tag">ausgewählt: ${esc(b.selected||'—')}</span></div><div class="securityFacts"><span><small>Verfügbar</small><b>${esc(available.join(', ')||'keine')}</b></span><span><small>Aktiv</small><b>${esc(active.join(', ')||'keine')}</b></span><span><small>Standard</small><b>${esc(r.default_inbound||'—')}</b></span><span><small>Zone</small><b>${esc(r.zone||b.default_zone||'—')}</b></span></div>${b.reason?`<div class="notice ${asArray(b.conflicts).length?'dangerNotice':'warnNotice'}">${esc(uiMessage(b.reason))}</div>`:''}<div class="field"><label>Vorhandene native Regeln/Status <span class="muted">· schreibgeschützt · fremde Regeln bleiben erhalten</span></label><pre class="terminal fwExisting" style="max-height:230px">${esc(r.raw||'Keine Laufzeitregeln gemeldet.')}</pre></div></div>`}
|
||||
function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`<div class="modalhead"><h2>Host-Firewall · ${esc(selected)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Provider-Modell:</b> Auto verwendet aktives UFW oder firewalld und fällt sonst auf natives nftables zurück. Dockwatch erhält fremde Regeln und führt niemals <code>ufw reset</code> oder <code>nft flush ruleset</code>.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Firewall-Provider</label><select id="fwProvider">${firewallProviderOptions(p.provider||'auto')}</select></div><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Von Dockwatch verwaltete Regeln aktivieren</label><label class="switch"><input id="fwManageDefault" type="checkbox" ${p.manage_default?'checked':''} ${selected==='nftables'?'disabled':''}> Globalen Eingangsstandard des Providers verwalten ${selected==='nftables'?'<span class="muted">(native Policy bleibt immer auf die Dockwatch-Tabelle beschränkt)</span>':''}</label><div class="field"><label>Standard eingehend</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div>${selected==='firewalld'?`<div class="field"><label>firewalld-Zone</label><input id="fwZone" value="${esc(p.zone||preview.runtime?.zone||backend.default_zone||'public')}" placeholder="public"></div>`:''}<label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''} ${selected!=='nftables'?'disabled':''}> ICMP / IPv6-ICMP erlauben ${selected!=='nftables'?'<span class="muted">(wird durch diesen Provider nativ verwaltet)</span>':''}</label><div class="field full"><label>Vertrauenswürdige CIDRs · eine pro Zeile</label><textarea id="fwTrusted" placeholder="192.0.2.0/24\n2001:db8::/32">${esc(asArray(p.trusted_cidrs).join('\n'))}</textarea></div></div>${firewallRuntimeHTML(preview)}<div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Von Dockwatch verwaltete Portregeln</h3><button class="btn tiny" id="fwAddRule">+ Regel</button></div><div id="fwRules">${rules.map(firewallRuleRow).join('')}</div></div><div class="notice dangerNotice" style="margin-top:12px"><b>Aussperrschutz:</b> Anwenden startet einen 90-Sekunden-Rollback-Timer. Fremde Provider-Regeln bleiben erhalten. Wenn du den globalen Eingangsstandard des Providers verwaltest, prüfe SSH-/Dockwatch-Zugriff, bevor du die Änderung bestätigst.</div></div><div class="modalfoot">${!installed&&selected!=='auto'?`<button class="btn" id="fwInstall" ${packages?'':'disabled'}>${esc(selected)} installieren</button>`:''}<button class="btn" data-close>Abbrechen</button><button class="btn" id="fwPreview">Vorschau ${esc(selected)}</button><button class="btn danger" id="fwApply" ${preview.can_apply===false?'disabled':''}>Mit Rollback anwenden</button></div>`,'modal-wide modal-firewall');$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'Firewall'} Vorschau`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Standard eingehend DROP kann die Verbindung zu diesem Host trennen. Bestätige, dass deine SSH-/Dockwatch-Verwaltungsports ausdrücklich erlaubt sind. Mit zeitgesteuertem Rollback fortfahren?'))return;const btn=$('#fwApply');setBusy(btn,true,'Wird angewendet…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function firewallRuleRow(r={}){return `<div class="fwRule"><select class="fwAction"><option value="accept" ${r.action==='accept'||!r.action?'selected':''}>ERLAUBEN</option><option value="drop" ${r.action==='drop'?'selected':''}>VERWEIGERN</option><option value="reject" ${r.action==='reject'?'selected':''}>ABLEHNEN</option><option value="limit" ${r.action==='limit'?'selected':''}>LIMITIEREN</option></select><select class="fwProto"><option value="tcp" ${r.protocol!=='udp'?'selected':''}>TCP</option><option value="udp" ${r.protocol==='udp'?'selected':''}>UDP</option></select><input class="fwPort" placeholder="22 oder 8000-8100" value="${esc(r.port||'')}"><input class="fwSource" placeholder="Quell-CIDR · optional" value="${esc(r.source||'')}"><input class="fwComment" placeholder="Kommentar" value="${esc(r.comment||'')}"><button class="iconbtn fwUp" title="Nach oben">↑</button><button class="iconbtn fwDown" title="Nach unten">↓</button><button class="iconbtn fwRemove" title="Entfernen">×</button></div>`}
|
||||
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})}
|
||||
function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,trusted_cidrs:($('#fwTrusted')?.value||'').split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
|
||||
function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${warnings.map(x=>`<div class="notice">${esc(x)}</div>`).join('')}${conflicts?.length?`<div class="notice dangerNotice">Conflicts: ${esc(conflicts.join(', '))}</div>`:''}<pre class="terminal" style="max-height:55vh">${esc(text||'')}</pre></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}
|
||||
function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`<div class="modalhead"><h2>Firewall applied · verification window</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Do not close this dialog yet.</b> If the new policy breaks access, Dockwatch will restore the previous Dockwatch-managed policy and provider default snapshot automatically.</div><div class="securityCountdown"><small>Automatic rollback in</small><strong id="fwCountdown">…</strong></div><p class="muted">Verify SSH and any other management path in a separate session. Then keep the change.</p></div><div class="modalfoot"><button class="btn danger" id="fwRollbackNow">Rollback now</button><button class="btn primary" id="fwCommitNow">Keep changes</button></div>`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall policy committed.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall rolled back.');closeModal();renderSecurity()}catch(e){toast(e.message)}}}
|
||||
function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${warnings.map(x=>`<div class="notice">${esc(x)}</div>`).join('')}${conflicts?.length?`<div class="notice dangerNotice">Konflikte: ${esc(conflicts.join(', '))}</div>`:''}<pre class="terminal" style="max-height:55vh">${esc(text||'')}</pre></div><div class="modalfoot"><button class="btn" data-close>Schließen</button></div>`)}
|
||||
function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`<div class="modalhead"><h2>Firewall angewendet · Prüfzeitraum</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Diesen Dialog noch nicht schließen.</b> Wenn die neue Policy den Zugriff unterbricht, stellt Dockwatch automatisch die vorherige verwaltete Policy und den Provider-Standard wieder her.</div><div class="securityCountdown"><small>Automatischer Rollback in</small><strong id="fwCountdown">…</strong></div><p class="muted">Prüfe SSH und weitere Verwaltungszugänge in einer separaten Sitzung. Bestätige anschließend die Änderung.</p></div><div class="modalfoot"><button class="btn danger" id="fwRollbackNow">Jetzt zurückrollen</button><button class="btn primary" id="fwCommitNow">Änderungen beibehalten</button></div>`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall-Policy bestätigt.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall zurückgerollt.');closeModal();renderSecurity()}catch(e){toast(e.message)}}}
|
||||
async function securityFail2BanModal(){try{const p=await api('/api/security/fail2ban'+qnode());fail2banEditor(p)}catch(e){toast(e.message)}}
|
||||
function fail2banEditor(p){modal(`<div class="modalhead"><h2>Fail2Ban policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch writes only <code>/etc/fail2ban/jail.d/dockwatch.local</code>. Other distro/user jails are preserved and remain effective.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Ban time</label><input id="f2bBan" value="${esc(p.bantime||'1h')}"></div><div class="field"><label>Find time</label><input id="f2bFind" value="${esc(p.findtime||'10m')}"></div><div class="field"><label>Max retry</label><input id="f2bRetry" type="number" min="1" max="1000" value="${esc(p.maxretry||5)}"></div><div class="field"><label>Backend</label><select id="f2bBackend"><option>auto</option><option>systemd</option><option>polling</option><option>pyinotify</option></select></div><div class="field full"><label>Ignore IP/CIDR · one per line</label><textarea id="f2bIgnore">${esc(asArray(p.ignore_ip).join('\n'))}</textarea></div></div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Jails</h3><button class="btn tiny" id="f2bAdd">+ Jail</button></div><div id="f2bJails">${asArray(p.jails).map(fail2banJailRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="f2bSave">Validate & apply</button></div>`);$('#f2bBackend').value=p.backend||'auto';$('#f2bAdd').onclick=()=>{$('#f2bJails').insertAdjacentHTML('beforeend',fail2banJailRow({enabled:true,backend:'auto'}));wireF2BRows()};wireF2BRows();$('#f2bSave').onclick=async()=>{const body={bantime:$('#f2bBan').value.trim(),findtime:$('#f2bFind').value.trim(),maxretry:Number($('#f2bRetry').value),backend:$('#f2bBackend').value,ignore_ip:$('#f2bIgnore').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),jails:$$('.f2bJail').map(r=>({name:r.querySelector('.f2bName').value.trim(),enabled:r.querySelector('.f2bEnabled').checked,port:r.querySelector('.f2bPort').value.trim(),filter:r.querySelector('.f2bFilter').value.trim(),backend:r.querySelector('.f2bBackend').value,logpath:r.querySelector('.f2bLog').value.trim(),maxretry:Number(r.querySelector('.f2bMax').value)||0})).filter(j=>j.name)};const btn=$('#f2bSave');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/fail2ban${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Fail2Ban applied');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function fail2banJailRow(j={}){return `<div class="f2bJail securityFormRow"><label class="switch"><input class="f2bEnabled" type="checkbox" ${j.enabled!==false?'checked':''}> enabled</label><input class="f2bName" placeholder="jail name" value="${esc(j.name||'')}"><input class="f2bPort" placeholder="port · ssh" value="${esc(j.port||'')}"><input class="f2bFilter" placeholder="filter" value="${esc(j.filter||'')}"><select class="f2bBackend"><option ${j.backend==='auto'||!j.backend?'selected':''}>auto</option><option ${j.backend==='systemd'?'selected':''}>systemd</option><option ${j.backend==='polling'?'selected':''}>polling</option><option ${j.backend==='pyinotify'?'selected':''}>pyinotify</option></select><input class="f2bLog" placeholder="log path · optional" value="${esc(j.logpath||'')}"><input class="f2bMax" type="number" min="0" max="1000" placeholder="retries" value="${esc(j.maxretry||'')}"><button class="iconbtn f2bRemove">×</button></div>`}
|
||||
function fail2banEditor(p){modal(`<div class="modalhead"><h2>Fail2Ban-Policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch schreibt ausschließlich <code>/etc/fail2ban/jail.d/dockwatch.local</code>. Andere Distributions-/Benutzer-Jails bleiben erhalten und wirksam.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Sperrzeit</label><input id="f2bBan" value="${esc(p.bantime||'1h')}"></div><div class="field"><label>Suchzeitraum</label><input id="f2bFind" value="${esc(p.findtime||'10m')}"></div><div class="field"><label>Max. Versuche</label><input id="f2bRetry" type="number" min="1" max="1000" value="${esc(p.maxretry||5)}"></div><div class="field"><label>Backend</label><select id="f2bBackend"><option>auto</option><option>systemd</option><option>polling</option><option>pyinotify</option></select></div><div class="field full"><label>IP/CIDR ignorieren · eine pro Zeile</label><textarea id="f2bIgnore">${esc(asArray(p.ignore_ip).join('\n'))}</textarea></div></div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Sperrregeln (Jails)</h3><button class="btn tiny" id="f2bAdd">+ Sperrregel</button></div><div id="f2bJails">${asArray(p.jails).map(fail2banJailRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="f2bSave">Prüfen & anwenden</button></div>`);$('#f2bBackend').value=p.backend||'auto';$('#f2bAdd').onclick=()=>{$('#f2bJails').insertAdjacentHTML('beforeend',fail2banJailRow({enabled:true,backend:'auto'}));wireF2BRows()};wireF2BRows();$('#f2bSave').onclick=async()=>{const body={bantime:$('#f2bBan').value.trim(),findtime:$('#f2bFind').value.trim(),maxretry:Number($('#f2bRetry').value),backend:$('#f2bBackend').value,ignore_ip:$('#f2bIgnore').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),jails:$$('.f2bJail').map(r=>({name:r.querySelector('.f2bName').value.trim(),enabled:r.querySelector('.f2bEnabled').checked,port:r.querySelector('.f2bPort').value.trim(),filter:r.querySelector('.f2bFilter').value.trim(),backend:r.querySelector('.f2bBackend').value,logpath:r.querySelector('.f2bLog').value.trim(),maxretry:Number(r.querySelector('.f2bMax').value)||0})).filter(j=>j.name)};const btn=$('#f2bSave');setBusy(btn,true,'Wird angewendet…');try{const out=await api(`/api/security/fail2ban${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Fail2Ban angewendet');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function fail2banJailRow(j={}){return `<div class="f2bJail securityFormRow"><label class="switch"><input class="f2bEnabled" type="checkbox" ${j.enabled!==false?'checked':''}> aktiviert</label><input class="f2bName" placeholder="Name der Sperrregel" value="${esc(j.name||'')}"><input class="f2bPort" placeholder="Port · ssh" value="${esc(j.port||'')}"><input class="f2bFilter" placeholder="Filter" value="${esc(j.filter||'')}"><select class="f2bBackend"><option ${j.backend==='auto'||!j.backend?'selected':''}>auto</option><option ${j.backend==='systemd'?'selected':''}>systemd</option><option ${j.backend==='polling'?'selected':''}>polling</option><option ${j.backend==='pyinotify'?'selected':''}>pyinotify</option></select><input class="f2bLog" placeholder="Log-Pfad · optional" value="${esc(j.logpath||'')}"><input class="f2bMax" type="number" min="0" max="1000" placeholder="Versuche" value="${esc(j.maxretry||'')}"><button class="iconbtn f2bRemove">×</button></div>`}
|
||||
function wireF2BRows(){$$('.f2bRemove').forEach(b=>b.onclick=()=>b.closest('.f2bJail').remove())}
|
||||
async function securityAuditdModal(){try{const p=await api('/api/security/auditd'+qnode());auditdEditor(p)}catch(e){toast(e.message)}}
|
||||
function auditdEditor(p){const check=(id,label,key)=>`<label class="switch"><input id="${id}" type="checkbox" ${p[key]?'checked':''}> ${label}</label>`;modal(`<div class="modalhead"><h2>Linux Audit policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch writes only <code>/etc/audit/rules.d/90-dockwatch.rules</code> and loads it through <code>augenrules</code>. Presets use file watches rather than broad syscall rules.</div><div class="securityChecks" style="margin-top:12px">${check('audIdentity','Identity files (/etc/passwd, shadow, group)','identity_files')}${check('audSudo','sudoers','sudoers')}${check('audSSH','SSH server configuration','ssh')}${check('audDocker','Docker socket and daemon configuration','docker')}${check('audSystemd','systemd unit configuration','systemd')}${check('audModules','Kernel module configuration','kernel_modules')}</div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Custom file watches</h3><button class="btn tiny" id="audAdd">+ Watch</button></div><div id="audWatches">${asArray(p.custom).map(auditWatchRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="audSave">Validate & load</button></div>`);$('#audAdd').onclick=()=>{$('#audWatches').insertAdjacentHTML('beforeend',auditWatchRow({permissions:'wa',key:'dockwatch-custom'}));wireAuditRows()};wireAuditRows();$('#audSave').onclick=async()=>{const body={identity_files:$('#audIdentity').checked,sudoers:$('#audSudo').checked,ssh:$('#audSSH').checked,docker:$('#audDocker').checked,systemd:$('#audSystemd').checked,kernel_modules:$('#audModules').checked,custom:$$('.audWatch').map(r=>({path:r.querySelector('.audPath').value.trim(),permissions:r.querySelector('.audPerms').value.trim(),key:r.querySelector('.audKey').value.trim()})).filter(x=>x.path)};const btn=$('#audSave');setBusy(btn,true,'Loading…');try{const out=await api(`/api/security/auditd${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Audit rules loaded');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function auditWatchRow(w={}){return `<div class="audWatch securityFormRow audit"><input class="audPath" placeholder="/srv/app/config" value="${esc(w.path||'')}"><input class="audPerms" placeholder="wa" value="${esc(w.permissions||'wa')}"><input class="audKey" placeholder="audit-key" value="${esc(w.key||'')}"><button class="iconbtn audRemove">×</button></div>`}
|
||||
function auditdEditor(p){const check=(id,label,key)=>`<label class="switch"><input id="${id}" type="checkbox" ${p[key]?'checked':''}> ${label}</label>`;modal(`<div class="modalhead"><h2>Linux-Audit-Policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch schreibt ausschließlich <code>/etc/audit/rules.d/90-dockwatch.rules</code> und lädt sie über <code>augenrules</code>. Voreinstellungen verwenden Datei-Watches statt breit gefasster Syscall-Regeln.</div><div class="securityChecks" style="margin-top:12px">${check('audIdentity','Identitätsdateien (/etc/passwd, shadow, group)','identity_files')}${check('audSudo','sudoers','sudoers')}${check('audSSH','SSH-Serverkonfiguration','ssh')}${check('audDocker','Docker-Socket- und Daemon-Konfiguration','docker')}${check('audSystemd','systemd-Unit-Konfiguration','systemd')}${check('audModules','Kernelmodul-Konfiguration','kernel_modules')}</div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Benutzerdefinierte Dateiüberwachungen</h3><button class="btn tiny" id="audAdd">+ Überwachung</button></div><div id="audWatches">${asArray(p.custom).map(auditWatchRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="audSave">Prüfen & laden</button></div>`);$('#audAdd').onclick=()=>{$('#audWatches').insertAdjacentHTML('beforeend',auditWatchRow({permissions:'wa',key:'dockwatch-custom'}));wireAuditRows()};wireAuditRows();$('#audSave').onclick=async()=>{const body={identity_files:$('#audIdentity').checked,sudoers:$('#audSudo').checked,ssh:$('#audSSH').checked,docker:$('#audDocker').checked,systemd:$('#audSystemd').checked,kernel_modules:$('#audModules').checked,custom:$$('.audWatch').map(r=>({path:r.querySelector('.audPath').value.trim(),permissions:r.querySelector('.audPerms').value.trim(),key:r.querySelector('.audKey').value.trim()})).filter(x=>x.path)};const btn=$('#audSave');setBusy(btn,true,'Wird geladen…');try{const out=await api(`/api/security/auditd${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Audit-Regeln geladen');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
|
||||
function auditWatchRow(w={}){return `<div class="audWatch securityFormRow audit"><input class="audPath" placeholder="/srv/app/config" value="${esc(w.path||'')}"><input class="audPerms" placeholder="wa" value="${esc(w.permissions||'wa')}"><input class="audKey" placeholder="Audit-Schlüssel" value="${esc(w.key||'')}"><button class="iconbtn audRemove">×</button></div>`}
|
||||
function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())}
|
||||
|
||||
window.addEventListener('resize',terminalResize);
|
||||
|
||||
+20
-20
@@ -4,40 +4,40 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Dockwatch</title>
|
||||
<link rel="stylesheet" href="/styles.css?v=9.5.2">
|
||||
<link rel="stylesheet" href="/styles.css?v=9.5.3">
|
||||
</head>
|
||||
<body>
|
||||
<div id="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><div class="brandmark">DW</div><div class="brandcopy"><strong>Dockwatch</strong><small>Control Plane</small></div><button class="collapseBtn" id="sidebarToggle" title="Sidebar ein-/ausklappen" aria-label="Sidebar ein-/ausklappen">‹</button></div>
|
||||
<div class="environment"><span class="pulse"></span><div><small>Environment</small><select id="globalNode"></select></div></div>
|
||||
<div class="brand"><div class="brandmark">DW</div><div class="brandcopy"><strong>Dockwatch</strong><small>Verwaltungsoberfläche</small></div><button class="collapseBtn" id="sidebarToggle" title="Seitenleiste ein-/ausklappen" aria-label="Seitenleiste ein-/ausklappen">‹</button></div>
|
||||
<div class="environment"><span class="pulse"></span><div><small>Umgebung</small><select id="globalNode"></select></div></div>
|
||||
<nav id="nav">
|
||||
<button data-view="dashboard" class="active"><span>⌂</span>Dashboard</button>
|
||||
<button data-view="dashboard" class="active"><span>⌂</span>Übersicht</button>
|
||||
<div class="navlabel">Docker</div>
|
||||
<button data-view="stacks"><span>▱</span>Compose Stacks <em id="navStackCount"></em></button>
|
||||
<button data-view="containers"><span>⬡</span>Containers</button>
|
||||
<button data-view="stacks"><span>▱</span>Compose-Stacks <em id="navStackCount"></em></button>
|
||||
<button data-view="containers"><span>⬡</span>Container</button>
|
||||
<button data-view="images"><span>◫</span>Images</button>
|
||||
<button data-view="volumes"><span>◉</span>Volumes</button>
|
||||
<button data-view="networks"><span>⌘</span>Networks</button>
|
||||
<button data-view="git"><span>⑂</span>Git Stacks</button>
|
||||
<div class="navlabel">Observability</div>
|
||||
<button data-view="monitors"><span>♡</span>Probes <em id="navMonitorCount"></em></button>
|
||||
<button data-view="services"><span>◈</span>Services</button>
|
||||
<button data-view="statuspages"><span>◌</span>Status Pages</button>
|
||||
<button data-view="maintenance"><span>◷</span>Maintenance</button>
|
||||
<button data-view="notifications"><span>⌁</span>Notifications</button>
|
||||
<button data-view="networks"><span>⌘</span>Netzwerke</button>
|
||||
<button data-view="git"><span>⑂</span>Git-Stacks</button>
|
||||
<div class="navlabel">Überwachung</div>
|
||||
<button data-view="monitors"><span>♡</span>Monitore <em id="navMonitorCount"></em></button>
|
||||
<button data-view="services"><span>◈</span>Dienste</button>
|
||||
<button data-view="statuspages"><span>◌</span>Statusseiten</button>
|
||||
<button data-view="maintenance"><span>◷</span>Wartung</button>
|
||||
<button data-view="notifications"><span>⌁</span>Benachrichtigungen</button>
|
||||
<div class="navlabel">System</div>
|
||||
<button data-view="nodes"><span>◎</span>Environments</button>
|
||||
<button data-view="security"><span>⛨</span>Host Security</button>
|
||||
<button data-view="activity"><span>≋</span>Activity</button>
|
||||
<button data-view="nodes"><span>◎</span>Umgebungen</button>
|
||||
<button data-view="security"><span>⛨</span>Host-Sicherheit</button>
|
||||
<button data-view="activity"><span>≋</span>Aktivitäten</button>
|
||||
</nav>
|
||||
<div class="sidebarMeta"><span id="buildVersion">v…</span></div><div class="sidebarFooter"><div class="avatar" id="avatar">U</div><div class="usertext"><b id="userName">…</b><small id="userRole">…</small></div><button id="logout" title="Logout">↪</button></div>
|
||||
<div class="sidebarMeta"><span id="buildVersion">v…</span></div><div class="sidebarFooter"><div class="avatar" id="avatar">U</div><div class="usertext"><b id="userName">…</b><small id="userRole">…</small></div><button id="logout" title="Abmelden">↪</button></div>
|
||||
</aside>
|
||||
<main>
|
||||
<header class="topbar"><div class="topLeft"><button class="iconbtn mobileMenu" id="mobileMenu" aria-label="Navigation öffnen">☰</button><span class="crumb" id="crumb">Dashboard</span></div><div class="topactions"><span id="apiState" class="apiState"><i></i><span>API</span></span><span id="dirtyTop" class="dirty" hidden>● Ungespeichert</span><button class="iconbtn" id="themeToggle" title="Theme wechseln" aria-label="Theme wechseln">◐</button><button class="iconbtn" id="refreshNow" title="Aktualisieren" aria-label="Aktualisieren">↻</button></div></header>
|
||||
<header class="topbar"><div class="topLeft"><button class="iconbtn mobileMenu" id="mobileMenu" aria-label="Navigation öffnen">☰</button><span class="crumb" id="crumb">Übersicht</span></div><div class="topactions"><span id="apiState" class="apiState"><i></i><span>API</span></span><span id="dirtyTop" class="dirty" hidden>● Ungespeichert</span><button class="iconbtn" id="themeToggle" title="Darstellung wechseln" aria-label="Darstellung wechseln">◐</button><button class="iconbtn" id="refreshNow" title="Aktualisieren" aria-label="Aktualisieren">↻</button></div></header>
|
||||
<section id="content"></section>
|
||||
</main>
|
||||
</div>
|
||||
<div id="modalRoot"></div><div id="toast"></div>
|
||||
<script src="/app.js?v=9.5.2"></script>
|
||||
<script src="/app.js?v=9.5.3"></script>
|
||||
</body></html>
|
||||
|
||||
Reference in New Issue
Block a user