Files
dockwatch/web/app.js
T
jbergner f3a687543c
release-tag / release-image (push) Successful in 2m28s
v9.6.0
2026-09-01 22:46:20 +02:00

314 lines
158 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const asArray=v=>Array.isArray(v)?v:[];
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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=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 statusLabel(s){return ({running:'Läuft',up:'Verfügbar',down:'Ausgefallen',maintenance:'Wartung',paused:'Pausiert',stopped:'Gestoppt',exited:'Beendet',created:'Erstellt',restarting:'Startet neu',new:'Neu',unknown:'Unbekannt',healthy:'Gesund',unhealthy:'Nicht gesund'}[String(s||'unknown').toLowerCase()]||String(s||'Unbekannt'))}
function roleLabel(r){return ({admin:'Administrator',operator:'Operator',viewer:'Betrachter'}[r]||r||'Betrachter')}
function badge(s){return `<span class="status ${esc(s||'unknown')}">${esc(statusLabel(s))}</span>`}
function setDirty(v){state.dirty=v;$('#dirtyTop').hidden=!v;window.onbeforeunload=v?()=>true:null}
function draftKey(name='new'){return `dockwatch:draft:${state.node}:${name||'new'}`}
function saveDraft(){if(!state.dirty)return;const c=$('#composeText'),e=$('#envText'),n=$('#stackName');if(!c||!n)return;const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value,content:r.querySelector('.secContent').value}));const env_files=collectManaged('.envfilerow');const configs=collectManaged('.configrow');localStorage.setItem(draftKey(n.value||'new'),JSON.stringify({name:n.value,compose:c.value,env:e?.value||'',secrets,env_files,configs,ts:Date.now()}))}
setInterval(saveDraft,2500);
function clearDraft(name){localStorage.removeItem(draftKey(name));localStorage.removeItem(draftKey('new'))}
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-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||'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"],#nav button[data-view="packages"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')}
function renderNodePicker(){const p=$('#globalNode');p.innerHTML=`<option value="0">Lokaler Docker-Host</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · deaktiviert'}</option>`).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}}
function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser ist offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)}
function navigate(v){if(state.dirty&&state.view==='stacks'&&v!=='stacks'&&!confirm('Ungespeicherte Stack-Änderungen verlassen?'))return;if(v!==state.view)teardownInteractive();state.view=v;document.body.classList.remove('sidebar-mobile-open');$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));render()}
async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('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,packages:renderPackages,activity:renderActivity}[state.view]||renderDashboard)()}
function pageHead(title,sub,actions=''){return `<div class="pagehead"><div><h1>${esc(title)}</h1><p>${esc(sub)}</p></div><div class="toolbar">${actions}</div></div>`}
function renderDashboard(){setCrumb('Übersicht');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Übersicht',nodeName()+' · Docker- und Uptime-Übersicht','<span class="keyboardHint"><kbd>Ctrl</kbd>+<kbd>S</kbd> speichert Stacks</span><button class="btn" id="dashRefresh">↻ Aktualisieren</button>')}<div class="stats"><div class="stat"><small>Compose-Stacks</small><strong>${state.stacks.length}</strong><div class="trend"><span class="green">${running} aktiv</span></div></div><div class="stat"><small>Monitore verfügbar</small><strong>${up}</strong><div class="trend">${state.monitors.length} konfiguriert</div></div><div class="stat"><small>Monitor-Störungen</small><strong class="${down?'red':''}">${down}</strong><div class="trend">aktuelle Prüfungsfehler</div></div><div class="stat"><small>Dienste</small><strong class="${svcDown?'red':''}">${svcUp}/${state.services.length}</strong><div class="trend">${svcDown} gestört</div></div></div><div class="twocol"><div class="panel"><div class="panelhead"><h2>Compose-Stacks</h2><button class="btn tiny" id="goStacks">Alle anzeigen</button></div>${stackTable(state.stacks.slice(0,8))}</div><div class="panel"><div class="panelhead"><h2>Uptime-Monitore</h2><button class="btn tiny" id="goMons">Alle anzeigen</button></div>${monitorTable(state.monitors.slice(0,8))}</div></div>`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))}
function stackTable(items){if(!items.length)return'<div class="empty">Keine Compose-Stacks gefunden.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Dienste</th><th>Images</th></tr></thead><tbody>${items.map(s=>`<tr class="${roleOK()?'clickrow':''}" data-openstack="${esc(s.name)}"><td><div class="namecell"><span class="cube">▱</span><b>${esc(s.name)}</b></div></td><td>${badge(s.status)}</td><td>${(s.services||[]).length}</td><td class="muted">${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}</td></tr>`).join('')}</tbody></table>`}
function monitorTable(items){if(!items.length)return'<div class="empty">Keine Monitore konfiguriert.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Typ</th><th>Uptime 24 h</th></tr></thead><tbody>${items.map(m=>`<tr class="clickrow" data-openmon="${m.id}"><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td><span class="tag">${esc(m.type.toUpperCase())}</span></td><td>${Number(m.uptime_24h||0).toFixed(2)}%</td></tr>`).join('')}</tbody></table>`}
function renderStacks(){setCrumb(`Docker / Compose-Stacks / ${nodeName()}`);const actions=roleOK()?'<button class="btn primary" id="newStack">+ Neuer Stack</button>':'';$('#content').innerHTML=`${pageHead('Compose-Stacks','Mehrcontainer-Anwendungen bearbeiten, bereitstellen und betreiben.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="stackSearch" placeholder="Stacks filtern…"><button class="btn tiny" id="stackRefresh">↻</button></div><div id="stackList">${renderStackList(state.stacks)}</div></div><div id="stackDetail" class="detail">${state.stack?stackDetailHTML(state.stack):'<div class="empty"><div class="big">▱</div>Wähle einen Stack aus oder lege einen neuen an.</div>'}</div></div>`;$('#newStack')?.addEventListener('click',newStack);$('#stackRefresh').onclick=async()=>{if(state.dirty)return toast('Der Editor enthält ungespeicherte Änderungen.');await refreshData(true);renderStacks()};$('#stackSearch').oninput=e=>$('#stackList').innerHTML=renderStackList(state.stacks.filter(s=>s.name.toLowerCase().includes(e.target.value.toLowerCase())));wireStackList();if(state.stack)wireStackDetail()}
function renderStackList(items){return items.map(s=>`<div class="stackitem ${state.stack?.name===s.name?'active':''}" data-stack="${esc(s.name)}"><div class="itemtop"><b>${esc(s.name)}</b>${badge(s.status)}</div><div class="itemsub">${(s.services||[]).length} Dienste · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'nicht bereitgestellt')}</div></div>`).join('')||'<div class="empty">Keine Stacks</div>'}
function wireStackList(){$$('[data-stack]').forEach(x=>x.onclick=()=>openStack(x.dataset.stack))}
async function openStack(name){if(!roleOK())return toast('Stack-Konfiguration und Logs erfordern Operator-Rechte.');if(state.dirty&&state.stack?.name!==name&&!confirm('Ungespeicherte Änderungen verwerfen?'))return;if(state.stack?.name!==name)teardownInteractive();try{state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);setDirty(false);state.view='stacks';renderStacks()}catch(e){toast(e.message)}}
function newStack(){if(state.dirty&&!confirm('Aktuellen Entwurf verwerfen?'))return;teardownInteractive();const draft=localStorage.getItem(draftKey('new'));let st={name:'',status:'new',services:[],compose:`services:
app:
image: nginx:alpine
restart: unless-stopped
`,env:'',secrets:[],env_files:[],configs:[]};if(draft){try{const d=JSON.parse(draft);if(confirm('Gespeicherten lokalen Stack-Entwurf wiederherstellen?'))st={...st,...d}}catch{}}state.stack=st;setDirty(false);renderStacks()}
function stackDetailHTML(st){const sv=st.services||[];return `<div class="detailhead"><div class="detailtitle"><span class="cube">▱</span><div><h2>${esc(st.name||'Neuer Compose-Stack')}</h2><small>${st.name?esc(nodeName()):'Entwurf · nicht bereitgestellt'}</small></div>${badge(st.status||'new')}</div><div class="actions">${st.name?`<button class="btn success" data-act="up">▶ Bereitstellen</button><button class="btn" data-act="pull">↓ Images laden</button><button class="btn" data-act="update">↻ Aktualisieren</button><button class="btn" data-act="restart">⟳ Neustart</button><button class="btn" data-act="stop">■ Stoppen</button>`:''}<button class="btn primary" id="saveStack">Speichern</button></div></div><div class="tabs"><button class="active" data-tab="compose">Compose</button><button data-tab="env">.env</button><button data-tab="envfiles">Env-Dateien</button><button data-tab="secrets">Secrets</button><button data-tab="configs">Configs</button><button data-tab="services">Container <span class="tag">${sv.length}</span></button><button data-tab="permissions">Berechtigungen</button><button data-tab="graph">Graph</button><button data-tab="updates">Updates</button><button data-tab="logs">Live-Logs</button><button data-tab="console">Terminal</button><button data-tab="danger">Gefahrenbereich</button></div><div class="tabbody"><div id="tab-compose">${composeTab(st)}</div><div id="tab-env" hidden>${envTab(st)}</div><div id="tab-envfiles" hidden>${managedFilesTab('envfile',st.env_files||[],'Zusätzliche Env-Dateien','In Compose verwenden als <code>env_file: ./envs/app.env</code>.')}</div><div id="tab-secrets" hidden>${secretsTab(st)}</div><div id="tab-configs" hidden>${managedFilesTab('config',st.configs||[],'Compose-Configs','In Compose verwenden als <code>configs: ... file: ./configs/name</code>.')}</div><div id="tab-services" hidden>${servicesTab(st)}</div><div id="tab-permissions" hidden>${permissionsTab()}</div><div id="tab-graph" hidden>${graphTab()}</div><div id="tab-updates" hidden>${updatesTab()}</div><div id="tab-logs" hidden>${logsTab()}</div><div id="tab-console" hidden>${consoleTab(sv)}</div><div id="tab-danger" hidden>${dangerTab(st)}</div><pre id="actionOut" class="terminal" style="min-height:90px;display:none;margin-top:10px"></pre></div>`}
function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'<div class="draftnotice"><span>Für diesen Stack existiert ein lokaler ungespeicherter Entwurf.</span><span><button class="btn tiny" id="restoreDraft">Wiederherstellen</button> <button class="btn tiny" id="discardDraft">Verwerfen</button></span></div>':''}<div class="field" style="margin-bottom:8px"><label>Stack-Name</label><input id="stackName" value="${esc(st.name)}" ${st.name?'disabled':''} placeholder="my-stack"></div><div class="compose-livebar"><div><b>Vollständiger Compose-Designer</b><span id="composeSyncState" class="muted"> wird analysiert…</span></div><div class="actions"><button class="btn tiny" id="composeExpandAll">Alle aufklappen</button><button class="btn tiny" id="composeCollapseAll">Alle einklappen</button></div></div><div class="compose-livegrid compose-fullgrid"><div class="codebox"><div class="editorlabel">compose.yaml <span class="muted">Verbindliche Quelle</span></div><textarea id="composeText" spellcheck="false">${esc(st.compose||'')}</textarea></div><div class="visualcompose"><div class="editorlabel">Visueller Editor <span class="muted">alle Felder · AST-Patch-Modus</span></div><div class="compose-sections" id="composeSections"></div><div id="composeVisual"><div class="empty">Compose wird analysiert…</div></div></div></div><div class="compose-help"><span>Jeder vorhandene Compose-Wert kann als String, Zahl, Boolean, Null, Map oder Array bearbeitet werden.</span><span>Felder der aktuellen Spezifikation werden vorgeschlagen; x-* und zukünftige Felder bleiben ebenfalls bearbeitbar.</span><span>Ungültiges YAML pausiert die visuelle Synchronisierung, ohne die Quelle zu ersetzen.</span></div>`}
const COMPOSE_SERVICE_FIELDS=['annotations','attach','build','blkio_config','cpu_count','cpu_percent','cpu_shares','cpu_period','cpu_quota','cpu_rt_runtime','cpu_rt_period','cpus','cpuset','cap_add','cap_drop','cgroup','cgroup_parent','command','configs','container_name','credential_spec','depends_on','deploy','develop','device_cgroup_rules','devices','dns','dns_opt','dns_search','domainname','driver_opts','entrypoint','env_file','environment','expose','extends','external_links','extra_hosts','gpus','group_add','healthcheck','hostname','image','init','ipc','isolation','labels','label_file','links','logging','mac_address','mem_limit','mem_reservation','mem_swappiness','memswap_limit','models','network_mode','networks','oom_kill_disable','oom_score_adj','pid','pids_limit','platform','ports','post_start','pre_start','pre_stop','privileged','profiles','provider','pull_policy','read_only','restart','runtime','scale','secrets','security_opt','shm_size','stdin_open','stop_grace_period','stop_signal','storage_opt','sysctls','tmpfs','tty','ulimits','use_api_socket','user','userns_mode','uts','volumes','volumes_from','working_dir'];
const COMPOSE_TOP_FIELDS=['name','include','services','models','networks','volumes','secrets','configs','version'];
const COMPOSE_FIELD_HINTS={image:'Container-Image',build:'Build-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 []}
let composeVisualModel=null,composeParseSeq=0,composePatchBusy=false,composePatchQueue=[],composeSection='services';
function pathKey(path){return path.map(String).join('\u001f')}
function pathAttr(path){return encodeURIComponent(JSON.stringify(path))}
function pathFromAttr(v){try{return JSON.parse(decodeURIComponent(v))}catch{return[]}}
function typeOfValue(v){if(v===null)return'null';if(Array.isArray(v))return'array';if(typeof v==='object')return'map';return typeof v}
function cloneJSON(v){return v===undefined?undefined:JSON.parse(JSON.stringify(v))}
function composeSetLocal(path,val,del=false){if(!composeVisualModel)return;let cur=composeVisualModel;for(let i=0;i<path.length-1;i++){const k=path[i];if(Array.isArray(cur))cur=cur[Number(k)];else cur=cur[k];if(cur==null)return}const k=path[path.length-1];if(del){if(Array.isArray(cur))cur.splice(Number(k),1);else delete cur[k]}else{if(Array.isArray(cur))cur[Number(k)]=val;else cur[k]=val}}
async function parseComposeVisual(){const box=$('#composeVisual'),ta=$('#composeText'),status=$('#composeSyncState');if(!box||!ta)return;const seq=++composeParseSeq;status.className='muted';status.textContent=' wird analysiert…';try{const r=await api('/api/compose/parse',{method:'POST',body:JSON.stringify({compose:ta.value})});if(seq!==composeParseSeq)return;composeVisualModel=r.value||{};status.className='green';status.textContent=' YAML ↔ Designer synchronisiert';renderComposeSections();renderComposeVisual()}catch(e){if(seq!==composeParseSeq)return;status.className='red';status.textContent=' Visuelle Synchronisierung pausiert';box.innerHTML=`<div class="compose-parseerror"><b>Visueller Editor pausiert</b><p>${esc(uiMessage(e.message))}</p><p>Die YAML-Quelle bleibt unverändert. Korrigiere die Syntax; anschließend wird automatisch weiter synchronisiert.</p></div>`}}
function renderComposeSections(){const host=$('#composeSections');if(!host)return;const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),missing=[...new Set(usages.filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];const volumeLabel=`Volumes <span class="tag ${missing.length?'warn':''}">${usages.length}${missing.length?` · ${missing.length}!`:''}</span>`;const items=[['project','Projekt'],['services','Dienste'],['networks','Netzwerke'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Modelle'],['include','Include']];host.innerHTML=items.map(([k,l])=>`<button class="${composeSection===k?'active':''}" data-csection="${k}">${l}</button>`).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})}
function sectionValue(){const m=composeVisualModel||{};if(composeSection==='project'){const x={};for(const k of Object.keys(m))if(!['services','networks','volumes','configs','secrets','models','include'].includes(k))x[k]=m[k];return x}return m[composeSection]??(composeSection==='include'?[]:{})}
function sectionPath(){return composeSection==='project'?[]:[composeSection]}
function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const scrollTop=box.scrollTop,v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(composeSection==='volumes'){body=composeVolumeSection(v,base)}else if(['networks','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'<div class="empty">In diesem Bereich ist nichts konfiguriert.</div>';wireComposeTree();wireComposeVolumeReconcile();box.scrollTop=Math.min(scrollTop,Math.max(0,box.scrollHeight-box.clientHeight))}
function composeObjectLabel(v){return ({service:'Dienst',volume:'Volume',network:'Netzwerk',config:'Config',secret:'Secret',model:'Modell',include:'Einbindung'}[v]||v)}
function composeTypeLabel(v){return ({string:'Zeichenkette',number:'Zahl',boolean:'Boolean',map:'Objekt',array:'Array',null:'Null'}[v]||v)}
function composeNamedObjectEditor(v,path,label,suggestions){if(!v||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,label);const displayLabel=composeObjectLabel(label),rows=Object.entries(v).map(([k,val])=>`<details class="compose-object compose-named" open><summary><span><b>${esc(k)}</b> <span class="tag">${esc(displayLabel)}</span></span><span class="summary-actions"><button class="iconbtn" data-cdelete="${pathAttr([...path,k])}" title="Entfernen">×</button></span></summary><div class="compose-object-body">${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}</div></details>`).join('');return `${rows}<div class="compose-addrow"><input data-cnewname="${pathAttr(path)}" placeholder="Neuer ${esc(displayLabel)}-Name"><button class="btn tiny" data-caddnamed="${pathAttr(path)}" data-ctype="map">+ ${esc(displayLabel)}</button></div>`}
function splitComposeMountShort(raw){const s=String(raw??''),cuts=[];let brace=0;for(let i=0;i<s.length;i++){if(s[i]==='$'&&s[i+1]==='{'){brace++;i++;continue}if(s[i]==='}'&&brace>0){brace--;continue}if(s[i]===':'&&brace===0)cuts.push(i)}if(cuts.length&&cuts[0]===1&&/^[A-Za-z]$/.test(s[0])&&/[\\/]/.test(s[2]||''))cuts.shift();if(!cuts.length)return{source:'',target:s,options:'',raw:s};const a=cuts[0],b=cuts[1];return{source:s.slice(0,a),target:b===undefined?s.slice(a+1):s.slice(a+1,b),options:b===undefined?'':s.slice(b+1),raw:s}}
function classifyComposeMountSource(source,type=''){if(!source)return String(type||'').toLowerCase()||'anonymous';const s=String(source),t=String(type||'').toLowerCase();if(s.includes('$'))return'dynamic';if(t)return t;if(s.startsWith('/')||s.startsWith('./')||s.startsWith('../')||s.startsWith('~/')||/^[A-Za-z]:[\\/]/.test(s)||s.includes('/'))return'bind';return'volume'}
function composeVolumeUsages(model=composeVisualModel||{}){const out=[];for(const [service,cfg] of Object.entries(model.services||{})){const mounts=Array.isArray(cfg?.volumes)?cfg.volumes:[];mounts.forEach((m,index)=>{if(typeof m==='string'){const x=splitComposeMountShort(m),kind=classifyComposeMountSource(x.source);out.push({service,index,kind,source:x.source,target:x.target,readOnly:(x.options||'').split(',').includes('ro'),raw:m})}else if(m&&typeof m==='object'){const kind=classifyComposeMountSource(m.source,m.type);out.push({service,index,kind,source:m.source||'',target:m.target||'',readOnly:!!m.read_only,raw:m})}})}return out}
function composeVolumeSection(v,path){const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),named=usages.filter(x=>x.kind==='volume'&&x.source),missing=[...new Set(named.map(x=>x.source).filter(x=>!declared.has(x)))],usedNames=new Set(named.map(x=>x.source)),unused=[...declared].filter(x=>!usedNames.has(x)),binds=usages.filter(x=>x.kind==='bind'),other=usages.filter(x=>!['volume','bind'].includes(x.kind));const status=missing.length?`<span class="tag warn">${missing.length} fehlende Deklaration${missing.length===1?'':'en'}</span>`:'<span class="tag oktag">benannte Volumes abgeglichen</span>';const rows=usages.map(x=>`<tr><td><b>${esc(x.service)}</b></td><td><span class="tag">${esc(x.kind)}</span></td><td><code>${esc(x.source||'—')}</code></td><td><code>${esc(x.target||'—')}</code></td><td>${x.readOnly?'schreibgeschützt':'beschreibbar'}</td><td>${x.kind==='volume'&&x.source?(declared.has(x.source)?'<span class="green">deklariert</span>':`<span class="amber">fehlt</span> <button class="btn tiny" data-vdeclare="${esc(x.source)}">Deklarieren</button>`):x.kind==='bind'?'<span class="muted">keine Top-Level-Deklaration</span>':'<span class="muted">nicht erforderlich</span>'}</td></tr>`).join('');return `<div class="volume-reconcile"><div class="panelhead"><div><h3>Volume-Abgleich</h3><p class="muted">Dienst-Mounts werden live mit Top-Level-<code>volumes:</code> verglichen. Bind-Mounts werden hier angezeigt, aber niemals in Named-Volume-Deklarationen umgewandelt.</p></div><div class="actions">${status}${missing.length?'<button class="btn tiny primary" id="syncVolumeDeclarations">+ Fehlende Deklarationen ergänzen</button>':''}</div></div>${rows?`<div class="tablewrap"><table class="table compact"><thead><tr><th>Dienst</th><th>Typ</th><th>Quelle</th><th>Ziel</th><th>Zugriff</th><th>Top-Level</th></tr></thead><tbody>${rows}</tbody></table></div>`:'<div class="empty small">Aktuell sind keine Dienst-Mounts konfiguriert.</div>'}${unused.length?`<div class="volume-unused"><b>Deklariert, aber aktuell ungenutzt:</b> ${unused.map(n=>`<span class="tag">${esc(n)} <button class="tagx" data-vremove="${esc(n)}" title="Ungenutzte Deklaration entfernen">×</button></span>`).join(' ')}</div>`:''}${binds.length?`<div class="muted volume-note">${binds.length} Bind-Mount${binds.length===1?'':'s'} erkannt. Bind-Quellen gehören zum Host-Dateisystem und erscheinen deshalb nicht als benannte Top-Level-Volumes.</div>`:''}${other.length?`<div class="muted volume-note">${other.length} anonyme/dynamische/spezielle Mount${other.length===1?'':'s'} ohne automatische Änderung der Deklarationen beibehalten.</div>`:''}</div><div class="compose-subsection"><div class="editorlabel">Top-Level-Volumes <span class="muted">Compose-Deklarationen</span></div>${composeNamedObjectEditor(v,path,'volume',[])}</div>`}
function wireComposeVolumeReconcile(){if(composeSection!=='volumes')return;$$('[data-vdeclare]').forEach(b=>b.onclick=()=>queueComposePatch(['volumes',b.dataset.vdeclare],{}));$('#syncVolumeDeclarations')?.addEventListener('click',()=>{const declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),names=[...new Set(composeVolumeUsages().filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];if(!names.length)return toast('Benannte Volumes sind bereits abgeglichen.');names.forEach(n=>queueComposePatch(['volumes',n],{}));toast(`${names.length} Volume-Deklaration${names.length===1?'':'en'} vorgemerkt.`)});$$('[data-vremove]').forEach(b=>b.onclick=()=>{const n=b.dataset.vremove;if(confirm(`Ungenutzte Top-Level-Volume-Deklaration ${n} entfernen? Das Docker-Volume selbst wird nicht gelöscht.`))queueComposePatch(['volumes',n],null,true)})}
function composeMapEditor(v,path,opt={}){if(v===null||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,'value');const entries=Object.entries(v);const serviceRoot=!!opt.serviceRoot;const suggestions=opt.rootProject?COMPOSE_TOP_FIELDS:composeSuggestions(path,opt);const listId='candidates-'+Math.abs(pathKey(path).split('').reduce((a,c)=>((a<<5)-a+c.charCodeAt(0))|0,0));return `<div class="compose-map">${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}</div><div class="compose-addrow"><input ${suggestions.length?`list="${listId}"`:''} data-cnewkey="${pathAttr(path)}" placeholder="Add field/key"><select data-cnewtype="${pathAttr(path)}"><option value="string">Zeichenkette</option><option value="map">Objekt</option><option value="array">Array</option><option value="boolean">Boolean</option><option value="number">Zahl</option><option value="null">Null</option></select><button class="btn tiny" data-caddkey="${pathAttr(path)}">+ Feld</button>${suggestions.length?`<datalist id="${listId}">${suggestions.map(x=>`<option value="${esc(x)}"></option>`).join('')}</datalist>`:''}</div>`}
function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `<div class="compose-fieldrow ${complex?'complex':''}"><div class="compose-fieldhead"><div><code>${esc(k)}</code>${hint?`<small>${esc(hint)}</small>`:''}${k.startsWith('x-')?'<span class="tag">Erweiterung</span>':''}</div><div class="summary-actions"><span class="typepill">${esc(composeTypeLabel(t))}</span><button class="iconbtn" data-cdelete="${pathAttr(path)}" title="Entfernen">×</button></div></div>${composeValueEditor(val,path,k,{serviceRoot:false})}</div>`}
function composeValueEditor(v,path,label,opt={}){const t=typeOfValue(v),pa=pathAttr(path);if(t==='map')return composeMapEditor(v,path,opt);if(t==='array')return `<div class="compose-array">${v.map((x,i)=>`<div class="compose-arrayitem"><div class="arrayindex">#${i+1}<button class="iconbtn" data-cdelete="${pathAttr([...path,String(i)])}" title="Entfernen">×</button></div>${composeValueEditor(x,[...path,String(i)],label)}</div>`).join('')}<div class="compose-addrow"><select data-carraytype="${pa}"><option value="string">Zeichenkette</option><option value="map">Objekt</option><option value="array">Array</option><option value="boolean">Boolean</option><option value="number">Zahl</option><option value="null">Null</option></select><button class="btn tiny" data-carrayadd="${pa}">+ Eintrag</button></div></div>`;if(t==='boolean')return `<div class="compose-scalar"><select data-cscalar="${pa}" data-ctype="boolean"><option value="true" ${v?'selected':''}>true</option><option value="false" ${!v?'selected':''}>false</option></select>${typeSwitcher(path,t)}</div>`;if(t==='null')return `<div class="compose-scalar"><span class="nullvalue">null</span>${typeSwitcher(path,t)}</div>`;if(t==='number')return `<div class="compose-scalar"><input data-cscalar="${pa}" data-ctype="number" type="number" step="any" value="${esc(v)}">${typeSwitcher(path,t)}</div>`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `<div class="compose-scalar">${multiline?`<textarea data-cscalar="${pa}" data-ctype="string">${esc(v??'')}</textarea>`:`<input data-cscalar="${pa}" data-ctype="string" value="${esc(v??'')}">`}${typeSwitcher(path,'string')}</div>`}
function typeSwitcher(path,t){return `<select class="typeswitch" data-ctypeswitch="${pathAttr(path)}"><option value="string" ${t==='string'?'selected':''}>Zeichenkette</option><option value="number" ${t==='number'?'selected':''}>Zahl</option><option value="boolean" ${t==='boolean'?'selected':''}>Boolean</option><option value="map" ${t==='map'?'selected':''}>Objekt</option><option value="array" ${t==='array'?'selected':''}>Array</option><option value="null" ${t==='null'?'selected':''}>Null</option></select>`}
function newValueForType(t){return t==='map'?{}:t==='array'?[]:t==='boolean'?false:t==='number'?0:t==='null'?null:''}
function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v,false,{refresh:false})};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('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 synchronisiert'}renderComposeSections()}
async function processComposePatchQueue(){if(composePatchBusy)return;composePatchBusy=true;try{while(composePatchQueue.length){const p=composePatchQueue.shift();try{const ta=$('#composeText');if(!ta){composePatchQueue=[];break}const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();if(p.refresh)await parseComposeVisual();else markComposeSynced()}catch(e){toast('Compose-Patch fehlgeschlagen: '+e.message);composePatchQueue=[];await parseComposeVisual();break}}}finally{composePatchBusy=false}}
function addComposeService(){composeSection='services';queueComposePatch(['services','service'+(((composeVisualModel?.services&&Object.keys(composeVisualModel.services).length)||0)+1)],{image:'nginx:alpine'})}
function envTab(st){return `<div class="field"><label>.env · Compose-Variablenersetzung</label><textarea id="envText" class="terminal" style="min-height:500px" spellcheck="false" placeholder="POSTGRES_TAG=16-alpine\nAPP_PORT=8080">${esc(st.env||'')}</textarea></div>`}
function secretsTab(st){return `<div class="notice" style="margin-bottom:10px">Secret-Werte werden mit Modus 0600 geschrieben. Verwende in Compose <code>secrets:</code> mit <code>file: ./secrets/name</code>. Vorhandene Werte sind nur für Operatoren sichtbar, die den Stack bearbeiten dürfen.</div><div id="secretList">${(st.secrets||[]).map(secretRow).join('')}</div><button class="btn" id="addSecret">+ Secret-Datei hinzufügen</button>`}
function secretRow(s={}){return `<div class="secretrow"><input class="secName" placeholder="db_password" value="${esc(s.name||'')}"><input class="secContent" type="password" placeholder="secret value" value="${esc(s.content||'')}"><button class="btn danger removeSecret">Entfernen</button></div>`}
function managedFilesTab(kind,files,title,help){return `<div class="notice" style="margin-bottom:10px"><b>${title}</b> · ${help} Verwaltete Dateien werden gemeinsam mit dem Stack validiert.</div><div id="${kind}List">${files.map(f=>managedFileRow(kind,f)).join('')}</div><button class="btn" id="add${kind}">+ Datei hinzufügen</button>`}
function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `<div class="secretrow ${cls}"><input class="managedName" placeholder="${kind==='envfile'?'app.env':'nginx.conf'}" value="${esc(f.name||'')}"><textarea class="managedContent" placeholder="Dateiinhalt">${esc(f.content||'')}</textarea><button class="btn danger removeManaged">Entfernen</button></div>`}
function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.managedName').value.trim(),content:r.querySelector('.managedContent').value})).filter(f=>f.name)}
function formatServicePorts(raw){const text=String(raw||'').trim();if(!text)return'—';try{const p=JSON.parse(text);if(Array.isArray(p)){const items=p.map(x=>{if(!x||typeof x!=='object')return String(x??'');const proto=x.Protocol||x.protocol||'tcp',target=x.TargetPort??x.target_port??x.target,pub=x.PublishedPort??x.published_port??x.published,url=x.URL??x.url??'';if(pub!==undefined&&pub!==null&&String(pub)!==''){const host=url?(String(url).includes(':')&&!String(url).startsWith('[')?`[${url}]`:String(url)):'';return `${host?host+':':''}${pub} → ${target??'?'}${proto?'/'+proto:''}`}return target!==undefined?`${target}${proto?'/'+proto:''}`:''}).filter(Boolean);if(items.length)return items.join(' · ')}}catch{}return text}
function svcMetaValue(value,opt={}){const full=String(value||'—'),shown=opt.ports?formatServicePorts(full):full;return `<code class="svcvalue" title="${esc(full)}">${esc(shown)}</code>`}
function servicesTab(st){const sv=st.services||[];if(!sv.length)return'<div class="empty">Für diesen Stack laufen noch keine Container.</div>';return `<div class="servicecards">${sv.map(v=>`<div class="servicecard"><div class="itemtop"><b>${esc(v.service||v.name)}</b>${badge(v.state||v.status)}</div><div class="svcmeta"><span class="muted">Image</span>${svcMetaValue(v.image)}<span class="muted">Ports</span>${svcMetaValue(v.ports,{ports:true})}<span class="muted">Befehl</span>${svcMetaValue(v.command)}</div></div>`).join('')}</div>`}
function logsTab(){return `<div class="consolebar"><button class="btn" id="loadLogs">500 laden</button><button class="btn success" id="liveLogs">▶ Folgen</button><button class="btn" id="pauseLogs">Ⅱ Pausieren</button><button class="btn danger" id="stopLogs">■ Stoppen</button><input id="logFilter" placeholder="Sichtbare Logs filtern…"><span class="spacer"></span><label class="switch"><input id="logAutoScroll" type="checkbox" checked> Automatisch scrollen</label><button class="btn" id="downloadLogs">Herunterladen</button><span id="logState" class="logstate">inaktiv</span></div><pre id="logBox" class="terminal">„Laden“ oder „Folgen“ auswählen.</pre>`}
function graphTab(){return `<div class="consolebar"><button class="btn primary" id="loadGraph">↻ Abhängigkeitsgraph erstellen</button></div><div id="graphBox" class="graphbox"><div class="empty">Normalisierten Compose-Abhängigkeitsgraph laden.</div></div>`}
function permissionsTab(){return `<div class="consolebar"><button class="btn primary" id="loadPermissions">Bind-Mounts analysieren</button><span class="muted">Prüft erwartete Anwendungs-UID/GID, Host-Besitz, Modus-Bits und ACL-Hinweise.</span></div><div id="permissionsBox"><div class="empty">Analyse starten, um beschreibbare Bind-Mounts aller erzeugten Dienste zu prüfen.</div></div>`}
function updatesTab(){return `<div class="consolebar"><button class="btn primary" id="checkUpdates">↻ Registry-Updates prüfen</button></div><div id="updateBox"><div class="empty">Installierte Image-Digests mit Registry-Manifesten vergleichen.</div></div>`}
function consoleTab(sv){return `<div class="notice" style="margin-bottom:8px">Interaktives Docker-Exec-Terminal mit echter PTY/WebSocket-Sitzung. In das Terminal klicken und normal tippen.</div><div class="consolebar"><select id="execService">${sv.map(v=>`<option>${esc(v.service||v.name)}</option>`).join('')}</select><select id="execShell"><option value="sh">sh</option><option value="bash">bash</option><option value="ash">ash</option></select><button class="btn primary" id="openTerminal">Verbinden</button><button class="btn danger" id="closeTerminal">Trennen</button></div><div id="execOut" class="terminal interactive" tabindex="0">Terminal disconnected.
</div>`}
function dangerTab(st){return st.name?`<div class="notice"><b>Sicheres Löschen ist der Standard.</b> Es werden nur compose.yaml, .env und von Dockwatch verwaltete Secret-/Env-/Config-Ordner entfernt. Unabhängige Bind-Mount-Daten neben dem Stack bleiben erhalten.</div><div class="toolbar" style="margin-top:10px"><button class="btn danger" data-act="down">Compose herunterfahren</button><button class="btn" data-act="recreate">Neu erstellen erzwingen</button><button class="btn danger" id="deleteStack">Definition löschen</button><button class="btn danger" id="purgeStack">Gesamten Ordner endgültig löschen…</button></div>`:'<div class="empty">Speichere zuerst den Stack.</div>'}
function wireStackDetail(){const root=$('#stackDetail');root.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x===b));['compose','env','envfiles','secrets','configs','services','permissions','graph','updates','logs','console','danger'].forEach(t=>{const e=$(`#tab-${t}`);if(e)e.hidden=t!==b.dataset.tab})});const mark=()=>{setDirty(true);saveDraft()};let composeTimer;root.addEventListener('input',e=>{if(e.target.matches('#composeText,#envText,#stackName,.secName,.secContent,.managedName,.managedContent'))mark();if(e.target.matches('#composeText')){clearTimeout(composeTimer);composeTimer=setTimeout(parseComposeVisual,220)}});$('#addComposeService')?.addEventListener('click',addComposeService);parseComposeVisual();$('#composeExpandAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=true)});$('#composeCollapseAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=false)});$('#addSecret')?.addEventListener('click',()=>{$('#secretList').insertAdjacentHTML('beforeend',secretRow());wireSecretRemovers();mark()});$('#addenvfile')?.addEventListener('click',()=>{$('#envfileList').insertAdjacentHTML('beforeend',managedFileRow('envfile'));wireManagedRemovers();mark()});$('#addconfig')?.addEventListener('click',()=>{$('#configList').insertAdjacentHTML('beforeend',managedFileRow('config'));wireManagedRemovers();mark()});wireSecretRemovers();wireManagedRemovers();$('#saveStack').onclick=saveStack;$$('[data-act]').forEach(b=>b.onclick=()=>stackAction(b.dataset.act));$('#loadLogs')?.addEventListener('click',loadLogs);$('#liveLogs')?.addEventListener('click',startLiveLogs);$('#stopLogs')?.addEventListener('click',stopLiveLogs);$('#pauseLogs')?.addEventListener('click',togglePauseLogs);$('#downloadLogs')?.addEventListener('click',downloadLogs);$('#logAutoScroll')?.addEventListener('change',e=>state.logAutoScroll=e.target.checked);$('#logFilter')?.addEventListener('input',filterLogs);$('#openTerminal')?.addEventListener('click',openTerminal);$('#closeTerminal')?.addEventListener('click',closeTerminal);$('#loadGraph')?.addEventListener('click',loadGraph);$('#loadPermissions')?.addEventListener('click',loadStackPermissions);$('#checkUpdates')?.addEventListener('click',loadImageUpdates);$('#deleteStack')?.addEventListener('click',()=>deleteStack(false));$('#purgeStack')?.addEventListener('click',()=>deleteStack(true));$('#restoreDraft')?.addEventListener('click',restoreDraft);$('#discardDraft')?.addEventListener('click',()=>{clearDraft(state.stack.name);renderStacks()})}
function wireSecretRemovers(){$$('.removeSecret').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})}
function wireManagedRemovers(){$$('.removeManaged').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})}
function restoreDraft(){try{const d=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('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(`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?'▶ 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+=`FEHLER: ${uiMessage(e.message)}`}}
function renderMonitors(){setCrumb('Überwachung / Monitore');const actions=roleOK()?'<button class="btn primary" id="newMonitor">+ Neuer Monitor</button>':'';$('#content').innerHTML=`${pageHead('Monitore','Uptime, Latenz, Docker-Status und Wartung über alle Umgebungen hinweg.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="monSearch" placeholder="Monitore filtern…"><button class="btn tiny" id="monRefresh">↻</button></div><div id="monList">${monitorListHTML(state.monitors)}</div></div><div id="monDetail" class="detail">${state.monitor?monitorDetailHTML(state.monitor,state.checks):'<div class="empty"><div class="big">♡</div>Wähle einen Monitor aus, um Uptime und Latenz anzuzeigen.</div>'}</div></div>`;$('#newMonitor')?.addEventListener('click',()=>monitorModal());$('#monSearch').oninput=e=>$('#monList').innerHTML=monitorListHTML(state.monitors.filter(m=>(m.name+' '+m.target).toLowerCase().includes(e.target.value.toLowerCase())));$('#monRefresh').onclick=()=>refreshData(true).then(renderMonitors);wireMonitorList();if(state.monitor)wireMonitorDetail()}
function monitorListHTML(ms){return ms.map(m=>`<div class="monitoritem ${state.monitor?.id===m.id?'active':''}" data-mon="${m.id}"><div class="itemtop"><b>${esc(m.name)}</b>${badge(m.status)}</div><div class="itemsub">${esc(m.type.toUpperCase())} · ${esc(m.target)}</div><div class="hb">${heartbeatBars([],m.status,24)}</div></div>`).join('')||'<div class="empty">Keine Monitore</div>'}
function wireMonitorList(){$$('[data-mon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.mon)))}
async function openMonitor(id){try{const [m,c]=await Promise.all([api(`/api/monitors/${id}`),api(`/api/monitors/${id}/checks?limit=80`)]);state.monitor=m;state.checks=Array.isArray(c)?c:[];state.view='monitors';renderMonitors()}catch(e){toast(e.message)}}
function heartbeatBars(checks,status,n=48){if(!checks?.length)return Array.from({length:n},()=>`<span class="${status==='up'?'up':status==='down'?'down':status==='maintenance'?'maintenance':'paused'}"></span>`).join('');const a=[...checks].reverse().slice(-n);return Array.from({length:n-a.length},()=>'<span></span>').join('')+a.map(c=>`<span class="${c.ok?'up':'down'}" title="${esc(fmtTime(c.checked_at))} · ${c.latency_ms}ms"></span>`).join('')}
function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'<div class="empty">Noch keine Heartbeat-Daten vorhanden.</div>';const max=Math.max(1,...a.map(x=>x.latency_ms)),pts=a.map((x,i)=>`${(i/(Math.max(1,a.length-1))*100).toFixed(2)},${(95-(x.latency_ms/max)*80).toFixed(2)}`).join(' ');return `<svg viewBox="0 0 100 100" preserveAspectRatio="none"><polyline points="${pts}" fill="none" stroke="#7c6cf7" stroke-width="1.3" vector-effect="non-scaling-stroke"/><line x1="0" x2="100" y1="95" y2="95" stroke="#273042" stroke-width=".5"/></svg>`}
function monitorDetailHTML(m,c){const avg=c?.length?Math.round(c.reduce((a,x)=>a+x.latency_ms,0)/c.length):0,ok=c?.filter(x=>x.ok).length||0,ratio=c?.length?ok/c.length*100:0;return `<div class="detailhead"><div class="detailtitle"><span class="cube">♡</span><div><h2>${esc(m.name)}</h2><small>${esc(m.target)}</small></div>${badge(m.status)}</div><div class="actions">${roleOK()?`<button class="btn primary" id="checkMon">↻ Jetzt prüfen</button><button class="btn" id="editMon">Bearbeiten</button>${m.status==='paused'?'<button class="btn success" id="resumeMon">Fortsetzen</button>':'<button class="btn" id="pauseMon">Pausieren</button>'}${m.status==='maintenance'?'<button class="btn" id="clearMaint">Wartung beenden</button>':'<button class="btn" id="maintMon">Wartung</button>'}`:''}</div></div><div class="monitorhero"><div><span class="muted">Uptime 24 h</span><div class="uptimebig ${m.status==='down'?'red':'green'}">${Number(m.uptime_24h||0).toFixed(3)}%</div></div><div>${badge(m.status)}</div></div><div class="tabbody"><div class="hb">${heartbeatBars(c,m.status,64)}</div><div class="monitorstats"><div class="mini"><small>Letzter Heartbeat</small><b>${fmtAgo(m.last_checked_at)}</b></div><div class="mini"><small>Letzte Latenz</small><b>${m.last_latency_ms||0} ms</b></div><div class="mini"><small>Durchschnittliche Latenz</small><b>${avg} ms</b></div><div class="mini"><small>Letzte Erfolge</small><b>${ratio.toFixed(1)}%</b></div><div class="mini"><small>Intervall</small><b>${m.interval_seconds}s</b></div></div>${m.last_message?`<div class="notice ${m.status==='down'?'red':''}" style="margin-bottom:10px"><b>Letztes Ergebnis:</b> ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}</div>`:''}<div class="panel"><div class="panelhead"><h2>Antwortzeit</h2><span class="muted">letzte ${c.length} Heartbeats</span></div><div class="tabbody"><div class="chart">${latencyChart(c)}</div></div></div>${m.status==='maintenance'?`<div class="notice" style="margin-top:10px">Wartung aktiv${m.maintenance_until?` bis ${esc(fmtTime(m.maintenance_until))}`:' bis zur manuellen Beendigung'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.</div>`:''}<div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Konfiguration</h2></div><table class="table"><tbody><tr><td class="muted">Typ</td><td>${esc(m.type.toUpperCase())}</td><td class="muted">Methode</td><td>${esc(m.method||'GET')}</td></tr><tr><td class="muted">Erwarteter Status</td><td>${m.expected_min}–${m.expected_max}</td><td class="muted">Timeout</td><td>${m.timeout_ms} ms</td></tr><tr><td class="muted">Schlüsselwort</td><td>${esc(m.keyword||'—')}</td><td class="muted">Prüfumgebung</td><td>${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / lokal'}</td></tr></tbody></table></div>${roleOK()?'<div class="toolbar" style="margin-top:10px"><button class="btn danger" id="deleteMon">Monitor löschen</button></div>':''}</div>`}
function wireMonitorDetail(){$('#checkMon')?.addEventListener('click',checkMonitorNow);$('#editMon')?.addEventListener('click',()=>monitorModal(state.monitor));$('#pauseMon')?.addEventListener('click',()=>monitorAction('pause'));$('#resumeMon')?.addEventListener('click',()=>monitorAction('resume'));$('#maintMon')?.addEventListener('click',()=>maintenanceModal(state.monitor));$('#clearMaint')?.addEventListener('click',()=>clearMaintenance(state.monitor.id));$('#deleteMon')?.addEventListener('click',deleteMonitor)}
async function checkMonitorNow(){const b=$('#checkMon');setBusy(b,true,'Prüfe…');try{const c=await api(`/api/monitors/${state.monitor.id}/check`,{method:'POST'});toast(c.ok?`Prüfung erfolgreich · ${c.latency_ms} ms`:`Prüfung fehlgeschlagen · ${c.message}`);await refreshData(false);await openMonitor(state.monitor.id)}catch(e){toast(e.message)}finally{setBusy(b,false)}}
function monitorModal(m=null){const isEdit=!!m;modal(`<div class="modalhead"><h2>${isEdit?'Monitor bearbeiten':'Neuer Monitor'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="mfName" value="${esc(m?.name||'')}"></div><div class="field"><label>Monitor-Typ</label><select id="mfType"><option value="http">HTTP(S)</option><option value="tcp">TCP-Port</option><option value="dns">DNS-Hostname</option><option value="docker">Docker-Container</option></select></div><div class="field full"><label>URL / Ziel / Containername oder ID</label><input id="mfTarget" list="dockerTargets" value="${esc(m?.target||'')}" placeholder="https://beispiel.de, host:port oder container-name"><datalist id="dockerTargets"></datalist></div><div class="field"><label>Dienstgruppe</label><select id="mfService"><option value="">Nicht gruppiert</option>${state.services.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label>Prüfumgebung</label><select id="mfNode"><option value="">Master / lokal</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · deaktiviert'}</option>`).join('')}</select></div><div class="field"><label>Intervall (Sekunden)</label><input id="mfInterval" type="number" min="10" max="86400" step="1" value="${m?.interval_seconds||60}"></div><div class="field"><label>Timeout (ms)</label><input id="mfTimeout" type="number" value="${m?.timeout_ms||5000}"></div><div class="field"><label>HTTP-Methode</label><select id="mfMethod">${['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].map(v=>`<option>${v}</option>`).join('')}</select></div><div class="field"><label>Erwarteter HTTP-Status</label><div style="display:flex;gap:5px"><input id="mfMin" type="number" value="${m?.expected_min||200}" style="width:50%"><input id="mfMax" type="number" value="${m?.expected_max||399}" style="width:50%"></div></div><div class="field full"><label>Schlüsselwortprüfung (optional)</label><input id="mfKeyword" value="${esc(m?.keyword||'')}" placeholder="Text, der in der Antwort vorkommen muss"></div><div class="field full"><label>Anfrage-Header als JSON</label><textarea id="mfHeaders" spellcheck="false">${esc(m?.headers_json||'{}')}</textarea></div><div class="field full"><label>Anfrage-Body</label><textarea id="mfBody" spellcheck="false">${esc(m?.body||'')}</textarea></div><label class="switch"><input id="mfInvert" type="checkbox" ${m?.invert_keyword?'checked':''}> Schlüsselwortprüfung umkehren</label><label class="switch"><input id="mfTLS" type="checkbox" ${m?.ignore_tls?'checked':''}> TLS-Zertifikatsfehler ignorieren</label><label class="switch"><input id="mfHealthy" type="checkbox" ${m?.require_healthy?'checked':''}> Docker: Healthy-Status erforderlich</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="saveMonitor">${isEdit?'Änderungen speichern':'Monitor anlegen'}</button></div>`);$('#mfType').value=m?.type||'http';$('#mfNode').value=m?.node_id||'';$('#mfService').value=m?.service_id||'';$('#mfInterval').value=String(m?.interval_seconds||60);$('#mfMethod').value=m?.method||'GET';const syncFields=()=>{const t=$('#mfType').value,isHTTP=t==='http',isDocker=t==='docker';['mfMethod','mfMin','mfMax','mfKeyword','mfHeaders','mfBody'].forEach(id=>{const e=$('#'+id);if(e?.closest('.field'))e.closest('.field').style.display=isHTTP?'':'none'});['mfInvert','mfTLS'].forEach(id=>{const e=$('#'+id);if(e?.closest('.switch'))e.closest('.switch').style.display=isHTTP?'':'none'});const he=$('#mfHealthy');if(he?.closest('.switch'))he.closest('.switch').style.display=isDocker?'':'none';const target=$('#mfTarget');if(target)target.placeholder=isHTTP?'https://beispiel.de':t==='tcp'?'host:port':t==='dns'?'beispiel.de':'container-name-oder-id'};const loadDockerTargets=async()=>{syncFields();if($('#mfType').value!=='docker')return;try{const nid=$('#mfNode').value;const rows=await api('/api/docker/containers'+(nid?`?node_id=${nid}`:''));$('#dockerTargets').innerHTML=asArray(rows).map(x=>`<option value="${esc(x.Names||x.Name||x.ID||x.name||x.id||'')}"></option>`).join('')}catch{}};$('#mfType').onchange=loadDockerTargets;$('#mfNode').onchange=loadDockerTargets;loadDockerTargets();$('#saveMonitor').onclick=()=>saveMonitor(m?.id,m?.enabled??true)}
async function saveMonitor(id,currentEnabled=true){const isEdit=id!==undefined&&id!==null;const enabled=isEdit?Boolean(currentEnabled):true;const body={name:$('#mfName').value,type:$('#mfType').value,target:$('#mfTarget').value,node_id:$('#mfNode').value?Number($('#mfNode').value):null,service_id:$('#mfService').value?Number($('#mfService').value):null,interval_seconds:Number($('#mfInterval').value),timeout_ms:Number($('#mfTimeout').value),expected_min:Number($('#mfMin').value),expected_max:Number($('#mfMax').value),method:$('#mfMethod').value,headers_json:$('#mfHeaders').value||'{}',body:$('#mfBody').value,keyword:$('#mfKeyword').value,invert_keyword:$('#mfInvert').checked,ignore_tls:$('#mfTLS').checked,require_healthy:$('#mfHealthy').checked,enabled};try{const m=await api(id?`/api/monitors/${id}`:'/api/monitors',{method:id?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);state.monitor=m;state.checks=id?await api(`/api/monitors/${id}/checks?limit=80`):[];renderMonitors();toast(id?'Monitor aktualisiert.':'Monitor angelegt.')}catch(e){toast(e.message)}}
async function monitorAction(a){try{await api(`/api/monitors/${state.monitor.id}/${a}`,{method:'POST'});await openMonitor(state.monitor.id);toast(a==='pause'?'Monitor pausiert.':'Monitor fortgesetzt.')}catch(e){toast(e.message)}}
function maintenanceModal(m){modal(`<div class="modalhead"><h2>Maintenance · ${esc(m.name)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice" style="margin-bottom:10px">Während der Wartung werden Prüfalarme unterdrückt; der Monitor wird als Wartung statt als ausgefallen angezeigt.</div><div class="fieldgrid"><div class="field"><label>Endmodus</label><select id="maintMode"><option value="manual">Bis zur manuellen Beendigung</option><option value="1h">1 Stunde</option><option value="4h">4 Stunden</option><option value="24h">24 Stunden</option><option value="custom">Benutzerdefiniertes Datum/Uhrzeit</option></select></div><div class="field"><label>Benutzerdefiniertes Ende</label><input id="maintUntil" type="datetime-local"></div><div class="field full"><label>Hinweis</label><input id="maintNote" placeholder="Planned deployment / provider maintenance"></div></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="startMaint">Wartung starten</button></div>`);$('#startMaint').onclick=()=>startMaintenance(m.id)}
async function startMaintenance(id){const mode=$('#maintMode').value;let until=null;if(mode==='custom'){const v=$('#maintUntil').value;if(v)until=Math.floor(new Date(v).getTime()/1000)}else if(mode!=='manual'){until=Math.floor(Date.now()/1000)+Number(mode.replace('h',''))*3600}try{await api(`/api/monitors/${id}/maintenance`,{method:'POST',body:JSON.stringify({until,note:$('#maintNote').value})});closeModal();await refreshData(true);await openMonitor(id);toast('Wartung gestartet.')}catch(e){toast(e.message)}}
async function clearMaintenance(id){await api(`/api/monitors/${id}/maintenance`,{method:'DELETE'});await refreshData(true);await openMonitor(id);toast('Wartung beendet.')}
async function deleteMonitor(){if(!confirm(`Delete monitor ${state.monitor.name} and its heartbeat history?`))return;await api(`/api/monitors/${state.monitor.id}`,{method:'DELETE'});state.monitor=null;state.checks=[];await refreshData(true);renderMonitors()}
async function renderServices(){setCrumb('Überwachung / Dienste');const actions=roleOK()?'<button class="btn primary" id="newService">+ Neuer Dienst</button>':'';$('#content').innerHTML=`${pageHead('Dienste','Monitore zu sichtbaren Diensten gruppieren. Eine fehlgeschlagene Prüfung setzt den gesamten Dienst auf gestört.',actions)}<div class="panel" id="servicePanel">${state.services.length?`<table class="table"><thead><tr><th>Dienst</th><th>Status</th><th>Monitore</th><th></th></tr></thead><tbody>${state.services.map(g=>`<tr><td><b>${esc(g.name)}</b><div class="muted">${esc(g.description||'')}</div></td><td>${badge(g.status)}</td><td>${(g.monitors||[]).length}<div class="muted">${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'Keine Monitore zugeordnet'}</div></td><td class="right">${roleOK()?`<button class="btn tiny" data-sedit="${g.id}">Bearbeiten</button> <button class="btn tiny danger" data-sdel="${g.id}">Löschen</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine Dienste konfiguriert. Lege einen Dienst an und ordne ihm Monitore zu.</div>'}</div>`;$('#newService')?.addEventListener('click',()=>serviceModal());$$('[data-sedit]').forEach(b=>b.onclick=()=>serviceModal(state.services.find(x=>x.id===Number(b.dataset.sedit))));$$('[data-sdel]').forEach(b=>b.onclick=async()=>{if(confirm('Dienst löschen? Die zugehörigen Monitore sind anschließend nicht mehr gruppiert.')){await api('/api/services/'+b.dataset.sdel,{method:'DELETE'});await refreshData(true);renderServices()}})}
function serviceModal(x=null){const selected=new Set((x?.monitors||[]).map(m=>m.id));modal(`<div class="modalhead"><h2>${x?'Dienst bearbeiten':'Neuer Dienst'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Name</label><input id="svcName" value="${esc(x?.name||'')}"></div><div class="field"><label>Beschreibung</label><textarea id="svcDesc">${esc(x?.description||'')}</textarea></div><div class="field"><label>Zugeordnete Monitore</label><div class="checklist serviceProbeList">${state.monitors.map(m=>`<label class="switch"><input class="svcProbe" type="checkbox" value="${m.id}" ${selected.has(m.id)?'checked':''}> <span>${esc(m.name)} · ${esc(m.type.toUpperCase())}</span> ${badge(m.status)}</label>`).join('')||'<span class="muted">Keine Monitore konfiguriert.</span>'}</div></div><div class="notice">Ein Monitor kann genau einem Dienst angehören. Eine Zuordnung hier verschiebt ihn aus dem bisherigen Dienst. Eine fehlgeschlagene Prüfung setzt den Dienst auf gestört.</div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="svcSave">Speichern</button></div>`);$('#svcSave').onclick=async()=>{const body={name:$('#svcName').value,description:$('#svcDesc').value,monitor_ids:$$('.svcProbe:checked').map(e=>Number(e.value))};try{await api(x?`/api/services/${x.id}`:'/api/services',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);renderServices()}catch(e){toast(e.message)}}}
async function renderStatusPages(){setCrumb('Überwachung / Statusseiten');$('#content').innerHTML=`${pageHead('Öffentliche Statusseiten','Nur ausgewählte Dienste und deren aggregierten Status veröffentlichen.',state.me.role==='admin'?'<button class="btn primary" id="newStatusPage">+ Neue Statusseite</button>':'')}<div class="panel" id="statusPagePanel"><div class="empty">Statusseiten werden geladen…</div></div>`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Öffentliche URL</th><th>Dienste</th><th>Aktiviert</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b><div class="muted">${esc(x.description||'')}</div></td><td><a href="/status/${encodeURIComponent(x.slug)}" target="_blank">/status/${esc(x.slug)}</a></td><td>${(x.service_ids||[]).length}</td><td>${x.enabled?'<span class="green">Öffentlich</span>':'Deaktiviert'}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-pedit="${x.id}">Bearbeiten</button> <button class="btn tiny danger" data-pdel="${x.id}">Löschen</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine öffentlichen Statusseiten konfiguriert.</div>';$$('[data-pedit]').forEach(b=>b.onclick=()=>statusPageModal(rows.find(x=>x.id===Number(b.dataset.pedit))));$$('[data-pdel]').forEach(b=>b.onclick=async()=>{if(confirm('Statusseite löschen?')){await api('/api/status-pages/'+b.dataset.pdel,{method:'DELETE'});renderStatusPages()}})}catch(e){$('#statusPagePanel').innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`<div class="modalhead"><h2>${x?'Statusseite bearbeiten':'Neue Statusseite'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="pgName" value="${esc(x?.name||'')}"></div><div class="field"><label>Slug</label><input id="pgSlug" value="${esc(x?.slug||'')}" placeholder="public-status"></div><div class="field full"><label>Beschreibung</label><textarea id="pgDesc">${esc(x?.description||'')}</textarea></div><div class="field full"><label>Veröffentlichte Dienste</label><div class="checklist">${state.services.map(g=>`<label class="switch"><input class="pgSvc" type="checkbox" value="${g.id}" ${selected.has(g.id)?'checked':''}> ${esc(g.name)} ${badge(g.status)}</label>`).join('')||'<span class="muted">Lege zuerst Dienste an.</span>'}</div></div><label class="switch"><input id="pgEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Öffentlich erreichbar</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="pgSave">Speichern</button></div>`);$('#pgSave').onclick=async()=>{const body={name:$('#pgName').value,slug:$('#pgSlug').value,description:$('#pgDesc').value,enabled:$('#pgEnabled').checked,service_ids:$$('.pgSvc:checked').map(e=>Number(e.value))};try{await api(x?`/api/status-pages/${x.id}`:'/api/status-pages',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();renderStatusPages()}catch(e){toast(e.message)}}}
function renderMaintenance(){setCrumb('Überwachung / Wartung');const ms=state.monitors.filter(m=>m.status==='maintenance');$('#content').innerHTML=`${pageHead('Wartung','Monitoring-Alarme während geplanter Arbeiten unterdrücken.')}<div class="panel"><div class="panelhead"><h2>Aktive Wartungsfenster</h2></div>${ms.length?`<table class="table"><thead><tr><th>Monitor</th><th>Ziel</th><th>Bis</th><th>Hinweis</th><th></th></tr></thead><tbody>${ms.map(m=>`<tr><td><b>${esc(m.name)}</b></td><td class="muted">${esc(m.target)}</td><td>${m.maintenance_until?fmtTime(m.maintenance_until):'Manuelles Ende'}</td><td>${esc(m.maintenance_note||'—')}</td><td><button class="btn tiny" data-endmaint="${m.id}">Beenden</button></td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine aktiven Wartungsfenster.</div>'}</div><div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Wartung starten</h2></div><table class="table"><tbody>${state.monitors.filter(m=>m.status!=='maintenance').map(m=>`<tr><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td style="text-align:right"><button class="btn tiny" data-startmaint="${m.id}">Zeitplan</button></td></tr>`).join('')}</tbody></table></div>`;$$('[data-endmaint]').forEach(b=>b.onclick=()=>clearMaintenance(Number(b.dataset.endmaint)).then(renderMaintenance));$$('[data-startmaint]').forEach(b=>b.onclick=()=>maintenanceModal(state.monitors.find(m=>m.id===Number(b.dataset.startmaint))))}
function renderNodes(){setCrumb('System / Umgebungen');const local=`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>Lokaler Docker-Host</b><div class="muted">Docker-Socket</div></div></div></td><td>${badge('up')}<div class="healthDetail">${esc(state.system?.build?.version||'lokal')}</div></td><td>Lokal</td><td>—</td></tr>`;$('#content').innerHTML=`${pageHead('Umgebungen','Master und Remote-Agenten über eine zentrale Oberfläche verwalten.',state.me.role==='admin'?'<button class="btn primary" id="addNode">+ Umgebung hinzufügen</button>':'')}<div class="panel"><table class="table"><thead><tr><th>Name</th><th>Status</th><th>Verbindung</th><th>Aktionen</th></tr></thead><tbody>${local}${state.nodes.map(n=>`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>${esc(n.name)}</b><div class="muted">${esc(n.base_url)}</div></div></div></td><td id="nodeHealth-${n.id}">${n.enabled?'<span class="status unknown">Wird geprüft…</span>':badge('paused')}</td><td>${n.enabled?'Bearer-Agent':'Deaktiviert'}</td><td>${state.me.role==='admin'?`<button class="btn tiny" data-editnode="${n.id}">Bearbeiten</button> <button class="btn tiny danger" data-delnode="${n.id}">Entfernen</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$('#addNode')?.addEventListener('click',()=>nodeModal());$$('[data-editnode]').forEach(b=>b.onclick=()=>nodeModal(state.nodes.find(n=>n.id===Number(b.dataset.editnode))));$$('[data-delnode]').forEach(b=>b.onclick=async()=>{if(confirm('Diese Umgebung entfernen?')){await api('/api/nodes/'+b.dataset.delnode,{method:'DELETE'});const x=await api('/api/nodes');state.nodes=Array.isArray(x)?x:[];renderNodePicker();renderNodes()}});loadNodeHealth()}
function nodeModal(x=null){modal(`<div class="modalhead"><h2>${x?'Remote-Agent bearbeiten':'Remote-Agent hinzufügen'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nodeName" value="${esc(x?.name||'')}" placeholder="server-2"></div><div class="field full"><label>Agent-Basis-URL</label><input id="nodeURL" value="${esc(x?.base_url||'')}" placeholder="https://server-2.example.com"></div><div class="field full"><label>Agent-Token ${x?'<span class="muted">(leer lassen = beibehalten)</span>':''}</label><input id="nodeToken" type="password"></div>${x?`<label class="switch"><input id="nodeEnabled" type="checkbox" ${x.enabled?'checked':''}> Agent aktiviert</label>`:''}</div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="saveNode">${x?'Speichern':'Umgebung hinzufügen'}</button></div>`);$('#saveNode').onclick=async()=>{try{const body={name:$('#nodeName').value,base_url:$('#nodeURL').value,token:$('#nodeToken').value};if(x)body.enabled=$('#nodeEnabled').checked;await api(x?`/api/nodes/${x.id}`:'/api/nodes',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();const rows=await api('/api/nodes');state.nodes=Array.isArray(rows)?rows:[];renderNodePicker();renderNodes()}catch(e){toast(e.message)}}}
async function loadNodeHealth(){await Promise.all(state.nodes.filter(n=>n.enabled).map(async n=>{const el=$(`#nodeHealth-${n.id}`);if(!el)return;try{const h=await api(`/api/nodes/${n.id}/health`);state.nodeHealth[n.id]=h;el.innerHTML=`${badge('up')}<div class="healthDetail">v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}</div>`}catch(e){el.innerHTML=`${badge('down')}<div class="healthDetail" title="${esc(uiMessage(e.message))}">nicht erreichbar</div>`}}))}
function resourceTitle(k){return({containers:'Container',images:'Images',volumes:'Volumes',networks:'Netzwerke'})[k]||k}
function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='containers')create='<button class="btn" id="identityAudit">Identitätsprüfung</button>';if(k==='images')create='<button class="btn primary" id="resourceCreate">↓ Image laden</button><button class="btn" id="registryLogin">Registry-Anmeldung</button><button class="btn" id="registryLogout">Abmelden</button>';if(k==='volumes')create='<button class="btn primary" id="resourceCreate">+ Volume erstellen</button>';if(k==='networks')create='<button class="btn primary" id="resourceCreate">+ Netzwerk erstellen</button>'}const prune=roleOK()&&k!=='containers'?'<button class="btn danger" id="resourcePrune">Ungenutzte bereinigen</button>':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}<button class="btn" id="invRefresh">↻ Aktualisieren</button>`)}<div class="panel"><div class="resourcebar"><input id="inventorySearch" placeholder="${n} filtern…"><span id="inventoryCount" class="muted"></span></div><div id="inventoryPanel"><div class="empty">${n} werden geladen…</div></div></div>`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)}
function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''}
async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
function inventoryLabelSummary(raw){const full=typeof raw==='string'?raw:raw&&typeof raw==='object'?Object.entries(raw).map(([k,v])=>`${k}=${v}`).join(','):String(raw||'');if(!full)return'—';const parts=full.split(',').map(x=>x.trim()).filter(Boolean),pretty=parts.slice(0,3).map(x=>x.replace(/^com\.docker\.compose\./,'compose.'));return pretty.join(' · ')+(parts.length>3?` · +${parts.length-3}`:'')}
function inventoryCell(kind,key,value,first=false){const labelRaw=key==='Labels'&&value&&typeof value==='object'?Object.entries(value).map(([k,v])=>`${k}=${v}`).join(','):null,raw=value===undefined||value===null||value===''?'—':labelRaw??String(value);if(first)return `<div class="namecell"><span class="cube">⬡</span><b title="${esc(raw)}">${esc(raw)}</b></div>`;if(key==='Labels')return `<span class="inventory-ellipsis inventory-labels" title="${esc(raw)}">${esc(inventoryLabelSummary(value))}</span>`;if(['Mountpoint','Ports','Status','ID'].includes(key))return `<span class="inventory-ellipsis" title="${esc(raw)}">${esc(raw)}</span>`;return esc(raw)}
function renderInventoryRows(kind,all){const p=$('#inventoryPanel');if(!p)return;const q=$('#inventorySearch')?.value.toLowerCase()||'',rows=q?all.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):all;const count=$('#inventoryCount');if(count)count.textContent=`${rows.length} / ${all.length}`;if(!rows.length){p.innerHTML='<div class="empty">Keine passenden Einträge gefunden.</div>';return}const defs={containers:[['Names','Name'],['Image','Image'],['State','Zustand'],['Status','Status'],['Ports','Ports']],images:[['Repository','Repository'],['Tag','Tag'],['ID','Image ID'],['Size','Größe'],['CreatedSince','Erstellt']],volumes:[['Name','Name'],['Driver','Treiber'],['Mountpoint','Einhängepunkt'],['Labels','Labels']],networks:[['Name','Name'],['Driver','Treiber'],['Scope','Geltungsbereich'],['IPv6','IPv6'],['Internal','Intern']]};const cols=defs[kind]||Object.keys(rows[0]).slice(0,5).map(k=>[k,k]);p.innerHTML=`<div class="inventory-tablewrap"><table class="table inventory-table inventory-${esc(kind)}"><thead><tr>${cols.map(c=>`<th data-col="${esc(c[0])}">${esc(c[1])}</th>`).join('')}<th class="right inventory-actions-cell">Aktionen</th></tr></thead><tbody>${rows.map(r=>{const idx=all.indexOf(r);return `<tr>${cols.map((c,i)=>`<td data-col="${esc(c[0])}" class="${i?'muted':''}">${inventoryCell(kind,c[0],r[c[0]],i===0)}</td>`).join('')}<td class="right inventory-actions-cell"><div class="inventory-actions">${resourceActions(kind,r,idx)}</div></td></tr>`}).join('')}</tbody></table></div>`;wireResourceRows(kind)}
function resourceActions(kind,r,idx){const inspect=roleOK()?`<button class="btn tiny" data-inspect="${idx}">Inspizieren</button>`:'';if(kind==='containers')return `${inspect}${roleOK()?` <button class="btn tiny" data-identity="${idx}">Identität</button> <button class="btn tiny" data-ract="start" data-row="${idx}">Starten</button> <button class="btn tiny" data-ract="restart" data-row="${idx}">Neustart</button> <button class="btn tiny" data-ract="stop" data-row="${idx}">Stoppen</button> <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Entfernen</button>`:''}`;if(!roleOK())return'';return `${inspect} <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Entfernen</button>`}
function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]));$$('[data-identity]').forEach(b=>b.onclick=()=>inspectContainerIdentity(window.__inventoryRows[Number(b.dataset.identity)]))}
async function dockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action} ${kind}? This can delete Docker resources.`))return;try{const r=await api(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);await loadInventory(kind)}catch(e){toast(e.message)}}
function registryLoginModal(){modal(`<div class="modalhead"><h2>Registry-Anmeldung</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div><div class="field"><label>Benutzername</label><input id="regUser"></div><div class="field"><label>Passwort / Token</label><input id="regPass" type="password"></div></div><div class="notice" style="margin-top:10px">Anmeldedaten werden von der Docker-CLI in die persistente Docker-Konfiguration dieser Umgebung geschrieben. Das Passwort wird über stdin und nicht als Kommandozeilenargument übergeben.</div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="regSave">Anmelden</button></div>`);$('#regSave').onclick=async()=>{const body={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)return toast('Registry, username and password required.');closeModal();await dockerResourceAction('images','login',body)}}
function registryLogoutModal(){modal(`<div class="modalhead"><h2>Registry-Abmeldung</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn danger" id="regSave">Abmelden</button></div>`);$('#regSave').onclick=async()=>{const registry=$('#regHost').value.trim();if(!registry)return toast('Registry required.');closeModal();await dockerResourceAction('images','logout',{registry})}}
function resourceCreateModal(kind){if(kind==='images'){modal(`<div class="modalhead"><h2>Image laden</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Image-Referenz</label><input id="resName" placeholder="nginx:alpine"></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="resSave">Laden</button></div>`);$('#resSave').onclick=async()=>{const name=$('#resName').value.trim();if(!name)return toast('Image-Referenz erforderlich.');closeModal();await dockerResourceAction('images','pull',{name})};return}const isNet=kind==='networks';modal(`<div class="modalhead"><h2>${isNet?'Netzwerk erstellen':'Volume erstellen'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="resName" placeholder="${isNet?'frontend':'app-data'}"></div><div class="field"><label>Treiber</label><input id="resDriver" placeholder="${isNet?'bridge':'lokal'}"></div>${isNet?'<label class="switch"><input id="resInternal" type="checkbox"> Internes Netzwerk</label><label class="switch"><input id="resAttachable" type="checkbox"> Anbindbar</label>':''}<div class="field full"><label>Labels (key=value, eines pro Zeile)</label><textarea id="resLabels" spellcheck="false"></textarea></div></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="resSave">Erstellen</button></div>`);$('#resSave').onclick=async()=>{const labels={};($('#resLabels').value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).forEach(x=>{const i=x.indexOf('=');if(i<0)labels[x]='';else labels[x.slice(0,i).trim()]=x.slice(i+1).trim()});const body={name:$('#resName').value.trim(),driver:$('#resDriver').value.trim(),labels};if(isNet){body.internal=$('#resInternal').checked;body.attachable=$('#resAttachable').checked}if(!body.name)return toast('Name ist erforderlich.');closeModal();await dockerResourceAction(kind,'create',body)}}
async function containerIdentityAudit(){const rows=asArray(window.__inventoryRows);if(!rows.length)return toast('Keine Container zum Prüfen vorhanden.');const btn=$('#identityAudit');setBusy(btn,true,'Wird geprüft…');try{const results=[];for(let i=0;i<rows.length;i+=4){const batch=rows.slice(i,i+4);results.push(...await Promise.allSettled(batch.map(r=>{const id=r.ID||r.Names||r.Name;return api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`).then(v=>({row:r,report:v}))})))}const ok=results.filter(x=>x.status==='fulfilled').map(x=>x.value),failed=results.filter(x=>x.status==='rejected');const rootCount=ok.filter(x=>x.report.runs_as_root===true).length,nonRoot=ok.filter(x=>x.report.runs_as_root===false).length,unknown=ok.length-rootCount-nonRoot;modal(`<div class="modalhead"><h2>Container-Identitätsprüfung · ${esc(nodeName())}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="stats"><div class="stat"><small>Geprüfte Container</small><strong>${ok.length}</strong></div><div class="stat"><small>PID 1 als Root</small><strong class="${rootCount?'amber':''}">${rootCount}</strong></div><div class="stat"><small>PID 1 ohne Root</small><strong class="green">${nonRoot}</strong></div><div class="stat"><small>Unbekannt / fehlgeschlagen</small><strong>${unknown+failed.length}</strong></div></div><div class="notice" style="margin-top:12px">„Root“ bezeichnet die effektive UID von PID 1, sofern Dockwatch sie lesen konnte. Das ist ein Prüfsignal, kein Beweis dafür, dass die Anwendung sicher auf Non-Root umgestellt werden kann.</div><div class="tablewrap" style="margin-top:12px"><table class="table"><thead><tr><th>Container</th><th>UID:GID</th><th>Bewertung</th><th>Host-Benutzer</th><th>Bind-Mounts</th></tr></thead><tbody>${ok.map(x=>{const d=x.report;return `<tr><td><b>${esc(d.container_name||x.row.Names||x.row.ID)}</b><div class="muted">${esc(d.image||'')}</div></td><td class="mono"><div>PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}</div><div class="muted">bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</div></td><td>${esc(identityAssessmentLabel(d.root_assessment))}</td><td>${d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)}</span>`:(d.host_access?.available&&d.bind_uid>0?'<span class="warn">fehlt</span>':'—')}</td><td>${asArray(d.bind_mounts).length}</td></tr>`}).join('')}${failed.map((x,i)=>`<tr><td colspan="5" class="red">Prüfung ${i+1} fehlgeschlagen: ${esc(uiMessage(x.reason?.message||x.reason))}</td></tr>`).join('')}</tbody></table></div></div><div class="modalfoot"><button class="btn" data-close>Schließen</button></div>`)}catch(e){toast(e.message)}finally{setBusy(btn,false)}}
function hostAccountSuggestion(name){let n=String(name||'container').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'');if(!/^[a-z_]/.test(n))n='c-'+n;return('dockwatch-'+n).slice(0,31).replace(/-+$/,'')||'dockwatch-app'}
function identityAssessmentLabel(v){return ({'non-root':'Ohne Root','root-high-privilege':'Root + privilegiert','root-review-docker-socket':'Root · Docker-Socket','root-review-devices':'Root · Gerätezugriff','root-not-obviously-required':'Root · Prüfung empfohlen','unknown':'Unbekannt'})[v]||v||'Unbekannt'}
function writeState(v){return v===true?'<span class="green">beschreibbar</span>':v===false?'<span class="red">nicht beschreibbar</span>':'<span class="muted">unbekannt</span>'}
async function inspectContainerIdentity(r){
const id=r.ID||r.Names||r.Name;if(!id)return;
try{
const d=await api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`),uid=d.effective_uid??'—',gid=d.effective_gid??'—';
const root=d.runs_as_root===true?'Yes':d.runs_as_root===false?'No':'Unknown',reasons=asArray(d.root_reasons).map(x=>`<li>${esc(x)}</li>`).join(''),recs=asArray(d.recommendations).map(x=>`<li>${esc(x)}</li>`).join(''),binds=asArray(d.bind_mounts),host=d.host_access||{};
window.__identityBindRows=binds.map(x=>({container:id,mount:x}));
const bindTable=binds.length?`<div class="field"><label>Bind mount permissions · expected ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')} (${esc(d.bind_identity_source||'unknown')})</label><div class="tablewrap"><table class="table"><thead><tr><th>Host-Pfad</th><th>Container-Pfad</th><th>Modus / Besitzer</th><th>Schreibzugriff</th><th></th></tr></thead><tbody>${binds.map((x,i)=>`<tr><td class="mono">${esc(x.source)}</td><td class="mono">${esc(x.destination)} ${x.read_only?'<span class="tag">ro</span>':'<span class="tag">rw</span>'}</td><td><code>${esc(x.mode||'—')}</code> · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'<div class="warn">ACL erkannt</div>':''}</td><td>${x.read_only?'<span class="muted">schreibgeschützt</span>':writeState(x.static_writable)}<div class="muted">${esc(x.writable_reason||x.ownership_note||'')}</div></td><td class="right">${!x.read_only?`<button class="btn tiny" data-bindpreview="${i}">Analysieren / reparieren</button>`:''}</td></tr>`).join('')}</tbody></table></div></div>`:'<div class="notice">Keine Bind-Mounts erkannt.</div>';
const bindHost=d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})</span>`:(host.available&&d.bind_uid>0?'<span class="warn">Kein passender Host-Benutzer</span>':'—');
const canCreate=state.me.role==='admin'&&host.available&&host.management_enabled&&d.host_id_mapping!=='remapped'&&Number.isInteger(d.bind_uid)&&d.bind_uid>0&&!d.bind_host_user;
modal(`<div class="modalhead"><h2>Identität & Bind-Berechtigungen · ${esc(d.container_name||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Prozess- und Speicheridentität werden getrennt bewertet.</b> PID 1 kann als root laufen, während ein Image Anwendungsdaten mit PUID/PGID schreibt. Dockwatch ändert Compose <code>user:</code> niemals automatisch.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>PID 1 UID:GID</small><strong>${esc(uid)}:${esc(gid)}</strong></div><div class="stat"><small>Bind-UID:GID</small><strong>${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</strong></div><div class="stat"><small>Läuft als root</small><strong>${esc(root)}</strong></div><div class="stat"><small>Bewertung</small><strong>${esc(identityAssessmentLabel(d.root_assessment))}</strong></div></div><div class="twocol" style="margin-top:12px"><div class="panel inset"><h3>Begründung</h3><ul class="compactList">${reasons||'<li>Keine zusätzlichen Gründe.</li>'}</ul></div><div class="panel inset"><h3>Empfehlungen</h3><ul class="compactList">${recs||'<li>Keine Empfehlungen.</li>'}</ul></div></div><div class="panel inset" style="margin-top:12px"><div class="row"><div><h3>Host-Identität</h3><div class="muted">${esc(uiMessage(host.message||'Host-Zugriff nicht verfügbar'))}</div></div><div>${bindHost}</div></div>${host.configured?`<div class="meta" style="margin-top:8px">Host-Root: <code>${esc(host.root||'')}</code> · Benutzeranlage ${host.management_enabled?'aktiviert':'aus'} · Berechtigungsreparatur ${host.permission_management_enabled?'aktiviert':'aus'} · UID-Zuordnung ${esc(d.host_id_mapping||'unbekannt')}</div>`:''}</div>${bindTable}</div><div class="modalfoot">${canCreate?'<button class="btn" id="createMatchingHostUser">Passenden Host-Benutzer anlegen…</button>':''}<button class="btn" data-close>Schließen</button></div>`);
$$('[data-bindpreview]').forEach(b=>b.onclick=()=>{const x=window.__identityBindRows[Number(b.dataset.bindpreview)];bindPermissionModal(x.container,x.mount.destination)});
if(canCreate)$('#createMatchingHostUser').onclick=()=>hostUserModal(id,d);
}catch(e){toast(e.message)}
}
function hostUserModal(containerID,d){const suggested=hostAccountSuggestion(d.container_name);modal(`<div class="modalhead"><h2>Passenden Host-Benutzer anlegen</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Host-Änderung:</b> Dockwatch prüft den Container erneut und leitet die Bind-UID/GID serverseitig ab. Numerische IDs können nicht im Browser vorgegeben werden.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>Container</small><strong>${esc(d.container_name)}</strong></div><div class="stat"><small>Bind-UID:GID</small><strong>${esc(d.bind_uid)}:${esc(d.bind_gid)}</strong></div><div class="stat"><small>Quelle</small><strong>${esc(d.bind_identity_source||'Container-Identität')}</strong></div></div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Host-Benutzername</label><input id="hostUsername" value="${esc(suggested)}"></div><div class="field"><label>Host-Gruppenname</label><input id="hostGroup" value="${esc(suggested)}"></div><label class="switch"><input id="hostHome" type="checkbox"> Home-Verzeichnis anlegen</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn danger" id="confirmHostUser">Auf Host anlegen</button></div>`);$('#confirmHostUser').onclick=async()=>{if(!confirm(`Lokalen Account auf ${nodeName()} passend zur Bind-Identität ${d.bind_uid}:${d.bind_gid} anlegen?`))return;const btn=$('#confirmHostUser');setBusy(btn,true,'Wird angelegt…');try{const out=await api(`/api/host/users${qnode()}`,{method:'POST',body:JSON.stringify({container_id:containerID,username:$('#hostUsername').value.trim(),group_name:$('#hostGroup').value.trim(),create_home:$('#hostHome').checked})});toast(out.message||'Host-Account angelegt.');closeModal();await inspectContainerIdentity({ID:containerID})}catch(e){toast(e.message);setBusy(btn,false)}}}
async function bindPermissionModal(containerID,destination,recursive=false){
try{const p=await api(`/api/docker/containers/${encodeURIComponent(containerID)}/bind-permissions/preview${qnode()}`,{method:'POST',body:JSON.stringify({destination,recursive})});showBindPermissionPreview(p)}catch(e){toast(e.message)}
}
function showBindPermissionPreview(p){
const canRepair=state.me.role==='admin'&&p.can_repair&&!p.scan_truncated,owner=`${p.owner_uid??'—'}:${p.owner_gid??'—'}`,expected=`${p.expected_uid??'—'}:${p.expected_gid??'—'}`,canCreateUser=state.me.role==='admin'&&p.host_access?.management_enabled&&p.expected_uid>0&&!p.host_user;
modal(`<div class="modalhead"><h2>Bind-Berechtigungsprüfung</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Mount</label><div class="mono">${esc(p.source)} → ${esc(p.destination)}</div></div><div class="field"><label>Erwartete Identität</label><b>${esc(expected)}</b><div class="muted">${esc(p.identity_source||'')}</div></div><div class="field"><label>Aktueller Besitzer / Modus</label><b>${esc(owner)} · ${esc(p.mode||'—')}</b></div><div class="field"><label>Host-Benutzer für erwartete UID</label>${p.host_user?`<span class="green">${esc(p.host_user.name)}</span>`:'<span class="warn">fehlt</span>'}<div class="muted">${p.host_group?`Gruppe ${esc(p.host_group.name)}`:'nur numerische GID'}</div></div><div class="field"><label>Statische Schreibprüfung</label>${writeState(p.static_writable)}<div class="muted">${esc(uiMessage(p.writable_reason||''))}</div></div><div class="field"><label>Laufzeit-Schreibprüfung</label>${writeState(p.runtime_writable)}<div class="muted">${esc(uiMessage(p.runtime_note||''))}</div></div><div class="field"><label>Besitzprüfung</label><b>${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} abweichend</b><div class="muted">${esc(p.files_scanned||0)} Dateien · ${esc(p.directories_scanned||0)} Verzeichnisse · ${esc(p.symlinks_skipped||0)} Symlinks übersprungen · ${esc(p.cross_filesystem_skipped||0)} eingebundene Dateisysteme übersprungen</div></div><div class="field"><label>ACL</label>${p.acl_detected?'<span class="warn">Erweiterte ACL erkannt</span>':'<span class="green">Keine erweiterte ACL erkannt</span>'}<div class="muted">${esc(uiMessage(p.acl_note||''))}</div></div></div><div class="notice ${p.blocked_reason?'dangerNotice':''}" style="margin-top:12px">${p.blocked_reason?`<b>Automatische Reparatur nicht verfügbar:</b> ${esc(uiMessage(p.blocked_reason))}<br>`:''}${esc(uiMessage(p.recommendation||''))}</div><div class="panel inset" style="margin-top:12px"><h3>Erneut prüfen / Reparaturumfang</h3><label class="switch"><input id="bindRecursive" type="checkbox" ${p.recursive?'checked':''}> Dateien unter diesem Bind-Root rekursiv prüfen/chownen</label><div class="field" style="margin-top:10px"><label>Optionaler chmod des Top-Level-Verzeichnisses</label><input id="bindMode" placeholder="z. B. 750 oder 775"><div class="muted">Wird nie automatisch und nie rekursiv angewendet. Leer lassen, um Modus-Bits beizubehalten.</div></div><label class="switch"><input id="bindFixOwner" type="checkbox" checked> Besitzer/Gruppe auf ${esc(expected)} korrigieren</label></div></div><div class="modalfoot"><button class="btn" id="rescanBind">Erneut prüfen</button>${canCreateUser?'<button class="btn" id="bindCreateUser">Host-Benutzer anlegen…</button>':''}${canRepair?'<button class="btn danger" id="repairBind">Reparieren & prüfen</button>':''}<button class="btn" data-close>Schließen</button></div>`);
$('#rescanBind').onclick=()=>bindPermissionModal(p.container_id,p.destination,$('#bindRecursive').checked);
if(canCreateUser)$('#bindCreateUser').onclick=()=>hostUserModal(p.container_id,{container_name:p.container_name,bind_uid:p.expected_uid,bind_gid:p.expected_gid,bind_identity_source:p.identity_source,bind_host_user:p.host_user});
if(canRepair)$('#repairBind').onclick=async()=>{const recursive=$('#bindRecursive').checked,fix=$('#bindFixOwner').checked,mode=$('#bindMode').value.trim();if(recursive&&!p.recursive)return toast('Run a recursive re-scan first so the affected file count is known.');const what=recursive?`${p.entries_ownership_mismatch} entries recursively`:'the bind root only';if(!confirm(`Repair ${what} on ${nodeName()} to ${expected}${mode?` and set top-level mode ${mode}`:''}?`))return;const btn=$('#repairBind');setBusy(btn,true,'Repairing…');try{const out=await api(`/api/host/bind-permissions/repair${qnode()}`,{method:'POST',body:JSON.stringify({container_id:p.container_id,destination:p.destination,recursive,fix_ownership:fix,mode})});toast(out.message||'Bind mount repaired');showBindPermissionPreview(out.after);if(state.stack?.name)setTimeout(loadStackPermissions,0)}catch(e){toast(e.message);setBusy(btn,false)}}
}
async function loadStackPermissions(){const box=$('#permissionsBox');if(!box||!state.stack?.name)return;box.innerHTML='<div class="empty">Container und Bind-Mounts werden geprüft…</div>';try{const d=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/bind-permissions${qnode()}`),rows=[];asArray(d.containers).forEach(c=>{if(c.error){rows.push({service:c.service,error:c.error});return}asArray(c.report?.bind_mounts).forEach(m=>rows.push({service:c.service,container:c.container_id,report:c.report,mount:m}))});window.__stackBindRows=rows;if(!rows.length){box.innerHTML='<div class="empty">Keine erzeugten Container oder Bind-Mounts gefunden.</div>';return}box.innerHTML=`<div class="tablewrap"><table class="table"><thead><tr><th>Dienst</th><th>Host → Container</th><th>Erwartet</th><th>Besitzer / Modus</th><th>Schreiben</th><th></th></tr></thead><tbody>${rows.map((x,i)=>x.error?`<tr><td><b>${esc(x.service)}</b></td><td colspan="5" class="red">${esc(x.error)}</td></tr>`:`<tr><td><b>${esc(x.service||x.report?.container_name)}</b></td><td><div class="mono">${esc(x.mount.source)}</div><div class="muted mono">→ ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'}</div></td><td class="mono">${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')}<div class="muted">${esc(x.report.bind_identity_source||'')}</div></td><td class="mono">${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')}</td><td>${x.mount.read_only?'<span class="muted">schreibgeschützt</span>':writeState(x.mount.static_writable)}</td><td class="right">${!x.mount.read_only?`<button class="btn tiny" data-stackbind="${i}">Analysieren / reparieren</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
async function inspectDockerResource(kind,r){const id=kind==='containers'?(r.ID||r.Names||r.Name):kind==='images'?(r.ID||([r.Repository,r.Tag].filter(Boolean).join(':'))):r.Name;if(!id)return;try{const d=await api(`/api/docker/${kind}/${encodeURIComponent(id)}/inspect${qnode()}`),i=d.inspect||{},st=d.stats||{};modal(`<div class="modalhead"><h2>${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${kind==='containers'?`<div class="stats"><div class="stat"><small>CPU</small><strong>${esc(st.CPUPerc||'—')}</strong></div><div class="stat"><small>Arbeitsspeicher</small><strong>${esc(st.MemUsage||'—')}</strong></div><div class="stat"><small>Netzwerk-I/O</small><strong>${esc(st.NetIO||'—')}</strong></div><div class="stat"><small>Block-I/O</small><strong>${esc(st.BlockIO||'—')}</strong></div></div>`:''}<div class="field"><label>Inspect-JSON <span class="muted">(nur Operator/Admin; kann sensible Umgebungswerte enthalten)</span></label><pre class="terminal" style="max-height:55vh">${esc(JSON.stringify(i,null,2))}</pre></div></div><div class="modalfoot"><button class="btn" data-close>Schließen</button></div>`)}catch(e){toast(e.message)}}
function showOutput(title,text){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><pre class="terminal" style="max-height:60vh">${esc(text||'OK')}</pre></div><div class="modalfoot"><button class="btn" data-close>Schließen</button></div>`)}
let terminalWS=null;
function wsURL(path){const proto=location.protocol==='https:'?'wss:':'ws:';return `${proto}//${location.host}${path}`}
function stripANSI(s){return String(s||'').replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g,'').replace(/\r/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('Speichere den Stack zuerst.');const service=$('#execService')?.value;if(!service)return toast('Kein Dienst ausgewählt.');const shell=$('#execShell')?.value||'sh';const q=new URLSearchParams({service,shell});if(state.node)q.set('node_id',String(state.node));const out=$('#execOut');out.textContent=`Verbindung zu ${service} wird hergestellt…\n`;terminalWS=new WebSocket(wsURL(`/api/stacks/${encodeURIComponent(state.stack.name)}/terminal?${q}`));terminalWS.onopen=()=>{out.textContent='';out.focus();terminalResize()};terminalWS.onmessage=e=>{try{const m=JSON.parse(e.data);if(m.type==='output'){out.textContent+=stripANSI(m.data);out.scrollTop=out.scrollHeight}else if(m.type==='error'){out.textContent+=`\n[Fehler] ${m.data}\n`}else if(m.type==='exit'){out.textContent+='\n[Sitzung beendet]\n'}}catch{out.textContent+=stripANSI(e.data)}};terminalWS.onerror=()=>toast('Terminal-WebSocket fehlgeschlagen.');terminalWS.onclose=()=>{terminalWS=null};out.onkeydown=terminalKey;out.onpaste=e=>{if(!terminalWS)return;e.preventDefault();terminalWS.send(JSON.stringify({type:'input',data:e.clipboardData.getData('text')}))};}
async function loadGraph(){if(!state.stack?.name)return;const box=$('#graphBox');box.innerHTML='<div class="empty">Compose-Modell wird aufgelöst…</div>';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='<div class="empty">Keine Graph-Knoten.</div>';return}const services=nodes.filter(n=>n.kind==='service'),resources=nodes.filter(n=>n.kind!=='service');const h=Math.max(360,Math.max(services.length,resources.length)*76+50),w=Math.max(760,box.clientWidth||900);const pos={};services.forEach((n,i)=>pos[n.id]={x:140,y:55+i*76});resources.forEach((n,i)=>pos[n.id]={x:w-160,y:55+i*76});const lines=edges.map(e=>{const a=pos[e.from],b=pos[e.to];if(!a||!b)return'';return `<path d="M${a.x+100},${a.y} C${a.x+240},${a.y} ${b.x-240},${b.y} ${b.x-100},${b.y}" class="edge ${esc(e.kind)}"/><text x="${(a.x+b.x)/2}" y="${(a.y+b.y)/2-4}" class="edgelabel">${esc(e.kind)}</text>`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `<g class="gnode ${esc(n.kind)}"><rect x="${p.x-100}" y="${p.y-24}" width="200" height="48" rx="7"/><circle cx="${p.x-80}" cy="${p.y}" r="5" class="${n.running?'ok':''}"/><text x="${p.x-67}" y="${p.y-3}" class="glabel">${esc(n.label)}</text><text x="${p.x-67}" y="${p.y+12}" class="gsub">${esc(n.image||n.kind)}</text></g>`}).join('');box.innerHTML=`<svg viewBox="0 0 ${w} ${h}" role="img">${lines}${ns}</svg>`}
async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='<div class="empty">Registry-Manifeste werden geprüft…</div>';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`<table class="table"><thead><tr><th>Dienst</th><th>Image</th><th>Lokaler Digest</th><th>Remote-Digest</th><th>Status</th></tr></thead><tbody>${rows.map(r=>`<tr><td><b>${esc(r.service)}</b></td><td>${esc(r.image)}</td><td class="digest">${esc(r.local_digest||'—')}</td><td class="digest">${esc(r.remote_digest||'—')}</td><td>${r.error?`<span class="red" title="${esc(r.error)}">Prüfung fehlgeschlagen</span>`:r.update?'<span class="amber">Update verfügbar</span>':'<span class="green">Aktuell</span>'}</td></tr>`).join('')}</tbody></table>`}catch(e){box.innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
async function renderActivity(){setCrumb('System / Aktivitäten');$('#content').innerHTML=`${pageHead('Aktivitäten','Persistentes Audit-Protokoll für Änderungen, Bereitstellungen und Monitor-Statuswechsel.','<input class="search" id="actFilter" placeholder="Aktion filtern…"><button class="btn" id="actRefresh">↻ Aktualisieren</button>')}<div class="panel" id="activityPanel"><div class="empty">Audit-Ereignisse werden geladen…</div></div>`;const load=async()=>{try{const q=$('#actFilter')?.value.trim()||'',rows=asArray(await api('/api/activity?limit=200'+(q?'&action='+encodeURIComponent(q):'')));$('#activityPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Zeit</th><th>Akteur</th><th>Aktion</th><th>Ressource</th><th>Status</th><th>Details</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${fmtTime(x.created_at)}</td><td><b>${esc(x.actor)}</b></td><td><span class="tag">${esc(x.action)}</span></td><td>${esc(x.resource||'—')}</td><td>${x.status>=200&&x.status<300?'<span class="green">'+x.status+'</span>':'<span class="red">'+x.status+'</span>'}</td><td class="muted">${esc(JSON.stringify(x.detail||{}))}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine passenden Audit-Ereignisse.</div>'}catch(e){$('#activityPanel').innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}};$('#actRefresh').onclick=load;let t;$('#actFilter').oninput=()=>{clearTimeout(t);t=setTimeout(load,300)};load()}
async function renderGit(){setCrumb('Docker / Git-Stacks');$('#content').innerHTML=`${pageHead('Git-Stacks','Compose-Stacks aus Git synchronisieren und über signierte Webhooks bereitstellen.',roleOK()?'<button class="btn primary" id="addGit">+ Git-Quelle hinzufügen</button>':'')}<div class="panel" id="gitPanel"><div class="empty">Git-Quellen werden geladen…</div></div>`;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Stack</th><th>Umgebung</th><th>Repository</th><th>Branch</th><th>Commit</th><th>Letzte Synchronisierung</th><th>Automatisch bereitstellen</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.stack_name)}</b>${x.last_error?`<div class="red">${esc(x.last_error)}</div>`:''}</td><td>${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote-Host'):'Lokaler Docker-Host')}</td><td>${esc(x.repo_url)}</td><td><span class="tag">${esc(x.branch)}</span></td><td class="digest">${esc((x.last_commit||'—').slice(0,12))}</td><td>${x.last_sync_at?fmtTime(x.last_sync_at):'Nie'}</td><td>${x.auto_deploy?'<span class="green">Ja</span>':'Nein'}</td><td class="right">${roleOK()?`<button class="btn tiny" data-gsync="${x.id}">Synchronisieren</button> <button class="btn tiny" data-gedit="${x.id}">Bearbeiten</button> <button class="btn tiny danger" data-gdel="${x.id}">Löschen</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine Git-Quellen konfiguriert.</div>';window.__gitRows=rows;$$('[data-gsync]').forEach(b=>b.onclick=()=>gitSync(Number(b.dataset.gsync)));$$('[data-gedit]').forEach(b=>b.onclick=()=>gitModal(rows.find(x=>x.id===Number(b.dataset.gedit))));$$('[data-gdel]').forEach(b=>b.onclick=async()=>{if(confirm('Git-Quellenkonfiguration löschen?')){await api('/api/git-sources/'+b.dataset.gdel,{method:'DELETE'});renderGit()}})}catch(e){$('#gitPanel').innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
function gitModal(x=null){modal(`<div class="modalhead"><h2>${x?'Git-Quelle bearbeiten':'Git-Quelle hinzufügen'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Stack-Name</label><input id="gitStack" value="${esc(x?.stack_name||'')}"></div><div class="field"><label>Umgebung</label><select id="gitNode"><option value="0">Lokaler Docker-Host</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · deaktiviert'}</option>`).join('')}</select></div><div class="field"><label>Branch</label><input id="gitBranch" value="${esc(x?.branch||'main')}"></div><div class="field full"><label>Repository-URL</label><input id="gitRepo" value="${esc(x?.repo_url||'')}" placeholder="https://github.com/org/repo.git oder ssh://..."></div><div class="field"><label>Arbeitsverzeichnis</label><input id="gitWorkdir" value="${esc(x?.workdir||'.')}"></div><div class="field"><label>Compose-Datei</label><input id="gitCompose" value="${esc(x?.compose_file||'compose.yaml')}"></div><label class="switch"><input id="gitAuto" type="checkbox" ${x?.auto_deploy?'checked':''}> Nach Sync/Webhook bereitstellen</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="saveGit">Speichern</button></div>`);$('#gitNode').value=String(x?.node_id||state.node||0);$('#saveGit').onclick=async()=>{const body={node_id:Number($('#gitNode').value)||null,stack_name:$('#gitStack').value,repo_url:$('#gitRepo').value,branch:$('#gitBranch').value,workdir:$('#gitWorkdir').value,compose_file:$('#gitCompose').value,auto_deploy:$('#gitAuto').checked};try{const r=await api(x?`/api/git-sources/${x.id}`:'/api/git-sources',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();if(!x)showGitSecret(r);renderGit()}catch(e){toast(e.message)}}}
function showGitSecret(r){modal(`<div class="modalhead"><h2>Webhook erstellt</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dieses Secret jetzt kopieren. Es wird verschlüsselt gespeichert und nicht erneut angezeigt.</div><div class="field" style="margin-top:10px"><label>Webhook-URL</label><input readonly value="${esc(r.webhook_url)}"></div><div class="field" style="margin-top:10px"><label>Webhook-Secret</label><input readonly value="${esc(r.webhook_secret)}"></div><p class="muted">GitHub: Secret für X-Hub-Signature-256 verwenden. GitLab: als X-Gitlab-Token senden. Generische Hooks können X-Webhook-Token verwenden.</p></div><div class="modalfoot"><button class="btn primary" data-close>Fertig</button></div>`)}
async function gitSync(id){try{toast('Git-Synchronisierung gestartet…');await api(`/api/git-sources/${id}/sync`,{method:'POST'});await refreshData(true);renderGit();toast('Git-Stack synchronisiert.')}catch(e){toast(e.message);renderGit()}}
async function renderNotifications(){setCrumb('Überwachung / Benachrichtigungen');$('#content').innerHTML=`${pageHead('Benachrichtigungen','Monitor-Statuswechsel per Webhook, ntfy, Gotify oder SMTP senden.',state.me.role==='admin'?'<button class="btn primary" id="addNotify">+ Provider hinzufügen</button>':'')}<div class="panel" id="notifyPanel"><div class="empty">Provider werden geladen…</div></div>`;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Provider</th><th>Aktiviert</th><th>Konfiguration</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b></td><td><span class="tag">${esc(x.type)}</span></td><td>${x.enabled?'<span class="green">Aktiviert</span>':'Deaktiviert'}</td><td class="muted">${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-ntest="${x.id}">Test</button> <button class="btn tiny" data-nedit="${x.id}">Bearbeiten</button> <button class="btn tiny danger" data-ndel="${x.id}">Löschen</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">Keine Benachrichtigungs-Provider konfiguriert.</div>';$$('[data-ntest]').forEach(b=>b.onclick=async()=>{try{await api(`/api/notifications/${b.dataset.ntest}/test`,{method:'POST'});toast('Testbenachrichtigung gesendet.')}catch(e){toast(e.message)}});$$('[data-nedit]').forEach(b=>b.onclick=()=>notificationModal(rows.find(x=>x.id===Number(b.dataset.nedit))));$$('[data-ndel]').forEach(b=>b.onclick=async()=>{if(confirm('Benachrichtigungs-Provider löschen?')){await api('/api/notifications/'+b.dataset.ndel,{method:'DELETE'});renderNotifications()}})}catch(e){$('#notifyPanel').innerHTML=`<div class="empty red">${esc(uiMessage(e.message))}</div>`}}
function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`<div class="field"><label>${label}</label><input id="${id}" ${secret?'type="password"':''} value="${esc(c[key]||'')}" placeholder="${esc(ph)}"></div>`;if(type==='webhook')return f('nUrl','URL','url',false,'https://...')+f('nToken','Bearer-Token','bearer_token',true);if(type==='ntfy')return f('nServer','Server','server',false,'https://ntfy.sh')+f('nTopic','Thema','topic')+f('nToken','Zugriffstoken','token',true);if(type==='gotify')return f('nServer','Server','server',false,'https://gotify.example.com')+f('nToken','App-Token','token',true);return f('nHost','SMTP-Host','host')+f('nPort','Port','port',false,'587')+`<div class="field"><label>Sicherheit</label><select id="nSecurity"><option value="starttls">STARTTLS</option><option value="tls">SSL/TLS</option><option value="none">Keine</option></select></div><label class="switch"><input id="nAuth" type="checkbox" ${c.auth==='true'||(!c.auth&&c.username)?'checked':''}> SMTP-Authentifizierung</label>`+f('nUser','Benutzername','username')+f('nPass','Passwort','password',true)+f('nFrom','Absender','from')+f('nTo','Empfänger','to',false,'ops@example.com')+`<label class="switch"><input id="nSkipVerify" type="checkbox" ${c.skip_verify==='true'?'checked':''}> TLS-Zertifikatsprüfung deaktivieren (unsicher)</label>`}
function notificationModal(x=null){const type=x?.type||'webhook';modal(`<div class="modalhead"><h2>${x?'Benachrichtigungs-Provider bearbeiten':'Benachrichtigungs-Provider hinzufügen'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nName" value="${esc(x?.name||'')}"></div><div class="field"><label>Typ</label><select id="nType"><option value="webhook">Webhook</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option><option value="smtp">SMTP</option></select></div><div id="nConfig" class="fieldgrid full" style="grid-column:1/-1">${notifyConfigFields(type,x?.config||{})}</div><label class="switch"><input id="nEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Aktiviert</label></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="nSave">Speichern</button></div>`);$('#nType').value=type;if(type==='smtp'&&$('#nSecurity'))$('#nSecurity').value=x?.config?.security||'starttls';$('#nType').onchange=()=>$('#nConfig').innerHTML=notifyConfigFields($('#nType').value,{});$('#nSave').onclick=async()=>{const t=$('#nType').value,c={};if(t==='webhook'){c.url=$('#nUrl').value;c.bearer_token=$('#nToken').value}else if(t==='ntfy'){c.server=$('#nServer').value;c.topic=$('#nTopic').value;c.token=$('#nToken').value}else if(t==='gotify'){c.server=$('#nServer').value;c.token=$('#nToken').value}else{c.host=$('#nHost').value;c.port=$('#nPort').value;c.security=$('#nSecurity').value;c.auth=String($('#nAuth').checked);c.username=$('#nUser').value;c.password=$('#nPass').value;c.from=$('#nFrom').value;c.to=$('#nTo').value;c.skip_verify=String($('#nSkipVerify').checked)}try{await api(x?`/api/notifications/${x.id}`:'/api/notifications',{method:x?'PUT':'POST',body:JSON.stringify({name:$('#nName').value,type:t,config:c,enabled:$('#nEnabled').checked})});closeModal();renderNotifications()}catch(e){toast(e.message)}}}
async function renderPackages(){
setCrumb(`System / System-Updates / ${nodeName()}`);
if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('System-Updates','Administratorrechte erforderlich.')}<div class="empty">Paketverwaltung ist auf Administratoren beschränkt.</div>`; return }
$('#content').innerHTML=`${pageHead('System-Updates',`Verfügbare Linux-Paketupdates prüfen und installieren · ${nodeName()}`,'<button class="btn" id="pkgReload">↻ Prüfen</button>')}<div class="panel"><div class="empty">Paketupdates werden geprüft…</div></div>`;
$('#pkgReload').onclick=renderPackages;
try{
const d=await api('/api/packages/updates'+qnode()),repo=asArray(d.updates),aur=asArray(d.aur_updates),can=!!d.can_manage,pm=d.package_manager||'unbekannt',aurReady=pm==='pacman'&&d.aur_helper&&d.aur_user;
const total=repo.length+aur.length;
const actionDisabled=can?'':'disabled title="Paketinstallation ist für diese Umgebung nicht freigegeben"';
$('#content').innerHTML=`${pageHead('System-Updates',`Verfügbare Linux-Paketupdates prüfen und installieren · ${nodeName()}`,'<button class="btn" id="pkgReload">↻ Prüfen</button>')}
<div class="packageHero"><div><small>PAKETSTATUS</small><div class="packageCount">${total}<span> Updates</span></div><p>${esc(d.os?.pretty_name||d.os?.name||'Host-Betriebssystem unbekannt')} · ${esc(pm)}${d.aur_helper?` · AUR: ${esc(d.aur_helper)}`:''}</p></div><div class="packageActions"><button class="btn" id="pkgRefresh" ${actionDisabled}>Paketlisten aktualisieren</button><button class="btn primary" id="pkgUpgradeSystem" ${repo.length&&can?'':'disabled'}>${pm==='pacman'?'Pacman-System aktualisieren':'Systemupdates installieren'}</button>${d.aur_helper?`<button class="btn" id="pkgUpgradeAur" ${aur.length&&aurReady&&can?'':'disabled'}>AUR aktualisieren</button><button class="btn primary" id="pkgUpgradeAll" ${(repo.length||aur.length)&&aurReady&&can?'':'disabled'}>Alle Updates</button>`:''}</div></div>
${!can?`<div class="notice warnNotice"><b>Nur Prüfung:</b> Für Installationen müssen <code>HOST_SECURITY_ENABLED</code>, <code>ALLOW_HOST_SECURITY_CHANGES</code> und <code>ALLOW_HOST_PACKAGE_MANAGEMENT</code> aktiviert sein.</div>`:''}
${pm==='pacman'&&d.aur_helper&&!d.aur_user?`<div class="notice warnNotice"><b>AUR nicht konfiguriert:</b> Setze <code>HOST_AUR_USER</code> auf einen lokalen Nicht-root-Benutzer, der ${esc(d.aur_helper)} verwendet und Paket-Eskalation non-interaktiv ausführen darf.</div>`:''}
${asArray(d.warnings).map(w=>`<div class="notice warnNotice">${esc(uiMessage(w))}</div>`).join('')}
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>System-Repositories</h2><span class="tag">${repo.length} verfügbar</span></div>${packageUpdateTable(repo,'Keine Updates aus den System-Repositories verfügbar.')}</div>
${pm==='pacman'||d.aur_helper?`<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>AUR</h2><span class="tag">${aur.length} verfügbar</span></div><div class="notice warnNotice" style="margin:10px"><b>AUR-Vertrauensgrenze:</b> AUR-Pakete stammen aus benutzergepflegten PKGBUILDs. Für den automatisierten Dockwatch-Workflow werden Review-/Auswahlmenüs des Helpers deaktiviert; installiere AUR-Updates nur auf Hosts, deren AUR-Pakete du bewusst vertraust.</div>${d.aur_helper?packageUpdateTable(aur,'Keine AUR-Updates verfügbar.'):'<div class="empty">Kein unterstützter AUR-Helper erkannt. Unterstützt werden <code>paru</code> und <code>yay</code>.</div>'}</div>`:''}
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Sicherheitsregeln für Paketupdates</h2></div><div class="securitySafety"><div><b>Keine freien Shell-Kommandos</b><p>Das Interface kann nur vordefinierte Update-Aktionen auslösen.</p></div><div><b>Arch ohne Partial Upgrade</b><p>Offizielle Arch-Pakete werden ausschließlich über vollständiges <code>pacman -Syu</code> aktualisiert.</p></div><div><b>AUR als Benutzer</b><p><code>paru</code>/<code>yay</code> laufen als konfigurierter Nicht-root-Benutzer; sudo wird non-interaktiv angefordert.</p></div><div><b>Master/Agent identisch</b><p>Remote-Aktionen werden an den ausgewählten Agent weitergereicht und dort gegen dessen Berechtigungen geprüft.</p></div></div></div>`;
$('#pkgReload').onclick=renderPackages;
$('#pkgRefresh')?.addEventListener('click',()=>packageRefresh($('#pkgRefresh')));
$('#pkgUpgradeSystem')?.addEventListener('click',()=>packageUpgrade('system',$('#pkgUpgradeSystem'),repo.length));
$('#pkgUpgradeAur')?.addEventListener('click',()=>packageUpgrade('aur',$('#pkgUpgradeAur'),aur.length));
$('#pkgUpgradeAll')?.addEventListener('click',()=>packageUpgrade('all',$('#pkgUpgradeAll'),total));
}catch(e){$('#content').innerHTML=`${pageHead('System-Updates',nodeName(),'<button class="btn" id="pkgReload">↻ Erneut prüfen</button>')}<div class="empty red">${esc(uiMessage(e.message))}</div>`;$('#pkgReload').onclick=renderPackages}
}
function packageUpdateTable(rows,emptyText){if(!rows.length)return `<div class="empty">${emptyText}</div>`;return `<div class="tablewrap"><table class="packageTable"><thead><tr><th>Paket</th><th>Installiert</th><th>Verfügbar</th><th>Quelle</th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b>${x.arch?`<small>${esc(x.arch)}</small>`:''}</td><td>${esc(x.current_version||'—')}</td><td><b>${esc(x.new_version||'—')}</b></td><td>${esc(x.repository||x.source||'—')}</td></tr>`).join('')}</tbody></table></div>`}
async function packageRefresh(btn){setBusy(btn,true,'Wird aktualisiert…');try{const out=await api(`/api/packages/refresh${qnode()}`,{method:'POST',body:'{}'});toast(out.message||'Paketlisten aktualisiert.');await renderPackages()}catch(e){toast(uiMessage(e.message));setBusy(btn,false)}}
async function packageUpgrade(scope,btn,count){const label=scope==='aur'?'AUR-Updates':scope==='all'?'alle System- und AUR-Updates':'Systemupdates',aurRisk=scope==='aur'||scope==='all'?' AUR-PKGBUILDs werden dabei ohne interaktives Review gebaut und installiert.':'';if(!confirm(`${count||''} ${label} auf ${nodeName()} jetzt installieren?${aurRisk} Paketdienste können dabei neu gestartet werden und ein Reboot kann anschließend erforderlich sein.`))return;setBusy(btn,true,'Updates werden installiert…');try{const out=await api(`/api/packages/upgrade${qnode()}`,{method:'POST',body:JSON.stringify({scope})});toast(out.message||'Updates installiert.');await renderPackages();if(out.output)showOutput(`System-Updates · ${nodeName()}`,out.output)}catch(e){toast(uiMessage(e.message));setBusy(btn,false)}}
async function renderSecurity(){
setCrumb(`System / Host-Sicherheit / ${nodeName()}`);
if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('Host-Sicherheit','Administratorrechte erforderlich.')}<div class="empty">Die Host-Sicherheitskonfiguration ist auf Administratoren beschränkt.</div>`; return }
$('#content').innerHTML=`${pageHead('Host-Sicherheit',`Linux-Host härten, konfigurieren und warten · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Sicherheitsprüfung</button>')}<div class="panel"><div class="empty">Host-Sicherheitsstatus wird geprüft…</div></div>`;
$('#securityRefresh').onclick=renderSecurity;
try{
const d=await api('/api/security/status'+qnode()),c=d.capabilities||{},os=d.os||{},managed=d.managed||{};
window.__securityCaps=c;
window.__firewallBackend=d.firewall_backend||{};
const capClass=!c.enabled?'securityOff':c.allow_changes&&c.executor_available?'securityManage':'securityAudit';
$('#content').innerHTML=`${pageHead('Host-Sicherheit',`Linux-Host härten, konfigurieren und warten · ${nodeName()}`,'<button class="btn" id="securityRefresh">↻ Sicherheitsprüfung</button>')}
<div class="securityHero ${capClass}"><div><small>HOST-SICHERHEITSSTATUS</small><div class="securityScore">${Number(d.score||0)}<span>/100</span></div><p>${esc(os.pretty_name||os.name||'Host-Betriebssystem unbekannt')} · ${esc(d.package_manager||'Paketmanager unbekannt')} · ${esc(d.init_system||'Init-System unbekannt')}</p></div><div class="securityCaps">${securityCapabilityPills(c)}</div></div>
${c.reason?`<div class="notice ${c.enabled?'':'warnNotice'}"><b>Berechtigungsmodus:</b> ${esc(c.reason)}</div>`:''}
<div class="securityGrid">
${securityComponentCard('firewall',`Firewall · ${esc((d.firewall_backend||{}).selected||'auto')}`,'Automatisch erkannte native Firewall-Verwaltung über UFW, firewalld oder isoliertes nftables.',d.firewall,managed.firewall,d.conflicts)}
${securityComponentCard('fail2ban','Fail2Ban','Wiederholte Authentifizierungsfehler begrenzen und über verwaltete jail.d-Regeln sperren.',d.fail2ban,managed.fail2ban)}
${securityComponentCard('auditd','Linux Audit · auditd','Änderungen an Identität, SSH, sudo, Docker und ausgewählten Host-Pfaden überwachen.',d.auditd,managed.auditd)}
</div>
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Sicherheitsbefunde</h2><span class="muted">Nur von Dockwatch verwalteter Sicherheitsstatus; kein Ersatz für einen vollständigen Host-Benchmark.</span></div>${securityFindings(d.findings||[])}</div>
<div class="panel" style="margin-top:12px"><div class="panelhead"><h2>Sicherheitsmodell</h2></div><div class="securitySafety"><div><b>Zuerst prüfen</b><p>Prüfungen können unabhängig von Host-Änderungen aktiviert werden.</p></div><div><b>Explizite Freigabe für Änderungen</b><p>Konfigurationsänderungen und Paketinstallation sind getrennte Berechtigungen.</p></div><div><b>Verwaltete Drop-ins</b><p>Fail2Ban und auditd verwenden eigene Dockwatch-Dateien; fremde Konfiguration bleibt erhalten.</p></div><div><b>Protokollierte Änderungen</b><p>Jede Änderung durchläuft Dockwatch-Admin-RBAC, Origin-Schutz und das Aktivitätsprotokoll.</p></div></div></div>`;
$('#securityRefresh').onclick=renderSecurity;
$$('[data-secinstall]').forEach(b=>b.onclick=()=>securityInstall(b.dataset.secinstall,b));
$$('[data-secaction]').forEach(b=>b.onclick=()=>securityComponentAction(b.dataset.seccomponent,b.dataset.secaction,b));
$('#configure-firewall')?.addEventListener('click',securityFirewallModal);
$('#configure-fail2ban')?.addEventListener('click',securityFail2BanModal);
$('#configure-auditd')?.addEventListener('click',securityAuditdModal);
}catch(e){$('#content').innerHTML=`${pageHead('Host-Sicherheit',nodeName())}<div class="empty red">${esc(uiMessage(e.message))}</div>`}
}
function securityCapabilityPills(c){return `<span class="tag ${c.host_root_available?'oktag':''}">Host-Root ${c.host_root_available?'✓':'×'}</span><span class="tag ${c.target_verified?'oktag':''}">Host-Namespace ${c.target_verified?'✓':'×'}</span><span class="tag ${c.allow_changes?'oktag':''}">${c.allow_changes?'Verwaltung':'nur Prüfung'}</span><span class="tag ${c.allow_package_management?'oktag':''}">Pakete ${c.allow_package_management?'freigegeben':'gesperrt'}</span>`}
function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host-Sicherheitsänderungen sind für diese Umgebung deaktiviert"',pkgDisabled=packages?'':'disabled title="Host-Paketverwaltung ist für diese Umgebung deaktiviert"',canConfigure=manage&&(key==='firewall'||installed);return `<section class="securityCard"><div class="securityCardHead"><div><span class="securityIcon">${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}</span><div><h2>${title}</h2><p>${esc(desc)}</p></div></div>${installed?(active?badge('up'):badge('paused')):badge('unknown')}</div><div class="securityFacts"><span><small>Installiert</small><b>${installed?'Ja':'Nein'}</b></span><span><small>Laufzeit</small><b class="${active?'green':'muted'}">${active?'Aktiv':'Inaktiv'}</b></span><span><small>Systemstart</small><b>${st.enabled?'Aktiviert':'—'}</b></span><span><small>Konfiguration</small><b class="${drift?'amber':''}">${managed?.configured?(drift?'Abweichung':'Verwaltet'):'Nicht verwaltet'}</b></span></div>${st.version?`<div class="securityVersion">${esc(st.version)}</div>`:''}${st.detail?`<pre class="securityDetail">${esc(st.detail)}</pre>`:''}${conflicts?.length?`<div class="notice dangerNotice"><b>Firewall-Konflikt:</b> ${esc(conflicts.join(', '))}. Aktive konkurrierende Frontends müssen vor Änderungen aufgelöst werden.</div>`:''}<div class="securityActions">${!installed?`<button class="btn primary" data-secinstall="${key}" ${pkgDisabled}>Installieren</button>`:''}<button class="btn" id="configure-${key}" ${!canConfigure?'disabled':''} ${!manage?'title="Host-Sicherheitsänderungen sind für diese Umgebung deaktiviert"':''}>Konfigurieren</button>${installed?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="enable" ${manageDisabled}>Aktivieren</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="disable" ${manageDisabled}>Deaktivieren</button><button class="btn tiny" data-seccomponent="${key}" data-secaction="restart" ${manageDisabled}>Neustart</button>${key!=='firewall'?`<button class="btn tiny" data-seccomponent="${key}" data-secaction="reload" ${manageDisabled}>Neu laden</button>`:''}<button class="btn tiny" data-secinstall="${key}" ${pkgDisabled} title="Verwendet den Host-Paketmanager, um die aktuell verfügbare Paketversion zu installieren">Aktualisieren</button>`:''}</div></section>`}
function securityFindings(rows){if(!rows.length)return '<div class="empty">Keine Befunde.</div>';return `<div class="securityFindings">${rows.map(f=>`<div class="securityFinding sev-${esc(f.severity)}"><span>${f.severity==='high'?'!':f.severity==='medium'?'△':f.severity==='ok'?'✓':'i'}</span><div><b>${esc(uiMessage(f.title))}</b><p>${esc(uiMessage(f.detail))}</p>${f.action?`<small>${esc(uiMessage(f.action))}</small>`:''}</div></div>`).join('')}</div>`}
async function securityInstall(component,btn,provider=''){if(component==='firewall'&&!provider)provider=(window.__firewallBackend||{}).selected||'nftables';if(!confirm(`${component==='firewall'?provider:component} auf ${nodeName()} über den Host-Paketmanager installieren oder aktualisieren?`))return;setBusy(btn,true,'Wird ausgeführt…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall',provider})});toast(out.message||'Paketvorgang abgeschlossen.');if(out.output)showOutput(`${component} · Paketvorgang`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${component} auf ${nodeName()} wirklich ${action==='disable'?'deaktivieren':'stoppen'}?`))return;setBusy(btn,true,'Wird ausgeführt…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component}: Aktion abgeschlossen.`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode()),preview=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(p)});firewallEditor(preview.policy||p,preview)}catch(e){toast(e.message)}}
function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>`<option value="${v}" ${current===v?'selected':''}>${v==='auto'?'Automatisch erkennen':v}</option>`).join('')}
function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `<div class="panel inset fwRuntime"><div class="panelhead"><h3>Erkannte Host-Firewall</h3><span class="tag">ausgewählt: ${esc(b.selected||'—')}</span></div><div class="securityFacts"><span><small>Verfügbar</small><b>${esc(available.join(', ')||'keine')}</b></span><span><small>Aktiv</small><b>${esc(active.join(', ')||'keine')}</b></span><span><small>Standard</small><b>${esc(r.default_inbound||'—')}</b></span><span><small>Zone</small><b>${esc(r.zone||b.default_zone||'—')}</b></span></div>${b.reason?`<div class="notice ${asArray(b.conflicts).length?'dangerNotice':'warnNotice'}">${esc(uiMessage(b.reason))}</div>`:''}<div class="field"><label>Vorhandene native Regeln/Status <span class="muted">· schreibgeschützt · fremde Regeln bleiben erhalten</span></label><pre class="terminal fwExisting" style="max-height:230px">${esc(r.raw||'Keine Laufzeitregeln gemeldet.')}</pre></div></div>`}
function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`<div class="modalhead"><h2>Host-Firewall · ${esc(selected)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Provider-Modell:</b> Auto verwendet aktives UFW oder firewalld und fällt sonst auf natives nftables zurück. Dockwatch erhält fremde Regeln und führt niemals <code>ufw reset</code> oder <code>nft flush ruleset</code>.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Firewall-Provider</label><select id="fwProvider">${firewallProviderOptions(p.provider||'auto')}</select></div><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Von Dockwatch verwaltete Regeln aktivieren</label><label class="switch"><input id="fwManageDefault" type="checkbox" ${p.manage_default?'checked':''} ${selected==='nftables'?'disabled':''}> Globalen Eingangsstandard des Providers verwalten ${selected==='nftables'?'<span class="muted">(native Policy bleibt immer auf die Dockwatch-Tabelle beschränkt)</span>':''}</label><div class="field"><label>Standard eingehend</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div>${selected==='firewalld'?`<div class="field"><label>firewalld-Zone</label><input id="fwZone" value="${esc(p.zone||preview.runtime?.zone||backend.default_zone||'public')}" placeholder="public"></div>`:''}<label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''} ${selected!=='nftables'?'disabled':''}> ICMP / IPv6-ICMP erlauben ${selected!=='nftables'?'<span class="muted">(wird durch diesen Provider nativ verwaltet)</span>':''}</label><div class="field full"><label>Vertrauenswürdige CIDRs · eine pro Zeile</label><textarea id="fwTrusted" placeholder="192.0.2.0/24\n2001:db8::/32">${esc(asArray(p.trusted_cidrs).join('\n'))}</textarea></div></div>${firewallRuntimeHTML(preview)}<div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Von Dockwatch verwaltete Portregeln</h3><button class="btn tiny" id="fwAddRule">+ Regel</button></div><div id="fwRules">${rules.map(firewallRuleRow).join('')}</div></div><div class="notice dangerNotice" style="margin-top:12px"><b>Aussperrschutz:</b> Anwenden startet einen 90-Sekunden-Rollback-Timer. Fremde Provider-Regeln bleiben erhalten. Wenn du den globalen Eingangsstandard des Providers verwaltest, prüfe SSH-/Dockwatch-Zugriff, bevor du die Änderung bestätigst.</div></div><div class="modalfoot">${!installed&&selected!=='auto'?`<button class="btn" id="fwInstall" ${packages?'':'disabled'}>${esc(selected)} installieren</button>`:''}<button class="btn" data-close>Abbrechen</button><button class="btn" id="fwPreview">Vorschau ${esc(selected)}</button><button class="btn danger" id="fwApply" ${preview.can_apply===false?'disabled':''}>Mit Rollback anwenden</button></div>`,'modal-wide modal-firewall');$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'Firewall'} Vorschau`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Standard eingehend DROP kann die Verbindung zu diesem Host trennen. Bestätige, dass deine SSH-/Dockwatch-Verwaltungsports ausdrücklich erlaubt sind. Mit zeitgesteuertem Rollback fortfahren?'))return;const btn=$('#fwApply');setBusy(btn,true,'Wird angewendet…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
function firewallRuleRow(r={}){return `<div class="fwRule"><select class="fwAction"><option value="accept" ${r.action==='accept'||!r.action?'selected':''}>ERLAUBEN</option><option value="drop" ${r.action==='drop'?'selected':''}>VERWEIGERN</option><option value="reject" ${r.action==='reject'?'selected':''}>ABLEHNEN</option><option value="limit" ${r.action==='limit'?'selected':''}>LIMITIEREN</option></select><select class="fwProto"><option value="tcp" ${r.protocol!=='udp'?'selected':''}>TCP</option><option value="udp" ${r.protocol==='udp'?'selected':''}>UDP</option></select><input class="fwPort" placeholder="22 oder 8000-8100" value="${esc(r.port||'')}"><input class="fwSource" placeholder="Quell-CIDR · optional" value="${esc(r.source||'')}"><input class="fwComment" placeholder="Kommentar" value="${esc(r.comment||'')}"><button class="iconbtn fwUp" title="Nach oben">↑</button><button class="iconbtn fwDown" title="Nach unten">↓</button><button class="iconbtn fwRemove" title="Entfernen">×</button></div>`}
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})}
function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,trusted_cidrs:($('#fwTrusted')?.value||'').split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${warnings.map(x=>`<div class="notice">${esc(x)}</div>`).join('')}${conflicts?.length?`<div class="notice dangerNotice">Konflikte: ${esc(conflicts.join(', '))}</div>`:''}<pre class="terminal" style="max-height:55vh">${esc(text||'')}</pre></div><div class="modalfoot"><button class="btn" data-close>Schließen</button></div>`)}
function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`<div class="modalhead"><h2>Firewall angewendet · Prüfzeitraum</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Diesen Dialog noch nicht schließen.</b> Wenn die neue Policy den Zugriff unterbricht, stellt Dockwatch automatisch die vorherige verwaltete Policy und den Provider-Standard wieder her.</div><div class="securityCountdown"><small>Automatischer Rollback in</small><strong id="fwCountdown">…</strong></div><p class="muted">Prüfe SSH und weitere Verwaltungszugänge in einer separaten Sitzung. Bestätige anschließend die Änderung.</p></div><div class="modalfoot"><button class="btn danger" id="fwRollbackNow">Jetzt zurückrollen</button><button class="btn primary" id="fwCommitNow">Änderungen beibehalten</button></div>`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall-Policy bestätigt.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall zurückgerollt.');closeModal();renderSecurity()}catch(e){toast(e.message)}}}
async function securityFail2BanModal(){try{const p=await api('/api/security/fail2ban'+qnode());fail2banEditor(p)}catch(e){toast(e.message)}}
function fail2banEditor(p){modal(`<div class="modalhead"><h2>Fail2Ban-Policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch schreibt ausschließlich <code>/etc/fail2ban/jail.d/dockwatch.local</code>. Andere Distributions-/Benutzer-Jails bleiben erhalten und wirksam.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Sperrzeit</label><input id="f2bBan" value="${esc(p.bantime||'1h')}"></div><div class="field"><label>Suchzeitraum</label><input id="f2bFind" value="${esc(p.findtime||'10m')}"></div><div class="field"><label>Max. Versuche</label><input id="f2bRetry" type="number" min="1" max="1000" value="${esc(p.maxretry||5)}"></div><div class="field"><label>Backend</label><select id="f2bBackend"><option>auto</option><option>systemd</option><option>polling</option><option>pyinotify</option></select></div><div class="field full"><label>IP/CIDR ignorieren · eine pro Zeile</label><textarea id="f2bIgnore">${esc(asArray(p.ignore_ip).join('\n'))}</textarea></div></div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Sperrregeln (Jails)</h3><button class="btn tiny" id="f2bAdd">+ Sperrregel</button></div><div id="f2bJails">${asArray(p.jails).map(fail2banJailRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="f2bSave">Prüfen & anwenden</button></div>`);$('#f2bBackend').value=p.backend||'auto';$('#f2bAdd').onclick=()=>{$('#f2bJails').insertAdjacentHTML('beforeend',fail2banJailRow({enabled:true,backend:'auto'}));wireF2BRows()};wireF2BRows();$('#f2bSave').onclick=async()=>{const body={bantime:$('#f2bBan').value.trim(),findtime:$('#f2bFind').value.trim(),maxretry:Number($('#f2bRetry').value),backend:$('#f2bBackend').value,ignore_ip:$('#f2bIgnore').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),jails:$$('.f2bJail').map(r=>({name:r.querySelector('.f2bName').value.trim(),enabled:r.querySelector('.f2bEnabled').checked,port:r.querySelector('.f2bPort').value.trim(),filter:r.querySelector('.f2bFilter').value.trim(),backend:r.querySelector('.f2bBackend').value,logpath:r.querySelector('.f2bLog').value.trim(),maxretry:Number(r.querySelector('.f2bMax').value)||0})).filter(j=>j.name)};const btn=$('#f2bSave');setBusy(btn,true,'Wird angewendet…');try{const out=await api(`/api/security/fail2ban${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Fail2Ban angewendet');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
function fail2banJailRow(j={}){return `<div class="f2bJail securityFormRow"><label class="switch"><input class="f2bEnabled" type="checkbox" ${j.enabled!==false?'checked':''}> aktiviert</label><input class="f2bName" placeholder="Name der Sperrregel" value="${esc(j.name||'')}"><input class="f2bPort" placeholder="Port · ssh" value="${esc(j.port||'')}"><input class="f2bFilter" placeholder="Filter" value="${esc(j.filter||'')}"><select class="f2bBackend"><option ${j.backend==='auto'||!j.backend?'selected':''}>auto</option><option ${j.backend==='systemd'?'selected':''}>systemd</option><option ${j.backend==='polling'?'selected':''}>polling</option><option ${j.backend==='pyinotify'?'selected':''}>pyinotify</option></select><input class="f2bLog" placeholder="Log-Pfad · optional" value="${esc(j.logpath||'')}"><input class="f2bMax" type="number" min="0" max="1000" placeholder="Versuche" value="${esc(j.maxretry||'')}"><button class="iconbtn f2bRemove">×</button></div>`}
function wireF2BRows(){$$('.f2bRemove').forEach(b=>b.onclick=()=>b.closest('.f2bJail').remove())}
async function securityAuditdModal(){try{const p=await api('/api/security/auditd'+qnode());auditdEditor(p)}catch(e){toast(e.message)}}
function auditdEditor(p){const check=(id,label,key)=>`<label class="switch"><input id="${id}" type="checkbox" ${p[key]?'checked':''}> ${label}</label>`;modal(`<div class="modalhead"><h2>Linux-Audit-Policy</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Dockwatch schreibt ausschließlich <code>/etc/audit/rules.d/90-dockwatch.rules</code> und lädt sie über <code>augenrules</code>. Voreinstellungen verwenden Datei-Watches statt breit gefasster Syscall-Regeln.</div><div class="securityChecks" style="margin-top:12px">${check('audIdentity','Identitätsdateien (/etc/passwd, shadow, group)','identity_files')}${check('audSudo','sudoers','sudoers')}${check('audSSH','SSH-Serverkonfiguration','ssh')}${check('audDocker','Docker-Socket- und Daemon-Konfiguration','docker')}${check('audSystemd','systemd-Unit-Konfiguration','systemd')}${check('audModules','Kernelmodul-Konfiguration','kernel_modules')}</div><div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Benutzerdefinierte Dateiüberwachungen</h3><button class="btn tiny" id="audAdd">+ Überwachung</button></div><div id="audWatches">${asArray(p.custom).map(auditWatchRow).join('')}</div></div></div><div class="modalfoot"><button class="btn" data-close>Abbrechen</button><button class="btn primary" id="audSave">Prüfen & laden</button></div>`);$('#audAdd').onclick=()=>{$('#audWatches').insertAdjacentHTML('beforeend',auditWatchRow({permissions:'wa',key:'dockwatch-custom'}));wireAuditRows()};wireAuditRows();$('#audSave').onclick=async()=>{const body={identity_files:$('#audIdentity').checked,sudoers:$('#audSudo').checked,ssh:$('#audSSH').checked,docker:$('#audDocker').checked,systemd:$('#audSystemd').checked,kernel_modules:$('#audModules').checked,custom:$$('.audWatch').map(r=>({path:r.querySelector('.audPath').value.trim(),permissions:r.querySelector('.audPerms').value.trim(),key:r.querySelector('.audKey').value.trim()})).filter(x=>x.path)};const btn=$('#audSave');setBusy(btn,true,'Wird geladen…');try{const out=await api(`/api/security/auditd${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Audit-Regeln geladen');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
function auditWatchRow(w={}){return `<div class="audWatch securityFormRow audit"><input class="audPath" placeholder="/srv/app/config" value="${esc(w.path||'')}"><input class="audPerms" placeholder="wa" value="${esc(w.permissions||'wa')}"><input class="audKey" placeholder="Audit-Schlüssel" value="${esc(w.key||'')}"><button class="iconbtn audRemove">×</button></div>`}
function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())}
window.addEventListener('resize',terminalResize);
function modal(html,className=''){const classes=['modal',...String(className||'').split(/\s+/).filter(Boolean)].join(' ');$('#modalRoot').innerHTML=`<div class="modalback"><div class="${classes}" role="dialog" aria-modal="true">${html}</div></div>`;$$('[data-close]').forEach(b=>b.onclick=closeModal);$('.modalback').onclick=e=>{if(e.target.classList.contains('modalback'))closeModal()};setTimeout(()=>$('.modal input:not([type=hidden]),.modal select,.modal button')?.focus(),0)}
function closeModal(){$('#modalRoot').innerHTML=''}
init().catch(e=>toast(e.message));