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"]');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,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)}}} 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));