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 `
`;$('#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 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)}
${envTab(st)}
${managedFilesTab('envfile',st.env_files||[],'Additional env files','Use these from Compose as env_file: ./envs/app.env.')}
${secretsTab(st)}
${managedFilesTab('config',st.configs||[],'Compose configs','Use these from Compose as 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?'
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,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;iVisual editor paused
${esc(e.message)}
Your YAML source is untouched. Fix the syntax and synchronization resumes automatically.
`}}
function renderComposeSections(){const host=$('#composeSections');if(!host)return;const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),missing=[...new Set(usages.filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];const volumeLabel=`Volumes ${usages.length}${missing.length?` · ${missing.length}!`:''}`;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Models'],['include','Include']];host.innerHTML=items.map(([k,l])=>``).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})}
function sectionValue(){const m=composeVisualModel||{};if(composeSection==='project'){const x={};for(const k of Object.keys(m))if(!['services','networks','volumes','configs','secrets','models','include'].includes(k))x[k]=m[k];return x}return m[composeSection]??(composeSection==='include'?[]:{})}
function sectionPath(){return composeSection==='project'?[]:[composeSection]}
function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const scrollTop=box.scrollTop,v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(composeSection==='volumes'){body=composeVolumeSection(v,base)}else if(['networks','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'
Nothing configured in this section.
';wireComposeTree();wireComposeVolumeReconcile();box.scrollTop=Math.min(scrollTop,Math.max(0,box.scrollHeight-box.clientHeight))}
function composeNamedObjectEditor(v,path,label,suggestions){if(!v||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,label);const rows=Object.entries(v).map(([k,val])=>`${esc(k)}${esc(label)}
Service mounts are compared live with top-level volumes:. Bind mounts are shown here but never turned into named-volume declarations.
${status}${missing.length?'':''}
${rows?`
Service
Type
Source
Target
Access
Top-level
${rows}
`:'
No service mounts are currently configured.
'}${unused.length?`
Declared but currently unused: ${unused.map(n=>`${esc(n)} `).join(' ')}
`:''}${binds.length?`
${binds.length} bind mount${binds.length===1?'':'s'} detected. Bind sources belong to the host filesystem and therefore do not appear as top-level named volumes.
`:''}${other.length?`
${other.length} anonymous/dynamic/special mount${other.length===1?'':'s'} preserved without automatic declaration changes.
`:''}
Top-level volumes Compose declarations
${composeNamedObjectEditor(v,path,'volume',[])}
`}
function wireComposeVolumeReconcile(){if(composeSection!=='volumes')return;$$('[data-vdeclare]').forEach(b=>b.onclick=()=>queueComposePatch(['volumes',b.dataset.vdeclare],{}));$('#syncVolumeDeclarations')?.addEventListener('click',()=>{const declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),names=[...new Set(composeVolumeUsages().filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];if(!names.length)return toast('Named volumes are already aligned.');names.forEach(n=>queueComposePatch(['volumes',n],{}));toast(`${names.length} volume declaration${names.length===1?'':'s'} queued.`)});$$('[data-vremove]').forEach(b=>b.onclick=()=>{const n=b.dataset.vremove;if(confirm(`Remove unused top-level volume declaration ${n}? This does not delete a Docker volume.`))queueComposePatch(['volumes',n],null,true)})}
function 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('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)}}
function composePatchPathKey(path){return JSON.stringify(path.map(String))}
function queueComposePatch(path,value,del=false,opt={}){const item={path,value,delete:del,refresh:opt.refresh!==false},key=composePatchPathKey(path);if(!del){const i=composePatchQueue.findIndex(x=>!x.delete&&composePatchPathKey(x.path)===key);if(i>=0)composePatchQueue[i]=item;else composePatchQueue.push(item)}else composePatchQueue.push(item);void processComposePatchQueue()}
function markComposeSynced(){const status=$('#composeSyncState');if(status){status.className='green';status.textContent=' YAML ↔ Designer synchronized'}renderComposeSections()}
async function processComposePatchQueue(){if(composePatchBusy)return;composePatchBusy=true;try{while(composePatchQueue.length){const p=composePatchQueue.shift();try{const ta=$('#composeText');if(!ta){composePatchQueue=[];break}const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();if(p.refresh)await parseComposeVisual();else markComposeSynced()}catch(e){toast('Compose patch failed: '+e.message);composePatchQueue=[];await parseComposeVisual();break}}}finally{composePatchBusy=false}}
function 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 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'
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)}
`;$('#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 `
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)}`:''}.
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)}
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'?'':'')}
';$$('[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(`
`;$$('[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'?'':'')}
Name
Status
Connection
Actions
${local}${state.nodes.map(n=>`
◎
${esc(n.name)}
${esc(n.base_url)}
${n.enabled?'Checking…':badge('paused')}
${n.enabled?'Bearer agent':'Disabled'}
${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('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(`
`}}))}
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 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 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(`
“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.
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.
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='
`)}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='
`}}
async function renderActivity(){setCrumb('System / Activity');$('#content').innerHTML=`${pageHead('Activity','Persistent audit trail for changes, deployments and monitor transitions.','')}
`}};$('#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()?'':'')}
`);$('#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'?'':'')}
`}
}
function securityCapabilityPills(c){return `host root ${c.host_root_available?'✓':'×'}host namespace ${c.target_verified?'✓':'×'}${c.allow_changes?'manage':'audit only'}packages ${c.allow_package_management?'enabled':'locked'}`}
function securityComponentCard(key,title,desc,st={},managed={},conflicts=[]){const installed=!!st.installed,active=!!st.active,drift=!!st.drift,c=window.__securityCaps||{},manage=!!(c.allow_changes&&c.executor_available),packages=!!(c.allow_package_management&&c.executor_available),manageDisabled=manage?'':'disabled title="Host security changes are disabled for this environment"',pkgDisabled=packages?'':'disabled title="Host package management is disabled for this environment"',canConfigure=manage&&(key==='firewall'||installed);return `
`}
async function securityInstall(component,btn,provider=''){if(component==='firewall'&&!provider)provider=(window.__firewallBackend||{}).selected||'nftables';if(!confirm(`Install or upgrade ${component==='firewall'?provider:component} on ${nodeName()} using the host package manager?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/install${qnode()}`,{method:'POST',body:JSON.stringify({enable:component!=='firewall',provider})});toast(out.message||'Package operation complete');if(out.output)showOutput(`${component} package operation`,out.output);else renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
async function securityComponentAction(component,action,btn){if(['disable','stop'].includes(action)&&!confirm(`${action} ${component} on ${nodeName()}?`))return;setBusy(btn,true,'Working…');try{const out=await api(`/api/security/components/${encodeURIComponent(component)}/actions/${encodeURIComponent(action)}${qnode()}`,{method:'POST',body:'{}'});toast(out.message||`${component} ${action} complete`);await renderSecurity()}catch(e){toast(e.message);setBusy(btn,false)}}
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 model: Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs ufw reset or nft flush ruleset.
${selected==='firewalld'?``:''}
${firewallRuntimeHTML(preview)}
Dockwatch-managed port rules
${rules.map(firewallRuleRow).join('')}
Lockout protection: Apply starts a 90-second rollback timer. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.
${!installed&&selected!=='auto'?``:''}
`,'modal-wide modal-firewall');$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
function firewallRuleRow(r={}){return ``}
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})}
function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,trusted_cidrs:($('#fwTrusted')?.value||'').split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
function showSecurityPreview(title,text,warnings=[],conflicts=[]){modal(`
${esc(title)}
${warnings.map(x=>`
${esc(x)}
`).join('')}${conflicts?.length?`
Conflicts: ${esc(conflicts.join(', '))}
`:''}
${esc(text||'')}
`)}
function firewallCommitModal(out){const end=Number(out.expires_at||0)*1000;modal(`
Firewall applied · verification window
Do not close this dialog yet. If the new policy breaks access, Dockwatch will restore the previous Dockwatch-managed policy and provider default snapshot automatically.
Automatic rollback in…
Verify SSH and any other management path in a separate session. Then keep the change.
`);const tick=()=>{const s=Math.max(0,Math.ceil((end-Date.now())/1000));const e=$('#fwCountdown');if(e)e.textContent=`${s}s`;if(s>0)setTimeout(tick,1000);else{closeModal();renderSecurity()}};tick();$('#fwCommitNow').onclick=async()=>{try{await api(`/api/security/firewall/commit${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall policy committed.');closeModal();renderSecurity()}catch(e){toast(e.message)}};$('#fwRollbackNow').onclick=async()=>{try{await api(`/api/security/firewall/rollback${qnode()}`,{method:'POST',body:JSON.stringify({change_id:out.change_id})});toast('Firewall rolled back.');closeModal();renderSecurity()}catch(e){toast(e.message)}}}
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,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));