205 lines
113 KiB
JavaScript
205 lines
113 KiB
JavaScript
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
||
const asArray=v=>Array.isArray(v)?v:[];
|
||
const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
const state={me:{role:'viewer'},system:null,nodes:[],nodeHealth:{},stacks:[],monitors:[],services:[],node:0,view:'dashboard',stack:null,monitor:null,checks:[],dirty:false,draftKey:'',logStream:null,logBuffer:'',logPaused:false,logAutoScroll:true,refreshing:false};
|
||
async function api(path,opt={}){let r;try{r=await fetch(path,{headers:{'Content-Type':'application/json',...(opt.headers||{})},...opt})}catch(e){if(e?.name!=='AbortError')setApiState(false,e.message);throw e}setApiState(true);if(r.status===401){location='/auth/login';throw Error('Nicht angemeldet')}const txt=await r.text();if(!r.ok)throw Error(txt||r.statusText);if(!txt)return null;try{return JSON.parse(txt)}catch{throw Error('Ungültige JSON-Antwort vom Server')}}
|
||
function setApiState(ok,msg=''){const e=$('#apiState');if(!e)return;e.classList.toggle('offline',!ok);e.title=ok?'API erreichbar':('API nicht erreichbar'+(msg?': '+msg:''));const t=e.querySelector('span');if(t)t.textContent=ok?'API':'Offline'}
|
||
function applyPreferences(){const theme=localStorage.getItem('dockwatch:theme')||'dark';document.documentElement.dataset.theme=theme;document.body.classList.toggle('sidebar-collapsed',localStorage.getItem('dockwatch:sidebar')==='collapsed'&&innerWidth>860);const t=$('#themeToggle');if(t)t.textContent=theme==='light'?'☾':'☀'}
|
||
function toggleTheme(){const next=(document.documentElement.dataset.theme||'dark')==='dark'?'light':'dark';localStorage.setItem('dockwatch:theme',next);applyPreferences()}
|
||
function teardownInteractive(){stopLiveLogs();closeTerminal()}
|
||
function setBusy(btn,on,label=''){if(!btn)return;btn.disabled=!!on;btn.classList.toggle('busy',!!on);if(on){if(!btn.dataset.oldText)btn.dataset.oldText=btn.textContent;if(label)btn.textContent=label}else if(btn.dataset.oldText){btn.textContent=btn.dataset.oldText;delete btn.dataset.oldText}}
|
||
function toast(msg){const e=$('#toast');e.textContent=msg;e.style.display='block';clearTimeout(e._t);e._t=setTimeout(()=>e.style.display='none',4200)}
|
||
function roleOK(min='operator'){return state.me.role==='admin'||(min==='operator'&&state.me.role==='operator')}
|
||
function qnode(){return state.node?`?node_id=${state.node}`:''}
|
||
function joinQ(base,extra){return base+(base.includes('?')?'&':'?')+extra}
|
||
function badge(s){return `<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));
|