const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const asArray=v=>Array.isArray(v)?v:[];
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const state={me:{role:'viewer'},system:null,nodes:[],nodeHealth:{},stacks:[],monitors:[],services:[],node:0,view:'dashboard',stack:null,monitor:null,checks:[],dirty:false,draftKey:'',logStream:null,logBuffer:'',logPaused:false,logAutoScroll:true,refreshing:false};
function uiMessage(msg){let s=String(msg??'').trim();if(!s)return s;const exact={
'host security service unavailable':'Host-Sicherheitsdienst ist nicht verfügbar.',
'another firewall frontend is active (ufw/firewalld); Dockwatch refuses to apply a competing managed ruleset':'Ein anderes Firewall-Frontend (UFW/firewalld) ist aktiv; Dockwatch wendet keinen konkurrierenden Regelsatz an.',
'HOST_ROOT does not match the configured host PID root; mount /:/host and use pid: host':'HOST_ROOT stimmt nicht mit dem Root des konfigurierten Host-PID überein; mounte /:/host und verwende pid: host.',
'Dockwatch appears to be PID 1; enable pid: host for host command execution':'Dockwatch scheint selbst PID 1 zu sein; aktiviere pid: host für Host-Befehle.',
'cross-site request rejected':'Seitenübergreifende Anfrage abgelehnt.',
'origin rejected':'Herkunft der Anfrage abgelehnt.',
'not found':'Nicht gefunden.',
'permission denied':'Zugriff verweigert.'};
if(exact[s])return exact[s];s=s.replace(/^agent returned (\d+) Bad Request:\s*/i,'Agent antwortete mit $1 (Ungültige Anfrage): ');s=s.replace(/^agent returned (\d+) (.+?):\s*/i,'Agent antwortete mit $1 $2: ');s=s.replace(/\bcheck failed\b/gi,'Prüfung fehlgeschlagen').replace(/\bnicht erreichbar\b/gi,'nicht erreichbar').replace(/\bhost access unavailable\b/gi,'Host-Zugriff nicht verfügbar');return s}
async function api(path,opt={}){let r;try{r=await fetch(path,{headers:{'Content-Type':'application/json',...(opt.headers||{})},...opt})}catch(e){if(e?.name!=='AbortError')setApiState(false,e.message);throw e}setApiState(true);if(r.status===401){location='/auth/login';throw Error('Nicht angemeldet')}const txt=await r.text();if(!r.ok)throw Error(txt||r.statusText);if(!txt)return null;try{return JSON.parse(txt)}catch{throw Error('Ungültige JSON-Antwort vom Server')}}
function setApiState(ok,msg=''){const e=$('#apiState');if(!e)return;e.classList.toggle('offline',!ok);e.title=ok?'API erreichbar':('API nicht erreichbar'+(msg?': '+msg:''));const t=e.querySelector('span');if(t)t.textContent=ok?'API':'Offline'}
function applyPreferences(){const theme=localStorage.getItem('dockwatch:theme')||'dark';document.documentElement.dataset.theme=theme;document.body.classList.toggle('sidebar-collapsed',localStorage.getItem('dockwatch:sidebar')==='collapsed'&&innerWidth>860);const t=$('#themeToggle');if(t)t.textContent=theme==='light'?'☾':'☀'}
function toggleTheme(){const next=(document.documentElement.dataset.theme||'dark')==='dark'?'light':'dark';localStorage.setItem('dockwatch:theme',next);applyPreferences()}
function teardownInteractive(){stopLiveLogs();closeTerminal()}
function setBusy(btn,on,label=''){if(!btn)return;btn.disabled=!!on;btn.classList.toggle('busy',!!on);if(on){if(!btn.dataset.oldText)btn.dataset.oldText=btn.textContent;if(label)btn.textContent=label}else if(btn.dataset.oldText){btn.textContent=btn.dataset.oldText;delete btn.dataset.oldText}}
function toast(msg){const e=$('#toast');e.textContent=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 `${esc(statusLabel(s))}`}
function setDirty(v){state.dirty=v;$('#dirtyTop').hidden=!v;window.onbeforeunload=v?()=>true:null}
function draftKey(name='new'){return `dockwatch:draft:${state.node}:${name||'new'}`}
function saveDraft(){if(!state.dirty)return;const c=$('#composeText'),e=$('#envText'),n=$('#stackName');if(!c||!n)return;const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value,content:r.querySelector('.secContent').value}));const env_files=collectManaged('.envfilerow');const configs=collectManaged('.configrow');localStorage.setItem(draftKey(n.value||'new'),JSON.stringify({name:n.value,compose:c.value,env:e?.value||'',secrets,env_files,configs,ts:Date.now()}))}
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=`${state.nodes.map(n=>``).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}}
function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser ist offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)}
function navigate(v){if(state.dirty&&state.view==='stacks'&&v!=='stacks'&&!confirm('Ungespeicherte Stack-Änderungen verlassen?'))return;if(v!==state.view)teardownInteractive();state.view=v;document.body.classList.remove('sidebar-mobile-open');$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));render()}
async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('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 `
${esc(title)}
${esc(sub)}
${actions}
`}
function renderDashboard(){setCrumb('Übersicht');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Übersicht',nodeName()+' · Docker- und Uptime-Übersicht','Ctrl+S speichert Stacks')}
Compose-Stacks${state.stacks.length}
${running} aktiv
Monitore verfügbar${up}
${state.monitors.length} konfiguriert
Monitor-Störungen${down}
aktuelle Prüfungsfehler
Dienste${svcUp}/${state.services.length}
${svcDown} gestört
Compose-Stacks
${stackTable(state.stacks.slice(0,8))}
Uptime-Monitore
${monitorTable(state.monitors.slice(0,8))}
`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))}
function stackTable(items){if(!items.length)return'
'}
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 `
▱
${esc(st.name||'Neuer Compose-Stack')}
${st.name?esc(nodeName()):'Entwurf · nicht bereitgestellt'}
${badge(st.status||'new')}
${st.name?``:''}
${composeTab(st)}
${envTab(st)}
${managedFilesTab('envfile',st.env_files||[],'Zusätzliche Env-Dateien','In Compose verwenden als env_file: ./envs/app.env.')}
${secretsTab(st)}
${managedFilesTab('config',st.configs||[],'Compose-Configs','In Compose verwenden als configs: ... file: ./configs/name.')}
${servicesTab(st)}
${permissionsTab()}
${graphTab()}
${updatesTab()}
${logsTab()}
${consoleTab(sv)}
${dangerTab(st)}
`}
function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'
Für diesen Stack existiert ein lokaler ungespeicherter Entwurf.
':''}
Vollständiger Compose-Designer wird analysiert…
compose.yaml Verbindliche Quelle
Visueller Editor alle Felder · AST-Patch-Modus
Compose wird analysiert…
Jeder vorhandene Compose-Wert kann als String, Zahl, Boolean, Null, Map oder Array bearbeitet werden.Felder der aktuellen Spezifikation werden vorgeschlagen; x-* und zukünftige Felder bleiben ebenfalls bearbeitbar.Ungültiges YAML pausiert die visuelle Synchronisierung, ohne die Quelle zu ersetzen.
`}
const COMPOSE_SERVICE_FIELDS=['annotations','attach','build','blkio_config','cpu_count','cpu_percent','cpu_shares','cpu_period','cpu_quota','cpu_rt_runtime','cpu_rt_period','cpus','cpuset','cap_add','cap_drop','cgroup','cgroup_parent','command','configs','container_name','credential_spec','depends_on','deploy','develop','device_cgroup_rules','devices','dns','dns_opt','dns_search','domainname','driver_opts','entrypoint','env_file','environment','expose','extends','external_links','extra_hosts','gpus','group_add','healthcheck','hostname','image','init','ipc','isolation','labels','label_file','links','logging','mac_address','mem_limit','mem_reservation','mem_swappiness','memswap_limit','models','network_mode','networks','oom_kill_disable','oom_score_adj','pid','pids_limit','platform','ports','post_start','pre_start','pre_stop','privileged','profiles','provider','pull_policy','read_only','restart','runtime','scale','secrets','security_opt','shm_size','stdin_open','stop_grace_period','stop_signal','storage_opt','sysctls','tmpfs','tty','ulimits','use_api_socket','user','userns_mode','uts','volumes','volumes_from','working_dir'];
const COMPOSE_TOP_FIELDS=['name','include','services','models','networks','volumes','secrets','configs','version'];
const COMPOSE_FIELD_HINTS={image:'Container-Image',build:'Build-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;iVisueller Editor pausiert
${esc(uiMessage(e.message))}
Die YAML-Quelle bleibt unverändert. Korrigiere die Syntax; anschließend wird automatisch weiter synchronisiert.
`}}
function renderComposeSections(){const host=$('#composeSections');if(!host)return;const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),missing=[...new Set(usages.filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];const volumeLabel=`Volumes ${usages.length}${missing.length?` · ${missing.length}!`:''}`;const items=[['project','Projekt'],['services','Dienste'],['networks','Netzwerke'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Modelle'],['include','Include']];host.innerHTML=items.map(([k,l])=>``).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})}
function sectionValue(){const m=composeVisualModel||{};if(composeSection==='project'){const x={};for(const k of Object.keys(m))if(!['services','networks','volumes','configs','secrets','models','include'].includes(k))x[k]=m[k];return x}return m[composeSection]??(composeSection==='include'?[]:{})}
function sectionPath(){return composeSection==='project'?[]:[composeSection]}
function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const scrollTop=box.scrollTop,v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(composeSection==='volumes'){body=composeVolumeSection(v,base)}else if(['networks','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'
In diesem Bereich ist nichts konfiguriert.
';wireComposeTree();wireComposeVolumeReconcile();box.scrollTop=Math.min(scrollTop,Math.max(0,box.scrollHeight-box.clientHeight))}
function composeObjectLabel(v){return ({service:'Dienst',volume:'Volume',network:'Netzwerk',config:'Config',secret:'Secret',model:'Modell',include:'Einbindung'}[v]||v)}
function composeTypeLabel(v){return ({string:'Zeichenkette',number:'Zahl',boolean:'Boolean',map:'Objekt',array:'Array',null:'Null'}[v]||v)}
function composeNamedObjectEditor(v,path,label,suggestions){if(!v||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,label);const displayLabel=composeObjectLabel(label),rows=Object.entries(v).map(([k,val])=>`${esc(k)}${esc(displayLabel)}
Dienst-Mounts werden live mit Top-Level-volumes: verglichen. Bind-Mounts werden hier angezeigt, aber niemals in Named-Volume-Deklarationen umgewandelt.
${status}${missing.length?'':''}
${rows?`
Dienst
Typ
Quelle
Ziel
Zugriff
Top-Level
${rows}
`:'
Aktuell sind keine Dienst-Mounts konfiguriert.
'}${unused.length?`
Deklariert, aber aktuell ungenutzt: ${unused.map(n=>`${esc(n)} `).join(' ')}
`:''}${binds.length?`
${binds.length} Bind-Mount${binds.length===1?'':'s'} erkannt. Bind-Quellen gehören zum Host-Dateisystem und erscheinen deshalb nicht als benannte Top-Level-Volumes.
`:''}${other.length?`
${other.length} anonyme/dynamische/spezielle Mount${other.length===1?'':'s'} ohne automatische Änderung der Deklarationen beibehalten.
`:''}
Top-Level-Volumes Compose-Deklarationen
${composeNamedObjectEditor(v,path,'volume',[])}
`}
function wireComposeVolumeReconcile(){if(composeSection!=='volumes')return;$$('[data-vdeclare]').forEach(b=>b.onclick=()=>queueComposePatch(['volumes',b.dataset.vdeclare],{}));$('#syncVolumeDeclarations')?.addEventListener('click',()=>{const declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),names=[...new Set(composeVolumeUsages().filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];if(!names.length)return toast('Benannte Volumes sind bereits abgeglichen.');names.forEach(n=>queueComposePatch(['volumes',n],{}));toast(`${names.length} Volume-Deklaration${names.length===1?'':'en'} vorgemerkt.`)});$$('[data-vremove]').forEach(b=>b.onclick=()=>{const n=b.dataset.vremove;if(confirm(`Ungenutzte Top-Level-Volume-Deklaration ${n} entfernen? Das Docker-Volume selbst wird nicht gelöscht.`))queueComposePatch(['volumes',n],null,true)})}
function composeMapEditor(v,path,opt={}){if(v===null||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,'value');const entries=Object.entries(v);const serviceRoot=!!opt.serviceRoot;const suggestions=opt.rootProject?COMPOSE_TOP_FIELDS:composeSuggestions(path,opt);const listId='candidates-'+Math.abs(pathKey(path).split('').reduce((a,c)=>((a<<5)-a+c.charCodeAt(0))|0,0));return `
`}
function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `
`}
function typeSwitcher(path,t){return ``}
function newValueForType(t){return t==='map'?{}:t==='array'?[]:t==='boolean'?false:t==='number'?0:t==='null'?null:''}
function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v,false,{refresh:false})};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('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 ``}
function secretsTab(st){return `
Secret-Werte werden mit Modus 0600 geschrieben. Verwende in Compose secrets: mit file: ./secrets/name. Vorhandene Werte sind nur für Operatoren sichtbar, die den Stack bearbeiten dürfen.
${(st.secrets||[]).map(secretRow).join('')}
`}
function secretRow(s={}){return ``}
function managedFilesTab(kind,files,title,help){return `
${title} · ${help} Verwaltete Dateien werden gemeinsam mit dem Stack validiert.
${files.map(f=>managedFileRow(kind,f)).join('')}
`}
function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return ``}
function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.managedName').value.trim(),content:r.querySelector('.managedContent').value})).filter(f=>f.name)}
function formatServicePorts(raw){const text=String(raw||'').trim();if(!text)return'—';try{const p=JSON.parse(text);if(Array.isArray(p)){const items=p.map(x=>{if(!x||typeof x!=='object')return String(x??'');const proto=x.Protocol||x.protocol||'tcp',target=x.TargetPort??x.target_port??x.target,pub=x.PublishedPort??x.published_port??x.published,url=x.URL??x.url??'';if(pub!==undefined&&pub!==null&&String(pub)!==''){const host=url?(String(url).includes(':')&&!String(url).startsWith('[')?`[${url}]`:String(url)):'';return `${host?host+':':''}${pub} → ${target??'?'}${proto?'/'+proto:''}`}return target!==undefined?`${target}${proto?'/'+proto:''}`:''}).filter(Boolean);if(items.length)return items.join(' · ')}}catch{}return text}
function svcMetaValue(value,opt={}){const full=String(value||'—'),shown=opt.ports?formatServicePorts(full):full;return `${esc(shown)}`}
function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
Prüft erwartete Anwendungs-UID/GID, Host-Besitz, Modus-Bits und ACL-Hinweise.
Analyse starten, um beschreibbare Bind-Mounts aller erzeugten Dienste zu prüfen.
`}
function updatesTab(){return `
Installierte Image-Digests mit Registry-Manifesten vergleichen.
`}
function consoleTab(sv){return `
Interaktives Docker-Exec-Terminal mit echter PTY/WebSocket-Sitzung. In das Terminal klicken und normal tippen.
Terminal disconnected.
`}
function dangerTab(st){return st.name?`
Sicheres Löschen ist der Standard. Es werden nur compose.yaml, .env und von Dockwatch verwaltete Secret-/Env-/Config-Ordner entfernt. Unabhängige Bind-Mount-Daten neben dem Stack bleiben erhalten.
`:'
Speichere zuerst den Stack.
'}
function wireStackDetail(){const root=$('#stackDetail');root.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x===b));['compose','env','envfiles','secrets','configs','services','permissions','graph','updates','logs','console','danger'].forEach(t=>{const e=$(`#tab-${t}`);if(e)e.hidden=t!==b.dataset.tab})});const mark=()=>{setDirty(true);saveDraft()};let composeTimer;root.addEventListener('input',e=>{if(e.target.matches('#composeText,#envText,#stackName,.secName,.secContent,.managedName,.managedContent'))mark();if(e.target.matches('#composeText')){clearTimeout(composeTimer);composeTimer=setTimeout(parseComposeVisual,220)}});$('#addComposeService')?.addEventListener('click',addComposeService);parseComposeVisual();$('#composeExpandAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=true)});$('#composeCollapseAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=false)});$('#addSecret')?.addEventListener('click',()=>{$('#secretList').insertAdjacentHTML('beforeend',secretRow());wireSecretRemovers();mark()});$('#addenvfile')?.addEventListener('click',()=>{$('#envfileList').insertAdjacentHTML('beforeend',managedFileRow('envfile'));wireManagedRemovers();mark()});$('#addconfig')?.addEventListener('click',()=>{$('#configList').insertAdjacentHTML('beforeend',managedFileRow('config'));wireManagedRemovers();mark()});wireSecretRemovers();wireManagedRemovers();$('#saveStack').onclick=saveStack;$$('[data-act]').forEach(b=>b.onclick=()=>stackAction(b.dataset.act));$('#loadLogs')?.addEventListener('click',loadLogs);$('#liveLogs')?.addEventListener('click',startLiveLogs);$('#stopLogs')?.addEventListener('click',stopLiveLogs);$('#pauseLogs')?.addEventListener('click',togglePauseLogs);$('#downloadLogs')?.addEventListener('click',downloadLogs);$('#logAutoScroll')?.addEventListener('change',e=>state.logAutoScroll=e.target.checked);$('#logFilter')?.addEventListener('input',filterLogs);$('#openTerminal')?.addEventListener('click',openTerminal);$('#closeTerminal')?.addEventListener('click',closeTerminal);$('#loadGraph')?.addEventListener('click',loadGraph);$('#loadPermissions')?.addEventListener('click',loadStackPermissions);$('#checkUpdates')?.addEventListener('click',loadImageUpdates);$('#deleteStack')?.addEventListener('click',()=>deleteStack(false));$('#purgeStack')?.addEventListener('click',()=>deleteStack(true));$('#restoreDraft')?.addEventListener('click',restoreDraft);$('#discardDraft')?.addEventListener('click',()=>{clearDraft(state.stack.name);renderStacks()})}
function wireSecretRemovers(){$$('.removeSecret').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})}
function wireManagedRemovers(){$$('.removeManaged').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})}
function restoreDraft(){try{const d=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('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()?'':'';$('#content').innerHTML=`${pageHead('Monitore','Uptime, Latenz, Docker-Status und Wartung über alle Umgebungen hinweg.',actions)}
Wähle einen Monitor aus, um Uptime und Latenz anzuzeigen.
'}
`;$('#newMonitor')?.addEventListener('click',()=>monitorModal());$('#monSearch').oninput=e=>$('#monList').innerHTML=monitorListHTML(state.monitors.filter(m=>(m.name+' '+m.target).toLowerCase().includes(e.target.value.toLowerCase())));$('#monRefresh').onclick=()=>refreshData(true).then(renderMonitors);wireMonitorList();if(state.monitor)wireMonitorDetail()}
function monitorListHTML(ms){return ms.map(m=>`
${esc(m.name)}${badge(m.status)}
${esc(m.type.toUpperCase())} · ${esc(m.target)}
${heartbeatBars([],m.status,24)}
`).join('')||'
Keine Monitore
'}
function wireMonitorList(){$$('[data-mon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.mon)))}
async function openMonitor(id){try{const [m,c]=await Promise.all([api(`/api/monitors/${id}`),api(`/api/monitors/${id}/checks?limit=80`)]);state.monitor=m;state.checks=Array.isArray(c)?c:[];state.view='monitors';renderMonitors()}catch(e){toast(e.message)}}
function heartbeatBars(checks,status,n=48){if(!checks?.length)return Array.from({length:n},()=>``).join('');const a=[...checks].reverse().slice(-n);return Array.from({length:n-a.length},()=>'').join('')+a.map(c=>``).join('')}
function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'
Noch keine Heartbeat-Daten vorhanden.
';const max=Math.max(1,...a.map(x=>x.latency_ms)),pts=a.map((x,i)=>`${(i/(Math.max(1,a.length-1))*100).toFixed(2)},${(95-(x.latency_ms/max)*80).toFixed(2)}`).join(' ');return ``}
function monitorDetailHTML(m,c){const avg=c?.length?Math.round(c.reduce((a,x)=>a+x.latency_ms,0)/c.length):0,ok=c?.filter(x=>x.ok).length||0,ratio=c?.length?ok/c.length*100:0;return `
Wartung aktiv${m.maintenance_until?` bis ${esc(fmtTime(m.maintenance_until))}`:' bis zur manuellen Beendigung'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.
Während der Wartung werden Prüfalarme unterdrückt; der Monitor wird als Wartung statt als ausgefallen angezeigt.
`);$('#startMaint').onclick=()=>startMaintenance(m.id)}
async function startMaintenance(id){const mode=$('#maintMode').value;let until=null;if(mode==='custom'){const v=$('#maintUntil').value;if(v)until=Math.floor(new Date(v).getTime()/1000)}else if(mode!=='manual'){until=Math.floor(Date.now()/1000)+Number(mode.replace('h',''))*3600}try{await api(`/api/monitors/${id}/maintenance`,{method:'POST',body:JSON.stringify({until,note:$('#maintNote').value})});closeModal();await refreshData(true);await openMonitor(id);toast('Wartung gestartet.')}catch(e){toast(e.message)}}
async function clearMaintenance(id){await api(`/api/monitors/${id}/maintenance`,{method:'DELETE'});await refreshData(true);await openMonitor(id);toast('Wartung beendet.')}
async function deleteMonitor(){if(!confirm(`Delete monitor ${state.monitor.name} and its heartbeat history?`))return;await api(`/api/monitors/${state.monitor.id}`,{method:'DELETE'});state.monitor=null;state.checks=[];await refreshData(true);renderMonitors()}
async function renderServices(){setCrumb('Überwachung / Dienste');const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Dienste','Monitore zu sichtbaren Diensten gruppieren. Eine fehlgeschlagene Prüfung setzt den gesamten Dienst auf gestört.',actions)}
Keine Dienste konfiguriert. Lege einen Dienst an und ordne ihm Monitore zu.
'}
`;$('#newService')?.addEventListener('click',()=>serviceModal());$$('[data-sedit]').forEach(b=>b.onclick=()=>serviceModal(state.services.find(x=>x.id===Number(b.dataset.sedit))));$$('[data-sdel]').forEach(b=>b.onclick=async()=>{if(confirm('Dienst löschen? Die zugehörigen Monitore sind anschließend nicht mehr gruppiert.')){await api('/api/services/'+b.dataset.sdel,{method:'DELETE'});await refreshData(true);renderServices()}})}
function serviceModal(x=null){const selected=new Set((x?.monitors||[]).map(m=>m.id));modal(`
Ein Monitor kann genau einem Dienst angehören. Eine Zuordnung hier verschiebt ihn aus dem bisherigen Dienst. Eine fehlgeschlagene Prüfung setzt den Dienst auf gestört.
`);$('#svcSave').onclick=async()=>{const body={name:$('#svcName').value,description:$('#svcDesc').value,monitor_ids:$$('.svcProbe:checked').map(e=>Number(e.value))};try{await api(x?`/api/services/${x.id}`:'/api/services',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);renderServices()}catch(e){toast(e.message)}}}
async function renderStatusPages(){setCrumb('Überwachung / Statusseiten');$('#content').innerHTML=`${pageHead('Öffentliche Statusseiten','Nur ausgewählte Dienste und deren aggregierten Status veröffentlichen.',state.me.role==='admin'?'':'')}
`;$$('[data-endmaint]').forEach(b=>b.onclick=()=>clearMaintenance(Number(b.dataset.endmaint)).then(renderMaintenance));$$('[data-startmaint]').forEach(b=>b.onclick=()=>maintenanceModal(state.monitors.find(m=>m.id===Number(b.dataset.startmaint))))}
function renderNodes(){setCrumb('System / Umgebungen');const local=`
◎
Lokaler Docker-Host
Docker-Socket
${badge('up')}
${esc(state.system?.build?.version||'lokal')}
Lokal
—
`;$('#content').innerHTML=`${pageHead('Umgebungen','Master und Remote-Agenten über eine zentrale Oberfläche verwalten.',state.me.role==='admin'?'':'')}
Name
Status
Verbindung
Aktionen
${local}${state.nodes.map(n=>`
◎
${esc(n.name)}
${esc(n.base_url)}
${n.enabled?'Wird geprüft…':badge('paused')}
${n.enabled?'Bearer-Agent':'Deaktiviert'}
${state.me.role==='admin'?` `:''}
`).join('')}
`;$('#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(`
`}}))}
function resourceTitle(k){return({containers:'Container',images:'Images',volumes:'Volumes',networks:'Netzwerke'})[k]||k}
function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='containers')create='';if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
${n} werden geladen…
`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)}
function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''}
async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`
${esc(uiMessage(e.message))}
`}}
function inventoryLabelSummary(raw){const full=typeof raw==='string'?raw:raw&&typeof raw==='object'?Object.entries(raw).map(([k,v])=>`${k}=${v}`).join(','):String(raw||'');if(!full)return'—';const parts=full.split(',').map(x=>x.trim()).filter(Boolean),pretty=parts.slice(0,3).map(x=>x.replace(/^com\.docker\.compose\./,'compose.'));return pretty.join(' · ')+(parts.length>3?` · +${parts.length-3}`:'')}
function inventoryCell(kind,key,value,first=false){const labelRaw=key==='Labels'&&value&&typeof value==='object'?Object.entries(value).map(([k,v])=>`${k}=${v}`).join(','):null,raw=value===undefined||value===null||value===''?'—':labelRaw??String(value);if(first)return `
`;wireResourceRows(kind)}
function resourceActions(kind,r,idx){const inspect=roleOK()?``:'';if(kind==='containers')return `${inspect}${roleOK()?` `:''}`;if(!roleOK())return'';return `${inspect} `}
function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]));$$('[data-identity]').forEach(b=>b.onclick=()=>inspectContainerIdentity(window.__inventoryRows[Number(b.dataset.identity)]))}
async function dockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action} ${kind}? This can delete Docker resources.`))return;try{const r=await api(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);await loadInventory(kind)}catch(e){toast(e.message)}}
function registryLoginModal(){modal(`
Registry-Anmeldung
Anmeldedaten werden von der Docker-CLI in die persistente Docker-Konfiguration dieser Umgebung geschrieben. Das Passwort wird über stdin und nicht als Kommandozeilenargument übergeben.
`);$('#regSave').onclick=async()=>{const body={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)return toast('Registry, username and password required.');closeModal();await dockerResourceAction('images','login',body)}}
function registryLogoutModal(){modal(`
Registry-Abmeldung
`);$('#regSave').onclick=async()=>{const registry=$('#regHost').value.trim();if(!registry)return toast('Registry required.');closeModal();await dockerResourceAction('images','logout',{registry})}}
function resourceCreateModal(kind){if(kind==='images'){modal(`
„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.
Prozess- und Speicheridentität werden getrennt bewertet. PID 1 kann als root laufen, während ein Image Anwendungsdaten mit PUID/PGID schreibt. Dockwatch ändert Compose user: niemals automatisch.
`);
$$('[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(`
Passenden Host-Benutzer anlegen
Host-Änderung: Dockwatch prüft den Container erneut und leitet die Bind-UID/GID serverseitig ab. Numerische IDs können nicht im Browser vorgegeben werden.
${p.blocked_reason?`Automatische Reparatur nicht verfügbar: ${esc(uiMessage(p.blocked_reason))} `:''}${esc(uiMessage(p.recommendation||''))}
Erneut prüfen / Reparaturumfang
Wird nie automatisch und nie rekursiv angewendet. Leer lassen, um Modus-Bits beizubehalten.
${canCreateUser?'':''}${canRepair?'':''}
`);
$('#rescanBind').onclick=()=>bindPermissionModal(p.container_id,p.destination,$('#bindRecursive').checked);
if(canCreateUser)$('#bindCreateUser').onclick=()=>hostUserModal(p.container_id,{container_name:p.container_name,bind_uid:p.expected_uid,bind_gid:p.expected_gid,bind_identity_source:p.identity_source,bind_host_user:p.host_user});
if(canRepair)$('#repairBind').onclick=async()=>{const recursive=$('#bindRecursive').checked,fix=$('#bindFixOwner').checked,mode=$('#bindMode').value.trim();if(recursive&&!p.recursive)return toast('Run a recursive re-scan first so the affected file count is known.');const what=recursive?`${p.entries_ownership_mismatch} entries recursively`:'the bind root only';if(!confirm(`Repair ${what} on ${nodeName()} to ${expected}${mode?` and set top-level mode ${mode}`:''}?`))return;const btn=$('#repairBind');setBusy(btn,true,'Repairing…');try{const out=await api(`/api/host/bind-permissions/repair${qnode()}`,{method:'POST',body:JSON.stringify({container_id:p.container_id,destination:p.destination,recursive,fix_ownership:fix,mode})});toast(out.message||'Bind mount repaired');showBindPermissionPreview(out.after);if(state.stack?.name)setTimeout(loadStackPermissions,0)}catch(e){toast(e.message);setBusy(btn,false)}}
}
async function loadStackPermissions(){const box=$('#permissionsBox');if(!box||!state.stack?.name)return;box.innerHTML='
`)}catch(e){toast(e.message)}}
function showOutput(title,text){modal(`
${esc(title)}
${esc(text||'OK')}
`)}
let terminalWS=null;
function wsURL(path){const proto=location.protocol==='https:'?'wss:':'ws:';return `${proto}//${location.host}${path}`}
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='
`}}
async function renderActivity(){setCrumb('System / Aktivitäten');$('#content').innerHTML=`${pageHead('Aktivitäten','Persistentes Audit-Protokoll für Änderungen, Bereitstellungen und Monitor-Statuswechsel.','')}
`}};$('#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()?'':'')}
`);$('#gitNode').value=String(x?.node_id||state.node||0);$('#saveGit').onclick=async()=>{const body={node_id:Number($('#gitNode').value)||null,stack_name:$('#gitStack').value,repo_url:$('#gitRepo').value,branch:$('#gitBranch').value,workdir:$('#gitWorkdir').value,compose_file:$('#gitCompose').value,auto_deploy:$('#gitAuto').checked};try{const r=await api(x?`/api/git-sources/${x.id}`:'/api/git-sources',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();if(!x)showGitSecret(r);renderGit()}catch(e){toast(e.message)}}}
function showGitSecret(r){modal(`
Webhook erstellt
Dieses Secret jetzt kopieren. Es wird verschlüsselt gespeichert und nicht erneut angezeigt.
GitHub: Secret für X-Hub-Signature-256 verwenden. GitLab: als X-Gitlab-Token senden. Generische Hooks können X-Webhook-Token verwenden.
`)}
async function gitSync(id){try{toast('Git-Synchronisierung gestartet…');await api(`/api/git-sources/${id}/sync`,{method:'POST'});await refreshData(true);renderGit();toast('Git-Stack synchronisiert.')}catch(e){toast(e.message);renderGit()}}
async function renderNotifications(){setCrumb('Überwachung / Benachrichtigungen');$('#content').innerHTML=`${pageHead('Benachrichtigungen','Monitor-Statuswechsel per Webhook, ntfy, Gotify oder SMTP senden.',state.me.role==='admin'?'':'')}
Paketverwaltung ist auf Administratoren beschränkt.
`; return }
$('#content').innerHTML=`${pageHead('System-Updates',`Verfügbare Linux-Paketupdates prüfen und installieren · ${nodeName()}`,'')}
Paketupdates werden geprüft…
`;
$('#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()}`,'')}
AUR nicht konfiguriert: Setze HOST_AUR_USER auf einen lokalen Nicht-root-Benutzer, der ${esc(d.aur_helper)} verwendet und Paket-Eskalation non-interaktiv ausführen darf.
`:''}
${asArray(d.warnings).map(w=>`
${esc(uiMessage(w))}
`).join('')}
System-Repositories
${repo.length} verfügbar
${packageUpdateTable(repo,'Keine Updates aus den System-Repositories verfügbar.')}
${pm==='pacman'||d.aur_helper?`
AUR
${aur.length} verfügbar
AUR-Vertrauensgrenze: 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.
`;$('#pkgReload').onclick=renderPackages}
}
function packageUpdateTable(rows,emptyText){if(!rows.length)return `
${emptyText}
`;return `
Paket
Installiert
Verfügbar
Quelle
${rows.map(x=>`
${esc(x.name)}${x.arch?`${esc(x.arch)}`:''}
${esc(x.current_version||'—')}
${esc(x.new_version||'—')}
${esc(x.repository||x.source||'—')}
`).join('')}
`}
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.')}
Die Host-Sicherheitskonfiguration ist auf Administratoren beschränkt.
`; return }
$('#content').innerHTML=`${pageHead('Host-Sicherheit',`Linux-Host härten, konfigurieren und warten · ${nodeName()}`,'')}
`}
}
function securityCapabilityPills(c){return `Host-Root ${c.host_root_available?'✓':'×'}Host-Namespace ${c.target_verified?'✓':'×'}${c.allow_changes?'Verwaltung':'nur Prüfung'}Pakete ${c.allow_package_management?'freigegeben':'gesperrt'}`}
function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host-Sicherheitsänderungen sind für diese Umgebung deaktiviert"',pkgDisabled=packages?'':'disabled title="Host-Paketverwaltung ist für diese Umgebung deaktiviert"',canConfigure=manage&&(key==='firewall'||installed);return `
`}
async function securityInstall(component,btn,provider=''){if(component==='firewall'&&!provider)provider=(window.__firewallBackend||{}).selected||'nftables';if(!confirm(`${component==='firewall'?provider:component} auf ${nodeName()} über den Host-Paketmanager installieren oder aktualisieren?`))return;setBusy(btn,true,'Wird ausgeführt…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall',provider})});toast(out.message||'Paketvorgang abgeschlossen.');if(out.output)showOutput(`${component} · Paketvorgang`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${component} auf ${nodeName()} wirklich ${action==='disable'?'deaktivieren':'stoppen'}?`))return;setBusy(btn,true,'Wird ausgeführt…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component}: Aktion abgeschlossen.`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode()),preview=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(p)});firewallEditor(preview.policy||p,preview)}catch(e){toast(e.message)}}
function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>``).join('')}
function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `
`}
function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`
Host-Firewall · ${esc(selected)}
Provider-Modell: Auto verwendet aktives UFW oder firewalld und fällt sonst auf natives nftables zurück. Dockwatch erhält fremde Regeln und führt niemals ufw reset oder nft flush ruleset.
${selected==='firewalld'?``:''}
${firewallRuntimeHTML(preview)}
Von Dockwatch verwaltete Portregeln
${rules.map(firewallRuleRow).join('')}
Aussperrschutz: Anwenden startet einen 90-Sekunden-Rollback-Timer. Fremde Provider-Regeln bleiben erhalten. Wenn du den globalen Eingangsstandard des Providers verwaltest, prüfe SSH-/Dockwatch-Zugriff, bevor du die Änderung bestätigst.
${!installed&&selected!=='auto'?``:''}
`,'modal-wide modal-firewall');$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'Firewall'} Vorschau`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Standard eingehend DROP kann die Verbindung zu diesem Host trennen. Bestätige, dass deine SSH-/Dockwatch-Verwaltungsports ausdrücklich erlaubt sind. Mit zeitgesteuertem Rollback fortfahren?'))return;const btn=$('#fwApply');setBusy(btn,true,'Wird angewendet…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
function firewallRuleRow(r={}){return ``}
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})}
function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,trusted_cidrs:($('#fwTrusted')?.value||'').split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`
${esc(title)}
${warnings.map(x=>`
${esc(x)}
`).join('')}${conflicts?.length?`
Konflikte: ${esc(conflicts.join(', '))}
`:''}
${esc(text||'')}
`)}
function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`
Firewall angewendet · Prüfzeitraum
Diesen Dialog noch nicht schließen. Wenn die neue Policy den Zugriff unterbricht, stellt Dockwatch automatisch die vorherige verwaltete Policy und den Provider-Standard wieder her.
Automatischer Rollback in…
Prüfe SSH und weitere Verwaltungszugänge in einer separaten Sitzung. Bestätige anschließend die Änderung.
`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall-Policy bestätigt.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall zurückgerollt.');closeModal();renderSecurity()}catch(e){toast(e.message)}}}
async function securityFail2BanModal(){try{const p=await api('/api/security/fail2ban'+qnode());fail2banEditor(p)}catch(e){toast(e.message)}}
function fail2banEditor(p){modal(`
Fail2Ban-Policy
Dockwatch schreibt ausschließlich /etc/fail2ban/jail.d/dockwatch.local. Andere Distributions-/Benutzer-Jails bleiben erhalten und wirksam.
Sperrregeln (Jails)
${asArray(p.jails).map(fail2banJailRow).join('')}
`);$('#f2bBackend').value=p.backend||'auto';$('#f2bAdd').onclick=()=>{$('#f2bJails').insertAdjacentHTML('beforeend',fail2banJailRow({enabled:true,backend:'auto'}));wireF2BRows()};wireF2BRows();$('#f2bSave').onclick=async()=>{const body={bantime:$('#f2bBan').value.trim(),findtime:$('#f2bFind').value.trim(),maxretry:Number($('#f2bRetry').value),backend:$('#f2bBackend').value,ignore_ip:$('#f2bIgnore').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),jails:$$('.f2bJail').map(r=>({name:r.querySelector('.f2bName').value.trim(),enabled:r.querySelector('.f2bEnabled').checked,port:r.querySelector('.f2bPort').value.trim(),filter:r.querySelector('.f2bFilter').value.trim(),backend:r.querySelector('.f2bBackend').value,logpath:r.querySelector('.f2bLog').value.trim(),maxretry:Number(r.querySelector('.f2bMax').value)||0})).filter(j=>j.name)};const btn=$('#f2bSave');setBusy(btn,true,'Wird angewendet…');try{const out=await api(`/api/security/fail2ban${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Fail2Ban angewendet');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
function fail2banJailRow(j={}){return ``}
function wireF2BRows(){$$('.f2bRemove').forEach(b=>b.onclick=()=>b.closest('.f2bJail').remove())}
async function securityAuditdModal(){try{const p=await api('/api/security/auditd'+qnode());auditdEditor(p)}catch(e){toast(e.message)}}
function auditdEditor(p){const check=(id,label,key)=>``;modal(`
Linux-Audit-Policy
Dockwatch schreibt ausschließlich /etc/audit/rules.d/90-dockwatch.rules und lädt sie über augenrules. Voreinstellungen verwenden Datei-Watches statt breit gefasster Syscall-Regeln.
${check('audIdentity','Identitätsdateien (/etc/passwd, shadow, group)','identity_files')}${check('audSudo','sudoers','sudoers')}${check('audSSH','SSH-Serverkonfiguration','ssh')}${check('audDocker','Docker-Socket- und Daemon-Konfiguration','docker')}${check('audSystemd','systemd-Unit-Konfiguration','systemd')}${check('audModules','Kernelmodul-Konfiguration','kernel_modules')}
Benutzerdefinierte Dateiüberwachungen
${asArray(p.custom).map(auditWatchRow).join('')}
`);$('#audAdd').onclick=()=>{$('#audWatches').insertAdjacentHTML('beforeend',auditWatchRow({permissions:'wa',key:'dockwatch-custom'}));wireAuditRows()};wireAuditRows();$('#audSave').onclick=async()=>{const body={identity_files:$('#audIdentity').checked,sudoers:$('#audSudo').checked,ssh:$('#audSSH').checked,docker:$('#audDocker').checked,systemd:$('#audSystemd').checked,kernel_modules:$('#audModules').checked,custom:$$('.audWatch').map(r=>({path:r.querySelector('.audPath').value.trim(),permissions:r.querySelector('.audPerms').value.trim(),key:r.querySelector('.audKey').value.trim()})).filter(x=>x.path)};const btn=$('#audSave');setBusy(btn,true,'Wird geladen…');try{const out=await api(`/api/security/auditd${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Audit-Regeln geladen');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}}
function auditWatchRow(w={}){return ``}
function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())}
window.addEventListener('resize',terminalResize);
function modal(html,className=''){const classes=['modal',...String(className||'').split(/\s+/).filter(Boolean)].join(' ');$('#modalRoot').innerHTML=`
${html}
`;$$('[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));