Files
dockwatch/web/app.js
T
jbergner 56170ccde9
release-tag / release-image (push) Failing after 1m15s
v9.3.1
2026-08-31 22:17:18 +02:00

205 lines
113 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const asArray=v=>Array.isArray(v)?v:[];
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const state={me:{role:'viewer'},system:null,nodes:[],nodeHealth:{},stacks:[],monitors:[],services:[],node:0,view:'dashboard',stack:null,monitor:null,checks:[],dirty:false,draftKey:'',logStream:null,logBuffer:'',logPaused:false,logAutoScroll:true,refreshing:false};
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 `<span class="status ${esc(s||'unknown')}">${esc(s||'unknown')}</span>`}
function setDirty(v){state.dirty=v;$('#dirtyTop').hidden=!v;window.onbeforeunload=v?()=>true:null}
function draftKey(name='new'){return `dockwatch:draft:${state.node}:${name||'new'}`}
function saveDraft(){if(!state.dirty)return;const c=$('#composeText'),e=$('#envText'),n=$('#stackName');if(!c||!n)return;const secrets=$$('.secretrow').filter(r=>r.querySelector('.secName')).map(r=>({name:r.querySelector('.secName').value,content:r.querySelector('.secContent').value}));const env_files=collectManaged('.envfilerow');const configs=collectManaged('.configrow');localStorage.setItem(draftKey(n.value||'new'),JSON.stringify({name:n.value,compose:c.value,env:e?.value||'',secrets,env_files,configs,ts:Date.now()}))}
setInterval(saveDraft,2500);
function clearDraft(name){localStorage.removeItem(draftKey(name));localStorage.removeItem(draftKey('new'))}
function fmtTime(ts){if(!ts)return'—';return new Date(ts*1000).toLocaleString()}
function fmtAgo(ts){if(!ts)return'nie';const s=Math.max(0,Math.floor(Date.now()/1000-ts));if(s<60)return`${s}s`;if(s<3600)return`${Math.floor(s/60)}m`;if(s<86400)return`${Math.floor(s/3600)}h`;return`${Math.floor(s/86400)}d`}
function setCrumb(t){$('#crumb').textContent=t}
function nodeName(){return state.node?(state.nodes.find(n=>n.id===state.node)?.name||'Remote'):'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=`<option value="0">Local Docker</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}`;if(state.node&&state.nodes.some(n=>n.id===state.node&&!n.enabled))state.node=0;p.value=String(state.node);p.onchange=async()=>{if(state.dirty&&!confirm('Ungespeicherte Stack-Änderungen verwerfen?')){p.value=String(state.node);return}state.node=Number(p.value);state.stack=null;setDirty(false);await refreshData(true);render()}}
function wireShell(){$$('#nav button').forEach(b=>b.onclick=()=>navigate(b.dataset.view));$('#logout').onclick=async()=>{await api('/auth/logout',{method:'POST'});location='/auth/login'};$('#refreshNow').onclick=()=>refreshData(true).then(render).catch(e=>toast(e.message));$('#themeToggle').onclick=toggleTheme;$('#sidebarToggle').onclick=()=>{if(innerWidth<=860){document.body.classList.toggle('sidebar-mobile-open');return}const c=!document.body.classList.contains('sidebar-collapsed');localStorage.setItem('dockwatch:sidebar',c?'collapsed':'expanded');applyPreferences()};$('#mobileMenu')?.addEventListener('click',()=>document.body.classList.toggle('sidebar-mobile-open'));document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='s'&&state.view==='stacks'&&$('#saveStack')){e.preventDefault();saveStack()}if(e.key==='Escape'&&$('#modalRoot')?.innerHTML)closeModal()});window.addEventListener('online',()=>setApiState(true));window.addEventListener('offline',()=>setApiState(false,'Browser 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 `<div class="pagehead"><div><h1>${esc(title)}</h1><p>${esc(sub)}</p></div><div class="toolbar">${actions}</div></div>`}
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','<span class="keyboardHint"><kbd>Ctrl</kbd>+<kbd>S</kbd> speichert Stacks</span><button class="btn" id="dashRefresh">↻ Refresh</button>')}<div class="stats"><div class="stat"><small>Compose stacks</small><strong>${state.stacks.length}</strong><div class="trend"><span class="green">${running} running</span></div></div><div class="stat"><small>Monitors up</small><strong>${up}</strong><div class="trend">${state.monitors.length} configured</div></div><div class="stat"><small>Monitor incidents</small><strong class="${down?'red':''}">${down}</strong><div class="trend">current probe failures</div></div><div class="stat"><small>Services</small><strong class="${svcDown?'red':''}">${svcUp}/${state.services.length}</strong><div class="trend">${svcDown} degraded</div></div></div><div class="twocol"><div class="panel"><div class="panelhead"><h2>Compose stacks</h2><button class="btn tiny" id="goStacks">View all</button></div>${stackTable(state.stacks.slice(0,8))}</div><div class="panel"><div class="panelhead"><h2>Uptime monitors</h2><button class="btn tiny" id="goMons">View all</button></div>${monitorTable(state.monitors.slice(0,8))}</div></div>`;$('#dashRefresh').onclick=()=>refreshData(true).then(render);$('#goStacks').onclick=()=>navigate('stacks');$('#goMons').onclick=()=>navigate('monitors');$$('[data-openstack]').forEach(x=>x.onclick=()=>openStack(x.dataset.openstack));$$('[data-openmon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.openmon)))}
function stackTable(items){if(!items.length)return'<div class="empty">No compose stacks found.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Services</th><th>Images</th></tr></thead><tbody>${items.map(s=>`<tr class="${roleOK()?'clickrow':''}" data-openstack="${esc(s.name)}"><td><div class="namecell"><span class="cube">▱</span><b>${esc(s.name)}</b></div></td><td>${badge(s.status)}</td><td>${(s.services||[]).length}</td><td class="muted">${esc((s.services||[]).slice(0,2).map(v=>v.image).filter(Boolean).join(', ')||'—')}</td></tr>`).join('')}</tbody></table>`}
function monitorTable(items){if(!items.length)return'<div class="empty">No monitors configured.</div>';return `<table class="table"><thead><tr><th>Name</th><th>Status</th><th>Type</th><th>Uptime 24h</th></tr></thead><tbody>${items.map(m=>`<tr class="clickrow" data-openmon="${m.id}"><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td><span class="tag">${esc(m.type.toUpperCase())}</span></td><td>${Number(m.uptime_24h||0).toFixed(2)}%</td></tr>`).join('')}</tbody></table>`}
function renderStacks(){setCrumb(`Docker / Compose Stacks / ${nodeName()}`);const actions=roleOK()?'<button class="btn primary" id="newStack">+ New stack</button>':'';$('#content').innerHTML=`${pageHead('Compose stacks','Edit, deploy and operate multi-container applications.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="stackSearch" placeholder="Filter stacks…"><button class="btn tiny" id="stackRefresh">↻</button></div><div id="stackList">${renderStackList(state.stacks)}</div></div><div id="stackDetail" class="detail">${state.stack?stackDetailHTML(state.stack):'<div class="empty"><div class="big">▱</div>Select a stack or create a new one.</div>'}</div></div>`;$('#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=>`<div class="stackitem ${state.stack?.name===s.name?'active':''}" data-stack="${esc(s.name)}"><div class="itemtop"><b>${esc(s.name)}</b>${badge(s.status)}</div><div class="itemsub">${(s.services||[]).length} services · ${esc((s.services||[]).map(v=>v.service||v.name).filter(Boolean).join(', ')||'not deployed')}</div></div>`).join('')||'<div class="empty">No stacks</div>'}
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 `<div class="detailhead"><div class="detailtitle"><span class="cube">▱</span><div><h2>${esc(st.name||'New compose stack')}</h2><small>${st.name?esc(nodeName()):'Draft · not deployed'}</small></div>${badge(st.status||'new')}</div><div class="actions">${st.name?`<button class="btn success" data-act="up">▶ Deploy</button><button class="btn" data-act="pull">↓ Pull</button><button class="btn" data-act="update">↻ Update</button><button class="btn" data-act="restart">⟳ Restart</button><button class="btn" data-act="stop">■ Stop</button>`:''}<button class="btn primary" id="saveStack">Save</button></div></div><div class="tabs"><button class="active" data-tab="compose">Compose</button><button data-tab="env">.env</button><button data-tab="envfiles">Env files</button><button data-tab="secrets">Secrets</button><button data-tab="configs">Configs</button><button data-tab="services">Containers <span class="tag">${sv.length}</span></button><button data-tab="permissions">Permissions</button><button data-tab="graph">Graph</button><button data-tab="updates">Updates</button><button data-tab="logs">Live logs</button><button data-tab="console">Terminal</button><button data-tab="danger">Danger zone</button></div><div class="tabbody"><div id="tab-compose">${composeTab(st)}</div><div id="tab-env" hidden>${envTab(st)}</div><div id="tab-envfiles" hidden>${managedFilesTab('envfile',st.env_files||[],'Additional env files','Use these from Compose as <code>env_file: ./envs/app.env</code>.')}</div><div id="tab-secrets" hidden>${secretsTab(st)}</div><div id="tab-configs" hidden>${managedFilesTab('config',st.configs||[],'Compose configs','Use these from Compose as <code>configs: ... file: ./configs/name</code>.')}</div><div id="tab-services" hidden>${servicesTab(st)}</div><div id="tab-permissions" hidden>${permissionsTab()}</div><div id="tab-graph" hidden>${graphTab()}</div><div id="tab-updates" hidden>${updatesTab()}</div><div id="tab-logs" hidden>${logsTab()}</div><div id="tab-console" hidden>${consoleTab(sv)}</div><div id="tab-danger" hidden>${dangerTab(st)}</div><pre id="actionOut" class="terminal" style="min-height:90px;display:none;margin-top:10px"></pre></div>`}
function composeTab(st){const d=st.name?localStorage.getItem(draftKey(st.name)):null;return `${d?'<div class="draftnotice"><span>A local unsaved draft exists for this stack.</span><span><button class="btn tiny" id="restoreDraft">Restore</button> <button class="btn tiny" id="discardDraft">Discard</button></span></div>':''}<div class="field" style="margin-bottom:8px"><label>Stack name</label><input id="stackName" value="${esc(st.name)}" ${st.name?'disabled':''} placeholder="my-stack"></div><div class="compose-livebar"><div><b>Full Compose Designer</b><span id="composeSyncState" class="muted"> parsing…</span></div><div class="actions"><button class="btn tiny" id="composeExpandAll">Expand all</button><button class="btn tiny" id="composeCollapseAll">Collapse all</button></div></div><div class="compose-livegrid compose-fullgrid"><div class="codebox"><div class="editorlabel">compose.yaml <span class="muted">Source of truth</span></div><textarea id="composeText" spellcheck="false">${esc(st.compose||'')}</textarea></div><div class="visualcompose"><div class="editorlabel">Visual editor <span class="muted">all fields · AST patch mode</span></div><div class="compose-sections" id="composeSections"></div><div id="composeVisual"><div class="empty">Parsing Compose…</div></div></div></div><div class="compose-help"><span>Every present Compose value is editable as string, number, boolean, null, map or array.</span><span>Current spec fields are suggested; x-* and future fields remain editable too.</span><span>Invalid YAML pauses visual sync without replacing your source.</span></div>`}
const COMPOSE_SERVICE_FIELDS=['annotations','attach','build','blkio_config','cpu_count','cpu_percent','cpu_shares','cpu_period','cpu_quota','cpu_rt_runtime','cpu_rt_period','cpus','cpuset','cap_add','cap_drop','cgroup','cgroup_parent','command','configs','container_name','credential_spec','depends_on','deploy','develop','device_cgroup_rules','devices','dns','dns_opt','dns_search','domainname','driver_opts','entrypoint','env_file','environment','expose','extends','external_links','extra_hosts','gpus','group_add','healthcheck','hostname','image','init','ipc','isolation','labels','label_file','links','logging','mac_address','mem_limit','mem_reservation','mem_swappiness','memswap_limit','models','network_mode','networks','oom_kill_disable','oom_score_adj','pid','pids_limit','platform','ports','post_start','pre_start','pre_stop','privileged','profiles','provider','pull_policy','read_only','restart','runtime','scale','secrets','security_opt','shm_size','stdin_open','stop_grace_period','stop_signal','storage_opt','sysctls','tmpfs','tty','ulimits','use_api_socket','user','userns_mode','uts','volumes','volumes_from','working_dir'];
const COMPOSE_TOP_FIELDS=['name','include','services','models','networks','volumes','secrets','configs','version'];
const COMPOSE_FIELD_HINTS={image:'Container image',build:'Build 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;i<path.length-1;i++){const k=path[i];if(Array.isArray(cur))cur=cur[Number(k)];else cur=cur[k];if(cur==null)return}const k=path[path.length-1];if(del){if(Array.isArray(cur))cur.splice(Number(k),1);else delete cur[k]}else{if(Array.isArray(cur))cur[Number(k)]=val;else cur[k]=val}}
async function parseComposeVisual(){const box=$('#composeVisual'),ta=$('#composeText'),status=$('#composeSyncState');if(!box||!ta)return;const seq=++composeParseSeq;status.className='muted';status.textContent=' parsing…';try{const r=await api('/api/compose/parse',{method:'POST',body:JSON.stringify({compose:ta.value})});if(seq!==composeParseSeq)return;composeVisualModel=r.value||{};status.className='green';status.textContent=' YAML ↔ Designer synchronized';renderComposeSections();renderComposeVisual()}catch(e){if(seq!==composeParseSeq)return;status.className='red';status.textContent=' Visual sync paused';box.innerHTML=`<div class="compose-parseerror"><b>Visual editor paused</b><p>${esc(e.message)}</p><p>Your YAML source is untouched. Fix the syntax and synchronization resumes automatically.</p></div>`}}
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])=>`<button class="${composeSection===k?'active':''}" data-csection="${k}">${l}</button>`).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})}
function sectionValue(){const m=composeVisualModel||{};if(composeSection==='project'){const x={};for(const k of Object.keys(m))if(!['services','networks','volumes','configs','secrets','models','include'].includes(k))x[k]=m[k];return x}return m[composeSection]??(composeSection==='include'?[]:{})}
function sectionPath(){return composeSection==='project'?[]:[composeSection]}
function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const 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||'<div class="empty">Nothing configured in this section.</div>';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])=>`<details class="compose-object compose-named" open><summary><span><b>${esc(k)}</b> <span class="tag">${esc(label)}</span></span><span class="summary-actions"><button class="iconbtn" data-cdelete="${pathAttr([...path,k])}" title="Remove">×</button></span></summary><div class="compose-object-body">${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}</div></details>`).join('');return `${rows}<div class="compose-addrow"><input data-cnewname="${pathAttr(path)}" placeholder="New ${esc(label)} name"><button class="btn tiny" data-caddnamed="${pathAttr(path)}" data-ctype="map">+ ${esc(label)}</button></div>`}
function composeMapEditor(v,path,opt={}){if(v===null||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,'value');const entries=Object.entries(v);const serviceRoot=!!opt.serviceRoot;const suggestions=opt.rootProject?COMPOSE_TOP_FIELDS:composeSuggestions(path,opt);const listId='candidates-'+Math.abs(pathKey(path).split('').reduce((a,c)=>((a<<5)-a+c.charCodeAt(0))|0,0));return `<div class="compose-map">${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}</div><div class="compose-addrow"><input ${suggestions.length?`list="${listId}"`:''} data-cnewkey="${pathAttr(path)}" placeholder="Add field/key"><select data-cnewtype="${pathAttr(path)}"><option value="string">string</option><option value="map">object</option><option value="array">array</option><option value="boolean">boolean</option><option value="number">number</option><option value="null">null</option></select><button class="btn tiny" data-caddkey="${pathAttr(path)}">+ Field</button>${suggestions.length?`<datalist id="${listId}">${suggestions.map(x=>`<option value="${esc(x)}"></option>`).join('')}</datalist>`:''}</div>`}
function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `<div class="compose-fieldrow ${complex?'complex':''}"><div class="compose-fieldhead"><div><code>${esc(k)}</code>${hint?`<small>${esc(hint)}</small>`:''}${k.startsWith('x-')?'<span class="tag">extension</span>':''}</div><div class="summary-actions"><span class="typepill">${t}</span><button class="iconbtn" data-cdelete="${pathAttr(path)}" title="Remove">×</button></div></div>${composeValueEditor(val,path,k,{serviceRoot:false})}</div>`}
function composeValueEditor(v,path,label,opt={}){const t=typeOfValue(v),pa=pathAttr(path);if(t==='map')return composeMapEditor(v,path,opt);if(t==='array')return `<div class="compose-array">${v.map((x,i)=>`<div class="compose-arrayitem"><div class="arrayindex">#${i+1}<button class="iconbtn" data-cdelete="${pathAttr([...path,String(i)])}" title="Remove">×</button></div>${composeValueEditor(x,[...path,String(i)],label)}</div>`).join('')}<div class="compose-addrow"><select data-carraytype="${pa}"><option value="string">string</option><option value="map">object</option><option value="array">array</option><option value="boolean">boolean</option><option value="number">number</option><option value="null">null</option></select><button class="btn tiny" data-carrayadd="${pa}">+ Item</button></div></div>`;if(t==='boolean')return `<div class="compose-scalar"><select data-cscalar="${pa}" data-ctype="boolean"><option value="true" ${v?'selected':''}>true</option><option value="false" ${!v?'selected':''}>false</option></select>${typeSwitcher(path,t)}</div>`;if(t==='null')return `<div class="compose-scalar"><span class="nullvalue">null</span>${typeSwitcher(path,t)}</div>`;if(t==='number')return `<div class="compose-scalar"><input data-cscalar="${pa}" data-ctype="number" type="number" step="any" value="${esc(v)}">${typeSwitcher(path,t)}</div>`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `<div class="compose-scalar">${multiline?`<textarea data-cscalar="${pa}" data-ctype="string">${esc(v??'')}</textarea>`:`<input data-cscalar="${pa}" data-ctype="string" value="${esc(v??'')}">`}${typeSwitcher(path,'string')}</div>`}
function typeSwitcher(path,t){return `<select class="typeswitch" data-ctypeswitch="${pathAttr(path)}"><option value="string" ${t==='string'?'selected':''}>string</option><option value="number" ${t==='number'?'selected':''}>number</option><option value="boolean" ${t==='boolean'?'selected':''}>boolean</option><option value="map" ${t==='map'?'selected':''}>object</option><option value="array" ${t==='array'?'selected':''}>array</option><option value="null" ${t==='null'?'selected':''}>null</option></select>`}
function newValueForType(t){return t==='map'?{}:t==='array'?[]:t==='boolean'?false:t==='number'?0:t==='null'?null:''}
function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v)};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 `<div class="field"><label>.env · Compose variable substitution</label><textarea id="envText" class="terminal" style="min-height:500px" spellcheck="false" placeholder="POSTGRES_TAG=16-alpine\nAPP_PORT=8080">${esc(st.env||'')}</textarea></div>`}
function secretsTab(st){return `<div class="notice" style="margin-bottom:10px">Secret values are written with mode 0600. Use Compose <code>secrets:</code> with <code>file: ./secrets/name</code>. Existing values are visible only to operators who can edit the stack.</div><div id="secretList">${(st.secrets||[]).map(secretRow).join('')}</div><button class="btn" id="addSecret">+ Add secret file</button>`}
function secretRow(s={}){return `<div class="secretrow"><input class="secName" placeholder="db_password" value="${esc(s.name||'')}"><input class="secContent" type="password" placeholder="secret value" value="${esc(s.content||'')}"><button class="btn danger removeSecret">Remove</button></div>`}
function managedFilesTab(kind,files,title,help){return `<div class="notice" style="margin-bottom:10px"><b>${title}</b> · ${help} Managed files are validated together with the stack.</div><div id="${kind}List">${files.map(f=>managedFileRow(kind,f)).join('')}</div><button class="btn" id="add${kind}">+ Add file</button>`}
function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `<div class="secretrow ${cls}"><input class="managedName" placeholder="${kind==='envfile'?'app.env':'nginx.conf'}" value="${esc(f.name||'')}"><textarea class="managedContent" placeholder="file contents">${esc(f.content||'')}</textarea><button class="btn danger removeManaged">Remove</button></div>`}
function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.managedName').value.trim(),content:r.querySelector('.managedContent').value})).filter(f=>f.name)}
function servicesTab(st){const sv=st.services||[];if(!sv.length)return'<div class="empty">This stack has no running containers yet.</div>';return `<div class="servicecards">${sv.map(v=>`<div class="servicecard"><div class="itemtop"><b>${esc(v.service||v.name)}</b>${badge(v.state||v.status)}</div><div class="svcmeta"><span class="muted">Image</span><span>${esc(v.image||'—')}</span><span class="muted">Ports</span><span>${esc(v.ports||'—')}</span><span class="muted">Command</span><span>${esc(v.command||'—')}</span></div></div>`).join('')}</div>`}
function logsTab(){return `<div class="consolebar"><button class="btn" id="loadLogs">Load 500</button><button class="btn success" id="liveLogs">▶ Follow</button><button class="btn" id="pauseLogs">Ⅱ Pause</button><button class="btn danger" id="stopLogs">■ Stop</button><input id="logFilter" placeholder="Filter visible logs…"><span class="spacer"></span><label class="switch"><input id="logAutoScroll" type="checkbox" checked> Auto-scroll</label><button class="btn" id="downloadLogs">Download</button><span id="logState" class="logstate">idle</span></div><pre id="logBox" class="terminal">Select “Load” or “Follow”.</pre>`}
function graphTab(){return `<div class="consolebar"><button class="btn primary" id="loadGraph">↻ Build dependency graph</button></div><div id="graphBox" class="graphbox"><div class="empty">Load the normalized Compose dependency graph.</div></div>`}
function permissionsTab(){return `<div class="consolebar"><button class="btn primary" id="loadPermissions">Analyze bind mounts</button><span class="muted">Checks expected application UID/GID, host ownership, mode bits and ACL hints.</span></div><div id="permissionsBox"><div class="empty">Run the analysis to review writable bind mounts for every created service.</div></div>`}
function updatesTab(){return `<div class="consolebar"><button class="btn primary" id="checkUpdates">↻ Check registry updates</button></div><div id="updateBox"><div class="empty">Compare installed image digests with registry manifests.</div></div>`}
function consoleTab(sv){return `<div class="notice" style="margin-bottom:8px">Interactive Docker Exec terminal backed by a real PTY/WebSocket session. Click the terminal and type normally.</div><div class="consolebar"><select id="execService">${sv.map(v=>`<option>${esc(v.service||v.name)}</option>`).join('')}</select><select id="execShell"><option value="sh">sh</option><option value="bash">bash</option><option value="ash">ash</option></select><button class="btn primary" id="openTerminal">Connect</button><button class="btn danger" id="closeTerminal">Disconnect</button></div><div id="execOut" class="terminal interactive" tabindex="0">Terminal disconnected.
</div>`}
function dangerTab(st){return st.name?`<div class="notice"><b>Safe delete is the default.</b> It removes only compose.yaml, .env and Dockwatch-managed secrets/env/config folders. Unrelated bind-mount data beside the stack is preserved.</div><div class="toolbar" style="margin-top:10px"><button class="btn danger" data-act="down">Compose down</button><button class="btn" data-act="recreate">Force recreate</button><button class="btn danger" id="deleteStack">Delete definition</button><button class="btn danger" id="purgeStack">Purge entire folder…</button></div>`:'<div class="empty">Save the stack first.</div>'}
function wireStackDetail(){const root=$('#stackDetail');root.querySelectorAll('.tabs button').forEach(b=>b.onclick=()=>{root.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x===b));['compose','env','envfiles','secrets','configs','services','permissions','graph','updates','logs','console','danger'].forEach(t=>{const e=$(`#tab-${t}`);if(e)e.hidden=t!==b.dataset.tab})});const mark=()=>{setDirty(true);saveDraft()};let composeTimer;root.addEventListener('input',e=>{if(e.target.matches('#composeText,#envText,#stackName,.secName,.secContent,.managedName,.managedContent'))mark();if(e.target.matches('#composeText')){clearTimeout(composeTimer);composeTimer=setTimeout(parseComposeVisual,220)}});$('#addComposeService')?.addEventListener('click',addComposeService);parseComposeVisual();$('#composeExpandAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=true)});$('#composeCollapseAll')?.addEventListener('click',()=>{$$('#composeVisual details').forEach(x=>x.open=false)});$('#addSecret')?.addEventListener('click',()=>{$('#secretList').insertAdjacentHTML('beforeend',secretRow());wireSecretRemovers();mark()});$('#addenvfile')?.addEventListener('click',()=>{$('#envfileList').insertAdjacentHTML('beforeend',managedFileRow('envfile'));wireManagedRemovers();mark()});$('#addconfig')?.addEventListener('click',()=>{$('#configList').insertAdjacentHTML('beforeend',managedFileRow('config'));wireManagedRemovers();mark()});wireSecretRemovers();wireManagedRemovers();$('#saveStack').onclick=saveStack;$$('[data-act]').forEach(b=>b.onclick=()=>stackAction(b.dataset.act));$('#loadLogs')?.addEventListener('click',loadLogs);$('#liveLogs')?.addEventListener('click',startLiveLogs);$('#stopLogs')?.addEventListener('click',stopLiveLogs);$('#pauseLogs')?.addEventListener('click',togglePauseLogs);$('#downloadLogs')?.addEventListener('click',downloadLogs);$('#logAutoScroll')?.addEventListener('change',e=>state.logAutoScroll=e.target.checked);$('#logFilter')?.addEventListener('input',filterLogs);$('#openTerminal')?.addEventListener('click',openTerminal);$('#closeTerminal')?.addEventListener('click',closeTerminal);$('#loadGraph')?.addEventListener('click',loadGraph);$('#loadPermissions')?.addEventListener('click',loadStackPermissions);$('#checkUpdates')?.addEventListener('click',loadImageUpdates);$('#deleteStack')?.addEventListener('click',()=>deleteStack(false));$('#purgeStack')?.addEventListener('click',()=>deleteStack(true));$('#restoreDraft')?.addEventListener('click',restoreDraft);$('#discardDraft')?.addEventListener('click',()=>{clearDraft(state.stack.name);renderStacks()})}
function wireSecretRemovers(){$$('.removeSecret').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})}
function wireManagedRemovers(){$$('.removeManaged').forEach(b=>b.onclick=()=>{b.closest('.secretrow').remove();setDirty(true);saveDraft()})}
function restoreDraft(){try{const d=JSON.parse(localStorage.getItem(draftKey(state.stack.name)));state.stack={...state.stack,...d};setDirty(true);renderStacks()}catch{toast('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()?'<button class="btn primary" id="newMonitor">+ New monitor</button>':'';$('#content').innerHTML=`${pageHead('Probes','Uptime, latency, Docker state and maintenance across your environments.',actions)}<div class="splitview"><div class="listpanel"><div class="listhead"><input id="monSearch" placeholder="Filter monitors…"><button class="btn tiny" id="monRefresh">↻</button></div><div id="monList">${monitorListHTML(state.monitors)}</div></div><div id="monDetail" class="detail">${state.monitor?monitorDetailHTML(state.monitor,state.checks):'<div class="empty"><div class="big">♡</div>Select a monitor to inspect uptime and latency.</div>'}</div></div>`;$('#newMonitor')?.addEventListener('click',()=>monitorModal());$('#monSearch').oninput=e=>$('#monList').innerHTML=monitorListHTML(state.monitors.filter(m=>(m.name+' '+m.target).toLowerCase().includes(e.target.value.toLowerCase())));$('#monRefresh').onclick=()=>refreshData(true).then(renderMonitors);wireMonitorList();if(state.monitor)wireMonitorDetail()}
function monitorListHTML(ms){return ms.map(m=>`<div class="monitoritem ${state.monitor?.id===m.id?'active':''}" data-mon="${m.id}"><div class="itemtop"><b>${esc(m.name)}</b>${badge(m.status)}</div><div class="itemsub">${esc(m.type.toUpperCase())} · ${esc(m.target)}</div><div class="hb">${heartbeatBars([],m.status,24)}</div></div>`).join('')||'<div class="empty">No monitors</div>'}
function wireMonitorList(){$$('[data-mon]').forEach(x=>x.onclick=()=>openMonitor(Number(x.dataset.mon)))}
async function openMonitor(id){try{const [m,c]=await Promise.all([api(`/api/monitors/${id}`),api(`/api/monitors/${id}/checks?limit=80`)]);state.monitor=m;state.checks=Array.isArray(c)?c:[];state.view='monitors';renderMonitors()}catch(e){toast(e.message)}}
function heartbeatBars(checks,status,n=48){if(!checks?.length)return Array.from({length:n},()=>`<span class="${status==='up'?'up':status==='down'?'down':status==='maintenance'?'maintenance':'paused'}"></span>`).join('');const a=[...checks].reverse().slice(-n);return Array.from({length:n-a.length},()=>'<span></span>').join('')+a.map(c=>`<span class="${c.ok?'up':'down'}" title="${esc(fmtTime(c.checked_at))} · ${c.latency_ms}ms"></span>`).join('')}
function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.length)return'<div class="empty">No heartbeat data yet.</div>';const max=Math.max(1,...a.map(x=>x.latency_ms)),pts=a.map((x,i)=>`${(i/(Math.max(1,a.length-1))*100).toFixed(2)},${(95-(x.latency_ms/max)*80).toFixed(2)}`).join(' ');return `<svg viewBox="0 0 100 100" preserveAspectRatio="none"><polyline points="${pts}" fill="none" stroke="#7c6cf7" stroke-width="1.3" vector-effect="non-scaling-stroke"/><line x1="0" x2="100" y1="95" y2="95" stroke="#273042" stroke-width=".5"/></svg>`}
function monitorDetailHTML(m,c){const avg=c?.length?Math.round(c.reduce((a,x)=>a+x.latency_ms,0)/c.length):0,ok=c?.filter(x=>x.ok).length||0,ratio=c?.length?ok/c.length*100:0;return `<div class="detailhead"><div class="detailtitle"><span class="cube">♡</span><div><h2>${esc(m.name)}</h2><small>${esc(m.target)}</small></div>${badge(m.status)}</div><div class="actions">${roleOK()?`<button class="btn primary" id="checkMon">↻ Check now</button><button class="btn" id="editMon">Edit</button>${m.status==='paused'?'<button class="btn success" id="resumeMon">Resume</button>':'<button class="btn" id="pauseMon">Pause</button>'}${m.status==='maintenance'?'<button class="btn" id="clearMaint">End maintenance</button>':'<button class="btn" id="maintMon">Maintenance</button>'}`:''}</div></div><div class="monitorhero"><div><span class="muted">24 hour uptime</span><div class="uptimebig ${m.status==='down'?'red':'green'}">${Number(m.uptime_24h||0).toFixed(3)}%</div></div><div>${badge(m.status)}</div></div><div class="tabbody"><div class="hb">${heartbeatBars(c,m.status,64)}</div><div class="monitorstats"><div class="mini"><small>Last heartbeat</small><b>${fmtAgo(m.last_checked_at)}</b></div><div class="mini"><small>Last latency</small><b>${m.last_latency_ms||0} ms</b></div><div class="mini"><small>Average latency</small><b>${avg} ms</b></div><div class="mini"><small>Recent success</small><b>${ratio.toFixed(1)}%</b></div><div class="mini"><small>Interval</small><b>${m.interval_seconds}s</b></div></div>${m.last_message?`<div class="notice ${m.status==='down'?'red':''}" style="margin-bottom:10px"><b>Last result:</b> ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}</div>`:''}<div class="panel"><div class="panelhead"><h2>Response time</h2><span class="muted">last ${c.length} heartbeats</span></div><div class="tabbody"><div class="chart">${latencyChart(c)}</div></div></div>${m.status==='maintenance'?`<div class="notice" style="margin-top:10px">Maintenance active${m.maintenance_until?` until ${esc(fmtTime(m.maintenance_until))}`:' until manually ended'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.</div>`:''}<div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Configuration</h2></div><table class="table"><tbody><tr><td class="muted">Type</td><td>${esc(m.type.toUpperCase())}</td><td class="muted">Method</td><td>${esc(m.method||'GET')}</td></tr><tr><td class="muted">Expected status</td><td>${m.expected_min}–${m.expected_max}</td><td class="muted">Timeout</td><td>${m.timeout_ms} ms</td></tr><tr><td class="muted">Keyword</td><td>${esc(m.keyword||'—')}</td><td class="muted">Probe environment</td><td>${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / local'}</td></tr></tbody></table></div>${roleOK()?'<div class="toolbar" style="margin-top:10px"><button class="btn danger" id="deleteMon">Delete monitor</button></div>':''}</div>`}
function wireMonitorDetail(){$('#checkMon')?.addEventListener('click',checkMonitorNow);$('#editMon')?.addEventListener('click',()=>monitorModal(state.monitor));$('#pauseMon')?.addEventListener('click',()=>monitorAction('pause'));$('#resumeMon')?.addEventListener('click',()=>monitorAction('resume'));$('#maintMon')?.addEventListener('click',()=>maintenanceModal(state.monitor));$('#clearMaint')?.addEventListener('click',()=>clearMaintenance(state.monitor.id));$('#deleteMon')?.addEventListener('click',deleteMonitor)}
async function checkMonitorNow(){const b=$('#checkMon');setBusy(b,true,'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(`<div class="modalhead"><h2>${isEdit?'Edit monitor':'New monitor'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="mfName" value="${esc(m?.name||'')}"></div><div class="field"><label>Monitor type</label><select id="mfType"><option value="http">HTTP(S)</option><option value="tcp">TCP port</option><option value="dns">DNS hostname</option><option value="docker">Docker container</option></select></div><div class="field full"><label>URL / target / container name or ID</label><input id="mfTarget" list="dockerTargets" value="${esc(m?.target||'')}" placeholder="https://example.com, host:port or container-name"><datalist id="dockerTargets"></datalist></div><div class="field"><label>Service group</label><select id="mfService"><option value="">Ungrouped</option>${state.services.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label>Probe environment</label><select id="mfNode"><option value="">Master / local</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}</select></div><div class="field"><label>Interval (seconds)</label><input id="mfInterval" type="number" min="10" max="86400" step="1" value="${m?.interval_seconds||60}"></div><div class="field"><label>Timeout (ms)</label><input id="mfTimeout" type="number" value="${m?.timeout_ms||5000}"></div><div class="field"><label>HTTP method</label><select id="mfMethod">${['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].map(v=>`<option>${v}</option>`).join('')}</select></div><div class="field"><label>Expected HTTP status</label><div style="display:flex;gap:5px"><input id="mfMin" type="number" value="${m?.expected_min||200}" style="width:50%"><input id="mfMax" type="number" value="${m?.expected_max||399}" style="width:50%"></div></div><div class="field full"><label>Keyword assertion (optional)</label><input id="mfKeyword" value="${esc(m?.keyword||'')}" placeholder="Text that must occur in the response"></div><div class="field full"><label>Request headers as JSON</label><textarea id="mfHeaders" spellcheck="false">${esc(m?.headers_json||'{}')}</textarea></div><div class="field full"><label>Request body</label><textarea id="mfBody" spellcheck="false">${esc(m?.body||'')}</textarea></div><label class="switch"><input id="mfInvert" type="checkbox" ${m?.invert_keyword?'checked':''}> Invert keyword match</label><label class="switch"><input id="mfTLS" type="checkbox" ${m?.ignore_tls?'checked':''}> Ignore TLS certificate errors</label><label class="switch"><input id="mfHealthy" type="checkbox" ${m?.require_healthy?'checked':''}> Docker: require Healthy condition</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveMonitor">${isEdit?'Save changes':'Create monitor'}</button></div>`);$('#mfType').value=m?.type||'http';$('#mfNode').value=m?.node_id||'';$('#mfService').value=m?.service_id||'';$('#mfInterval').value=String(m?.interval_seconds||60);$('#mfMethod').value=m?.method||'GET';const syncFields=()=>{const t=$('#mfType').value,isHTTP=t==='http',isDocker=t==='docker';['mfMethod','mfMin','mfMax','mfKeyword','mfHeaders','mfBody'].forEach(id=>{const e=$('#'+id);if(e?.closest('.field'))e.closest('.field').style.display=isHTTP?'':'none'});['mfInvert','mfTLS'].forEach(id=>{const e=$('#'+id);if(e?.closest('.switch'))e.closest('.switch').style.display=isHTTP?'':'none'});const he=$('#mfHealthy');if(he?.closest('.switch'))he.closest('.switch').style.display=isDocker?'':'none';const target=$('#mfTarget');if(target)target.placeholder=isHTTP?'https://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=>`<option value="${esc(x.Names||x.Name||x.ID||x.name||x.id||'')}"></option>`).join('')}catch{}};$('#mfType').onchange=loadDockerTargets;$('#mfNode').onchange=loadDockerTargets;loadDockerTargets();$('#saveMonitor').onclick=()=>saveMonitor(m?.id)}
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(`<div class="modalhead"><h2>Maintenance · ${esc(m.name)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice" style="margin-bottom:10px">Checks are suppressed during maintenance and the monitor is shown as maintenance instead of down.</div><div class="fieldgrid"><div class="field"><label>End mode</label><select id="maintMode"><option value="manual">Until manually ended</option><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="24h">24 hours</option><option value="custom">Custom date/time</option></select></div><div class="field"><label>Custom end</label><input id="maintUntil" type="datetime-local"></div><div class="field full"><label>Note</label><input id="maintNote" placeholder="Planned deployment / provider maintenance"></div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="startMaint">Start maintenance</button></div>`);$('#startMaint').onclick=()=>startMaintenance(m.id)}
async function startMaintenance(id){const mode=$('#maintMode').value;let until=null;if(mode==='custom'){const v=$('#maintUntil').value;if(v)until=Math.floor(new Date(v).getTime()/1000)}else if(mode!=='manual'){until=Math.floor(Date.now()/1000)+Number(mode.replace('h',''))*3600}try{await api(`/api/monitors/${id}/maintenance`,{method:'POST',body:JSON.stringify({until,note:$('#maintNote').value})});closeModal();await refreshData(true);await openMonitor(id);toast('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()?'<button class="btn primary" id="newService">+ New service</button>':'';$('#content').innerHTML=`${pageHead('Services','Group probes into user-facing services. One failed probe makes the whole service fail.',actions)}<div class="panel" id="servicePanel">${state.services.length?`<table class="table"><thead><tr><th>Service</th><th>Status</th><th>Probes</th><th></th></tr></thead><tbody>${state.services.map(g=>`<tr><td><b>${esc(g.name)}</b><div class="muted">${esc(g.description||'')}</div></td><td>${badge(g.status)}</td><td>${(g.monitors||[]).length}<div class="muted">${(g.monitors||[]).map(m=>esc(m.name)).join(' · ')||'No probes assigned'}</div></td><td class="right">${roleOK()?`<button class="btn tiny" data-sedit="${g.id}">Edit</button> <button class="btn tiny danger" data-sdel="${g.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No services configured. Create a service and assign probes to it.</div>'}</div>`;$('#newService')?.addEventListener('click',()=>serviceModal());$$('[data-sedit]').forEach(b=>b.onclick=()=>serviceModal(state.services.find(x=>x.id===Number(b.dataset.sedit))));$$('[data-sdel]').forEach(b=>b.onclick=async()=>{if(confirm('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(`<div class="modalhead"><h2>${x?'Edit':'New'} service</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Name</label><input id="svcName" value="${esc(x?.name||'')}"></div><div class="field"><label>Description</label><textarea id="svcDesc">${esc(x?.description||'')}</textarea></div><div class="field"><label>Assigned probes</label><div class="checklist serviceProbeList">${state.monitors.map(m=>`<label class="switch"><input class="svcProbe" type="checkbox" value="${m.id}" ${selected.has(m.id)?'checked':''}> <span>${esc(m.name)} · ${esc(m.type.toUpperCase())}</span> ${badge(m.status)}</label>`).join('')||'<span class="muted">No probes configured.</span>'}</div></div><div class="notice">A probe can belong to one service. Assigning it here moves it from a previous service. One DOWN probe makes this service DOWN.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="svcSave">Save</button></div>`);$('#svcSave').onclick=async()=>{const body={name:$('#svcName').value,description:$('#svcDesc').value,monitor_ids:$$('.svcProbe:checked').map(e=>Number(e.value))};try{await api(x?`/api/services/${x.id}`:'/api/services',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);renderServices()}catch(e){toast(e.message)}}}
async function renderStatusPages(){setCrumb('Observability / Status Pages');$('#content').innerHTML=`${pageHead('Public status pages','Publish only selected services and their aggregate status.',state.me.role==='admin'?'<button class="btn primary" id="newStatusPage">+ New status page</button>':'')}<div class="panel" id="statusPagePanel"><div class="empty">Loading status pages…</div></div>`;$('#newStatusPage')?.addEventListener('click',()=>statusPageModal());try{const raw=await api('/api/status-pages'),rows=Array.isArray(raw)?raw:[];$('#statusPagePanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Public URL</th><th>Services</th><th>Enabled</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b><div class="muted">${esc(x.description||'')}</div></td><td><a href="/status/${encodeURIComponent(x.slug)}" target="_blank">/status/${esc(x.slug)}</a></td><td>${(x.service_ids||[]).length}</td><td>${x.enabled?'<span class="green">Public</span>':'Disabled'}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-pedit="${x.id}">Edit</button> <button class="btn tiny danger" data-pdel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No public status pages configured.</div>';$$('[data-pedit]').forEach(b=>b.onclick=()=>statusPageModal(rows.find(x=>x.id===Number(b.dataset.pedit))));$$('[data-pdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete status page?')){await api('/api/status-pages/'+b.dataset.pdel,{method:'DELETE'});renderStatusPages()}})}catch(e){$('#statusPagePanel').innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
function statusPageModal(x=null){const selected=new Set(x?.service_ids||[]);modal(`<div class="modalhead"><h2>${x?'Edit':'New'} public status page</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="pgName" value="${esc(x?.name||'')}"></div><div class="field"><label>Slug</label><input id="pgSlug" value="${esc(x?.slug||'')}" placeholder="public-status"></div><div class="field full"><label>Description</label><textarea id="pgDesc">${esc(x?.description||'')}</textarea></div><div class="field full"><label>Published services</label><div class="checklist">${state.services.map(g=>`<label class="switch"><input class="pgSvc" type="checkbox" value="${g.id}" ${selected.has(g.id)?'checked':''}> ${esc(g.name)} ${badge(g.status)}</label>`).join('')||'<span class="muted">Create services first.</span>'}</div></div><label class="switch"><input id="pgEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Publicly accessible</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="pgSave">Save</button></div>`);$('#pgSave').onclick=async()=>{const body={name:$('#pgName').value,slug:$('#pgSlug').value,description:$('#pgDesc').value,enabled:$('#pgEnabled').checked,service_ids:$$('.pgSvc:checked').map(e=>Number(e.value))};try{await api(x?`/api/status-pages/${x.id}`:'/api/status-pages',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();renderStatusPages()}catch(e){toast(e.message)}}}
function renderMaintenance(){setCrumb('Observability / Maintenance');const ms=state.monitors.filter(m=>m.status==='maintenance');$('#content').innerHTML=`${pageHead('Maintenance','Suppress monitoring alerts during planned work.')}<div class="panel"><div class="panelhead"><h2>Active maintenance windows</h2></div>${ms.length?`<table class="table"><thead><tr><th>Monitor</th><th>Target</th><th>Until</th><th>Note</th><th></th></tr></thead><tbody>${ms.map(m=>`<tr><td><b>${esc(m.name)}</b></td><td class="muted">${esc(m.target)}</td><td>${m.maintenance_until?fmtTime(m.maintenance_until):'Manual end'}</td><td>${esc(m.maintenance_note||'—')}</td><td><button class="btn tiny" data-endmaint="${m.id}">End</button></td></tr>`).join('')}</tbody></table>`:'<div class="empty">No active maintenance windows.</div>'}</div><div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Start maintenance</h2></div><table class="table"><tbody>${state.monitors.filter(m=>m.status!=='maintenance').map(m=>`<tr><td><b>${esc(m.name)}</b><div class="muted">${esc(m.target)}</div></td><td>${badge(m.status)}</td><td style="text-align:right"><button class="btn tiny" data-startmaint="${m.id}">Schedule</button></td></tr>`).join('')}</tbody></table></div>`;$$('[data-endmaint]').forEach(b=>b.onclick=()=>clearMaintenance(Number(b.dataset.endmaint)).then(renderMaintenance));$$('[data-startmaint]').forEach(b=>b.onclick=()=>maintenanceModal(state.monitors.find(m=>m.id===Number(b.dataset.startmaint))))}
function renderNodes(){setCrumb('System / Environments');const local=`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>Local Docker</b><div class="muted">Docker socket</div></div></div></td><td>${badge('up')}<div class="healthDetail">${esc(state.system?.build?.version||'local')}</div></td><td>Local</td><td>—</td></tr>`;$('#content').innerHTML=`${pageHead('Environments','Master and remote agents managed from one control plane.',state.me.role==='admin'?'<button class="btn primary" id="addNode">+ Add environment</button>':'')}<div class="panel"><table class="table"><thead><tr><th>Name</th><th>Status</th><th>Connection</th><th>Actions</th></tr></thead><tbody>${local}${state.nodes.map(n=>`<tr><td><div class="namecell"><span class="cube">◎</span><div><b>${esc(n.name)}</b><div class="muted">${esc(n.base_url)}</div></div></div></td><td id="nodeHealth-${n.id}">${n.enabled?'<span class="status unknown">Checking…</span>':badge('paused')}</td><td>${n.enabled?'Bearer agent':'Disabled'}</td><td>${state.me.role==='admin'?`<button class="btn tiny" data-editnode="${n.id}">Edit</button> <button class="btn tiny danger" data-delnode="${n.id}">Remove</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$('#addNode')?.addEventListener('click',()=>nodeModal());$$('[data-editnode]').forEach(b=>b.onclick=()=>nodeModal(state.nodes.find(n=>n.id===Number(b.dataset.editnode))));$$('[data-delnode]').forEach(b=>b.onclick=async()=>{if(confirm('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(`<div class="modalhead"><h2>${x?'Edit':'Add'} remote agent</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nodeName" value="${esc(x?.name||'')}" placeholder="server-2"></div><div class="field full"><label>Agent base URL</label><input id="nodeURL" value="${esc(x?.base_url||'')}" placeholder="https://server-2.example.com"></div><div class="field full"><label>Agent token ${x?'<span class="muted">(leer lassen = beibehalten)</span>':''}</label><input id="nodeToken" type="password"></div>${x?`<label class="switch"><input id="nodeEnabled" type="checkbox" ${x.enabled?'checked':''}> Agent enabled</label>`:''}</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveNode">${x?'Save':'Add environment'}</button></div>`);$('#saveNode').onclick=async()=>{try{const body={name:$('#nodeName').value,base_url:$('#nodeURL').value,token:$('#nodeToken').value};if(x)body.enabled=$('#nodeEnabled').checked;await api(x?`/api/nodes/${x.id}`:'/api/nodes',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();const rows=await api('/api/nodes');state.nodes=Array.isArray(rows)?rows:[];renderNodePicker();renderNodes()}catch(e){toast(e.message)}}}
async function loadNodeHealth(){await Promise.all(state.nodes.filter(n=>n.enabled).map(async n=>{const el=$(`#nodeHealth-${n.id}`);if(!el)return;try{const h=await api(`/api/nodes/${n.id}/health`);state.nodeHealth[n.id]=h;el.innerHTML=`${badge('up')}<div class="healthDetail">v${esc(h?.build?.version||'?')} · ${esc(h?.mode||'agent')}</div>`}catch(e){el.innerHTML=`${badge('down')}<div class="healthDetail" title="${esc(e.message)}">unreachable</div>`}}))}
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='<button class="btn" id="identityAudit">Identity audit</button>';if(k==='images')create='<button class="btn primary" id="resourceCreate">↓ Pull image</button><button class="btn" id="registryLogin">Registry login</button><button class="btn" id="registryLogout">Logout</button>';if(k==='volumes')create='<button class="btn primary" id="resourceCreate">+ Create volume</button>';if(k==='networks')create='<button class="btn primary" id="resourceCreate">+ Create network</button>'}const prune=roleOK()&&k!=='containers'?'<button class="btn danger" id="resourcePrune">Prune unused</button>':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}<button class="btn" id="invRefresh">↻ Refresh</button>`)}<div class="panel"><div class="resourcebar"><input id="inventorySearch" placeholder="Filter ${n.toLowerCase()}…"><span id="inventoryCount" class="muted"></span></div><div id="inventoryPanel"><div class="empty">Loading ${n.toLowerCase()}…</div></div></div>`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)}
function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''}
async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
function renderInventoryRows(kind,all){const p=$('#inventoryPanel');if(!p)return;const q=$('#inventorySearch')?.value.toLowerCase()||'',rows=q?all.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):all;const count=$('#inventoryCount');if(count)count.textContent=`${rows.length} / ${all.length}`;if(!rows.length){p.innerHTML='<div class="empty">No matching items found.</div>';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=`<table class="table"><thead><tr>${cols.map(c=>`<th>${esc(c[1])}</th>`).join('')}<th class="right">Actions</th></tr></thead><tbody>${rows.map(r=>{const idx=all.indexOf(r);return `<tr>${cols.map((c,i)=>`<td class="${i?'muted':''}">${i===0?`<div class="namecell"><span class="cube">⬡</span><b>${esc(r[c[0]]||'—')}</b></div>`:esc(r[c[0]]||'—')}</td>`).join('')}<td class="right">${resourceActions(kind,r,idx)}</td></tr>`}).join('')}</tbody></table>`;wireResourceRows(kind)}
function resourceActions(kind,r,idx){const inspect=roleOK()?`<button class="btn tiny" data-inspect="${idx}">Inspect</button>`:'';if(kind==='containers')return `${inspect}${roleOK()?` <button class="btn tiny" data-identity="${idx}">Identity</button> <button class="btn tiny" data-ract="start" data-row="${idx}">Start</button> <button class="btn tiny" data-ract="restart" data-row="${idx}">Restart</button> <button class="btn tiny" data-ract="stop" data-row="${idx}">Stop</button> <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`:''}`;if(!roleOK())return'';return `${inspect} <button class="btn tiny danger" data-ract="remove" data-row="${idx}">Remove</button>`}
function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]));$$('[data-identity]').forEach(b=>b.onclick=()=>inspectContainerIdentity(window.__inventoryRows[Number(b.dataset.identity)]))}
async function dockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action} ${kind}? This can delete Docker resources.`))return;try{const r=await api(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);await loadInventory(kind)}catch(e){toast(e.message)}}
function registryLoginModal(){modal(`<div class="modalhead"><h2>Registry login</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div><div class="field"><label>Username</label><input id="regUser"></div><div class="field"><label>Password / token</label><input id="regPass" type="password"></div></div><div class="notice" style="margin-top:10px">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.</div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="regSave">Login</button></div>`);$('#regSave').onclick=async()=>{const body={registry:$('#regHost').value.trim(),username:$('#regUser').value.trim(),password:$('#regPass').value};if(!body.registry||!body.username||!body.password)return toast('Registry, username and password required.');closeModal();await dockerResourceAction('images','login',body)}}
function registryLogoutModal(){modal(`<div class="modalhead"><h2>Registry logout</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Registry</label><input id="regHost" placeholder="registry.example.com"></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn danger" id="regSave">Logout</button></div>`);$('#regSave').onclick=async()=>{const registry=$('#regHost').value.trim();if(!registry)return toast('Registry required.');closeModal();await dockerResourceAction('images','logout',{registry})}}
function resourceCreateModal(kind){if(kind==='images'){modal(`<div class="modalhead"><h2>Pull image</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="field"><label>Image reference</label><input id="resName" placeholder="nginx:alpine"></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="resSave">Pull</button></div>`);$('#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(`<div class="modalhead"><h2>Create ${isNet?'network':'volume'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="resName" placeholder="${isNet?'frontend':'app-data'}"></div><div class="field"><label>Driver</label><input id="resDriver" placeholder="${isNet?'bridge':'local'}"></div>${isNet?'<label class="switch"><input id="resInternal" type="checkbox"> Internal network</label><label class="switch"><input id="resAttachable" type="checkbox"> Attachable</label>':''}<div class="field full"><label>Labels (key=value, one per line)</label><textarea id="resLabels" spellcheck="false"></textarea></div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="resSave">Create</button></div>`);$('#resSave').onclick=async()=>{const labels={};($('#resLabels').value||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean).forEach(x=>{const i=x.indexOf('=');if(i<0)labels[x]='';else labels[x.slice(0,i).trim()]=x.slice(i+1).trim()});const body={name:$('#resName').value.trim(),driver:$('#resDriver').value.trim(),labels};if(isNet){body.internal=$('#resInternal').checked;body.attachable=$('#resAttachable').checked}if(!body.name)return toast('Name 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<rows.length;i+=4){const batch=rows.slice(i,i+4);results.push(...await Promise.allSettled(batch.map(r=>{const id=r.ID||r.Names||r.Name;return api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`).then(v=>({row:r,report:v}))})))}const ok=results.filter(x=>x.status==='fulfilled').map(x=>x.value),failed=results.filter(x=>x.status==='rejected');const rootCount=ok.filter(x=>x.report.runs_as_root===true).length,nonRoot=ok.filter(x=>x.report.runs_as_root===false).length,unknown=ok.length-rootCount-nonRoot;modal(`<div class="modalhead"><h2>Container identity audit · ${esc(nodeName())}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="stats"><div class="stat"><small>Containers checked</small><strong>${ok.length}</strong></div><div class="stat"><small>Root PID 1</small><strong class="${rootCount?'amber':''}">${rootCount}</strong></div><div class="stat"><small>Non-root PID 1</small><strong class="green">${nonRoot}</strong></div><div class="stat"><small>Unknown / failed</small><strong>${unknown+failed.length}</strong></div></div><div class="notice" style="margin-top:12px">“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.</div><div class="tablewrap" style="margin-top:12px"><table class="table"><thead><tr><th>Container</th><th>UID:GID</th><th>Assessment</th><th>Host account</th><th>Bind mounts</th></tr></thead><tbody>${ok.map(x=>{const d=x.report;return `<tr><td><b>${esc(d.container_name||x.row.Names||x.row.ID)}</b><div class="muted">${esc(d.image||'')}</div></td><td class="mono"><div>PID ${esc(d.effective_uid??'—')}:${esc(d.effective_gid??'—')}</div><div class="muted">bind ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</div></td><td>${esc(identityAssessmentLabel(d.root_assessment))}</td><td>${d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)}</span>`:(d.host_access?.available&&d.bind_uid>0?'<span class="warn">missing</span>':'—')}</td><td>${asArray(d.bind_mounts).length}</td></tr>`}).join('')}${failed.map((x,i)=>`<tr><td colspan="5" class="red">Check ${i+1} failed: ${esc(x.reason?.message||x.reason)}</td></tr>`).join('')}</tbody></table></div></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}catch(e){toast(e.message)}finally{setBusy(btn,false)}}
function hostAccountSuggestion(name){let n=String(name||'container').toLowerCase().replace(/[^a-z0-9_-]+/g,'-').replace(/^-+|-+$/g,'');if(!/^[a-z_]/.test(n))n='c-'+n;return('dockwatch-'+n).slice(0,31).replace(/-+$/,'')||'dockwatch-app'}
function identityAssessmentLabel(v){return ({'non-root':'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?'<span class="green">writable</span>':v===false?'<span class="red">not writable</span>':'<span class="muted">unknown</span>'}
async function inspectContainerIdentity(r){
const id=r.ID||r.Names||r.Name;if(!id)return;
try{
const d=await api(`/api/docker/containers/${encodeURIComponent(id)}/identity${qnode()}`),uid=d.effective_uid??'—',gid=d.effective_gid??'—';
const root=d.runs_as_root===true?'Yes':d.runs_as_root===false?'No':'Unknown',reasons=asArray(d.root_reasons).map(x=>`<li>${esc(x)}</li>`).join(''),recs=asArray(d.recommendations).map(x=>`<li>${esc(x)}</li>`).join(''),binds=asArray(d.bind_mounts),host=d.host_access||{};
window.__identityBindRows=binds.map(x=>({container:id,mount:x}));
const bindTable=binds.length?`<div class="field"><label>Bind mount permissions · expected ${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')} (${esc(d.bind_identity_source||'unknown')})</label><div class="tablewrap"><table class="table"><thead><tr><th>Host path</th><th>Container path</th><th>Mode / owner</th><th>Write access</th><th></th></tr></thead><tbody>${binds.map((x,i)=>`<tr><td class="mono">${esc(x.source)}</td><td class="mono">${esc(x.destination)} ${x.read_only?'<span class="tag">ro</span>':'<span class="tag">rw</span>'}</td><td><code>${esc(x.mode||'—')}</code> · ${x.owner_uid===undefined?'—':`${esc(x.owner_user||x.owner_uid)} (${esc(x.owner_uid)}:${esc(x.owner_gid)})`}${x.acl_detected?'<div class="warn">ACL detected</div>':''}</td><td>${x.read_only?'<span class="muted">read-only</span>':writeState(x.static_writable)}<div class="muted">${esc(x.writable_reason||x.ownership_note||'')}</div></td><td class="right">${!x.read_only?`<button class="btn tiny" data-bindpreview="${i}">Analyze / repair</button>`:''}</td></tr>`).join('')}</tbody></table></div></div>`:'<div class="notice">No bind mounts detected.</div>';
const bindHost=d.bind_host_user?`<span class="green">${esc(d.bind_host_user.name)} (${esc(d.bind_host_user.uid)}:${esc(d.bind_host_user.gid)})</span>`:(host.available&&d.bind_uid>0?'<span class="warn">No matching host user</span>':'—');
const canCreate=state.me.role==='admin'&&host.available&&host.management_enabled&&d.host_id_mapping!=='remapped'&&Number.isInteger(d.bind_uid)&&d.bind_uid>0&&!d.bind_host_user;
modal(`<div class="modalhead"><h2>Identity & bind permissions · ${esc(d.container_name||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Process identity and storage identity are evaluated separately.</b> PID 1 may run as root while an image writes application data with PUID/PGID. Dockwatch never changes Compose <code>user:</code> automatically.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>PID 1 UID:GID</small><strong>${esc(uid)}:${esc(gid)}</strong></div><div class="stat"><small>Bind UID:GID</small><strong>${esc(d.bind_uid??'—')}:${esc(d.bind_gid??'—')}</strong></div><div class="stat"><small>Runs as root</small><strong>${esc(root)}</strong></div><div class="stat"><small>Assessment</small><strong>${esc(identityAssessmentLabel(d.root_assessment))}</strong></div></div><div class="twocol" style="margin-top:12px"><div class="panel inset"><h3>Why</h3><ul class="compactList">${reasons||'<li>No additional reasons.</li>'}</ul></div><div class="panel inset"><h3>Recommendations</h3><ul class="compactList">${recs||'<li>No recommendations.</li>'}</ul></div></div><div class="panel inset" style="margin-top:12px"><div class="row"><div><h3>Host identity</h3><div class="muted">${esc(host.message||'Host access unavailable')}</div></div><div>${bindHost}</div></div>${host.configured?`<div class="meta" style="margin-top:8px">Host root: <code>${esc(host.root||'')}</code> · user creation ${host.management_enabled?'enabled':'off'} · permission repair ${host.permission_management_enabled?'enabled':'off'} · UID mapping ${esc(d.host_id_mapping||'unknown')}</div>`:''}</div>${bindTable}</div><div class="modalfoot">${canCreate?'<button class="btn" id="createMatchingHostUser">Create matching host account…</button>':''}<button class="btn" data-close>Close</button></div>`);
$$('[data-bindpreview]').forEach(b=>b.onclick=()=>{const x=window.__identityBindRows[Number(b.dataset.bindpreview)];bindPermissionModal(x.container,x.mount.destination)});
if(canCreate)$('#createMatchingHostUser').onclick=()=>hostUserModal(id,d);
}catch(e){toast(e.message)}
}
function hostUserModal(containerID,d){const suggested=hostAccountSuggestion(d.container_name);modal(`<div class="modalhead"><h2>Create matching host account</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice dangerNotice"><b>Host change:</b> Dockwatch re-checks the container and derives the bind UID/GID server-side. Numeric IDs cannot be chosen by the browser.</div><div class="stats" style="margin-top:12px"><div class="stat"><small>Container</small><strong>${esc(d.container_name)}</strong></div><div class="stat"><small>Bind UID:GID</small><strong>${esc(d.bind_uid)}:${esc(d.bind_gid)}</strong></div><div class="stat"><small>Source</small><strong>${esc(d.bind_identity_source||'container identity')}</strong></div></div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Host username</label><input id="hostUsername" value="${esc(suggested)}"></div><div class="field"><label>Host group name</label><input id="hostGroup" value="${esc(suggested)}"></div><label class="switch"><input id="hostHome" type="checkbox"> Create home directory</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn danger" id="confirmHostUser">Create on host</button></div>`);$('#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(`<div class="modalhead"><h2>Bind permission review</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field full"><label>Mount</label><div class="mono">${esc(p.source)} → ${esc(p.destination)}</div></div><div class="field"><label>Expected identity</label><b>${esc(expected)}</b><div class="muted">${esc(p.identity_source||'')}</div></div><div class="field"><label>Current owner / mode</label><b>${esc(owner)} · ${esc(p.mode||'—')}</b></div><div class="field"><label>Host account for expected UID</label>${p.host_user?`<span class="green">${esc(p.host_user.name)}</span>`:'<span class="warn">missing</span>'}<div class="muted">${p.host_group?`group ${esc(p.host_group.name)}`:'numeric GID only'}</div></div><div class="field"><label>Static write check</label>${writeState(p.static_writable)}<div class="muted">${esc(p.writable_reason||'')}</div></div><div class="field"><label>Runtime write check</label>${writeState(p.runtime_writable)}<div class="muted">${esc(p.runtime_note||'')}</div></div><div class="field"><label>Ownership scan</label><b>${esc(p.entries_ownership_mismatch||0)} / ${esc(p.entries_scanned||0)} differ</b><div class="muted">${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</div></div><div class="field"><label>ACL</label>${p.acl_detected?'<span class="warn">extended ACL detected</span>':'<span class="green">no extended ACL detected</span>'}<div class="muted">${esc(p.acl_note||'')}</div></div></div><div class="notice ${p.blocked_reason?'dangerNotice':''}" style="margin-top:12px">${p.blocked_reason?`<b>Automatic repair unavailable:</b> ${esc(p.blocked_reason)}<br>`:''}${esc(p.recommendation||'')}</div><div class="panel inset" style="margin-top:12px"><h3>Re-scan / repair scope</h3><label class="switch"><input id="bindRecursive" type="checkbox" ${p.recursive?'checked':''}> Recursively inspect/chown files below this bind root</label><div class="field" style="margin-top:10px"><label>Optional top-level chmod</label><input id="bindMode" placeholder="e.g. 750 or 775"><div class="muted">Never applied automatically and never recursively. Leave empty to preserve mode bits.</div></div><label class="switch"><input id="bindFixOwner" type="checkbox" checked> Fix owner/group to ${esc(expected)}</label></div></div><div class="modalfoot"><button class="btn" id="rescanBind">Re-scan</button>${canCreateUser?'<button class="btn" id="bindCreateUser">Create host account…</button>':''}${canRepair?'<button class="btn danger" id="repairBind">Repair & verify</button>':''}<button class="btn" data-close>Close</button></div>`);
$('#rescanBind').onclick=()=>bindPermissionModal(p.container_id,p.destination,$('#bindRecursive').checked);
if(canCreateUser)$('#bindCreateUser').onclick=()=>hostUserModal(p.container_id,{container_name:p.container_name,bind_uid:p.expected_uid,bind_gid:p.expected_gid,bind_identity_source:p.identity_source,bind_host_user:p.host_user});
if(canRepair)$('#repairBind').onclick=async()=>{const recursive=$('#bindRecursive').checked,fix=$('#bindFixOwner').checked,mode=$('#bindMode').value.trim();if(recursive&&!p.recursive)return toast('Run a recursive re-scan first so the affected file count is known.');const what=recursive?`${p.entries_ownership_mismatch} entries recursively`:'the bind root only';if(!confirm(`Repair ${what} on ${nodeName()} to ${expected}${mode?` and set top-level mode ${mode}`:''}?`))return;const btn=$('#repairBind');setBusy(btn,true,'Repairing…');try{const out=await api(`/api/host/bind-permissions/repair${qnode()}`,{method:'POST',body:JSON.stringify({container_id:p.container_id,destination:p.destination,recursive,fix_ownership:fix,mode})});toast(out.message||'Bind mount repaired');showBindPermissionPreview(out.after);if(state.stack?.name)setTimeout(loadStackPermissions,0)}catch(e){toast(e.message);setBusy(btn,false)}}
}
async function loadStackPermissions(){const box=$('#permissionsBox');if(!box||!state.stack?.name)return;box.innerHTML='<div class="empty">Inspecting containers and bind mounts…</div>';try{const d=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/bind-permissions${qnode()}`),rows=[];asArray(d.containers).forEach(c=>{if(c.error){rows.push({service:c.service,error:c.error});return}asArray(c.report?.bind_mounts).forEach(m=>rows.push({service:c.service,container:c.container_id,report:c.report,mount:m}))});window.__stackBindRows=rows;if(!rows.length){box.innerHTML='<div class="empty">No created containers or bind mounts found.</div>';return}box.innerHTML=`<div class="tablewrap"><table class="table"><thead><tr><th>Service</th><th>Host → container</th><th>Expected</th><th>Owner / mode</th><th>Write</th><th></th></tr></thead><tbody>${rows.map((x,i)=>x.error?`<tr><td><b>${esc(x.service)}</b></td><td colspan="5" class="red">${esc(x.error)}</td></tr>`:`<tr><td><b>${esc(x.service||x.report?.container_name)}</b></td><td><div class="mono">${esc(x.mount.source)}</div><div class="muted mono">→ ${esc(x.mount.destination)} ${x.mount.read_only?'(ro)':'(rw)'}</div></td><td class="mono">${esc(x.report.bind_uid??'—')}:${esc(x.report.bind_gid??'—')}<div class="muted">${esc(x.report.bind_identity_source||'')}</div></td><td class="mono">${esc(x.mount.owner_uid??'—')}:${esc(x.mount.owner_gid??'—')} · ${esc(x.mount.mode||'—')}</td><td>${x.mount.read_only?'<span class="muted">read-only</span>':writeState(x.mount.static_writable)}</td><td class="right">${!x.mount.read_only?`<button class="btn tiny" data-stackbind="${i}">Analyze / repair</button>`:''}</td></tr>`).join('')}</tbody></table></div>`;$$('[data-stackbind]').forEach(b=>b.onclick=()=>{const x=window.__stackBindRows[Number(b.dataset.stackbind)];bindPermissionModal(x.container,x.mount.destination)})}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
async function inspectDockerResource(kind,r){const id=kind==='containers'?(r.ID||r.Names||r.Name):kind==='images'?(r.ID||([r.Repository,r.Tag].filter(Boolean).join(':'))):r.Name;if(!id)return;try{const d=await api(`/api/docker/${kind}/${encodeURIComponent(id)}/inspect${qnode()}`),i=d.inspect||{},st=d.stats||{};modal(`<div class="modalhead"><h2>${esc(resourceTitle(kind))} · ${esc(r.Names||r.Name||r.Repository||id)}</h2><button class="closex" data-close>×</button></div><div class="modalbody">${kind==='containers'?`<div class="stats"><div class="stat"><small>CPU</small><strong>${esc(st.CPUPerc||'—')}</strong></div><div class="stat"><small>Memory</small><strong>${esc(st.MemUsage||'—')}</strong></div><div class="stat"><small>Network I/O</small><strong>${esc(st.NetIO||'—')}</strong></div><div class="stat"><small>Block I/O</small><strong>${esc(st.BlockIO||'—')}</strong></div></div>`:''}<div class="field"><label>Inspect JSON <span class="muted">(operator/admin only; may contain sensitive environment values)</span></label><pre class="terminal" style="max-height:55vh">${esc(JSON.stringify(i,null,2))}</pre></div></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}catch(e){toast(e.message)}}
function showOutput(title,text){modal(`<div class="modalhead"><h2>${esc(title)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><pre class="terminal" style="max-height:60vh">${esc(text||'OK')}</pre></div><div class="modalfoot"><button class="btn" data-close>Close</button></div>`)}
let terminalWS=null;
function wsURL(path){const proto=location.protocol==='https:'?'wss:':'ws:';return `${proto}//${location.host}${path}`}
function stripANSI(s){return String(s||'').replace(/\x1b\[[0-?]*[ -\/]*[@-~]/g,'').replace(/\r/g,'')}
function closeTerminal(){const had=!!terminalWS;if(terminalWS){terminalWS.close();terminalWS=null}const out=$('#execOut');if(had&&out)out.textContent+='\n[disconnected]\n'}
function terminalKey(e){if(!terminalWS||terminalWS.readyState!==WebSocket.OPEN)return;let d='';if(e.ctrlKey&&e.key.length===1){const c=e.key.toUpperCase().charCodeAt(0);if(c>=64&&c<=95)d=String.fromCharCode(c-64)}else{const map={Enter:'\r',Backspace:'\x7f',Tab:'\t',Escape:'\x1b',ArrowUp:'\x1b[A',ArrowDown:'\x1b[B',ArrowRight:'\x1b[C',ArrowLeft:'\x1b[D',Home:'\x1b[H',End:'\x1b[F',Delete:'\x1b[3~',PageUp:'\x1b[5~',PageDown:'\x1b[6~'};d=map[e.key]||(e.key.length===1&&!e.metaKey&&!e.altKey?e.key:'')}if(d){e.preventDefault();terminalWS.send(JSON.stringify({type:'input',data:d}))}}
function terminalResize(){if(!terminalWS||terminalWS.readyState!==WebSocket.OPEN)return;const el=$('#execOut');if(!el)return;const cols=Math.max(40,Math.floor(el.clientWidth/7.2)),rows=Math.max(12,Math.floor(el.clientHeight/17));terminalWS.send(JSON.stringify({type:'resize',cols,rows}))}
function openTerminal(){closeTerminal();if(!state.stack?.name)return toast('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='<div class="empty">Resolving Compose model…</div>';try{const g=await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/graph${qnode()}`);renderGraph(box,g)}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
function renderGraph(box,g){const nodes=g.nodes||[],edges=g.edges||[];if(!nodes.length){box.innerHTML='<div class="empty">No graph nodes.</div>';return}const services=nodes.filter(n=>n.kind==='service'),resources=nodes.filter(n=>n.kind!=='service');const h=Math.max(360,Math.max(services.length,resources.length)*76+50),w=Math.max(760,box.clientWidth||900);const pos={};services.forEach((n,i)=>pos[n.id]={x:140,y:55+i*76});resources.forEach((n,i)=>pos[n.id]={x:w-160,y:55+i*76});const lines=edges.map(e=>{const a=pos[e.from],b=pos[e.to];if(!a||!b)return'';return `<path d="M${a.x+100},${a.y} C${a.x+240},${a.y} ${b.x-240},${b.y} ${b.x-100},${b.y}" class="edge ${esc(e.kind)}"/><text x="${(a.x+b.x)/2}" y="${(a.y+b.y)/2-4}" class="edgelabel">${esc(e.kind)}</text>`}).join('');const ns=nodes.map(n=>{const p=pos[n.id];return `<g class="gnode ${esc(n.kind)}"><rect x="${p.x-100}" y="${p.y-24}" width="200" height="48" rx="7"/><circle cx="${p.x-80}" cy="${p.y}" r="5" class="${n.running?'ok':''}"/><text x="${p.x-67}" y="${p.y-3}" class="glabel">${esc(n.label)}</text><text x="${p.x-67}" y="${p.y+12}" class="gsub">${esc(n.image||n.kind)}</text></g>`}).join('');box.innerHTML=`<svg viewBox="0 0 ${w} ${h}" role="img">${lines}${ns}</svg>`}
async function loadImageUpdates(){if(!state.stack?.name)return;const box=$('#updateBox');box.innerHTML='<div class="empty">Checking registry manifests…</div>';try{const rows=asArray(await api(`/api/stacks/${encodeURIComponent(state.stack.name)}/image-updates${qnode()}`));box.innerHTML=`<table class="table"><thead><tr><th>Service</th><th>Image</th><th>Local digest</th><th>Remote digest</th><th>Status</th></tr></thead><tbody>${rows.map(r=>`<tr><td><b>${esc(r.service)}</b></td><td>${esc(r.image)}</td><td class="digest">${esc(r.local_digest||'—')}</td><td class="digest">${esc(r.remote_digest||'—')}</td><td>${r.error?`<span class="red" title="${esc(r.error)}">check failed</span>`:r.update?'<span class="amber">Update available</span>':'<span class="green">Current</span>'}</td></tr>`).join('')}</tbody></table>`}catch(e){box.innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
async function renderActivity(){setCrumb('System / Activity');$('#content').innerHTML=`${pageHead('Activity','Persistent audit trail for changes, deployments and monitor transitions.','<input class="search" id="actFilter" placeholder="Filter action…"><button class="btn" id="actRefresh">↻ Refresh</button>')}<div class="panel" id="activityPanel"><div class="empty">Loading audit events…</div></div>`;const load=async()=>{try{const q=$('#actFilter')?.value.trim()||'',rows=asArray(await api('/api/activity?limit=200'+(q?'&action='+encodeURIComponent(q):'')));$('#activityPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Time</th><th>Actor</th><th>Action</th><th>Resource</th><th>Status</th><th>Details</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${fmtTime(x.created_at)}</td><td><b>${esc(x.actor)}</b></td><td><span class="tag">${esc(x.action)}</span></td><td>${esc(x.resource||'—')}</td><td>${x.status>=200&&x.status<300?'<span class="green">'+x.status+'</span>':'<span class="red">'+x.status+'</span>'}</td><td class="muted">${esc(JSON.stringify(x.detail||{}))}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No matching audit events.</div>'}catch(e){$('#activityPanel').innerHTML=`<div class="empty red">${esc(e.message)}</div>`}};$('#actRefresh').onclick=load;let t;$('#actFilter').oninput=()=>{clearTimeout(t);t=setTimeout(load,300)};load()}
async function renderGit(){setCrumb('Docker / Git Stacks');$('#content').innerHTML=`${pageHead('Git stacks','Synchronize Compose stacks from Git and deploy them from signed webhooks.',roleOK()?'<button class="btn primary" id="addGit">+ Add Git source</button>':'')}<div class="panel" id="gitPanel"><div class="empty">Loading Git sources…</div></div>`;$('#addGit')?.addEventListener('click',()=>gitModal());try{const rows=asArray(await api('/api/git-sources'));$('#gitPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Stack</th><th>Environment</th><th>Repository</th><th>Branch</th><th>Commit</th><th>Last sync</th><th>Auto deploy</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.stack_name)}</b>${x.last_error?`<div class="red">${esc(x.last_error)}</div>`:''}</td><td>${esc(x.node_id?(state.nodes.find(n=>n.id===x.node_id)?.name||'Remote'):'Local Docker')}</td><td>${esc(x.repo_url)}</td><td><span class="tag">${esc(x.branch)}</span></td><td class="digest">${esc((x.last_commit||'—').slice(0,12))}</td><td>${x.last_sync_at?fmtTime(x.last_sync_at):'Never'}</td><td>${x.auto_deploy?'<span class="green">Yes</span>':'No'}</td><td class="right">${roleOK()?`<button class="btn tiny" data-gsync="${x.id}">Sync</button> <button class="btn tiny" data-gedit="${x.id}">Edit</button> <button class="btn tiny danger" data-gdel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No Git sources configured.</div>';window.__gitRows=rows;$$('[data-gsync]').forEach(b=>b.onclick=()=>gitSync(Number(b.dataset.gsync)));$$('[data-gedit]').forEach(b=>b.onclick=()=>gitModal(rows.find(x=>x.id===Number(b.dataset.gedit))));$$('[data-gdel]').forEach(b=>b.onclick=async()=>{if(confirm('Delete Git source configuration?')){await api('/api/git-sources/'+b.dataset.gdel,{method:'DELETE'});renderGit()}})}catch(e){$('#gitPanel').innerHTML=`<div class="empty red">${esc(e.message)}</div>`}}
function gitModal(x=null){modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} Git source</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Stack name</label><input id="gitStack" value="${esc(x?.stack_name||'')}"></div><div class="field"><label>Environment</label><select id="gitNode"><option value="0">Local Docker</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}</select></div><div class="field"><label>Branch</label><input id="gitBranch" value="${esc(x?.branch||'main')}"></div><div class="field full"><label>Repository URL</label><input id="gitRepo" value="${esc(x?.repo_url||'')}" placeholder="https://github.com/org/repo.git or ssh://..."></div><div class="field"><label>Workdir</label><input id="gitWorkdir" value="${esc(x?.workdir||'.')}"></div><div class="field"><label>Compose file</label><input id="gitCompose" value="${esc(x?.compose_file||'compose.yaml')}"></div><label class="switch"><input id="gitAuto" type="checkbox" ${x?.auto_deploy?'checked':''}> Deploy after sync/webhook</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveGit">Save</button></div>`);$('#gitNode').value=String(x?.node_id||state.node||0);$('#saveGit').onclick=async()=>{const body={node_id:Number($('#gitNode').value)||null,stack_name:$('#gitStack').value,repo_url:$('#gitRepo').value,branch:$('#gitBranch').value,workdir:$('#gitWorkdir').value,compose_file:$('#gitCompose').value,auto_deploy:$('#gitAuto').checked};try{const r=await api(x?`/api/git-sources/${x.id}`:'/api/git-sources',{method:x?'PUT':'POST',body:JSON.stringify(body)});closeModal();if(!x)showGitSecret(r);renderGit()}catch(e){toast(e.message)}}}
function showGitSecret(r){modal(`<div class="modalhead"><h2>Webhook created</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice">Copy this secret now. It is stored encrypted and will not be shown again.</div><div class="field" style="margin-top:10px"><label>Webhook URL</label><input readonly value="${esc(r.webhook_url)}"></div><div class="field" style="margin-top:10px"><label>Webhook secret</label><input readonly value="${esc(r.webhook_secret)}"></div><p class="muted">GitHub: use the secret for X-Hub-Signature-256. GitLab: send it as X-Gitlab-Token. Generic hooks may use X-Webhook-Token.</p></div><div class="modalfoot"><button class="btn primary" data-close>Done</button></div>`)}
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'?'<button class="btn primary" id="addNotify">+ Add provider</button>':'')}<div class="panel" id="notifyPanel"><div class="empty">Loading providers…</div></div>`;$('#addNotify')?.addEventListener('click',()=>notificationModal());try{const rows=asArray(await api('/api/notifications'));$('#notifyPanel').innerHTML=rows.length?`<table class="table"><thead><tr><th>Name</th><th>Provider</th><th>Enabled</th><th>Configuration</th><th></th></tr></thead><tbody>${rows.map(x=>`<tr><td><b>${esc(x.name)}</b></td><td><span class="tag">${esc(x.type)}</span></td><td>${x.enabled?'<span class="green">Enabled</span>':'Disabled'}</td><td class="muted">${esc(Object.entries(x.config||{}).map(([k,v])=>`${k}=${v}`).join(' · '))}</td><td class="right">${state.me.role==='admin'?`<button class="btn tiny" data-ntest="${x.id}">Test</button> <button class="btn tiny" data-nedit="${x.id}">Edit</button> <button class="btn tiny danger" data-ndel="${x.id}">Delete</button>`:''}</td></tr>`).join('')}</tbody></table>`:'<div class="empty">No notification providers configured.</div>';$$('[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=`<div class="empty red">${esc(e.message)}</div>`}}
function notifyConfigFields(type,c={}){const f=(id,label,key,secret=false,ph='')=>`<div class="field"><label>${label}</label><input id="${id}" ${secret?'type="password"':''} value="${esc(c[key]||'')}" placeholder="${esc(ph)}"></div>`;if(type==='webhook')return f('nUrl','URL','url',false,'https://...')+f('nToken','Bearer token','bearer_token',true);if(type==='ntfy')return f('nServer','Server','server',false,'https://ntfy.sh')+f('nTopic','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')+`<div class="field"><label>Security</label><select id="nSecurity"><option value="starttls">STARTTLS</option><option value="tls">SSL/TLS</option><option value="none">None</option></select></div><label class="switch"><input id="nAuth" type="checkbox" ${c.auth==='true'||(!c.auth&&c.username)?'checked':''}> SMTP authentication</label>`+f('nUser','Username','username')+f('nPass','Password','password',true)+f('nFrom','From','from')+f('nTo','To','to',false,'ops@example.com')+`<label class="switch"><input id="nSkipVerify" type="checkbox" ${c.skip_verify==='true'?'checked':''}> Disable TLS certificate verification (unsafe)</label>`}
function notificationModal(x=null){const type=x?.type||'webhook';modal(`<div class="modalhead"><h2>${x?'Edit':'Add'} notification provider</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="nName" value="${esc(x?.name||'')}"></div><div class="field"><label>Type</label><select id="nType"><option value="webhook">Webhook</option><option value="ntfy">ntfy</option><option value="gotify">Gotify</option><option value="smtp">SMTP</option></select></div><div id="nConfig" class="fieldgrid full" style="grid-column:1/-1">${notifyConfigFields(type,x?.config||{})}</div><label class="switch"><input id="nEnabled" type="checkbox" ${x?.enabled!==false?'checked':''}> Enabled</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="nSave">Save</button></div>`);$('#nType').value=type;if(type==='smtp'&&$('#nSecurity'))$('#nSecurity').value=x?.config?.security||'starttls';$('#nType').onchange=()=>$('#nConfig').innerHTML=notifyConfigFields($('#nType').value,{});$('#nSave').onclick=async()=>{const t=$('#nType').value,c={};if(t==='webhook'){c.url=$('#nUrl').value;c.bearer_token=$('#nToken').value}else if(t==='ntfy'){c.server=$('#nServer').value;c.topic=$('#nTopic').value;c.token=$('#nToken').value}else if(t==='gotify'){c.server=$('#nServer').value;c.token=$('#nToken').value}else{c.host=$('#nHost').value;c.port=$('#nPort').value;c.security=$('#nSecurity').value;c.auth=String($('#nAuth').checked);c.username=$('#nUser').value;c.password=$('#nPass').value;c.from=$('#nFrom').value;c.to=$('#nTo').value;c.skip_verify=String($('#nSkipVerify').checked)}try{await api(x?`/api/notifications/${x.id}`:'/api/notifications',{method:x?'PUT':'POST',body:JSON.stringify({name:$('#nName').value,type:t,config:c,enabled:$('#nEnabled').checked})});closeModal();renderNotifications()}catch(e){toast(e.message)}}}
window.addEventListener('resize',terminalResize);
function modal(html){$('#modalRoot').innerHTML=`<div class="modalback"><div class="modal" role="dialog" aria-modal="true">${html}</div></div>`;$$('[data-close]').forEach(b=>b.onclick=closeModal);$('.modalback').onclick=e=>{if(e.target.classList.contains('modalback'))closeModal()};setTimeout(()=>$('.modal input:not([type=hidden]),.modal select,.modal button')?.focus(),0)}
function closeModal(){$('#modalRoot').innerHTML=''}
init().catch(e=>toast(e.message));