v9.3.1
release-tag / release-image (push) Failing after 1m15s

This commit is contained in:
2026-08-31 22:17:18 +02:00
parent c6c25d26c3
commit 56170ccde9
19 changed files with 485 additions and 51 deletions
+36 -5
View File
@@ -42,7 +42,7 @@ function newStack(){if(state.dirty&&!confirm('Aktuellen Entwurf verwerfen?'))ret
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="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-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 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'];
@@ -82,11 +82,12 @@ function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.mana
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','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);$('#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 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.')}}
@@ -130,18 +131,48 @@ function renderNodes(){setCrumb('System / Environments');const local=`<tr><td><d
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==='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);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(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-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)]))}
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>`)}
+2
View File
@@ -18,3 +18,5 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap
@media(max-width:860px){#shell{display:block}.sidebar{transform:translateX(-100%);transition:transform .18s;width:218px;box-shadow:var(--shadow)}body.sidebar-mobile-open .sidebar{transform:translateX(0)}main{grid-column:auto}.topbar{padding-left:50px}.topbar:before{content:"☰";position:absolute;left:14px;font-size:19px;color:var(--muted);cursor:pointer}.splitview{grid-template-columns:1fr}.listpanel{max-height:300px}.twocol{grid-template-columns:1fr}.compose-livegrid{grid-template-columns:1fr!important}}
.topLeft{display:flex;align-items:center;gap:8px}.mobileMenu{display:none}@media(max-width:860px){.topbar{padding-left:12px}.topbar:before{display:none}.mobileMenu{display:inline-grid;place-items:center}}
.serviceProbeList{max-height:320px;overflow:auto;border:1px solid var(--line);border-radius:7px;padding:6px;background:var(--panel2)}.serviceProbeList .switch{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;padding:7px;border-radius:5px}.serviceProbeList .switch:hover{background:var(--panel3)}
/* v9.2 host/container identity diagnostics */
.mono{font:11px/1.45 "Cascadia Code","SFMono-Regular",Consolas,monospace}.warn{color:var(--amber)}.compactList{margin:8px 0 0;padding-left:18px;color:var(--muted)}.compactList li{margin:6px 0}.panel.inset{padding:12px;background:var(--panel2);overflow:visible}.panel.inset h3{margin:0;font-size:12px}.dangerNotice{border-color:#592733!important;background:#2b171e!important;color:#f1bcc5!important}.tablewrap{overflow:auto;border:1px solid var(--line);border-radius:7px}.tablewrap .table{min-width:720px}