diff --git a/README.md b/README.md index 6042b6e..ab29170 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index bfb405c..5ff29a2 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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(`{{.Name}} · Status
Dockwatch Public Status

{{.Name}}

{{.Description}}
{{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}}
This page refreshes automatically every 30 seconds.
{{upper .Status}}
{{range .Services}}

{{.Name}}

{{.Description}}
{{upper .Status}}
{{range .Monitors}}
{{.Name}}{{printf "%.2f" .Uptime24h}}% / 24h{{if .LastLatencyMS}} · {{.LastLatencyMS}} ms{{end}}
{{end}}
{{else}}
No public services configured.
{{end}}
`)) +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(`{{.Name}} · Status
Dockwatch · Öffentlicher Status

{{.Name}}

{{.Description}}
{{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}}
Diese Seite wird automatisch alle 30 Sekunden aktualisiert.
{{statusDE .Status}}
{{range .Services}}

{{.Name}}

{{.Description}}
{{statusDE .Status}}
{{range .Monitors}}
{{.Name}}{{printf "%.2f" .Uptime24h}}% / 24h{{if .LastLatencyMS}} · {{.LastLatencyMS}} ms{{end}}
{{end}}
{{else}}
Keine öffentlichen Dienste konfiguriert.
{{end}}
`)) func (s *Server) publicStatusPage(w http.ResponseWriter, r *http.Request) { v, e := s.monitors.PublicStatusPage(r.Context(), r.PathValue("slug")) diff --git a/web/app.js b/web/app.js index 92f6478..f3603e6 100644 --- a/web/app.js +++ b/web/app.js @@ -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 `${esc(s||'unknown')}`} +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 `${esc(statusLabel(s))}`} 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=`${state.nodes.map(n=>``).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=`${state.nodes.map(n=>``).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 `

${esc(title)}

${esc(sub)}

${actions}
`} -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','Ctrl+S speichert Stacks')}
Compose stacks${state.stacks.length}
${running} running
Monitors up${up}
${state.monitors.length} configured
Monitor incidents${down}
current probe failures
Services${svcUp}/${state.services.length}
${svcDown} degraded

Compose stacks

${stackTable(state.stacks.slice(0,8))}

Uptime monitors

${monitorTable(state.monitors.slice(0,8))}
`;$('#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'
No compose stacks found.
';return `${items.map(s=>``).join('')}
NameStatusServicesImages
▱${esc(s.name)}
${badge(s.status)}${(s.services||[]).length}${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}
`} -function monitorTable(items){if(!items.length)return'
No monitors configured.
';return `${items.map(m=>``).join('')}
NameStatusTypeUptime 24h
${esc(m.name)}
${esc(m.target)}
${badge(m.status)}${esc(m.type.toUpperCase())}${Number(m.uptime_24h||0).toFixed(2)}%
`} -function renderStacks(){setCrumb(`Docker / Compose Stacks / ${nodeName()}`);const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Compose stacks','Edit, deploy and operate multi-container applications.',actions)}
${renderStackList(state.stacks)}
${state.stack?stackDetailHTML(state.stack):'
▱
Select a stack or create a new one.
'}
`;$('#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=>`
${esc(s.name)}${badge(s.status)}
${(s.services||[]).length} services · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'not deployed')}
`).join('')||'
No stacks
'} +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','Ctrl+S speichert Stacks')}
Compose-Stacks${state.stacks.length}
${running} aktiv
Monitore verfügbar${up}
${state.monitors.length} konfiguriert
Monitor-Störungen${down}
aktuelle Prüfungsfehler
Dienste${svcUp}/${state.services.length}
${svcDown} gestört

Compose-Stacks

${stackTable(state.stacks.slice(0,8))}

Uptime-Monitore

${monitorTable(state.monitors.slice(0,8))}
`;$('#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'
Keine Compose-Stacks gefunden.
';return `${items.map(s=>``).join('')}
NameStatusDiensteImages
▱${esc(s.name)}
${badge(s.status)}${(s.services||[]).length}${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}
`} +function monitorTable(items){if(!items.length)return'
Keine Monitore konfiguriert.
';return `${items.map(m=>``).join('')}
NameStatusTypUptime 24 h
${esc(m.name)}
${esc(m.target)}
${badge(m.status)}${esc(m.type.toUpperCase())}${Number(m.uptime_24h||0).toFixed(2)}%
`} +function renderStacks(){setCrumb(`Docker / Compose-Stacks / ${nodeName()}`);const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Compose-Stacks','Mehrcontainer-Anwendungen bearbeiten, bereitstellen und betreiben.',actions)}
${renderStackList(state.stacks)}
${state.stack?stackDetailHTML(state.stack):'
▱
Wähle einen Stack aus oder lege einen neuen an.
'}
`;$('#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=>`
${esc(s.name)}${badge(s.status)}
${(s.services||[]).length} Dienste · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'nicht bereitgestellt')}
`).join('')||'
Keine Stacks
'} 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 `
▱

${esc(st.name||'New compose stack')}

${st.name?esc(nodeName()):'Draft · not deployed'}
${badge(st.status||'new')}
${st.name?``:''}
${composeTab(st)}
`} -function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'
A local unsaved draft exists for this stack.
':''}
Full Compose Designer parsing…
compose.yaml Source of truth
Visual editor all fields · AST patch mode
Parsing Compose…
Every present Compose value is editable as string, number, boolean, null, map or array.Current spec fields are suggested; x-* and future fields remain editable too.Invalid YAML pauses visual sync without replacing your source.
`} +function stackDetailHTML(st){const sv=st.services||[];return `
▱

${esc(st.name||'Neuer Compose-Stack')}

${st.name?esc(nodeName()):'Entwurf · nicht bereitgestellt'}
${badge(st.status||'new')}
${st.name?``:''}
${composeTab(st)}
`} +function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'
Für diesen Stack existiert ein lokaler ungespeicherter Entwurf.
':''}
Vollständiger Compose-Designer wird analysiert…
compose.yaml Verbindliche Quelle
Visueller Editor alle Felder · AST-Patch-Modus
Compose wird analysiert…
Jeder vorhandene Compose-Wert kann als String, Zahl, Boolean, Null, Map oder Array bearbeitet werden.Felder der aktuellen Spezifikation werden vorgeschlagen; x-* und zukünftige Felder bleiben ebenfalls bearbeitbar.Ungültiges YAML pausiert die visuelle Synchronisierung, ohne die Quelle zu ersetzen.
`} 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;iVisual editor paused

${esc(e.message)}

Your YAML source is untouched. Fix the syntax and synchronization resumes automatically.

`}} -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 ${usages.length}${missing.length?` · ${missing.length}!`:''}`;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Models'],['include','Include']];host.innerHTML=items.map(([k,l])=>``).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=`
Visueller Editor pausiert

${esc(uiMessage(e.message))}

Die YAML-Quelle bleibt unverändert. Korrigiere die Syntax; anschließend wird automatisch weiter synchronisiert.

`}} +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 ${usages.length}${missing.length?` · ${missing.length}!`:''}`;const items=[['project','Projekt'],['services','Dienste'],['networks','Netzwerke'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Modelle'],['include','Include']];host.innerHTML=items.map(([k,l])=>``).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||'
Nothing configured in this section.
';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])=>`
${esc(k)} ${esc(label)}
${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}
`).join('');return `${rows}
`} +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||'
In diesem Bereich ist nichts konfiguriert.
';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])=>`
${esc(k)} ${esc(displayLabel)}
${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}
`).join('');return `${rows}
`} function splitComposeMountShort(raw){const s=String(raw??''),cuts=[];let brace=0;for(let i=0;i0){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?`${missing.length} missing declaration${missing.length===1?'':'s'}`:'named volumes aligned';const rows=usages.map(x=>`${esc(x.service)}${esc(x.kind)}${esc(x.source||'—')}${esc(x.target||'—')}${x.readOnly?'read-only':'read-write'}${x.kind==='volume'&&x.source?(declared.has(x.source)?'declared':`missing `):x.kind==='bind'?'no top-level declaration':'not applicable'}`).join('');return `

Volume reconciliation

Service mounts are compared live with top-level volumes:. Bind mounts are shown here but never turned into named-volume declarations.

${status}${missing.length?'':''}
${rows?`
${rows}
ServiceTypeSourceTargetAccessTop-level
`:'
No service mounts are currently configured.
'}${unused.length?`
Declared but currently unused: ${unused.map(n=>`${esc(n)} `).join(' ')}
`:''}${binds.length?`
${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.
`:''}${other.length?`
${other.length} anonymous/dynamic/special mount${other.length===1?'':'s'} preserved without automatic declaration changes.
`:''}
Top-level volumes Compose declarations
${composeNamedObjectEditor(v,path,'volume',[])}
`} -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?`${missing.length} fehlende Deklaration${missing.length===1?'':'en'}`:'benannte Volumes abgeglichen';const rows=usages.map(x=>`${esc(x.service)}${esc(x.kind)}${esc(x.source||'—')}${esc(x.target||'—')}${x.readOnly?'schreibgeschützt':'beschreibbar'}${x.kind==='volume'&&x.source?(declared.has(x.source)?'deklariert':`fehlt `):x.kind==='bind'?'keine Top-Level-Deklaration':'nicht erforderlich'}`).join('');return `

Volume-Abgleich

Dienst-Mounts werden live mit Top-Level-volumes: verglichen. Bind-Mounts werden hier angezeigt, aber niemals in Named-Volume-Deklarationen umgewandelt.

${status}${missing.length?'':''}
${rows?`
${rows}
DienstTypQuelleZielZugriffTop-Level
`:'
Aktuell sind keine Dienst-Mounts konfiguriert.
'}${unused.length?`
Deklariert, aber aktuell ungenutzt: ${unused.map(n=>`${esc(n)} `).join(' ')}
`:''}${binds.length?`
${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.
`:''}${other.length?`
${other.length} anonyme/dynamische/spezielle Mount${other.length===1?'':'s'} ohne automatische Änderung der Deklarationen beibehalten.
`:''}
Top-Level-Volumes Compose-Deklarationen
${composeNamedObjectEditor(v,path,'volume',[])}
`} +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 `
${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}
${suggestions.length?`${suggestions.map(x=>``).join('')}`:''}
`} -function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `
${esc(k)}${hint?`${esc(hint)}`:''}${k.startsWith('x-')?'extension':''}
${t}
${composeValueEditor(val,path,k,{serviceRoot:false})}
`} -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 `
${v.map((x,i)=>`
#${i+1}
${composeValueEditor(x,[...path,String(i)],label)}
`).join('')}
`;if(t==='boolean')return `
${typeSwitcher(path,t)}
`;if(t==='null')return `
null${typeSwitcher(path,t)}
`;if(t==='number')return `
${typeSwitcher(path,t)}
`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `
${multiline?``:``}${typeSwitcher(path,'string')}
`} -function typeSwitcher(path,t){return ``} +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 `
${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}
${suggestions.length?`${suggestions.map(x=>``).join('')}`:''}
`} +function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `
${esc(k)}${hint?`${esc(hint)}`:''}${k.startsWith('x-')?'Erweiterung':''}
${esc(composeTypeLabel(t))}
${composeValueEditor(val,path,k,{serviceRoot:false})}
`} +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 `
${v.map((x,i)=>`
#${i+1}
${composeValueEditor(x,[...path,String(i)],label)}
`).join('')}
`;if(t==='boolean')return `
${typeSwitcher(path,t)}
`;if(t==='null')return `
null${typeSwitcher(path,t)}
`;if(t==='number')return `
${typeSwitcher(path,t)}
`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `
${multiline?``:``}${typeSwitcher(path,'string')}
`} +function typeSwitcher(path,t){return ``} 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 `
`} -function secretsTab(st){return `
Secret values are written with mode 0600. Use Compose secrets: with file: ./secrets/name. Existing values are visible only to operators who can edit the stack.
${(st.secrets||[]).map(secretRow).join('')}
`} -function secretRow(s={}){return `
`} -function managedFilesTab(kind,files,title,help){return `
${title} · ${help} Managed files are validated together with the stack.
${files.map(f=>managedFileRow(kind,f)).join('')}
`} -function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `
`} +function envTab(st){return `
`} +function secretsTab(st){return `
Secret-Werte werden mit Modus 0600 geschrieben. Verwende in Compose secrets: mit file: ./secrets/name. Vorhandene Werte sind nur für Operatoren sichtbar, die den Stack bearbeiten dürfen.
${(st.secrets||[]).map(secretRow).join('')}
`} +function secretRow(s={}){return `
`} +function managedFilesTab(kind,files,title,help){return `
${title} · ${help} Verwaltete Dateien werden gemeinsam mit dem Stack validiert.
${files.map(f=>managedFileRow(kind,f)).join('')}
`} +function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `
`} 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 `${esc(shown)}`} -function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
This stack has no running containers yet.
';return `
${sv.map(v=>`
${esc(v.service||v.name)}${badge(v.state||v.status)}
Image${svcMetaValue(v.image)}Ports${svcMetaValue(v.ports,{ports:true})}Command${svcMetaValue(v.command)}
`).join('')}
`} -function logsTab(){return `
idle
Select “Load” or “Follow”.
`} -function graphTab(){return `
Load the normalized Compose dependency graph.
`} -function permissionsTab(){return `
Checks expected application UID/GID, host ownership, mode bits and ACL hints.
Run the analysis to review writable bind mounts for every created service.
`} -function updatesTab(){return `
Compare installed image digests with registry manifests.
`} -function consoleTab(sv){return `
Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.
Terminal disconnected. +function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
Für diesen Stack laufen noch keine Container.
';return `
${sv.map(v=>`
${esc(v.service||v.name)}${badge(v.state||v.status)}
Image${svcMetaValue(v.image)}Ports${svcMetaValue(v.ports,{ports:true})}Befehl${svcMetaValue(v.command)}
`).join('')}
`} +function logsTab(){return `
inaktiv
„Laden“ oder „Folgen“ auswählen.
`} +function graphTab(){return `
Normalisierten Compose-Abhängigkeitsgraph laden.
`} +function permissionsTab(){return `
Prüft erwartete Anwendungs-UID/GID, Host-Besitz, Modus-Bits und ACL-Hinweise.
Analyse starten, um beschreibbare Bind-Mounts aller erzeugten Dienste zu prüfen.
`} +function updatesTab(){return `
Installierte Image-Digests mit Registry-Manifesten vergleichen.
`} +function consoleTab(sv){return `
Interaktives Docker-Exec-Terminal mit echter PTY/WebSocket-Sitzung. In das Terminal klicken und normal tippen.
Terminal disconnected.
`} -function dangerTab(st){return st.name?`
Safe delete is the default. It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.
`:'
Save the stack first.
'} +function dangerTab(st){return st.name?`
Sicheres Löschen ist der Standard. 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.
`:'
Speichere zuerst den Stack.
'} 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()?'':'';$('#content').innerHTML=`${pageHead('Probes','Uptime, latency, Docker state and maintenance across your environments.',actions)}
${monitorListHTML(state.monitors)}
${state.monitor?monitorDetailHTML(state.monitor,state.checks):'
♡
Select a monitor to inspect uptime and latency.
'}
`;$('#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=>`
${esc(m.name)}${badge(m.status)}
${esc(m.type.toUpperCase())} · ${esc(m.target)}
${heartbeatBars([],m.status,24)}
`).join('')||'
No monitors
'} +function renderMonitors(){setCrumb('Überwachung / Monitore');const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Monitore','Uptime, Latenz, Docker-Status und Wartung über alle Umgebungen hinweg.',actions)}
${monitorListHTML(state.monitors)}
${state.monitor?monitorDetailHTML(state.monitor,state.checks):'
♡
Wähle einen Monitor aus, um Uptime und Latenz anzuzeigen.
'}
`;$('#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=>`
${esc(m.name)}${badge(m.status)}
${esc(m.type.toUpperCase())} · ${esc(m.target)}
${heartbeatBars([],m.status,24)}
`).join('')||'
Keine Monitore
'} 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},()=>``).join('');const a=[...checks].reverse().slice(-n);return Array.from({length:n-a.length},()=>'').join('')+a.map(c=>``).join('')} -function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'
No heartbeat data yet.
';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 ``} -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 `
♡

${esc(m.name)}

${esc(m.target)}
${badge(m.status)}
${roleOK()?`${m.status==='paused'?'':''}${m.status==='maintenance'?'':''}`:''}
24 hour uptime
${Number(m.uptime_24h||0).toFixed(3)}%
${badge(m.status)}
${heartbeatBars(c,m.status,64)}
Last heartbeat${fmtAgo(m.last_checked_at)}
Last latency${m.last_latency_ms||0} ms
Average latency${avg} ms
Recent success${ratio.toFixed(1)}%
Interval${m.interval_seconds}s
${m.last_message?`
Last result: ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}
`:''}

Response time

last ${c.length} heartbeats
${latencyChart(c)}
${m.status==='maintenance'?`
Maintenance active${m.maintenance_until?` until ${esc(fmtTime(m.maintenance_until))}`:' until manually ended'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.
`:''}

Configuration

Type${esc(m.type.toUpperCase())}Method${esc(m.method||'GET')}
Expected status${m.expected_min}–${m.expected_max}Timeout${m.timeout_ms} ms
Keyword${esc(m.keyword||'—')}Probe environment${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / local'}
${roleOK()?'
':''}
`} +function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'
Noch keine Heartbeat-Daten vorhanden.
';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 ``} +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 `
♡

${esc(m.name)}

${esc(m.target)}
${badge(m.status)}
${roleOK()?`${m.status==='paused'?'':''}${m.status==='maintenance'?'':''}`:''}
Uptime 24 h
${Number(m.uptime_24h||0).toFixed(3)}%
${badge(m.status)}
${heartbeatBars(c,m.status,64)}
Letzter Heartbeat${fmtAgo(m.last_checked_at)}
Letzte Latenz${m.last_latency_ms||0} ms
Durchschnittliche Latenz${avg} ms
Letzte Erfolge${ratio.toFixed(1)}%
Intervall${m.interval_seconds}s
${m.last_message?`
Letztes Ergebnis: ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}
`:''}

Antwortzeit

letzte ${c.length} Heartbeats
${latencyChart(c)}
${m.status==='maintenance'?`
Wartung aktiv${m.maintenance_until?` bis ${esc(fmtTime(m.maintenance_until))}`:' bis zur manuellen Beendigung'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.
`:''}

Konfiguration

Typ${esc(m.type.toUpperCase())}Methode${esc(m.method||'GET')}
Erwarteter Status${m.expected_min}–${m.expected_max}Timeout${m.timeout_ms} ms
Schlüsselwort${esc(m.keyword||'—')}Prüfumgebung${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / lokal'}
${roleOK()?'
':''}
`} 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(`

${isEdit?'Edit monitor':'New monitor'}

`);$('#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=>``).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(`

Maintenance · ${esc(m.name)}

Checks are suppressed during maintenance and the monitor is shown as maintenance instead of down.
`);$('#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(`

${isEdit?'Monitor bearbeiten':'Neuer Monitor'}

`);$('#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=>``).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(`

Maintenance · ${esc(m.name)}

Während der Wartung werden Prüfalarme unterdrückt; der Monitor wird als Wartung statt als ausgefallen angezeigt.
`);$('#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()?'':'';$('#content').innerHTML=`${pageHead('Services','Group probes into user-facing services. One failed probe makes the whole service fail.',actions)}
${state.services.length?`${state.services.map(g=>``).join('')}
ServiceStatusProbes
${esc(g.name)}
${esc(g.description||'')}
${badge(g.status)}${(g.monitors||[]).length}
${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'No probes assigned'}
${roleOK()?` `:''}
`:'
No services configured. Create a service and assign probes to it.
'}
`;$('#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(`

${x?'Edit':'New'} service

${state.monitors.map(m=>``).join('')||'No probes configured.'}
A probe can belong to one service. Assigning it here moves it from a previous service. One DOWN probe makes this service DOWN.
`);$('#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'?'':'')}
Loading status pages…
`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
NamePublic URLServicesEnabled
${esc(x.name)}
${esc(x.description||'')}
/status/${esc(x.slug)}${(x.service_ids||[]).length}${x.enabled?'Public':'Disabled'}${state.me.role==='admin'?` `:''}
`:'
No public status pages configured.
';$$('[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=`
${esc(e.message)}
`}} -function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`

${x?'Edit':'New'} public status page

${state.services.map(g=>``).join('')||'Create services first.'}
`);$('#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()?'':'';$('#content').innerHTML=`${pageHead('Dienste','Monitore zu sichtbaren Diensten gruppieren. Eine fehlgeschlagene Prüfung setzt den gesamten Dienst auf gestört.',actions)}
${state.services.length?`${state.services.map(g=>``).join('')}
DienstStatusMonitore
${esc(g.name)}
${esc(g.description||'')}
${badge(g.status)}${(g.monitors||[]).length}
${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'Keine Monitore zugeordnet'}
${roleOK()?` `:''}
`:'
Keine Dienste konfiguriert. Lege einen Dienst an und ordne ihm Monitore zu.
'}
`;$('#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(`

${x?'Dienst bearbeiten':'Neuer Dienst'}

${state.monitors.map(m=>``).join('')||'Keine Monitore konfiguriert.'}
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.
`);$('#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'?'':'')}
Statusseiten werden geladen…
`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
NameÖffentliche URLDiensteAktiviert
${esc(x.name)}
${esc(x.description||'')}
/status/${esc(x.slug)}${(x.service_ids||[]).length}${x.enabled?'Öffentlich':'Deaktiviert'}${state.me.role==='admin'?` `:''}
`:'
Keine öffentlichen Statusseiten konfiguriert.
';$$('[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=`
${esc(uiMessage(e.message))}
`}} +function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`

${x?'Statusseite bearbeiten':'Neue Statusseite'}

${state.services.map(g=>``).join('')||'Lege zuerst Dienste an.'}
`);$('#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.')}

Active maintenance windows

${ms.length?`${ms.map(m=>``).join('')}
MonitorTargetUntilNote
${esc(m.name)}${esc(m.target)}${m.maintenance_until?fmtTime(m.maintenance_until):'Manual end'}${esc(m.maintenance_note||'—')}
`:'
No active maintenance windows.
'}

Start maintenance

${state.monitors.filter(m=>m.status!=='maintenance').map(m=>``).join('')}
${esc(m.name)}
${esc(m.target)}
${badge(m.status)}
`;$$('[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=`
◎
Local Docker
Docker socket
${badge('up')}
${esc(state.system?.build?.version||'local')}
Local—`;$('#content').innerHTML=`${pageHead('Environments','Master and remote agents managed from one control plane.',state.me.role==='admin'?'':'')}
${local}${state.nodes.map(n=>``).join('')}
NameStatusConnectionActions
◎
${esc(n.name)}
${esc(n.base_url)}
${n.enabled?'Checking…':badge('paused')}${n.enabled?'Bearer agent':'Disabled'}${state.me.role==='admin'?` `:''}
`;$('#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(`

${x?'Edit':'Add'} remote agent

${x?``:''}
`);$('#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')}
v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}
`}catch(e){el.innerHTML=`${badge('down')}
unreachable
`}}))} -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='';if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
Loading ${n.toLowerCase()}…
`;$('#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.')}

Aktive Wartungsfenster

${ms.length?`${ms.map(m=>``).join('')}
MonitorZielBisHinweis
${esc(m.name)}${esc(m.target)}${m.maintenance_until?fmtTime(m.maintenance_until):'Manuelles Ende'}${esc(m.maintenance_note||'—')}
`:'
Keine aktiven Wartungsfenster.
'}

Wartung starten

${state.monitors.filter(m=>m.status!=='maintenance').map(m=>``).join('')}
${esc(m.name)}
${esc(m.target)}
${badge(m.status)}
`;$$('[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=`
◎
Lokaler Docker-Host
Docker-Socket
${badge('up')}
${esc(state.system?.build?.version||'lokal')}
Lokal—`;$('#content').innerHTML=`${pageHead('Umgebungen','Master und Remote-Agenten über eine zentrale Oberfläche verwalten.',state.me.role==='admin'?'':'')}
${local}${state.nodes.map(n=>``).join('')}
NameStatusVerbindungAktionen
◎
${esc(n.name)}
${esc(n.base_url)}
${n.enabled?'Wird geprüft…':badge('paused')}${n.enabled?'Bearer-Agent':'Deaktiviert'}${state.me.role==='admin'?` `:''}
`;$('#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(`

${x?'Remote-Agent bearbeiten':'Remote-Agent hinzufügen'}

${x?``:''}
`);$('#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')}
v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}
`}catch(e){el.innerHTML=`${badge('down')}
nicht erreichbar
`}}))} +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='';if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
${n} werden geladen…
`;$('#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=`
${esc(e.message)}
`}} +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=`
${esc(uiMessage(e.message))}
`}} 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 `
⬡${esc(raw)}
`;if(key==='Labels')return `${esc(inventoryLabelSummary(value))}`;if(['Mountpoint','Ports','Status','ID'].includes(key))return `${esc(raw)}`;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='
No matching items found.
';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=`
${cols.map(c=>``).join('')}${rows.map(r=>{const idx=all.indexOf(r);return `${cols.map((c,i)=>``).join('')}`}).join('')}
${esc(c[1])}Actions
${inventoryCell(kind,c[0],r[c[0]],i===0)}
${resourceActions(kind,r,idx)}
`;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='
Keine passenden Einträge gefunden.
';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=`
${cols.map(c=>``).join('')}${rows.map(r=>{const idx=all.indexOf(r);return `${cols.map((c,i)=>``).join('')}`}).join('')}
${esc(c[1])}Aktionen
${inventoryCell(kind,c[0],r[c[0]],i===0)}
${resourceActions(kind,r,idx)}
`;wireResourceRows(kind)} -function resourceActions(kind,r,idx){const inspect=roleOK()?``:'';if(kind==='containers')return `${inspect}${roleOK()?` `:''}`;if(!roleOK())return'';return `${inspect} `} +function resourceActions(kind,r,idx){const inspect=roleOK()?``:'';if(kind==='containers')return `${inspect}${roleOK()?` `:''}`;if(!roleOK())return'';return `${inspect} `} 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(`

Registry login

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.
`);$('#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(`

Registry logout

`);$('#regSave').onclick=async()=>{const registry=$('#regHost').value.trim();if(!registry)return toast('Registry required.');closeModal();await dockerResourceAction('images','logout',{registry})}} +function registryLoginModal(){modal(`

Registry-Anmeldung

Anmeldedaten werden von der Docker-CLI in die persistente Docker-Konfiguration dieser Umgebung geschrieben. Das Passwort wird über stdin und nicht als Kommandozeilenargument übergeben.
`);$('#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(`

Registry-Abmeldung

`);$('#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(`

Pull image

`);$('#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(`

Create ${isNet?'network':'volume'}

${isNet?'':''}
`);$('#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{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(`

Container identity audit · ${esc(nodeName())}

Containers checked${ok.length}
Root PID 1${rootCount}
Non-root PID 1${nonRoot}
Unknown / failed${unknown+failed.length}
“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.
${ok.map(x=>{const d=x.report;return ``}).join('')}${failed.map((x,i)=>``).join('')}
ContainerUID:GIDAssessmentHost accountBind mounts
${esc(d.container_name||x.row.Names||x.row.ID)}
${esc(d.image||'')}
PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}
bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}
${esc(identityAssessmentLabel(d.root_assessment))}${d.bind_host_user?`${esc(d.bind_host_user.name)}`:(d.host_access?.available&&d.bind_uid>0?'missing':'—')}${asArray(d.bind_mounts).length}
Check ${i+1} failed: ${esc(x.reason?.message||x.reason)}
`)}catch(e){toast(e.message)}finally{setBusy(btn,false)}} +function resourceCreateModal(kind){if(kind==='images'){modal(`

Image laden

`);$('#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(`

${isNet?'Netzwerk erstellen':'Volume erstellen'}

${isNet?'':''}
`);$('#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{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(`

Container-Identitätsprüfung · ${esc(nodeName())}

Geprüfte Container${ok.length}
PID 1 als Root${rootCount}
PID 1 ohne Root${nonRoot}
Unbekannt / fehlgeschlagen${unknown+failed.length}
„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.
${ok.map(x=>{const d=x.report;return ``}).join('')}${failed.map((x,i)=>``).join('')}
ContainerUID:GIDBewertungHost-BenutzerBind-Mounts
${esc(d.container_name||x.row.Names||x.row.ID)}
${esc(d.image||'')}
PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}
bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}
${esc(identityAssessmentLabel(d.root_assessment))}${d.bind_host_user?`${esc(d.bind_host_user.name)}`:(d.host_access?.available&&d.bind_uid>0?'fehlt':'—')}${asArray(d.bind_mounts).length}
Prüfung ${i+1} fehlgeschlagen: ${esc(uiMessage(x.reason?.message||x.reason))}
`)}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?'writable':v===false?'not writable':'unknown'} +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?'beschreibbar':v===false?'nicht beschreibbar':'unbekannt'} 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=>`
  • ${esc(x)}
  • `).join(''),recs=asArray(d.recommendations).map(x=>`
  • ${esc(x)}
  • `).join(''),binds=asArray(d.bind_mounts),host=d.host_access||{}; window.__identityBindRows=binds.map(x=>({container:id,mount:x})); - const bindTable=binds.length?`
    ${binds.map((x,i)=>``).join('')}
    Host pathContainer pathMode / ownerWrite access
    ${esc(x.source)}${esc(x.destination)} ${x.read_only?'ro':'rw'}${esc(x.mode||'—')} · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'
    ACL detected
    ':''}
    ${x.read_only?'read-only':writeState(x.static_writable)}
    ${esc(x.writable_reason||x.ownership_note||'')}
    ${!x.read_only?``:''}
    `:'
    No bind mounts detected.
    '; - const bindHost=d.bind_host_user?`${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})`:(host.available&&d.bind_uid>0?'No matching host user':'—'); + const bindTable=binds.length?`
    ${binds.map((x,i)=>``).join('')}
    Host-PfadContainer-PfadModus / BesitzerSchreibzugriff
    ${esc(x.source)}${esc(x.destination)} ${x.read_only?'ro':'rw'}${esc(x.mode||'—')} · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'
    ACL erkannt
    ':''}
    ${x.read_only?'schreibgeschützt':writeState(x.static_writable)}
    ${esc(x.writable_reason||x.ownership_note||'')}
    ${!x.read_only?``:''}
    `:'
    Keine Bind-Mounts erkannt.
    '; + const bindHost=d.bind_host_user?`${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})`:(host.available&&d.bind_uid>0?'Kein passender Host-Benutzer':'—'); 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(`

    Identity & bind permissions · ${esc(d.container_name||id)}

    Process identity and storage identity are evaluated separately. PID 1 may run as root while an image writes application data with PUID/PGID. Dockwatch never changes Compose user: automatically.
    PID 1 UID:GID${esc(uid)}:${esc(gid)}
    Bind UID:GID${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}
    Runs as root${esc(root)}
    Assessment${esc(identityAssessmentLabel(d.root_assessment))}

    Why

      ${reasons||'
    • No additional reasons.
    • '}

    Recommendations

      ${recs||'
    • No recommendations.
    • '}

    Host identity

    ${esc(host.message||'Host access unavailable')}
    ${bindHost}
    ${host.configured?`
    Host root: ${esc(host.root||'')} · user creation ${host.management_enabled?'enabled':'off'} · permission repair ${host.permission_management_enabled?'enabled':'off'} · UID mapping ${esc(d.host_id_mapping||'unknown')}
    `:''}
    ${bindTable}
    ${canCreate?'':''}
    `); + modal(`

    Identität & Bind-Berechtigungen · ${esc(d.container_name||id)}

    Prozess- und Speicheridentität werden getrennt bewertet. PID 1 kann als root laufen, während ein Image Anwendungsdaten mit PUID/PGID schreibt. Dockwatch ändert Compose user: niemals automatisch.
    PID 1 UID:GID${esc(uid)}:${esc(gid)}
    Bind-UID:GID${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}
    Läuft als root${esc(root)}
    Bewertung${esc(identityAssessmentLabel(d.root_assessment))}

    Begründung

      ${reasons||'
    • Keine zusätzlichen Gründe.
    • '}

    Empfehlungen

      ${recs||'
    • Keine Empfehlungen.
    • '}

    Host-Identität

    ${esc(uiMessage(host.message||'Host-Zugriff nicht verfügbar'))}
    ${bindHost}
    ${host.configured?`
    Host-Root: ${esc(host.root||'')} · Benutzeranlage ${host.management_enabled?'aktiviert':'aus'} · Berechtigungsreparatur ${host.permission_management_enabled?'aktiviert':'aus'} · UID-Zuordnung ${esc(d.host_id_mapping||'unbekannt')}
    `:''}
    ${bindTable}
    ${canCreate?'':''}
    `); $$('[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(`

    Create matching host account

    Host change: Dockwatch re-checks the container and derives the bind UID/GID server-side. Numeric IDs cannot be chosen by the browser.
    Container${esc(d.container_name)}
    Bind UID:GID${esc(d.bind_uid)}:${esc(d.bind_gid)}
    Source${esc(d.bind_identity_source||'container identity')}
    `);$('#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(`

    Passenden Host-Benutzer anlegen

    Host-Änderung: Dockwatch prüft den Container erneut und leitet die Bind-UID/GID serverseitig ab. Numerische IDs können nicht im Browser vorgegeben werden.
    Container${esc(d.container_name)}
    Bind-UID:GID${esc(d.bind_uid)}:${esc(d.bind_gid)}
    Quelle${esc(d.bind_identity_source||'Container-Identität')}
    `);$('#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(`

    Bind permission review

    ${esc(p.source)} → ${esc(p.destination)}
    ${esc(expected)}
    ${esc(p.identity_source||'')}
    ${esc(owner)} · ${esc(p.mode||'—')}
    ${p.host_user?`${esc(p.host_user.name)}`:'missing'}
    ${p.host_group?`group ${esc(p.host_group.name)}`:'numeric GID only'}
    ${writeState(p.static_writable)}
    ${esc(p.writable_reason||'')}
    ${writeState(p.runtime_writable)}
    ${esc(p.runtime_note||'')}
    ${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} differ
    ${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
    ${p.acl_detected?'extended ACL detected':'no extended ACL detected'}
    ${esc(p.acl_note||'')}
    ${p.blocked_reason?`Automatic repair unavailable: ${esc(p.blocked_reason)}
    `:''}${esc(p.recommendation||'')}

    Re-scan / repair scope

    Never applied automatically and never recursively. Leave empty to preserve mode bits.
    ${canCreateUser?'':''}${canRepair?'':''}
    `); + modal(`

    Bind-Berechtigungsprüfung

    ${esc(p.source)} → ${esc(p.destination)}
    ${esc(expected)}
    ${esc(p.identity_source||'')}
    ${esc(owner)} · ${esc(p.mode||'—')}
    ${p.host_user?`${esc(p.host_user.name)}`:'fehlt'}
    ${p.host_group?`Gruppe ${esc(p.host_group.name)}`:'nur numerische GID'}
    ${writeState(p.static_writable)}
    ${esc(uiMessage(p.writable_reason||''))}
    ${writeState(p.runtime_writable)}
    ${esc(uiMessage(p.runtime_note||''))}
    ${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} abweichend
    ${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
    ${p.acl_detected?'Erweiterte ACL erkannt':'Keine erweiterte ACL erkannt'}
    ${esc(uiMessage(p.acl_note||''))}
    ${p.blocked_reason?`Automatische Reparatur nicht verfügbar: ${esc(uiMessage(p.blocked_reason))}
    `:''}${esc(uiMessage(p.recommendation||''))}

    Erneut prüfen / Reparaturumfang

    Wird nie automatisch und nie rekursiv angewendet. Leer lassen, um Modus-Bits beizubehalten.
    ${canCreateUser?'':''}${canRepair?'':''}
    `); $('#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='
    Inspecting containers and bind mounts…
    ';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='
    No created containers or bind mounts found.
    ';return}box.innerHTML=`
    ${rows.map((x,i)=>x.error?``:``).join('')}
    ServiceHost → containerExpectedOwner / modeWrite
    ${esc(x.service)}${esc(x.error)}
    ${esc(x.service||x.report?.container_name)}
    ${esc(x.mount.source)}
    → ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'}
    ${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')}
    ${esc(x.report.bind_identity_source||'')}
    ${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')}${x.mount.read_only?'read-only':writeState(x.mount.static_writable)}${!x.mount.read_only?``:''}
    `;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`
    ${esc(e.message)}
    `}} -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(`

    ${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}

    ${kind==='containers'?`
    CPU${esc(st.CPUPerc||'—')}
    Memory${esc(st.MemUsage||'—')}
    Network I/O${esc(st.NetIO||'—')}
    Block I/O${esc(st.BlockIO||'—')}
    `:''}
    ${esc(JSON.stringify(i,null,2))}
    `)}catch(e){toast(e.message)}} -function showOutput(title,text){modal(`

    ${esc(title)}

    ${esc(text||'OK')}
    `)} +async function loadStackPermissions(){const box=$('#permissionsBox');if(!box||!state.stack?.name)return;box.innerHTML='
    Container und Bind-Mounts werden geprüft…
    ';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='
    Keine erzeugten Container oder Bind-Mounts gefunden.
    ';return}box.innerHTML=`
    ${rows.map((x,i)=>x.error?``:``).join('')}
    DienstHost → ContainerErwartetBesitzer / ModusSchreiben
    ${esc(x.service)}${esc(x.error)}
    ${esc(x.service||x.report?.container_name)}
    ${esc(x.mount.source)}
    → ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'}
    ${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')}
    ${esc(x.report.bind_identity_source||'')}
    ${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')}${x.mount.read_only?'schreibgeschützt':writeState(x.mount.static_writable)}${!x.mount.read_only?``:''}
    `;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`
    ${esc(uiMessage(e.message))}
    `}} +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(`

    ${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}

    ${kind==='containers'?`
    CPU${esc(st.CPUPerc||'—')}
    Arbeitsspeicher${esc(st.MemUsage||'—')}
    Netzwerk-I/O${esc(st.NetIO||'—')}
    Block-I/O${esc(st.BlockIO||'—')}
    `:''}
    ${esc(JSON.stringify(i,null,2))}
    `)}catch(e){toast(e.message)}} +function showOutput(title,text){modal(`

    ${esc(title)}

    ${esc(text||'OK')}
    `)} 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='
    Resolving Compose model…
    ';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`
    ${esc(e.message)}
    `}} -function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='
    No graph nodes.
    ';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 `${esc(e.kind)}`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `${esc(n.label)}${esc(n.image||n.kind)}`}).join('');box.innerHTML=`${lines}${ns}`} -async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='
    Checking registry manifests…
    ';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`${rows.map(r=>``).join('')}
    ServiceImageLocal digestRemote digestStatus
    ${esc(r.service)}${esc(r.image)}${esc(r.local_digest||'—')}${esc(r.remote_digest||'—')}${r.error?`check failed`:r.update?'Update available':'Current'}
    `}catch(e){box.innerHTML=`
    ${esc(e.message)}
    `}} +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='
    Compose-Modell wird aufgelöst…
    ';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`
    ${esc(uiMessage(e.message))}
    `}} +function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='
    Keine Graph-Knoten.
    ';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 `${esc(e.kind)}`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `${esc(n.label)}${esc(n.image||n.kind)}`}).join('');box.innerHTML=`${lines}${ns}`} +async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='
    Registry-Manifeste werden geprüft…
    ';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`${rows.map(r=>``).join('')}
    DienstImageLokaler DigestRemote-DigestStatus
    ${esc(r.service)}${esc(r.image)}${esc(r.local_digest||'—')}${esc(r.remote_digest||'—')}${r.error?`Prüfung fehlgeschlagen`:r.update?'Update verfügbar':'Aktuell'}
    `}catch(e){box.innerHTML=`
    ${esc(uiMessage(e.message))}
    `}} -async function renderActivity(){setCrumb('System / Activity');$('#content').innerHTML=`${pageHead('Activity','Persistent audit trail for changes, deployments and monitor transitions.','')}
    Loading audit events…
    `;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?`${rows.map(x=>``).join('')}
    TimeActorActionResourceStatusDetails
    ${fmtTime(x.created_at)}${esc(x.actor)}${esc(x.action)}${esc(x.resource||'—')}${x.status>=200&&x.status<300?''+x.status+'':''+x.status+''}${esc(JSON.stringify(x.detail||{}))}
    `:'
    No matching audit events.
    '}catch(e){$('#activityPanel').innerHTML=`
    ${esc(e.message)}
    `}};$('#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.','')}
    Audit-Ereignisse werden geladen…
    `;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?`${rows.map(x=>``).join('')}
    ZeitAkteurAktionRessourceStatusDetails
    ${fmtTime(x.created_at)}${esc(x.actor)}${esc(x.action)}${esc(x.resource||'—')}${x.status>=200&&x.status<300?''+x.status+'':''+x.status+''}${esc(JSON.stringify(x.detail||{}))}
    `:'
    Keine passenden Audit-Ereignisse.
    '}catch(e){$('#activityPanel').innerHTML=`
    ${esc(uiMessage(e.message))}
    `}};$('#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()?'':'')}
    Loading Git sources…
    `;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
    StackEnvironmentRepositoryBranchCommitLast syncAuto deploy
    ${esc(x.stack_name)}${x.last_error?`
    ${esc(x.last_error)}
    `:''}
    ${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote'):'Local Docker')}${esc(x.repo_url)}${esc(x.branch)}${esc((x.last_commit||'—').slice(0,12))}${x.last_sync_at?fmtTime(x.last_sync_at):'Never'}${x.auto_deploy?'Yes':'No'}${roleOK()?` `:''}
    `:'
    No Git sources configured.
    ';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=`
    ${esc(e.message)}
    `}} -function gitModal(x=null){modal(`

    ${x?'Edit':'Add'} Git source

    `);$('#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(`

    Webhook created

    Copy this secret now. It is stored encrypted and will not be shown again.

    GitHub: use the secret for X-Hub-Signature-256. GitLab: send it as X-Gitlab-Token. Generic hooks may use X-Webhook-Token.

    `)} -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()?'':'')}
    Git-Quellen werden geladen…
    `;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
    StackUmgebungRepositoryBranchCommitLetzte SynchronisierungAutomatisch bereitstellen
    ${esc(x.stack_name)}${x.last_error?`
    ${esc(x.last_error)}
    `:''}
    ${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote-Host'):'Lokaler Docker-Host')}${esc(x.repo_url)}${esc(x.branch)}${esc((x.last_commit||'—').slice(0,12))}${x.last_sync_at?fmtTime(x.last_sync_at):'Nie'}${x.auto_deploy?'Ja':'Nein'}${roleOK()?` `:''}
    `:'
    Keine Git-Quellen konfiguriert.
    ';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=`
    ${esc(uiMessage(e.message))}
    `}} +function gitModal(x=null){modal(`

    ${x?'Git-Quelle bearbeiten':'Git-Quelle hinzufügen'}

    `);$('#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(`

    Webhook erstellt

    Dieses Secret jetzt kopieren. Es wird verschlüsselt gespeichert und nicht erneut angezeigt.

    GitHub: Secret für X-Hub-Signature-256 verwenden. GitLab: als X-Gitlab-Token senden. Generische Hooks können X-Webhook-Token verwenden.

    `)} +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'?'':'')}
    Loading providers…
    `;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
    NameProviderEnabledConfiguration
    ${esc(x.name)}${esc(x.type)}${x.enabled?'Enabled':'Disabled'}${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}${state.me.role==='admin'?` `:''}
    `:'
    No notification providers configured.
    ';$$('[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=`
    ${esc(e.message)}
    `}} -function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`
    `;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')+`
    `+f('nUser','Username','username')+f('nPass','Password','password',true)+f('nFrom','From','from')+f('nTo','To','to',false,'ops@example.com')+``} -function notificationModal(x=null){const type=x?.type||'webhook';modal(`

    ${x?'Edit':'Add'} notification provider

    ${notifyConfigFields(type,x?.config||{})}
    `);$('#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'?'':'')}
    Provider werden geladen…
    `;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
    NameProviderAktiviertKonfiguration
    ${esc(x.name)}${esc(x.type)}${x.enabled?'Aktiviert':'Deaktiviert'}${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}${state.me.role==='admin'?` `:''}
    `:'
    Keine Benachrichtigungs-Provider konfiguriert.
    ';$$('[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=`
    ${esc(uiMessage(e.message))}
    `}} +function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`
    `;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')+`
    `+f('nUser','Benutzername','username')+f('nPass','Passwort','password',true)+f('nFrom','Absender','from')+f('nTo','Empfänger','to',false,'ops@example.com')+``} +function notificationModal(x=null){const type=x?.type||'webhook';modal(`

    ${x?'Benachrichtigungs-Provider bearbeiten':'Benachrichtigungs-Provider hinzufügen'}

    ${notifyConfigFields(type,x?.config||{})}
    `);$('#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.')}
    Host security configuration is restricted to administrators.
    `; return } - $('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'')}
    Auditing host security posture…
    `; + setCrumb(`System / Host-Sicherheit / ${nodeName()}`); + if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('Host-Sicherheit','Administratorrechte erforderlich.')}
    Die Host-Sicherheitskonfiguration ist auf Administratoren beschränkt.
    `; return } + $('#content').innerHTML=`${pageHead('Host-Sicherheit',`Linux-Host härten, konfigurieren und warten · ${nodeName()}`,'')}
    Host-Sicherheitsstatus wird geprüft…
    `; $('#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()}`,'')} -
    HOST SECURITY POSTURE
    ${Number(d.score||0)}/100

    ${esc(os.pretty_name||os.name||'Host OS unknown')} · ${esc(d.package_manager||'package manager unknown')} · ${esc(d.init_system||'init unknown')}

    ${securityCapabilityPills(c)}
    - ${c.reason?`
    Capability: ${esc(c.reason)}
    `:''} + $('#content').innerHTML=`${pageHead('Host-Sicherheit',`Linux-Host härten, konfigurieren und warten · ${nodeName()}`,'')} +
    HOST-SICHERHEITSSTATUS
    ${Number(d.score||0)}/100

    ${esc(os.pretty_name||os.name||'Host-Betriebssystem unbekannt')} · ${esc(d.package_manager||'Paketmanager unbekannt')} · ${esc(d.init_system||'Init-System unbekannt')}

    ${securityCapabilityPills(c)}
    + ${c.reason?`
    Berechtigungsmodus: ${esc(c.reason)}
    `:''}
    - ${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)}
    -

    Security findings

    Dockwatch-managed posture only; not a substitute for a full host benchmark.
    ${securityFindings(d.findings||[])}
    -

    Safety model

    Audit first

    Inspection can be enabled independently from host mutations.

    Explicit mutation opt-in

    Configuration and package installation are separate capabilities.

    Managed drop-ins

    Fail2Ban and auditd use dedicated Dockwatch files; foreign configuration is preserved.

    Audited changes

    Every mutation passes the existing Dockwatch admin RBAC, origin guard and Activity audit trail.

    `; +

    Sicherheitsbefunde

    Nur von Dockwatch verwalteter Sicherheitsstatus; kein Ersatz für einen vollständigen Host-Benchmark.
    ${securityFindings(d.findings||[])}
    +

    Sicherheitsmodell

    Zuerst prüfen

    Prüfungen können unabhängig von Host-Änderungen aktiviert werden.

    Explizite Freigabe für Änderungen

    Konfigurationsänderungen und Paketinstallation sind getrennte Berechtigungen.

    Verwaltete Drop-ins

    Fail2Ban und auditd verwenden eigene Dockwatch-Dateien; fremde Konfiguration bleibt erhalten.

    Protokollierte Änderungen

    Jede Änderung durchläuft Dockwatch-Admin-RBAC, Origin-Schutz und das Aktivitätsprotokoll.

    `; $('#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())}
    ${esc(e.message)}
    `} + }catch(e){$('#content').innerHTML=`${pageHead('Host-Sicherheit',nodeName())}
    ${esc(uiMessage(e.message))}
    `} } -function securityCapabilityPills(c){return `host root ${c.host_root_available?'✓':'×'}host namespace ${c.target_verified?'✓':'×'}${c.allow_changes?'manage':'audit only'}packages ${c.allow_package_management?'enabled':'locked'}`} -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 `
    ${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}

    ${title}

    ${esc(desc)}

    ${installed?(active?badge('up'):badge('paused')):badge('unknown')}
    Installed${installed?'Yes':'No'}Runtime${active?'Active':'Inactive'}Boot${st.enabled?'Enabled':'—'}Config${managed?.configured?(drift?'Drift':'Managed'):'Not managed'}
    ${st.version?`
    ${esc(st.version)}
    `:''}${st.detail?`
    ${esc(st.detail)}
    `:''}${conflicts?.length?`
    Firewall conflict: ${esc(conflicts.join(', '))}. Resolve competing active frontends before applying changes.
    `:''}
    ${!installed?``:''}${installed?`${key!=='firewall'?``:''}`:''}
    `} -function securityFindings(rows){if(!rows.length)return '
    No findings.
    ';return `
    ${rows.map(f=>`
    ${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}
    ${esc(f.title)}

    ${esc(f.detail)}

    ${f.action?`${esc(f.action)}`:''}
    `).join('')}
    `} -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 `Host-Root ${c.host_root_available?'✓':'×'}Host-Namespace ${c.target_verified?'✓':'×'}${c.allow_changes?'Verwaltung':'nur Prüfung'}Pakete ${c.allow_package_management?'freigegeben':'gesperrt'}`} +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 `
    ${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}

    ${title}

    ${esc(desc)}

    ${installed?(active?badge('up'):badge('paused')):badge('unknown')}
    Installiert${installed?'Ja':'Nein'}Laufzeit${active?'Aktiv':'Inaktiv'}Systemstart${st.enabled?'Aktiviert':'—'}Konfiguration${managed?.configured?(drift?'Abweichung':'Verwaltet'):'Nicht verwaltet'}
    ${st.version?`
    ${esc(st.version)}
    `:''}${st.detail?`
    ${esc(st.detail)}
    `:''}${conflicts?.length?`
    Firewall-Konflikt: ${esc(conflicts.join(', '))}. Aktive konkurrierende Frontends müssen vor Änderungen aufgelöst werden.
    `:''}
    ${!installed?``:''}${installed?`${key!=='firewall'?``:''}`:''}
    `} +function securityFindings(rows){if(!rows.length)return '
    Keine Befunde.
    ';return `
    ${rows.map(f=>`
    ${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}
    ${esc(uiMessage(f.title))}

    ${esc(uiMessage(f.detail))}

    ${f.action?`${esc(uiMessage(f.action))}`:''}
    `).join('')}
    `} +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=>``).join('')} -function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `

    Detected host firewall

    selected: ${esc(b.selected||'—')}
    Available${esc(available.join(', ')||'none')}Active${esc(active.join(', ')||'none')}Default${esc(r.default_inbound||'—')}Zone${esc(r.zone||b.default_zone||'—')}
    ${b.reason?`
    ${esc(b.reason)}
    `:''}
    ${esc(r.raw||'No runtime rules reported.')}
    `} -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(`

    Host firewall · ${esc(selected)}

    Provider model: Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs ufw reset or nft flush ruleset.
    ${selected==='firewalld'?`
    `:''}
    ${firewallRuntimeHTML(preview)}

    Dockwatch-managed port rules

    ${rules.map(firewallRuleRow).join('')}
    Lockout protection: 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.
    ${!installed&&selected!=='auto'?``:''}
    `,'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 `
    `} +function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>``).join('')} +function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `

    Erkannte Host-Firewall

    ausgewählt: ${esc(b.selected||'—')}
    Verfügbar${esc(available.join(', ')||'keine')}Aktiv${esc(active.join(', ')||'keine')}Standard${esc(r.default_inbound||'—')}Zone${esc(r.zone||b.default_zone||'—')}
    ${b.reason?`
    ${esc(uiMessage(b.reason))}
    `:''}
    ${esc(r.raw||'Keine Laufzeitregeln gemeldet.')}
    `} +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(`

    Host-Firewall · ${esc(selected)}

    Provider-Modell: Auto verwendet aktives UFW oder firewalld und fällt sonst auf natives nftables zurück. Dockwatch erhält fremde Regeln und führt niemals ufw reset oder nft flush ruleset.
    ${selected==='firewalld'?`
    `:''}
    ${firewallRuntimeHTML(preview)}

    Von Dockwatch verwaltete Portregeln

    ${rules.map(firewallRuleRow).join('')}
    Aussperrschutz: 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.
    ${!installed&&selected!=='auto'?``:''}
    `,'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 `
    `} 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(`

    ${esc(title)}

    ${warnings.map(x=>`
    ${esc(x)}
    `).join('')}${conflicts?.length?`
    Conflicts: ${esc(conflicts.join(', '))}
    `:''}
    ${esc(text||'')}
    `)} -function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`

    Firewall applied · verification window

    Do not close this dialog yet. If the new policy breaks access, Dockwatch will restore the previous Dockwatch-managed policy and provider default snapshot automatically.
    Automatic rollback in…

    Verify SSH and any other management path in a separate session. Then keep the change.

    `);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(`

    ${esc(title)}

    ${warnings.map(x=>`
    ${esc(x)}
    `).join('')}${conflicts?.length?`
    Konflikte: ${esc(conflicts.join(', '))}
    `:''}
    ${esc(text||'')}
    `)} +function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`

    Firewall angewendet · Prüfzeitraum

    Diesen Dialog noch nicht schließen. Wenn die neue Policy den Zugriff unterbricht, stellt Dockwatch automatisch die vorherige verwaltete Policy und den Provider-Standard wieder her.
    Automatischer Rollback in…

    Prüfe SSH und weitere Verwaltungszugänge in einer separaten Sitzung. Bestätige anschließend die Änderung.

    `);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(`

    Fail2Ban policy

    Dockwatch writes only /etc/fail2ban/jail.d/dockwatch.local. Other distro/user jails are preserved and remain effective.

    Jails

    ${asArray(p.jails).map(fail2banJailRow).join('')}
    `);$('#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 `
    `} +function fail2banEditor(p){modal(`

    Fail2Ban-Policy

    Dockwatch schreibt ausschließlich /etc/fail2ban/jail.d/dockwatch.local. Andere Distributions-/Benutzer-Jails bleiben erhalten und wirksam.

    Sperrregeln (Jails)

    ${asArray(p.jails).map(fail2banJailRow).join('')}
    `);$('#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 `
    `} 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)=>``;modal(`

    Linux Audit policy

    Dockwatch writes only /etc/audit/rules.d/90-dockwatch.rules and loads it through augenrules. Presets use file watches rather than broad syscall rules.
    ${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')}

    Custom file watches

    ${asArray(p.custom).map(auditWatchRow).join('')}
    `);$('#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 `
    `} +function auditdEditor(p){const check=(id,label,key)=>``;modal(`

    Linux-Audit-Policy

    Dockwatch schreibt ausschließlich /etc/audit/rules.d/90-dockwatch.rules und lädt sie über augenrules. Voreinstellungen verwenden Datei-Watches statt breit gefasster Syscall-Regeln.
    ${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')}

    Benutzerdefinierte Dateiüberwachungen

    ${asArray(p.custom).map(auditWatchRow).join('')}
    `);$('#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 `
    `} function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())} window.addEventListener('resize',terminalResize); diff --git a/web/index.html b/web/index.html index 7a52db3..f899e61 100644 --- a/web/index.html +++ b/web/index.html @@ -4,40 +4,40 @@ Dockwatch - +
    -
    Dashboard
    API
    +
    Übersicht
    API
    - +