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}; async function api(path,opt={}){let r;try{r=await fetch(path,{headers:{'Content-Type':'application/json',...(opt.headers||{})},...opt})}catch(e){if(e?.name!=='AbortError')setApiState(false,e.message);throw e}setApiState(true);if(r.status===401){location='/auth/login';throw Error('Nicht angemeldet')}const txt=await r.text();if(!r.ok)throw Error(txt||r.statusText);if(!txt)return null;try{return JSON.parse(txt)}catch{throw Error('Ungültige JSON-Antwort vom Server')}} function setApiState(ok,msg=''){const e=$('#apiState');if(!e)return;e.classList.toggle('offline',!ok);e.title=ok?'API erreichbar':('API nicht erreichbar'+(msg?': '+msg:''));const t=e.querySelector('span');if(t)t.textContent=ok?'API':'Offline'} function applyPreferences(){const theme=localStorage.getItem('dockwatch:theme')||'dark';document.documentElement.dataset.theme=theme;document.body.classList.toggle('sidebar-collapsed',localStorage.getItem('dockwatch:sidebar')==='collapsed'&&innerWidth>860);const t=$('#themeToggle');if(t)t.textContent=theme==='light'?'☾':'☀'} function toggleTheme(){const next=(document.documentElement.dataset.theme||'dark')==='dark'?'light':'dark';localStorage.setItem('dockwatch:theme',next);applyPreferences()} function teardownInteractive(){stopLiveLogs();closeTerminal()} function setBusy(btn,on,label=''){if(!btn)return;btn.disabled=!!on;btn.classList.toggle('busy',!!on);if(on){if(!btn.dataset.oldText)btn.dataset.oldText=btn.textContent;if(label)btn.textContent=label}else if(btn.dataset.oldText){btn.textContent=btn.dataset.oldText;delete btn.dataset.oldText}} function toast(msg){const e=$('#toast');e.textContent=msg;e.style.display='block';clearTimeout(e._t);e._t=setTimeout(()=>e.style.display='none',4200)} function roleOK(min='operator'){return state.me.role==='admin'||(min==='operator'&&state.me.role==='operator')} function qnode(){return state.node?`?node_id=${state.node}`:''} function joinQ(base,extra){return base+(base.includes('?')?'&':'?')+extra} function badge(s){return `${esc(s||'unknown')}`} function 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'):'Local Docker'} async function init(){applyPreferences();state.me=await api('/api/me');const [sysR,nodesR]=await Promise.allSettled([api('/api/system'),api('/api/nodes')]);state.system=sysR.status==='fulfilled'?sysR.value:null;state.nodes=nodesR.status==='fulfilled'&&Array.isArray(nodesR.value)?nodesR.value:[];renderUser();renderNodePicker();wireShell();await refreshData(true);navigate('dashboard')} function renderUser(){const name=state.me.name||state.me.email||'User';$('#userName').textContent=name;$('#userRole').textContent=state.me.role;$('#avatar').textContent=name[0]?.toUpperCase()||'U';const b=state.system?.build;if($('#buildVersion'))$('#buildVersion').textContent=b?`v${b.version} · ${String(b.commit||'dev').slice(0,8)}`:'Dockwatch';const adminOnly=$$('#nav button[data-view="activity"],#nav button[data-view="notifications"],#nav button[data-view="security"]');adminOnly.forEach(e=>e.hidden=state.me.role!=='admin')} function renderNodePicker(){const p=$('#globalNode');p.innerHTML=`${state.nodes.map(n=>``).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}} function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser offline'));setInterval(()=>{if(!document.hidden)refreshData(false).catch(()=>{})},20000)} function navigate(v){if(state.dirty&&state.view==='stacks'&&v!=='stacks'&&!confirm('Ungespeicherte Stack-Änderungen verlassen?'))return;if(v!==state.view)teardownInteractive();state.view=v;document.body.classList.remove('sidebar-mobile-open');$$('#nav button').forEach(b=>b.classList.toggle('active',b.dataset.view===v));render()} async function refreshData(force=false){if(state.refreshing)return;state.refreshing=true;try{const [stR,moR,svR]=await Promise.allSettled([api('/api/stacks'+qnode()),api('/api/monitors'),api('/api/services')]);const errs=[];if(stR.status==='fulfilled')state.stacks=Array.isArray(stR.value)?stR.value:[];else errs.push('Stacks: '+stR.reason.message);if(moR.status==='fulfilled')state.monitors=Array.isArray(moR.value)?moR.value:[];else errs.push('Monitors: '+moR.reason.message);if(svR.status==='fulfilled')state.services=Array.isArray(svR.value)?svR.value:[];else errs.push('Services: '+svR.reason.message);$('#navStackCount').textContent=state.stacks.length;$('#navMonitorCount').textContent=state.monitors.length;if(force||!state.dirty){if(state.stack?.name){const n=state.stacks.find(x=>x.name===state.stack.name);if(!n&&stR.status==='fulfilled')state.stack=null}if(state.monitor&&moR.status==='fulfilled')state.monitor=state.monitors.find(m=>m.id===state.monitor.id)||state.monitor}if(errs.length&&force)toast(errs.join(' · '))}finally{state.refreshing=false}} function render(){({dashboard:renderDashboard,stacks:renderStacks,monitors:renderMonitors,services:renderServices,statuspages:renderStatusPages,maintenance:renderMaintenance,nodes:renderNodes,containers:renderDockerResource,images:renderDockerResource,volumes:renderDockerResource,networks:renderDockerResource,git:renderGit,notifications:renderNotifications,security:renderSecurity,activity:renderActivity}[state.view]||renderDashboard)()} function pageHead(title,sub,actions=''){return `

${esc(title)}

${esc(sub)}

${actions}
`} function renderDashboard(){setCrumb('Dashboard');const running=state.stacks.filter(s=>s.status==='running').length,down=state.monitors.filter(m=>m.status==='down').length,up=state.monitors.filter(m=>m.status==='up').length,svcDown=state.services.filter(s=>s.status==='down').length,svcUp=state.services.filter(s=>s.status==='up').length;$('#content').innerHTML=`${pageHead('Dashboard',nodeName()+' · Docker & Uptime overview','Ctrl+S speichert Stacks')}
Compose stacks${state.stacks.length}
${running} running
Monitors up${up}
${state.monitors.length} configured
Monitor incidents${down}
current probe failures
Services${svcUp}/${state.services.length}
${svcDown} degraded

Compose stacks

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

Uptime monitors

${monitorTable(state.monitors.slice(0,8))}
`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))} function stackTable(items){if(!items.length)return'
No compose stacks found.
';return `${items.map(s=>``).join('')}
NameStatusServicesImages
▱${esc(s.name)}
${badge(s.status)}${(s.services||[]).length}${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}
`} function monitorTable(items){if(!items.length)return'
No monitors configured.
';return `${items.map(m=>``).join('')}
NameStatusTypeUptime 24h
${esc(m.name)}
${esc(m.target)}
${badge(m.status)}${esc(m.type.toUpperCase())}${Number(m.uptime_24h||0).toFixed(2)}%
`} function renderStacks(){setCrumb(`Docker / Compose Stacks / ${nodeName()}`);const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Compose stacks','Edit, deploy and operate multi-container applications.',actions)}
${renderStackList(state.stacks)}
${state.stack?stackDetailHTML(state.stack):'
▱
Select a stack or create a new one.
'}
`;$('#newStack')?.addEventListener('click',newStack);$('#stackRefresh').onclick=async()=>{if(state.dirty)return toast('Editor contains unsaved changes.');await refreshData(true);renderStacks()};$('#stackSearch').oninput=e=>$('#stackList').innerHTML=renderStackList(state.stacks.filter(s=>s.name.toLowerCase().includes(e.target.value.toLowerCase())));wireStackList();if(state.stack)wireStackDetail()} function renderStackList(items){return items.map(s=>`
${esc(s.name)}${badge(s.status)}
${(s.services||[]).length} services · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'not deployed')}
`).join('')||'
No stacks
'} function wireStackList(){$$('[data-stack]').forEach(x=>x.onclick=()=>openStack(x.dataset.stack))} async function openStack(name){if(!roleOK())return toast('Stack configuration and logs require Operator access.');if(state.dirty&&state.stack?.name!==name&&!confirm('Ungespeicherte Änderungen verwerfen?'))return;if(state.stack?.name!==name)teardownInteractive();try{state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);setDirty(false);state.view='stacks';renderStacks()}catch(e){toast(e.message)}} function newStack(){if(state.dirty&&!confirm('Aktuellen Entwurf verwerfen?'))return;teardownInteractive();const draft=localStorage.getItem(draftKey('new'));let st={name:'',status:'new',services:[],compose:`services: app: image: nginx:alpine restart: unless-stopped `,env:'',secrets:[],env_files:[],configs:[]};if(draft){try{const d=JSON.parse(draft);if(confirm('Gespeicherten lokalen Stack-Entwurf wiederherstellen?'))st={...st,...d}}catch{}}state.stack=st;setDirty(false);renderStacks()} function stackDetailHTML(st){const sv=st.services||[];return `
▱

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

${st.name?esc(nodeName()):'Draft · not deployed'}
${badge(st.status||'new')}
${st.name?``:''}
${composeTab(st)}
`} function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'
A local unsaved draft exists for this stack.
':''}
Full Compose Designer parsing…
compose.yaml Source of truth
Visual editor all fields · AST patch mode
Parsing Compose…
Every present Compose value is editable as string, number, boolean, null, map or array.Current spec fields are suggested; x-* and future fields remain editable too.Invalid YAML pauses visual sync without replacing your source.
`} const COMPOSE_SERVICE_FIELDS=['annotations','attach','build','blkio_config','cpu_count','cpu_percent','cpu_shares','cpu_period','cpu_quota','cpu_rt_runtime','cpu_rt_period','cpus','cpuset','cap_add','cap_drop','cgroup','cgroup_parent','command','configs','container_name','credential_spec','depends_on','deploy','develop','device_cgroup_rules','devices','dns','dns_opt','dns_search','domainname','driver_opts','entrypoint','env_file','environment','expose','extends','external_links','extra_hosts','gpus','group_add','healthcheck','hostname','image','init','ipc','isolation','labels','label_file','links','logging','mac_address','mem_limit','mem_reservation','mem_swappiness','memswap_limit','models','network_mode','networks','oom_kill_disable','oom_score_adj','pid','pids_limit','platform','ports','post_start','pre_start','pre_stop','privileged','profiles','provider','pull_policy','read_only','restart','runtime','scale','secrets','security_opt','shm_size','stdin_open','stop_grace_period','stop_signal','storage_opt','sysctls','tmpfs','tty','ulimits','use_api_socket','user','userns_mode','uts','volumes','volumes_from','working_dir']; const COMPOSE_TOP_FIELDS=['name','include','services','models','networks','volumes','secrets','configs','version']; const COMPOSE_FIELD_HINTS={image:'Container image',build:'Build configuration',command:'Override image command',entrypoint:'Override image entrypoint',environment:'Environment variables',env_file:'Environment files',ports:'Published ports',expose:'Exposed container ports',volumes:'Mounts and named volumes',networks:'Network attachments',depends_on:'Service dependencies',healthcheck:'Container health check',deploy:'Deployment constraints and resources',develop:'Compose watch/development settings',secrets:'Granted secrets',configs:'Granted configs',logging:'Logging driver/options',restart:'Container restart policy',pull_policy:'Image pull policy',provider:'External provider configuration',post_start:'Post-start lifecycle hooks',pre_start:'Pre-start lifecycle hooks',pre_stop:'Pre-stop lifecycle hooks',models:'AI model attachments'}; const COMPOSE_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,composePendingPatch=null,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;iVisual editor paused

${esc(e.message)}

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

`}} function renderComposeSections(){const host=$('#composeSections');if(!host)return;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes','Volumes'],['configs','Configs'],['secrets','Secrets'],['models','Models'],['include','Include']];host.innerHTML=items.map(([k,l])=>``).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})} 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 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(['networks','volumes','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'
Nothing configured in this section.
';wireComposeTree()} function composeNamedObjectEditor(v,path,label,suggestions){if(!v||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,label);const rows=Object.entries(v).map(([k,val])=>`
${esc(k)} ${esc(label)}
${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}
`).join('');return `${rows}
`} function composeMapEditor(v,path,opt={}){if(v===null||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,'value');const entries=Object.entries(v);const serviceRoot=!!opt.serviceRoot;const suggestions=opt.rootProject?COMPOSE_TOP_FIELDS:composeSuggestions(path,opt);const listId='candidates-'+Math.abs(pathKey(path).split('').reduce((a,c)=>((a<<5)-a+c.charCodeAt(0))|0,0));return `
${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}
${suggestions.length?`${suggestions.map(x=>``).join('')}`:''}
`} function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `
${esc(k)}${hint?`${esc(hint)}`:''}${k.startsWith('x-')?'extension':''}
${t}
${composeValueEditor(val,path,k,{serviceRoot:false})}
`} function composeValueEditor(v,path,label,opt={}){const t=typeOfValue(v),pa=pathAttr(path);if(t==='map')return composeMapEditor(v,path,opt);if(t==='array')return `
${v.map((x,i)=>`
#${i+1}
${composeValueEditor(x,[...path,String(i)],label)}
`).join('')}
`;if(t==='boolean')return `
${typeSwitcher(path,t)}
`;if(t==='null')return `
null${typeSwitcher(path,t)}
`;if(t==='number')return `
${typeSwitcher(path,t)}
`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `
${multiline?``:``}${typeSwitcher(path,'string')}
`} function typeSwitcher(path,t){return ``} function 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)};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Enter a field name.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Enter a name.');queueComposePatch([...p,k],{})})} function debounceFn(fn,ms){let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}} async function queueComposePatch(path,value,del=false){composePendingPatch={path,value,delete:del};if(composePatchBusy)return;while(composePendingPatch){const p=composePendingPatch;composePendingPatch=null;composePatchBusy=true;try{const ta=$('#composeText');if(!ta)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();await parseComposeVisual()}catch(e){toast('Compose patch failed: '+e.message);await parseComposeVisual()}finally{composePatchBusy=false}}} function addComposeService(){composeSection='services';queueComposePatch(['services','service'+(((composeVisualModel?.services&&Object.keys(composeVisualModel.services).length)||0)+1)],{image:'nginx:alpine'})} function envTab(st){return `
`} function secretsTab(st){return `
Secret values are written with mode 0600. Use Compose secrets: with file: ./secrets/name. Existing values are visible only to operators who can edit the stack.
${(st.secrets||[]).map(secretRow).join('')}
`} function secretRow(s={}){return `
`} function managedFilesTab(kind,files,title,help){return `
${title} · ${help} Managed files are validated together with the stack.
${files.map(f=>managedFileRow(kind,f)).join('')}
`} function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `
`} function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.managedName').value.trim(),content:r.querySelector('.managedContent').value})).filter(f=>f.name)} function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
This stack has no running containers yet.
';return `
${sv.map(v=>`
${esc(v.service||v.name)}${badge(v.state||v.status)}
Image${esc(v.image||'—')}Ports${esc(v.ports||'—')}Command${esc(v.command||'—')}
`).join('')}
`} function logsTab(){return `
idle
Select “Load” or “Follow”.
`} function graphTab(){return `
Load the normalized Compose dependency graph.
`} function permissionsTab(){return `
Checks expected application UID/GID, host ownership, mode bits and ACL hints.
Run the analysis to review writable bind mounts for every created service.
`} function updatesTab(){return `
Compare installed image digests with registry manifests.
`} function consoleTab(sv){return `
Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.
Terminal disconnected.
`} function dangerTab(st){return st.name?`
Safe delete is the default. It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.
`:'
Save the stack first.
'} function wireStackDetail(){const root=$('#stackDetail');root.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x===b));['compose','env','envfiles','secrets','configs','services','permissions','graph','updates','logs','console','danger'].forEach(t=>{const e=$(`#tab-${t}`);if(e)e.hidden=t!==b.dataset.tab})});const mark=()=>{setDirty(true);saveDraft()};let composeTimer;root.addEventListener('input',e=>{if(e.target.matches('#composeText,#envText,#stackName,.secName,.secContent,.managedName,.managedContent'))mark();if(e.target.matches('#composeText')){clearTimeout(composeTimer);composeTimer=setTimeout(parseComposeVisual,220)}});$('#addComposeService')?.addEventListener('click',addComposeService);parseComposeVisual();$('#composeExpandAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=true)});$('#composeCollapseAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=false)});$('#addSecret')?.addEventListener('click',()=>{$('#secretList').insertAdjacentHTML('beforeend',secretRow());wireSecretRemovers();mark()});$('#addenvfile')?.addEventListener('click',()=>{$('#envfileList').insertAdjacentHTML('beforeend',managedFileRow('envfile'));wireManagedRemovers();mark()});$('#addconfig')?.addEventListener('click',()=>{$('#configList').insertAdjacentHTML('beforeend',managedFileRow('config'));wireManagedRemovers();mark()});wireSecretRemovers();wireManagedRemovers();$('#saveStack').onclick=saveStack;$$('[data-act]').forEach(b=>b.onclick=()=>stackAction(b.dataset.act));$('#loadLogs')?.addEventListener('click',loadLogs);$('#liveLogs')?.addEventListener('click',startLiveLogs);$('#stopLogs')?.addEventListener('click',stopLiveLogs);$('#pauseLogs')?.addEventListener('click',togglePauseLogs);$('#downloadLogs')?.addEventListener('click',downloadLogs);$('#logAutoScroll')?.addEventListener('change',e=>state.logAutoScroll=e.target.checked);$('#logFilter')?.addEventListener('input',filterLogs);$('#openTerminal')?.addEventListener('click',openTerminal);$('#closeTerminal')?.addEventListener('click',closeTerminal);$('#loadGraph')?.addEventListener('click',loadGraph);$('#loadPermissions')?.addEventListener('click',loadStackPermissions);$('#checkUpdates')?.addEventListener('click',loadImageUpdates);$('#deleteStack')?.addEventListener('click',()=>deleteStack(false));$('#purgeStack')?.addEventListener('click',()=>deleteStack(true));$('#restoreDraft')?.addEventListener('click',restoreDraft);$('#discardDraft')?.addEventListener('click',()=>{clearDraft(state.stack.name);renderStacks()})} function wireSecretRemovers(){$$('.removeSecret').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})} function wireManagedRemovers(){$$('.removeManaged').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})} function restoreDraft(){try{const d=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('Draft could not be restored.')}} async function saveStack(){const name=$('#stackName')?.value.trim();if(!name)return toast('Stack name is required.');const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value.trim(),content:r.querySelector('.secContent').value})).filter(s=>s.name);const env_files=collectManaged('.envfilerow'),configs=collectManaged('.configrow');try{await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`,{method:'PUT',body:JSON.stringify({compose:$('#composeText').value,env:$('#envText')?.value||state.stack.env||'',secrets,env_files,configs})});clearDraft(name);setDirty(false);toast('Stack saved and Compose validated.');state.stack=await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}`);await refreshData(false);renderStacks()}catch(e){toast(e.message)}} async function stackAction(action){if(!state.stack?.name)return;try{const r=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/actions/${action}${qnode()}`,{method:'POST'});const out=$('#actionOut');out.style.display='block';out.textContent=r.output||'OK';toast(`${action} completed`);await refreshData(false);state.stack=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}${qnode()}`);renderStacks()}catch(e){toast(e.message)}} async function deleteStack(purge=false){const name=state.stack?.name;if(!name)return;if(purge){const typed=prompt(`FULL PURGE deletes the entire stack folder, including unrelated files or bind-mount data.\n\nType ${name} to continue:`);if(typed!==name)return}else if(!confirm(`Delete the Dockwatch-managed definition for ${name}? Containers are not automatically removed and unrelated files are preserved.`))return;try{await api(`/api/stacks/${encodeURIComponent(name)}${qnode()}${state.node?'&':'?'}purge=${purge?'true':'false'}`,{method:'DELETE'});state.stack=null;setDirty(false);clearDraft(name);await refreshData(true);renderStacks();toast(purge?'Stack folder purged.':'Stack definition deleted safely.')}catch(e){toast(e.message)}} async function loadLogs(){try{const r=await api(joinQ(`/api/stacks/${encodeURIComponent(state.stack.name)}/logs${qnode()}`,'tail=500'));state.logBuffer=r.output||'';state.logPaused=false;renderLogBuffer();setLogState('loaded')}catch(e){toast(e.message)}} function startLiveLogs(){stopLiveLogs();const url=joinQ(`/api/stacks/${encodeURIComponent(state.stack.name)}/logs${qnode()}`,'tail=200&live=true');state.logBuffer='';state.logPaused=false;state.logStream=new EventSource(url);setLogState('live');state.logStream.onmessage=e=>{let line;try{line=JSON.parse(e.data)}catch{line=e.data}state.logBuffer+=(line+'\n');if(state.logBuffer.length>1048576)state.logBuffer=state.logBuffer.slice(-1048576);if(!state.logPaused)renderLogBuffer()};state.logStream.onerror=()=>setLogState('reconnecting')} function stopLiveLogs(){state.logStream?.close();state.logStream=null;setLogState('stopped')} function filterLogs(){renderLogBuffer()} function renderLogBuffer(){const b=$('#logBox');if(!b)return;const q=$('#logFilter')?.value.toLowerCase()||'',raw=state.logBuffer||'';b.textContent=q?raw.split('\n').filter(l=>l.toLowerCase().includes(q)).join('\n'):raw;if(state.logAutoScroll)b.scrollTop=b.scrollHeight} function setLogState(v){const e=$('#logState');if(!e)return;e.textContent=v;e.className='logstate '+(v==='live'?'live':v==='reconnecting'?'reconnecting':v==='paused'?'paused':'')} function togglePauseLogs(){state.logPaused=!state.logPaused;const b=$('#pauseLogs');if(b)b.textContent=state.logPaused?'▶ Resume':'Ⅱ Pause';setLogState(state.logPaused?'paused':(state.logStream?'live':'loaded'));if(!state.logPaused)renderLogBuffer()} function downloadLogs(){const blob=new Blob([state.logBuffer||''],{type:'text/plain;charset=utf-8'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=`${state.stack?.name||'dockwatch'}-${new Date().toISOString().replace(/[:.]/g,'-')}.log`;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)} async function runExec(){const service=$('#execService').value,command=$('#execCmd').value;if(!service||!command)return;$('#execOut').textContent=`$ ${command}\n`;try{const r=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/exec${qnode()}`,{method:'POST',body:JSON.stringify({service,command})});$('#execOut').textContent+=r.output||''}catch(e){$('#execOut').textContent+=`ERROR: ${e.message}`}} function renderMonitors(){setCrumb('Observability / Probes');const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Probes','Uptime, latency, Docker state and maintenance across your environments.',actions)}
${monitorListHTML(state.monitors)}
${state.monitor?monitorDetailHTML(state.monitor,state.checks):'
♡
Select a monitor to inspect uptime and latency.
'}
`;$('#newMonitor')?.addEventListener('click',()=>monitorModal());$('#monSearch').oninput=e=>$('#monList').innerHTML=monitorListHTML(state.monitors.filter(m=>(m.name+' '+m.target).toLowerCase().includes(e.target.value.toLowerCase())));$('#monRefresh').onclick=()=>refreshData(true).then(renderMonitors);wireMonitorList();if(state.monitor)wireMonitorDetail()} function monitorListHTML(ms){return ms.map(m=>`
${esc(m.name)}${badge(m.status)}
${esc(m.type.toUpperCase())} · ${esc(m.target)}
${heartbeatBars([],m.status,24)}
`).join('')||'
No monitors
'} function wireMonitorList(){$$('[data-mon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.mon)))} async function openMonitor(id){try{const [m,c]=await Promise.all([api(`/api/monitors/${id}`),api(`/api/monitors/${id}/checks?limit=80`)]);state.monitor=m;state.checks=Array.isArray(c)?c:[];state.view='monitors';renderMonitors()}catch(e){toast(e.message)}} function heartbeatBars(checks,status,n=48){if(!checks?.length)return Array.from({length:n},()=>``).join('');const a=[...checks].reverse().slice(-n);return Array.from({length:n-a.length},()=>'').join('')+a.map(c=>``).join('')} function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'
No heartbeat data yet.
';const max=Math.max(1,...a.map(x=>x.latency_ms)),pts=a.map((x,i)=>`${(i/(Math.max(1,a.length-1))*100).toFixed(2)},${(95-(x.latency_ms/max)*80).toFixed(2)}`).join(' ');return ``} function monitorDetailHTML(m,c){const avg=c?.length?Math.round(c.reduce((a,x)=>a+x.latency_ms,0)/c.length):0,ok=c?.filter(x=>x.ok).length||0,ratio=c?.length?ok/c.length*100:0;return `
♡

${esc(m.name)}

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

Response time

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

Configuration

Type${esc(m.type.toUpperCase())}Method${esc(m.method||'GET')}
Expected status${m.expected_min}–${m.expected_max}Timeout${m.timeout_ms} ms
Keyword${esc(m.keyword||'—')}Probe environment${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / local'}
${roleOK()?'
':''}
`} function wireMonitorDetail(){$('#checkMon')?.addEventListener('click',checkMonitorNow);$('#editMon')?.addEventListener('click',()=>monitorModal(state.monitor));$('#pauseMon')?.addEventListener('click',()=>monitorAction('pause'));$('#resumeMon')?.addEventListener('click',()=>monitorAction('resume'));$('#maintMon')?.addEventListener('click',()=>maintenanceModal(state.monitor));$('#clearMaint')?.addEventListener('click',()=>clearMaintenance(state.monitor.id));$('#deleteMon')?.addEventListener('click',deleteMonitor)} async function checkMonitorNow(){const b=$('#checkMon');setBusy(b,true,'Checking');try{const c=await api(`/api/monitors/${state.monitor.id}/check`,{method:'POST'});toast(c.ok?`Check OK · ${c.latency_ms} ms`:`Check failed · ${c.message}`);await refreshData(false);await openMonitor(state.monitor.id)}catch(e){toast(e.message)}finally{setBusy(b,false)}} function monitorModal(m=null){const isEdit=!!m;modal(`

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

`);$('#mfType').value=m?.type||'http';$('#mfNode').value=m?.node_id||'';$('#mfService').value=m?.service_id||'';$('#mfInterval').value=String(m?.interval_seconds||60);$('#mfMethod').value=m?.method||'GET';const syncFields=()=>{const t=$('#mfType').value,isHTTP=t==='http',isDocker=t==='docker';['mfMethod','mfMin','mfMax','mfKeyword','mfHeaders','mfBody'].forEach(id=>{const e=$('#'+id);if(e?.closest('.field'))e.closest('.field').style.display=isHTTP?'':'none'});['mfInvert','mfTLS'].forEach(id=>{const e=$('#'+id);if(e?.closest('.switch'))e.closest('.switch').style.display=isHTTP?'':'none'});const he=$('#mfHealthy');if(he?.closest('.switch'))he.closest('.switch').style.display=isDocker?'':'none';const target=$('#mfTarget');if(target)target.placeholder=isHTTP?'https://example.com':t==='tcp'?'host:port':t==='dns'?'example.com':'container-name-or-id'};const loadDockerTargets=async()=>{syncFields();if($('#mfType').value!=='docker')return;try{const nid=$('#mfNode').value;const rows=await api('/api/docker/containers'+(nid?`?node_id=${nid}`:''));$('#dockerTargets').innerHTML=asArray(rows).map(x=>``).join('')}catch{}};$('#mfType').onchange=loadDockerTargets;$('#mfNode').onchange=loadDockerTargets;loadDockerTargets();$('#saveMonitor').onclick=()=>saveMonitor(m?.id)} async function saveMonitor(id){const enabled=state.monitor?.id===id?state.monitor.enabled:true;const body={name:$('#mfName').value,type:$('#mfType').value,target:$('#mfTarget').value,node_id:$('#mfNode').value?Number($('#mfNode').value):null,service_id:$('#mfService').value?Number($('#mfService').value):null,interval_seconds:Number($('#mfInterval').value),timeout_ms:Number($('#mfTimeout').value),expected_min:Number($('#mfMin').value),expected_max:Number($('#mfMax').value),method:$('#mfMethod').value,headers_json:$('#mfHeaders').value||'{}',body:$('#mfBody').value,keyword:$('#mfKeyword').value,invert_keyword:$('#mfInvert').checked,ignore_tls:$('#mfTLS').checked,require_healthy:$('#mfHealthy').checked,enabled};try{const m=await api(id?`/api/monitors/${id}`:'/api/monitors',{method:id?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);state.monitor=m;state.checks=id?await api(`/api/monitors/${id}/checks?limit=80`):[];renderMonitors();toast(id?'Monitor updated.':'Monitor created.')}catch(e){toast(e.message)}} async function monitorAction(a){try{await api(`/api/monitors/${state.monitor.id}/${a}`,{method:'POST'});await openMonitor(state.monitor.id);toast(a==='pause'?'Monitor paused.':'Monitor resumed.')}catch(e){toast(e.message)}} function maintenanceModal(m){modal(`

Maintenance · ${esc(m.name)}

Checks are suppressed during maintenance and the monitor is shown as maintenance instead of down.
`);$('#startMaint').onclick=()=>startMaintenance(m.id)} async function startMaintenance(id){const mode=$('#maintMode').value;let until=null;if(mode==='custom'){const v=$('#maintUntil').value;if(v)until=Math.floor(new Date(v).getTime()/1000)}else if(mode!=='manual'){until=Math.floor(Date.now()/1000)+Number(mode.replace('h',''))*3600}try{await api(`/api/monitors/${id}/maintenance`,{method:'POST',body:JSON.stringify({until,note:$('#maintNote').value})});closeModal();await refreshData(true);await openMonitor(id);toast('Maintenance started.')}catch(e){toast(e.message)}} async function clearMaintenance(id){await api(`/api/monitors/${id}/maintenance`,{method:'DELETE'});await refreshData(true);await openMonitor(id);toast('Maintenance ended.')} async function deleteMonitor(){if(!confirm(`Delete monitor ${state.monitor.name} and its heartbeat history?`))return;await api(`/api/monitors/${state.monitor.id}`,{method:'DELETE'});state.monitor=null;state.checks=[];await refreshData(true);renderMonitors()} async function renderServices(){setCrumb('Observability / Services');const actions=roleOK()?'':'';$('#content').innerHTML=`${pageHead('Services','Group probes into user-facing services. One failed probe makes the whole service fail.',actions)}
${state.services.length?`${state.services.map(g=>``).join('')}
ServiceStatusProbes
${esc(g.name)}
${esc(g.description||'')}
${badge(g.status)}${(g.monitors||[]).length}
${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'No probes assigned'}
${roleOK()?` `:''}
`:'
No services configured. Create a service and assign probes to it.
'}
`;$('#newService')?.addEventListener('click',()=>serviceModal());$$('[data-sedit]').forEach(b=>b.onclick=()=>serviceModal(state.services.find(x=>x.id===Number(b.dataset.sedit))));$$('[data-sdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete service? Probes become ungrouped.')){await api('/api/services/'+b.dataset.sdel,{method:'DELETE'});await refreshData(true);renderServices()}})} function serviceModal(x=null){const selected=new Set((x?.monitors||[]).map(m=>m.id));modal(`

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

${state.monitors.map(m=>``).join('')||'No probes configured.'}
A probe can belong to one service. Assigning it here moves it from a previous service. One DOWN probe makes this service DOWN.
`);$('#svcSave').onclick=async()=>{const body={name:$('#svcName').value,description:$('#svcDesc').value,monitor_ids:$$('.svcProbe:checked').map(e=>Number(e.value))};try{await api(x?`/api/services/${x.id}`:'/api/services',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);renderServices()}catch(e){toast(e.message)}}} async function renderStatusPages(){setCrumb('Observability / Status Pages');$('#content').innerHTML=`${pageHead('Public status pages','Publish only selected services and their aggregate status.',state.me.role==='admin'?'':'')}
Loading status pages…
`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
NamePublic URLServicesEnabled
${esc(x.name)}
${esc(x.description||'')}
/status/${esc(x.slug)}${(x.service_ids||[]).length}${x.enabled?'Public':'Disabled'}${state.me.role==='admin'?` `:''}
`:'
No public status pages configured.
';$$('[data-pedit]').forEach(b=>b.onclick=()=>statusPageModal(rows.find(x=>x.id===Number(b.dataset.pedit))));$$('[data-pdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete status page?')){await api('/api/status-pages/'+b.dataset.pdel,{method:'DELETE'});renderStatusPages()}})}catch(e){$('#statusPagePanel').innerHTML=`
${esc(e.message)}
`}} function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`

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

${state.services.map(g=>``).join('')||'Create services first.'}
`);$('#pgSave').onclick=async()=>{const body={name:$('#pgName').value,slug:$('#pgSlug').value,description:$('#pgDesc').value,enabled:$('#pgEnabled').checked,service_ids:$$('.pgSvc:checked').map(e=>Number(e.value))};try{await api(x?`/api/status-pages/${x.id}`:'/api/status-pages',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();renderStatusPages()}catch(e){toast(e.message)}}} function renderMaintenance(){setCrumb('Observability / Maintenance');const ms=state.monitors.filter(m=>m.status==='maintenance');$('#content').innerHTML=`${pageHead('Maintenance','Suppress monitoring alerts during planned work.')}

Active maintenance windows

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

Start maintenance

${state.monitors.filter(m=>m.status!=='maintenance').map(m=>``).join('')}
${esc(m.name)}
${esc(m.target)}
${badge(m.status)}
`;$$('[data-endmaint]').forEach(b=>b.onclick=()=>clearMaintenance(Number(b.dataset.endmaint)).then(renderMaintenance));$$('[data-startmaint]').forEach(b=>b.onclick=()=>maintenanceModal(state.monitors.find(m=>m.id===Number(b.dataset.startmaint))))} function renderNodes(){setCrumb('System / Environments');const local=`
◎
Local Docker
Docker socket
${badge('up')}
${esc(state.system?.build?.version||'local')}
Local—`;$('#content').innerHTML=`${pageHead('Environments','Master and remote agents managed from one control plane.',state.me.role==='admin'?'':'')}
${local}${state.nodes.map(n=>``).join('')}
NameStatusConnectionActions
◎
${esc(n.name)}
${esc(n.base_url)}
${n.enabled?'Checking…':badge('paused')}${n.enabled?'Bearer agent':'Disabled'}${state.me.role==='admin'?` `:''}
`;$('#addNode')?.addEventListener('click',()=>nodeModal());$$('[data-editnode]').forEach(b=>b.onclick=()=>nodeModal(state.nodes.find(n=>n.id===Number(b.dataset.editnode))));$$('[data-delnode]').forEach(b=>b.onclick=async()=>{if(confirm('Remove this environment?')){await api('/api/nodes/'+b.dataset.delnode,{method:'DELETE'});const x=await api('/api/nodes');state.nodes=Array.isArray(x)?x:[];renderNodePicker();renderNodes()}});loadNodeHealth()} function nodeModal(x=null){modal(`

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

${x?``:''}
`);$('#saveNode').onclick=async()=>{try{const body={name:$('#nodeName').value,base_url:$('#nodeURL').value,token:$('#nodeToken').value};if(x)body.enabled=$('#nodeEnabled').checked;await api(x?`/api/nodes/${x.id}`:'/api/nodes',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();const rows=await api('/api/nodes');state.nodes=Array.isArray(rows)?rows:[];renderNodePicker();renderNodes()}catch(e){toast(e.message)}}} async function loadNodeHealth(){await Promise.all(state.nodes.filter(n=>n.enabled).map(async n=>{const el=$(`#nodeHealth-${n.id}`);if(!el)return;try{const h=await api(`/api/nodes/${n.id}/health`);state.nodeHealth[n.id]=h;el.innerHTML=`${badge('up')}
v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}
`}catch(e){el.innerHTML=`${badge('down')}
unreachable
`}}))} function resourceTitle(k){return({containers:'Containers',images:'Images',volumes:'Volumes',networks:'Networks'})[k]||k} function renderDockerResource(){const k=state.view,n=resourceTitle(k);setCrumb(`Docker / ${n}`);let create='';if(roleOK()){if(k==='containers')create='';if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
Loading ${n.toLowerCase()}…
`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)} function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''} async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`
${esc(e.message)}
`}} function renderInventoryRows(kind,all){const p=$('#inventoryPanel');if(!p)return;const q=$('#inventorySearch')?.value.toLowerCase()||'',rows=q?all.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):all;const count=$('#inventoryCount');if(count)count.textContent=`${rows.length} / ${all.length}`;if(!rows.length){p.innerHTML='
No matching items found.
';return}const defs={containers:[['Names','Name'],['Image','Image'],['State','State'],['Status','Status'],['Ports','Ports']],images:[['Repository','Repository'],['Tag','Tag'],['ID','Image ID'],['Size','Size'],['CreatedSince','Created']],volumes:[['Name','Name'],['Driver','Driver'],['Mountpoint','Mountpoint'],['Labels','Labels']],networks:[['Name','Name'],['Driver','Driver'],['Scope','Scope'],['IPv6','IPv6'],['Internal','Internal']]};const cols=defs[kind]||Object.keys(rows[0]).slice(0,5).map(k=>[k,k]);p.innerHTML=`${cols.map(c=>``).join('')}${rows.map(r=>{const idx=all.indexOf(r);return `${cols.map((c,i)=>``).join('')}`}).join('')}
${esc(c[1])}Actions
${i===0?`
⬡${esc(r[c[0]]||'—')}
`:esc(r[c[0]]||'—')}
${resourceActions(kind,r,idx)}
`;wireResourceRows(kind)} function resourceActions(kind,r,idx){const inspect=roleOK()?``:'';if(kind==='containers')return `${inspect}${roleOK()?` `:''}`;if(!roleOK())return'';return `${inspect} `} function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]));$$('[data-identity]').forEach(b=>b.onclick=()=>inspectContainerIdentity(window.__inventoryRows[Number(b.dataset.identity)]))} async function dockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action} ${kind}? This can delete Docker resources.`))return;try{const r=await api(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);await loadInventory(kind)}catch(e){toast(e.message)}} function registryLoginModal(){modal(`

Registry login

Credentials are written by Docker CLI to the persistent Docker config on this environment. The password is passed through stdin, not a command-line argument.
`);$('#regSave').onclick=async()=>{const body={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)return toast('Registry, username and password required.');closeModal();await dockerResourceAction('images','login',body)}} function registryLogoutModal(){modal(`

Registry logout

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

Pull image

`);$('#resSave').onclick=async()=>{const name=$('#resName').value.trim();if(!name)return toast('Image reference required.');closeModal();await dockerResourceAction('images','pull',{name})};return}const isNet=kind==='networks';modal(`

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

${isNet?'':''}
`);$('#resSave').onclick=async()=>{const labels={};($('#resLabels').value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).forEach(x=>{const i=x.indexOf('=');if(i<0)labels[x]='';else labels[x.slice(0,i).trim()]=x.slice(i+1).trim()});const body={name:$('#resName').value.trim(),driver:$('#resDriver').value.trim(),labels};if(isNet){body.internal=$('#resInternal').checked;body.attachable=$('#resAttachable').checked}if(!body.name)return toast('Name required.');closeModal();await dockerResourceAction(kind,'create',body)}} async function containerIdentityAudit(){const rows=asArray(window.__inventoryRows);if(!rows.length)return toast('No containers to inspect.');const btn=$('#identityAudit');setBusy(btn,true,'Scanning…');try{const results=[];for(let i=0;i{const id=r.ID||r.Names||r.Name;return api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`).then(v=>({row:r,report:v}))})))}const ok=results.filter(x=>x.status==='fulfilled').map(x=>x.value),failed=results.filter(x=>x.status==='rejected');const rootCount=ok.filter(x=>x.report.runs_as_root===true).length,nonRoot=ok.filter(x=>x.report.runs_as_root===false).length,unknown=ok.length-rootCount-nonRoot;modal(`

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

Containers checked${ok.length}
Root PID 1${rootCount}
Non-root PID 1${nonRoot}
Unknown / failed${unknown+failed.length}
“Root” means the effective UID of PID 1 where Dockwatch could read it. This is a review signal, not proof that the application can safely be converted to non-root.
${ok.map(x=>{const d=x.report;return ``}).join('')}${failed.map((x,i)=>``).join('')}
ContainerUID:GIDAssessmentHost accountBind mounts
${esc(d.container_name||x.row.Names||x.row.ID)}
${esc(d.image||'')}
PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}
bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}
${esc(identityAssessmentLabel(d.root_assessment))}${d.bind_host_user?`${esc(d.bind_host_user.name)}`:(d.host_access?.available&&d.bind_uid>0?'missing':'—')}${asArray(d.bind_mounts).length}
Check ${i+1} failed: ${esc(x.reason?.message||x.reason)}
`)}catch(e){toast(e.message)}finally{setBusy(btn,false)}} function hostAccountSuggestion(name){let n=String(name||'container').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'');if(!/^[a-z_]/.test(n))n='c-'+n;return('dockwatch-'+n).slice(0,31).replace(/-+$/,'')||'dockwatch-app'} function identityAssessmentLabel(v){return ({'non-root':'Non-root','root-high-privilege':'Root + privileged','root-review-docker-socket':'Root · Docker socket','root-review-devices':'Root · devices','root-not-obviously-required':'Root · review recommended','unknown':'Unknown'})[v]||v||'Unknown'} function writeState(v){return v===true?'writable':v===false?'not writable':'unknown'} async function inspectContainerIdentity(r){ const id=r.ID||r.Names||r.Name;if(!id)return; try{ const d=await api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`),uid=d.effective_uid??'—',gid=d.effective_gid??'—'; const root=d.runs_as_root===true?'Yes':d.runs_as_root===false?'No':'Unknown',reasons=asArray(d.root_reasons).map(x=>`
  • ${esc(x)}
  • `).join(''),recs=asArray(d.recommendations).map(x=>`
  • ${esc(x)}
  • `).join(''),binds=asArray(d.bind_mounts),host=d.host_access||{}; window.__identityBindRows=binds.map(x=>({container:id,mount:x})); const bindTable=binds.length?`
    ${binds.map((x,i)=>``).join('')}
    Host pathContainer pathMode / ownerWrite access
    ${esc(x.source)}${esc(x.destination)} ${x.read_only?'ro':'rw'}${esc(x.mode||'—')} · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'
    ACL detected
    ':''}
    ${x.read_only?'read-only':writeState(x.static_writable)}
    ${esc(x.writable_reason||x.ownership_note||'')}
    ${!x.read_only?``:''}
    `:'
    No bind mounts detected.
    '; const bindHost=d.bind_host_user?`${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})`:(host.available&&d.bind_uid>0?'No matching host user':'—'); const canCreate=state.me.role==='admin'&&host.available&&host.management_enabled&&d.host_id_mapping!=='remapped'&&Number.isInteger(d.bind_uid)&&d.bind_uid>0&&!d.bind_host_user; modal(`

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

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

    Why

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

    Recommendations

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

    Host identity

    ${esc(host.message||'Host access unavailable')}
    ${bindHost}
    ${host.configured?`
    Host root: ${esc(host.root||'')} · user creation ${host.management_enabled?'enabled':'off'} · permission repair ${host.permission_management_enabled?'enabled':'off'} · UID mapping ${esc(d.host_id_mapping||'unknown')}
    `:''}
    ${bindTable}
    ${canCreate?'':''}
    `); $$('[data-bindpreview]').forEach(b=>b.onclick=()=>{const x=window.__identityBindRows[Number(b.dataset.bindpreview)];bindPermissionModal(x.container,x.mount.destination)}); if(canCreate)$('#createMatchingHostUser').onclick=()=>hostUserModal(id,d); }catch(e){toast(e.message)} } function hostUserModal(containerID,d){const suggested=hostAccountSuggestion(d.container_name);modal(`

    Create matching host account

    Host change: Dockwatch re-checks the container and derives the bind UID/GID server-side. Numeric IDs cannot be chosen by the browser.
    Container${esc(d.container_name)}
    Bind UID:GID${esc(d.bind_uid)}:${esc(d.bind_gid)}
    Source${esc(d.bind_identity_source||'container identity')}
    `);$('#confirmHostUser').onclick=async()=>{if(!confirm(`Create a local account on ${nodeName()} matching bind identity ${d.bind_uid}:${d.bind_gid}?`))return;const btn=$('#confirmHostUser');setBusy(btn,true,'Creating…');try{const out=await api(`/api/host/users${qnode()}`,{method:'POST',body:JSON.stringify({container_id:containerID,username:$('#hostUsername').value.trim(),group_name:$('#hostGroup').value.trim(),create_home:$('#hostHome').checked})});toast(out.message||'Host account created');closeModal();await inspectContainerIdentity({ID:containerID})}catch(e){toast(e.message);setBusy(btn,false)}}} async function bindPermissionModal(containerID,destination,recursive=false){ try{const p=await api(`/api/docker/containers/${encodeURIComponent(containerID)}/bind-permissions/preview${qnode()}`,{method:'POST',body:JSON.stringify({destination,recursive})});showBindPermissionPreview(p)}catch(e){toast(e.message)} } function showBindPermissionPreview(p){ const canRepair=state.me.role==='admin'&&p.can_repair&&!p.scan_truncated,owner=`${p.owner_uid??'—'}:${p.owner_gid??'—'}`,expected=`${p.expected_uid??'—'}:${p.expected_gid??'—'}`,canCreateUser=state.me.role==='admin'&&p.host_access?.management_enabled&&p.expected_uid>0&&!p.host_user; modal(`

    Bind permission review

    ${esc(p.source)} → ${esc(p.destination)}
    ${esc(expected)}
    ${esc(p.identity_source||'')}
    ${esc(owner)} · ${esc(p.mode||'—')}
    ${p.host_user?`${esc(p.host_user.name)}`:'missing'}
    ${p.host_group?`group ${esc(p.host_group.name)}`:'numeric GID only'}
    ${writeState(p.static_writable)}
    ${esc(p.writable_reason||'')}
    ${writeState(p.runtime_writable)}
    ${esc(p.runtime_note||'')}
    ${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} differ
    ${esc(p.files_scanned||0)} files · ${esc(p.directories_scanned||0)} dirs · ${esc(p.symlinks_skipped||0)} symlinks · ${esc(p.cross_filesystem_skipped||0)} nested filesystems skipped
    ${p.acl_detected?'extended ACL detected':'no extended ACL detected'}
    ${esc(p.acl_note||'')}
    ${p.blocked_reason?`Automatic repair unavailable: ${esc(p.blocked_reason)}
    `:''}${esc(p.recommendation||'')}

    Re-scan / repair scope

    Never applied automatically and never recursively. Leave empty to preserve mode bits.
    ${canCreateUser?'':''}${canRepair?'':''}
    `); $('#rescanBind').onclick=()=>bindPermissionModal(p.container_id,p.destination,$('#bindRecursive').checked); if(canCreateUser)$('#bindCreateUser').onclick=()=>hostUserModal(p.container_id,{container_name:p.container_name,bind_uid:p.expected_uid,bind_gid:p.expected_gid,bind_identity_source:p.identity_source,bind_host_user:p.host_user}); if(canRepair)$('#repairBind').onclick=async()=>{const recursive=$('#bindRecursive').checked,fix=$('#bindFixOwner').checked,mode=$('#bindMode').value.trim();if(recursive&&!p.recursive)return toast('Run a recursive re-scan first so the affected file count is known.');const what=recursive?`${p.entries_ownership_mismatch} entries recursively`:'the bind root only';if(!confirm(`Repair ${what} on ${nodeName()} to ${expected}${mode?` and set top-level mode ${mode}`:''}?`))return;const btn=$('#repairBind');setBusy(btn,true,'Repairing…');try{const out=await api(`/api/host/bind-permissions/repair${qnode()}`,{method:'POST',body:JSON.stringify({container_id:p.container_id,destination:p.destination,recursive,fix_ownership:fix,mode})});toast(out.message||'Bind mount repaired');showBindPermissionPreview(out.after);if(state.stack?.name)setTimeout(loadStackPermissions,0)}catch(e){toast(e.message);setBusy(btn,false)}} } async function loadStackPermissions(){const box=$('#permissionsBox');if(!box||!state.stack?.name)return;box.innerHTML='
    Inspecting containers and bind mounts…
    ';try{const d=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/bind-permissions${qnode()}`),rows=[];asArray(d.containers).forEach(c=>{if(c.error){rows.push({service:c.service,error:c.error});return}asArray(c.report?.bind_mounts).forEach(m=>rows.push({service:c.service,container:c.container_id,report:c.report,mount:m}))});window.__stackBindRows=rows;if(!rows.length){box.innerHTML='
    No created containers or bind mounts found.
    ';return}box.innerHTML=`
    ${rows.map((x,i)=>x.error?``:``).join('')}
    ServiceHost → containerExpectedOwner / modeWrite
    ${esc(x.service)}${esc(x.error)}
    ${esc(x.service||x.report?.container_name)}
    ${esc(x.mount.source)}
    → ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'}
    ${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')}
    ${esc(x.report.bind_identity_source||'')}
    ${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')}${x.mount.read_only?'read-only':writeState(x.mount.static_writable)}${!x.mount.read_only?``:''}
    `;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`
    ${esc(e.message)}
    `}} async function inspectDockerResource(kind,r){const id=kind==='containers'?(r.ID||r.Names||r.Name):kind==='images'?(r.ID||([r.Repository,r.Tag].filter(Boolean).join(':'))):r.Name;if(!id)return;try{const d=await api(`/api/docker/${kind}/${encodeURIComponent(id)}/inspect${qnode()}`),i=d.inspect||{},st=d.stats||{};modal(`

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

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

    ${esc(title)}

    ${esc(text||'OK')}
    `)} 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('Save the stack first.');const service=$('#execService')?.value;if(!service)return toast('No service selected.');const shell=$('#execShell')?.value||'sh';const q=new URLSearchParams({service,shell});if(state.node)q.set('node_id',String(state.node));const out=$('#execOut');out.textContent=`Connecting to ${service}...\n`;terminalWS=new WebSocket(wsURL(`/api/stacks/${encodeURIComponent(state.stack.name)}/terminal?${q}`));terminalWS.onopen=()=>{out.textContent='';out.focus();terminalResize()};terminalWS.onmessage=e=>{try{const m=JSON.parse(e.data);if(m.type==='output'){out.textContent+=stripANSI(m.data);out.scrollTop=out.scrollHeight}else if(m.type==='error'){out.textContent+=`\n[error] ${m.data}\n`}else if(m.type==='exit'){out.textContent+='\n[session ended]\n'}}catch{out.textContent+=stripANSI(e.data)}};terminalWS.onerror=()=>toast('Terminal websocket failed.');terminalWS.onclose=()=>{terminalWS=null};out.onkeydown=terminalKey;out.onpaste=e=>{if(!terminalWS)return;e.preventDefault();terminalWS.send(JSON.stringify({type:'input',data:e.clipboardData.getData('text')}))};} async function loadGraph(){if(!state.stack?.name)return;const box=$('#graphBox');box.innerHTML='
    Resolving Compose model…
    ';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`
    ${esc(e.message)}
    `}} function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='
    No graph nodes.
    ';return}const services=nodes.filter(n=>n.kind==='service'),resources=nodes.filter(n=>n.kind!=='service');const h=Math.max(360,Math.max(services.length,resources.length)*76+50),w=Math.max(760,box.clientWidth||900);const pos={};services.forEach((n,i)=>pos[n.id]={x:140,y:55+i*76});resources.forEach((n,i)=>pos[n.id]={x:w-160,y:55+i*76});const lines=edges.map(e=>{const a=pos[e.from],b=pos[e.to];if(!a||!b)return'';return `${esc(e.kind)}`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `${esc(n.label)}${esc(n.image||n.kind)}`}).join('');box.innerHTML=`${lines}${ns}`} async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='
    Checking registry manifests…
    ';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`${rows.map(r=>``).join('')}
    ServiceImageLocal digestRemote digestStatus
    ${esc(r.service)}${esc(r.image)}${esc(r.local_digest||'—')}${esc(r.remote_digest||'—')}${r.error?`check failed`:r.update?'Update available':'Current'}
    `}catch(e){box.innerHTML=`
    ${esc(e.message)}
    `}} async function renderActivity(){setCrumb('System / Activity');$('#content').innerHTML=`${pageHead('Activity','Persistent audit trail for changes, deployments and monitor transitions.','')}
    Loading audit events…
    `;const load=async()=>{try{const q=$('#actFilter')?.value.trim()||'',rows=asArray(await api('/api/activity?limit=200'+(q?'&action='+encodeURIComponent(q):'')));$('#activityPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
    TimeActorActionResourceStatusDetails
    ${fmtTime(x.created_at)}${esc(x.actor)}${esc(x.action)}${esc(x.resource||'—')}${x.status>=200&&x.status<300?''+x.status+'':''+x.status+''}${esc(JSON.stringify(x.detail||{}))}
    `:'
    No matching audit events.
    '}catch(e){$('#activityPanel').innerHTML=`
    ${esc(e.message)}
    `}};$('#actRefresh').onclick=load;let t;$('#actFilter').oninput=()=>{clearTimeout(t);t=setTimeout(load,300)};load()} async function renderGit(){setCrumb('Docker / Git Stacks');$('#content').innerHTML=`${pageHead('Git stacks','Synchronize Compose stacks from Git and deploy them from signed webhooks.',roleOK()?'':'')}
    Loading Git sources…
    `;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
    StackEnvironmentRepositoryBranchCommitLast syncAuto deploy
    ${esc(x.stack_name)}${x.last_error?`
    ${esc(x.last_error)}
    `:''}
    ${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote'):'Local Docker')}${esc(x.repo_url)}${esc(x.branch)}${esc((x.last_commit||'—').slice(0,12))}${x.last_sync_at?fmtTime(x.last_sync_at):'Never'}${x.auto_deploy?'Yes':'No'}${roleOK()?` `:''}
    `:'
    No Git sources configured.
    ';window.__gitRows=rows;$$('[data-gsync]').forEach(b=>b.onclick=()=>gitSync(Number(b.dataset.gsync)));$$('[data-gedit]').forEach(b=>b.onclick=()=>gitModal(rows.find(x=>x.id===Number(b.dataset.gedit))));$$('[data-gdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete Git source configuration?')){await api('/api/git-sources/'+b.dataset.gdel,{method:'DELETE'});renderGit()}})}catch(e){$('#gitPanel').innerHTML=`
    ${esc(e.message)}
    `}} function gitModal(x=null){modal(`

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

    `);$('#gitNode').value=String(x?.node_id||state.node||0);$('#saveGit').onclick=async()=>{const body={node_id:Number($('#gitNode').value)||null,stack_name:$('#gitStack').value,repo_url:$('#gitRepo').value,branch:$('#gitBranch').value,workdir:$('#gitWorkdir').value,compose_file:$('#gitCompose').value,auto_deploy:$('#gitAuto').checked};try{const r=await api(x?`/api/git-sources/${x.id}`:'/api/git-sources',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();if(!x)showGitSecret(r);renderGit()}catch(e){toast(e.message)}}} function showGitSecret(r){modal(`

    Webhook created

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

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

    `)} async function gitSync(id){try{toast('Git sync started…');await api(`/api/git-sources/${id}/sync`,{method:'POST'});await refreshData(true);renderGit();toast('Git stack synchronized.')}catch(e){toast(e.message);renderGit()}} async function renderNotifications(){setCrumb('Observability / Notifications');$('#content').innerHTML=`${pageHead('Notifications','Send monitor state transitions to Webhook, ntfy, Gotify or SMTP.',state.me.role==='admin'?'':'')}
    Loading providers…
    `;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`${rows.map(x=>``).join('')}
    NameProviderEnabledConfiguration
    ${esc(x.name)}${esc(x.type)}${x.enabled?'Enabled':'Disabled'}${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}${state.me.role==='admin'?` `:''}
    `:'
    No notification providers configured.
    ';$$('[data-ntest]').forEach(b=>b.onclick=async()=>{try{await api(`/api/notifications/${b.dataset.ntest}/test`,{method:'POST'});toast('Test notification sent.')}catch(e){toast(e.message)}});$$('[data-nedit]').forEach(b=>b.onclick=()=>notificationModal(rows.find(x=>x.id===Number(b.dataset.nedit))));$$('[data-ndel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete notification provider?')){await api('/api/notifications/'+b.dataset.ndel,{method:'DELETE'});renderNotifications()}})}catch(e){$('#notifyPanel').innerHTML=`
    ${esc(e.message)}
    `}} function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`
    `;if(type==='webhook')return f('nUrl','URL','url',false,'https://...')+f('nToken','Bearer token','bearer_token',true);if(type==='ntfy')return f('nServer','Server','server',false,'https://ntfy.sh')+f('nTopic','Topic','topic')+f('nToken','Access token','token',true);if(type==='gotify')return f('nServer','Server','server',false,'https://gotify.example.com')+f('nToken','App token','token',true);return f('nHost','SMTP host','host')+f('nPort','Port','port',false,'587')+`
    `+f('nUser','Username','username')+f('nPass','Password','password',true)+f('nFrom','From','from')+f('nTo','To','to',false,'ops@example.com')+``} function notificationModal(x=null){const type=x?.type||'webhook';modal(`

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

    ${notifyConfigFields(type,x?.config||{})}
    `);$('#nType').value=type;if(type==='smtp'&&$('#nSecurity'))$('#nSecurity').value=x?.config?.security||'starttls';$('#nType').onchange=()=>$('#nConfig').innerHTML=notifyConfigFields($('#nType').value,{});$('#nSave').onclick=async()=>{const t=$('#nType').value,c={};if(t==='webhook'){c.url=$('#nUrl').value;c.bearer_token=$('#nToken').value}else if(t==='ntfy'){c.server=$('#nServer').value;c.topic=$('#nTopic').value;c.token=$('#nToken').value}else if(t==='gotify'){c.server=$('#nServer').value;c.token=$('#nToken').value}else{c.host=$('#nHost').value;c.port=$('#nPort').value;c.security=$('#nSecurity').value;c.auth=String($('#nAuth').checked);c.username=$('#nUser').value;c.password=$('#nPass').value;c.from=$('#nFrom').value;c.to=$('#nTo').value;c.skip_verify=String($('#nSkipVerify').checked)}try{await api(x?`/api/notifications/${x.id}`:'/api/notifications',{method:x?'PUT':'POST',body:JSON.stringify({name:$('#nName').value,type:t,config:c,enabled:$('#nEnabled').checked})});closeModal();renderNotifications()}catch(e){toast(e.message)}}} async function renderSecurity(){ setCrumb(`System / Host Security / ${nodeName()}`); if(state.me.role!=='admin'){ $('#content').innerHTML=`${pageHead('Host Security','Administrator access required.')}
    Host security configuration is restricted to administrators.
    `; return } $('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'')}
    Auditing host security posture…
    `; $('#securityRefresh').onclick=renderSecurity; try{ const d=await api('/api/security/status'+qnode()),c=d.capabilities||{},os=d.os||{},managed=d.managed||{}; window.__securityCaps=c; const capClass=!c.enabled?'securityOff':c.allow_changes&&c.executor_available?'securityManage':'securityAudit'; $('#content').innerHTML=`${pageHead('Host Security',`Linux host hardening, configuration and maintenance · ${nodeName()}`,'')}
    HOST SECURITY POSTURE
    ${Number(d.score||0)}/100

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

    ${securityCapabilityPills(c)}
    ${c.reason?`
    Capability: ${esc(c.reason)}
    `:''}
    ${securityComponentCard('firewall','Firewall · nftables','Isolated host INPUT policy. Docker NAT/FORWARD chains remain untouched.',d.firewall,managed.firewall,d.conflicts)} ${securityComponentCard('fail2ban','Fail2Ban','Rate-limit and ban repeated authentication failures using managed jail.d overrides.',d.fail2ban,managed.fail2ban)} ${securityComponentCard('auditd','Linux Audit · auditd','Track changes to identity, SSH, sudo, Docker and selected host paths.',d.auditd,managed.auditd)}

    Security findings

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

    Safety model

    Audit first

    Inspection can be enabled independently from host mutations.

    Explicit mutation opt-in

    Configuration and package installation are separate capabilities.

    Managed drop-ins

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

    Audited changes

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

    `; $('#securityRefresh').onclick=renderSecurity; $$('[data-secinstall]').forEach(b=>b.onclick=()=>securityInstall(b.dataset.secinstall,b)); $$('[data-secaction]').forEach(b=>b.onclick=()=>securityComponentAction(b.dataset.seccomponent,b.dataset.secaction,b)); $('#configure-firewall')?.addEventListener('click',securityFirewallModal); $('#configure-fail2ban')?.addEventListener('click',securityFail2BanModal); $('#configure-auditd')?.addEventListener('click',securityAuditdModal); }catch(e){$('#content').innerHTML=`${pageHead('Host Security',nodeName())}
    ${esc(e.message)}
    `} } function securityCapabilityPills(c){return `host root ${c.host_root_available?'✓':'×'}host namespace ${c.target_verified?'✓':'×'}${c.allow_changes?'manage':'audit only'}packages ${c.allow_package_management?'enabled':'locked'}`} function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host security changes are disabled for this environment"',pkgDisabled=packages?'':'disabled title="Host package management is disabled for this environment"';return `
    ${key==='firewall'?'⛨':key==='fail2ban'?'⊘':'≋'}

    ${esc(title)}

    ${esc(desc)}

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

    ${esc(f.detail)}

    ${f.action?`${esc(f.action)}`:''}
    `).join('')}
    `} async function securityInstall(component,btn){if(!confirm(`Install or upgrade ${component} on ${nodeName()} using the host package manager?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall'})});toast(out.message||'Package operation complete');if(out.output)showOutput(`${component} package operation`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}} async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${action} ${component} on ${nodeName()}?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component} ${action} complete`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}} async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode());firewallEditor(p)}catch(e){toast(e.message)}} function firewallEditor(p){const rules=asArray(p.rules);modal(`

    Managed nftables firewall

    Scope: Dockwatch manages only table inet dockwatch and its INPUT chain. It never flushes the global ruleset and does not alter Docker forwarding/NAT chains.

    Port rules

    ${rules.map(firewallRuleRow).join('')}
    Lockout protection: Apply starts a 90-second rollback timer. You must explicitly keep the rules after confirming that this UI is still reachable. Default DROP requires that you add the management ports/CIDRs you need.
    `);$('#fwDefault').value=p.default_inbound||'accept';$('#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('nftables preview',x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy();if(policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}} function firewallRuleRow(r={}){return `
    `} function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove())} function collectFirewallPolicy(){return {enabled:$('#fwEnabled').checked,default_inbound:$('#fwDefault').value,allow_icmp:$('#fwICMP').checked,trusted_cidrs:$('#fwTrusted').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}} function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`

    ${esc(title)}

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

    Firewall applied · verification window

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

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

    `);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall policy committed.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall rolled back.');closeModal();renderSecurity()}catch(e){toast(e.message)}}} async function securityFail2BanModal(){try{const p=await api('/api/security/fail2ban'+qnode());fail2banEditor(p)}catch(e){toast(e.message)}} function fail2banEditor(p){modal(`

    Fail2Ban policy

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

    Jails

    ${asArray(p.jails).map(fail2banJailRow).join('')}
    `);$('#f2bBackend').value=p.backend||'auto';$('#f2bAdd').onclick=()=>{$('#f2bJails').insertAdjacentHTML('beforeend',fail2banJailRow({enabled:true,backend:'auto'}));wireF2BRows()};wireF2BRows();$('#f2bSave').onclick=async()=>{const body={bantime:$('#f2bBan').value.trim(),findtime:$('#f2bFind').value.trim(),maxretry:Number($('#f2bRetry').value),backend:$('#f2bBackend').value,ignore_ip:$('#f2bIgnore').value.split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),jails:$$('.f2bJail').map(r=>({name:r.querySelector('.f2bName').value.trim(),enabled:r.querySelector('.f2bEnabled').checked,port:r.querySelector('.f2bPort').value.trim(),filter:r.querySelector('.f2bFilter').value.trim(),backend:r.querySelector('.f2bBackend').value,logpath:r.querySelector('.f2bLog').value.trim(),maxretry:Number(r.querySelector('.f2bMax').value)||0})).filter(j=>j.name)};const btn=$('#f2bSave');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/fail2ban${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Fail2Ban applied');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}} function fail2banJailRow(j={}){return `
    `} function wireF2BRows(){$$('.f2bRemove').forEach(b=>b.onclick=()=>b.closest('.f2bJail').remove())} async function securityAuditdModal(){try{const p=await api('/api/security/auditd'+qnode());auditdEditor(p)}catch(e){toast(e.message)}} function auditdEditor(p){const check=(id,label,key)=>``;modal(`

    Linux Audit policy

    Dockwatch writes only /etc/audit/rules.d/90-dockwatch.rules and loads it through augenrules. Presets use file watches rather than broad syscall rules.
    ${check('audIdentity','Identity files (/etc/passwd, shadow, group)','identity_files')}${check('audSudo','sudoers','sudoers')}${check('audSSH','SSH server configuration','ssh')}${check('audDocker','Docker socket and daemon configuration','docker')}${check('audSystemd','systemd unit configuration','systemd')}${check('audModules','Kernel module configuration','kernel_modules')}

    Custom file watches

    ${asArray(p.custom).map(auditWatchRow).join('')}
    `);$('#audAdd').onclick=()=>{$('#audWatches').insertAdjacentHTML('beforeend',auditWatchRow({permissions:'wa',key:'dockwatch-custom'}));wireAuditRows()};wireAuditRows();$('#audSave').onclick=async()=>{const body={identity_files:$('#audIdentity').checked,sudoers:$('#audSudo').checked,ssh:$('#audSSH').checked,docker:$('#audDocker').checked,systemd:$('#audSystemd').checked,kernel_modules:$('#audModules').checked,custom:$$('.audWatch').map(r=>({path:r.querySelector('.audPath').value.trim(),permissions:r.querySelector('.audPerms').value.trim(),key:r.querySelector('.audKey').value.trim()})).filter(x=>x.path)};const btn=$('#audSave');setBusy(btn,true,'Loading…');try{const out=await api(`/api/security/auditd${qnode()}`,{method:'PUT',body:JSON.stringify(body)});toast(out.message||'Audit rules loaded');closeModal();renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}} function auditWatchRow(w={}){return `
    `} function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())} window.addEventListener('resize',terminalResize); function modal(html){$('#modalRoot').innerHTML=`
    `;$$('[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));