Update
release-tag / release-image (push) Successful in 2m39s

This commit is contained in:
2026-09-01 14:27:24 +02:00
parent 6eb4e093ec
commit 98fe0ed746
4 changed files with 191 additions and 11 deletions
+16 -1
View File
@@ -1,4 +1,4 @@
# Dockwatch v9.5
# Dockwatch v9.5.1
> Go module: `git.send.nrw/sendnrw/dockwatch`
@@ -6,6 +6,15 @@ Dockwatch is a single-binary Go control plane for Docker Compose, Docker resourc
The same binary runs as `standalone`, `master` or `agent`. SQLite uses `modernc.org/sqlite`, so the application itself builds with `CGO_ENABLED=0`.
## v9.5.1 UI / Compose editor fixes
- Compose scalar edits no longer re-render the complete visual tree after every keystroke, so text/port fields keep focus while typing. Structural changes (add/remove/type changes) still re-parse the YAML AST.
- Compose designer controls now use the application theme instead of browser-default white form controls in dark mode.
- Stack container cards format Compose publisher JSON into readable port mappings and wrap long image/command values safely.
- Docker inventory tables now have horizontal overflow handling, sensible column widths, compact label summaries, and a sticky action column so buttons are no longer squeezed off-screen.
- Static assets are cache-busted to `v9.5.1`.
## v9.5 firewall-provider layer
- Host Security firewall management now uses a provider model: **Auto detect / UFW / firewalld / native nftables**
@@ -535,3 +544,9 @@ v9.3.2 fixes Docker/CI builds that stopped at `go: updates to go.mod needed; to
### Build-context note (v9.3.3)
Runtime directories in `.dockerignore` and `.gitignore` are root-anchored (`/stacks/`, `/data/`, `/bin/`, `/dist/`). This is intentional: unanchored patterns such as `stacks/` also match the source package `internal/stacks/` and can make Go try to resolve the project's own internal package as a remote module during `go mod tidy`.
### v9.5.2 UI polish
- Firewall rule editor uses a wide responsive modal and keeps long CIDRs, comments and provider output inside the dialog.
- Security details and notices wrap long unbroken values safely.
- Light mode now has consistent navigation, buttons, inputs, tables, tabs, code/editor surfaces, notices, modals and Host Security cards instead of inheriting dark-only hard-coded colors.
+14 -8
View File
@@ -62,7 +62,7 @@ async function parseComposeVisual(){const box=$('#composeVisual'),ta=$('#compose
function renderComposeSections(){const host=$('#composeSections');if(!host)return;const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),missing=[...new Set(usages.filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];const volumeLabel=`Volumes <span class="tag ${missing.length?'warn':''}">${usages.length}${missing.length?` · ${missing.length}!`:''}</span>`;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes',volumeLabel],['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(composeSection==='volumes'){body=composeVolumeSection(v,base)}else if(['networks','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'<div class="empty">Nothing configured in this section.</div>';wireComposeTree();wireComposeVolumeReconcile()}
function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const scrollTop=box.scrollTop,v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(composeSection==='volumes'){body=composeVolumeSection(v,base)}else if(['networks','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'<div class="empty">Nothing configured in this section.</div>';wireComposeTree();wireComposeVolumeReconcile();box.scrollTop=Math.min(scrollTop,Math.max(0,box.scrollHeight-box.clientHeight))}
function composeNamedObjectEditor(v,path,label,suggestions){if(!v||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,label);const rows=Object.entries(v).map(([k,val])=>`<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 splitComposeMountShort(raw){const s=String(raw??''),cuts=[];let brace=0;for(let i=0;i<s.length;i++){if(s[i]==='$'&&s[i+1]==='{'){brace++;i++;continue}if(s[i]==='}'&&brace>0){brace--;continue}if(s[i]===':'&&brace===0)cuts.push(i)}if(cuts.length&&cuts[0]===1&&/^[A-Za-z]$/.test(s[0])&&/[\\/]/.test(s[2]||''))cuts.shift();if(!cuts.length)return{source:'',target:s,options:'',raw:s};const a=cuts[0],b=cuts[1];return{source:s.slice(0,a),target:b===undefined?s.slice(a+1):s.slice(a+1,b),options:b===undefined?'':s.slice(b+1),raw:s}}
function classifyComposeMountSource(source,type=''){if(!source)return String(type||'').toLowerCase()||'anonymous';const s=String(source),t=String(type||'').toLowerCase();if(s.includes('$'))return'dynamic';if(t)return t;if(s.startsWith('/')||s.startsWith('./')||s.startsWith('../')||s.startsWith('~/')||/^[A-Za-z]:[\\/]/.test(s)||s.includes('/'))return'bind';return'volume'}
@@ -75,11 +75,12 @@ function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),
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 wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v,false,{refresh:false})};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Enter a field name.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Enter a name.');queueComposePatch([...p,k],{})})}
function debounceFn(fn,ms){let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
function composePatchPathKey(path){return JSON.stringify(path.map(String))}
function queueComposePatch(path,value,del=false){const item={path,value,delete:del},key=composePatchPathKey(path);if(!del){const i=composePatchQueue.findIndex(x=>!x.delete&&composePatchPathKey(x.path)===key);if(i>=0)composePatchQueue[i]=item;else composePatchQueue.push(item)}else composePatchQueue.push(item);void processComposePatchQueue()}
async function processComposePatchQueue(){if(composePatchBusy)return;composePatchBusy=true;try{while(composePatchQueue.length){const p=composePatchQueue.shift();try{const ta=$('#composeText');if(!ta){composePatchQueue=[];break}const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();await parseComposeVisual()}catch(e){toast('Compose patch failed: '+e.message);composePatchQueue=[];await parseComposeVisual();break}}}finally{composePatchBusy=false}}
function queueComposePatch(path,value,del=false,opt={}){const item={path,value,delete:del,refresh:opt.refresh!==false},key=composePatchPathKey(path);if(!del){const i=composePatchQueue.findIndex(x=>!x.delete&&composePatchPathKey(x.path)===key);if(i>=0)composePatchQueue[i]=item;else composePatchQueue.push(item)}else composePatchQueue.push(item);void processComposePatchQueue()}
function markComposeSynced(){const status=$('#composeSyncState');if(status){status.className='green';status.textContent=' YAML ↔ Designer synchronized'}renderComposeSections()}
async function processComposePatchQueue(){if(composePatchBusy)return;composePatchBusy=true;try{while(composePatchQueue.length){const p=composePatchQueue.shift();try{const ta=$('#composeText');if(!ta){composePatchQueue=[];break}const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();if(p.refresh)await parseComposeVisual();else markComposeSynced()}catch(e){toast('Compose patch failed: '+e.message);composePatchQueue=[];await parseComposeVisual();break}}}finally{composePatchBusy=false}}
function addComposeService(){composeSection='services';queueComposePatch(['services','service'+(((composeVisualModel?.services&&Object.keys(composeVisualModel.services).length)||0)+1)],{image:'nginx:alpine'})}
function envTab(st){return `<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>`}
@@ -87,7 +88,9 @@ function secretRow(s={}){return `<div class="secretrow"><input class="secName" p
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 formatServicePorts(raw){const text=String(raw||'').trim();if(!text)return'—';try{const p=JSON.parse(text);if(Array.isArray(p)){const items=p.map(x=>{if(!x||typeof x!=='object')return String(x??'');const proto=x.Protocol||x.protocol||'tcp',target=x.TargetPort??x.target_port??x.target,pub=x.PublishedPort??x.published_port??x.published,url=x.URL??x.url??'';if(pub!==undefined&&pub!==null&&String(pub)!==''){const host=url?(String(url).includes(':')&&!String(url).startsWith('[')?`[${url}]`:String(url)):'';return `${host?host+':':''}${pub} → ${target??'?'}${proto?'/'+proto:''}`}return target!==undefined?`${target}${proto?'/'+proto:''}`:''}).filter(Boolean);if(items.length)return items.join(' · ')}}catch{}return text}
function svcMetaValue(value,opt={}){const full=String(value||'—'),shown=opt.ports?formatServicePorts(full):full;return `<code class="svcvalue" title="${esc(full)}">${esc(shown)}</code>`}
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>${svcMetaValue(v.image)}<span class="muted">Ports</span>${svcMetaValue(v.ports,{ports:true})}<span class="muted">Command</span>${svcMetaValue(v.command)}</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>`}
@@ -142,7 +145,10 @@ function resourceTitle(k){return({containers:'Containers',images:'Images',volume
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 inventoryLabelSummary(raw){const full=typeof raw==='string'?raw:raw&&typeof raw==='object'?Object.entries(raw).map(([k,v])=>`${k}=${v}`).join(','):String(raw||'');if(!full)return'—';const parts=full.split(',').map(x=>x.trim()).filter(Boolean),pretty=parts.slice(0,3).map(x=>x.replace(/^com\.docker\.compose\./,'compose.'));return pretty.join(' · ')+(parts.length>3?` · +${parts.length-3}`:'')}
function inventoryCell(kind,key,value,first=false){const labelRaw=key==='Labels'&&value&&typeof value==='object'?Object.entries(value).map(([k,v])=>`${k}=${v}`).join(','):null,raw=value===undefined||value===null||value===''?'—':labelRaw??String(value);if(first)return `<div class="namecell"><span class="cube">⬡</span><b title="${esc(raw)}">${esc(raw)}</b></div>`;if(key==='Labels')return `<span class="inventory-ellipsis inventory-labels" title="${esc(raw)}">${esc(inventoryLabelSummary(value))}</span>`;if(['Mountpoint','Ports','Status','ID'].includes(key))return `<span class="inventory-ellipsis" title="${esc(raw)}">${esc(raw)}</span>`;return esc(raw)}
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=`<div class="inventory-tablewrap"><table class="table inventory-table inventory-${esc(kind)}"><thead><tr>${cols.map(c=>`<th data-col="${esc(c[0])}">${esc(c[1])}</th>`).join('')}<th class="right inventory-actions-cell">Actions</th></tr></thead><tbody>${rows.map(r=>{const idx=all.indexOf(r);return `<tr>${cols.map((c,i)=>`<td data-col="${esc(c[0])}" class="${i?'muted':''}">${inventoryCell(kind,c[0],r[c[0]],i===0)}</td>`).join('')}<td class="right inventory-actions-cell"><div class="inventory-actions">${resourceActions(kind,r,idx)}</div></td></tr>`}).join('')}</tbody></table></div>`;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)}}
@@ -244,7 +250,7 @@ async function securityComponentAction(component,action,btn){if(['disable','stop
async function securityFirewallModal(){try{const p=await api('/api/security/firewall'+qnode()),preview=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(p)});firewallEditor(preview.policy||p,preview)}catch(e){toast(e.message)}}
function firewallProviderOptions(current='auto'){return ['auto','ufw','firewalld','nftables'].map(v=>`<option value="${v}" ${current===v?'selected':''}>${v==='auto'?'Auto detect':v}</option>`).join('')}
function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `<div class="panel inset fwRuntime"><div class="panelhead"><h3>Detected host firewall</h3><span class="tag">selected: ${esc(b.selected||'—')}</span></div><div class="securityFacts"><span><small>Available</small><b>${esc(available.join(', ')||'none')}</b></span><span><small>Active</small><b>${esc(active.join(', ')||'none')}</b></span><span><small>Default</small><b>${esc(r.default_inbound||'—')}</b></span><span><small>Zone</small><b>${esc(r.zone||b.default_zone||'—')}</b></span></div>${b.reason?`<div class="notice ${asArray(b.conflicts).length?'dangerNotice':'warnNotice'}">${esc(b.reason)}</div>`:''}<div class="field"><label>Existing native rules/state <span class="muted">· read-only · foreign rules are preserved</span></label><pre class="terminal fwExisting" style="max-height:230px">${esc(r.raw||'No runtime rules reported.')}</pre></div></div>`}
function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`<div class="modalhead"><h2>Host firewall · ${esc(selected)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Provider model:</b> Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs <code>ufw reset</code> or <code>nft flush ruleset</code>.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Firewall provider</label><select id="fwProvider">${firewallProviderOptions(p.provider||'auto')}</select></div><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Enable Dockwatch-managed rules</label><label class="switch"><input id="fwManageDefault" type="checkbox" ${p.manage_default?'checked':''} ${selected==='nftables'?'disabled':''}> Manage provider default inbound ${selected==='nftables'?'<span class="muted">(native policy is always local to Dockwatch table)</span>':''}</label><div class="field"><label>Default inbound</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div>${selected==='firewalld'?`<div class="field"><label>firewalld zone</label><input id="fwZone" value="${esc(p.zone||preview.runtime?.zone||backend.default_zone||'public')}" placeholder="public"></div>`:''}<label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''} ${selected!=='nftables'?'disabled':''}> Allow ICMP / IPv6 ICMP ${selected!=='nftables'?'<span class="muted">(kept native by this provider)</span>':''}</label><div class="field full"><label>Trusted CIDRs · one per line</label><textarea id="fwTrusted" placeholder="192.0.2.0/24\n2001:db8::/32">${esc(asArray(p.trusted_cidrs).join('\n'))}</textarea></div></div>${firewallRuntimeHTML(preview)}<div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Dockwatch-managed port rules</h3><button class="btn tiny" id="fwAddRule">+ Rule</button></div><div id="fwRules">${rules.map(firewallRuleRow).join('')}</div></div><div class="notice dangerNotice" style="margin-top:12px"><b>Lockout protection:</b> Apply starts a 90-second rollback timer. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.</div></div><div class="modalfoot">${!installed&&selected!=='auto'?`<button class="btn" id="fwInstall" ${packages?'':'disabled'}>Install ${esc(selected)}</button>`:''}<button class="btn" data-close>Cancel</button><button class="btn" id="fwPreview">Preview ${esc(selected)}</button><button class="btn danger" id="fwApply" ${preview.can_apply===false?'disabled':''}>Apply with rollback</button></div>`);$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
function firewallEditor(p,preview={}){const rules=asArray(p.rules),backend=preview.backend||{},selected=backend.selected||p.provider||'auto',installed=asArray(backend.available).includes(selected),packages=!!((window.__securityCaps||{}).allow_package_management&&(window.__securityCaps||{}).executor_available);modal(`<div class="modalhead"><h2>Host firewall · ${esc(selected)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice"><b>Provider model:</b> Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs <code>ufw reset</code> or <code>nft flush ruleset</code>.</div><div class="fieldgrid" style="margin-top:12px"><div class="field"><label>Firewall provider</label><select id="fwProvider">${firewallProviderOptions(p.provider||'auto')}</select></div><label class="switch"><input id="fwEnabled" type="checkbox" ${p.enabled?'checked':''}> Enable Dockwatch-managed rules</label><label class="switch"><input id="fwManageDefault" type="checkbox" ${p.manage_default?'checked':''} ${selected==='nftables'?'disabled':''}> Manage provider default inbound ${selected==='nftables'?'<span class="muted">(native policy is always local to Dockwatch table)</span>':''}</label><div class="field"><label>Default inbound</label><select id="fwDefault"><option value="accept">ACCEPT</option><option value="drop">DROP</option></select></div>${selected==='firewalld'?`<div class="field"><label>firewalld zone</label><input id="fwZone" value="${esc(p.zone||preview.runtime?.zone||backend.default_zone||'public')}" placeholder="public"></div>`:''}<label class="switch"><input id="fwICMP" type="checkbox" ${p.allow_icmp!==false?'checked':''} ${selected!=='nftables'?'disabled':''}> Allow ICMP / IPv6 ICMP ${selected!=='nftables'?'<span class="muted">(kept native by this provider)</span>':''}</label><div class="field full"><label>Trusted CIDRs · one per line</label><textarea id="fwTrusted" placeholder="192.0.2.0/24\n2001:db8::/32">${esc(asArray(p.trusted_cidrs).join('\n'))}</textarea></div></div>${firewallRuntimeHTML(preview)}<div class="panel inset" style="margin-top:12px"><div class="panelhead"><h3>Dockwatch-managed port rules</h3><button class="btn tiny" id="fwAddRule">+ Rule</button></div><div id="fwRules">${rules.map(firewallRuleRow).join('')}</div></div><div class="notice dangerNotice" style="margin-top:12px"><b>Lockout protection:</b> Apply starts a 90-second rollback timer. Foreign provider rules remain intact. If you opt into managing the provider's global default inbound, verify SSH/Dockwatch access before keeping the change.</div></div><div class="modalfoot">${!installed&&selected!=='auto'?`<button class="btn" id="fwInstall" ${packages?'':'disabled'}>Install ${esc(selected)}</button>`:''}<button class="btn" data-close>Cancel</button><button class="btn" id="fwPreview">Preview ${esc(selected)}</button><button class="btn danger" id="fwApply" ${preview.can_apply===false?'disabled':''}>Apply with rollback</button></div>`,'modal-wide modal-firewall');$('#fwDefault').value=p.default_inbound||'accept';$('#fwProvider').onchange=async()=>{const policy=collectFirewallPolicy();policy.provider=$('#fwProvider').value;try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(policy)});firewallEditor(x.policy||policy,x)}catch(e){toast(e.message)}};$('#fwInstall')?.addEventListener('click',e=>securityInstall('firewall',e.currentTarget,selected));$('#fwAddRule').onclick=()=>{$('#fwRules').insertAdjacentHTML('beforeend',firewallRuleRow({action:'accept',protocol:'tcp',port:'',source:'',comment:''}));wireFirewallRows()};wireFirewallRows();$('#fwPreview').onclick=async()=>{try{const x=await api(`/api/security/firewall/preview${qnode()}`,{method:'POST',body:JSON.stringify(collectFirewallPolicy())});showSecurityPreview(`${x.backend?.selected||'firewall'} preview`,x.rendered,x.warnings,x.conflicts)}catch(e){toast(e.message)}};$('#fwApply').onclick=async()=>{const policy=collectFirewallPolicy(),provider=(preview.backend||{}).selected||policy.provider;if((provider==='nftables'||policy.manage_default)&&policy.default_inbound==='drop'&&!confirm('Default inbound DROP can disconnect this host. Confirm that your SSH/Dockwatch management ports are explicitly allowed. Continue with timed rollback?'))return;const btn=$('#fwApply');setBusy(btn,true,'Applying…');try{const out=await api(`/api/security/firewall/apply${qnode()}`,{method:'POST',body:JSON.stringify({policy,rollback_seconds:90})});firewallCommitModal(out)}catch(e){toast(e.message);setBusy(btn,false)}}}
function firewallRuleRow(r={}){return `<div class="fwRule"><select class="fwAction"><option value="accept" ${r.action==='accept'||!r.action?'selected':''}>ALLOW</option><option value="drop" ${r.action==='drop'?'selected':''}>DENY</option><option value="reject" ${r.action==='reject'?'selected':''}>REJECT</option><option value="limit" ${r.action==='limit'?'selected':''}>LIMIT</option></select><select class="fwProto"><option value="tcp" ${r.protocol!=='udp'?'selected':''}>TCP</option><option value="udp" ${r.protocol==='udp'?'selected':''}>UDP</option></select><input class="fwPort" placeholder="22 or 8000-8100" value="${esc(r.port||'')}"><input class="fwSource" placeholder="Source CIDR · optional" value="${esc(r.source||'')}"><input class="fwComment" placeholder="Comment" value="${esc(r.comment||'')}"><button class="iconbtn fwUp" title="Move up">↑</button><button class="iconbtn fwDown" title="Move down">↓</button><button class="iconbtn fwRemove" title="Remove">×</button></div>`}
function wireFirewallRows(){$$('.fwRemove').forEach(b=>b.onclick=()=>b.closest('.fwRule').remove());$$('.fwUp').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule');if(r.previousElementSibling)r.parentElement.insertBefore(r,r.previousElementSibling)});$$('.fwDown').forEach(b=>b.onclick=()=>{const r=b.closest('.fwRule'),n=r.nextElementSibling;if(n)r.parentElement.insertBefore(n,r)})}
function collectFirewallPolicy(){return {provider:$('#fwProvider')?.value||'auto',enabled:!!$('#fwEnabled')?.checked,manage_default:!!$('#fwManageDefault')?.checked,default_inbound:$('#fwDefault')?.value||'accept',zone:$('#fwZone')?.value.trim()||'',allow_icmp:$('#fwICMP')?.checked!==false,trusted_cidrs:($('#fwTrusted')?.value||'').split(/[\n,]+/).map(x=>x.trim()).filter(Boolean),rules:$$('.fwRule').map(r=>({action:r.querySelector('.fwAction').value,protocol:r.querySelector('.fwProto').value,port:r.querySelector('.fwPort').value.trim(),source:r.querySelector('.fwSource').value.trim(),comment:r.querySelector('.fwComment').value.trim()})).filter(r=>r.port)}}
@@ -260,6 +266,6 @@ function auditWatchRow(w={}){return `<div class="audWatch securityFormRow audit"
function wireAuditRows(){$$('.audRemove').forEach(b=>b.onclick=()=>b.closest('.audWatch').remove())}
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 modal(html,className=''){const classes=['modal',...String(className||'').split(/\s+/).filter(Boolean)].join(' ');$('#modalRoot').innerHTML=`<div class="modalback"><div class="${classes}" 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));
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dockwatch</title>
<link rel="stylesheet" href="/styles.css?v=9.5">
<link rel="stylesheet" href="/styles.css?v=9.5.2">
</head>
<body>
<div id="shell">
@@ -39,5 +39,5 @@
</main>
</div>
<div id="modalRoot"></div><div id="toast"></div>
<script src="/app.js?v=9.5"></script>
<script src="/app.js?v=9.5.2"></script>
</body></html>
+159
View File
@@ -27,3 +27,162 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap
@media(max-width:860px){.securityHero{align-items:flex-start;flex-direction:column}.securityCaps{justify-content:flex-start}.securityFacts{grid-template-columns:repeat(2,1fr)}.securitySafety{grid-template-columns:1fr}.fwRule{grid-template-columns:1fr 1fr}.fwRule .fwPort,.fwRule .fwSource,.fwRule .fwComment{grid-column:span 2}.securityFormRow{grid-template-columns:1fr 1fr}.securityFormRow .f2bLog{grid-column:span 2}.securityFormRow.audit{grid-template-columns:1fr}.securityChecks{grid-template-columns:1fr}}
.volume-reconcile{margin-bottom:14px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}
.volume-reconcile .panelhead{align-items:flex-start;margin-bottom:10px}.volume-reconcile .panelhead h3{margin:0 0 3px;font-size:13px}.volume-reconcile .panelhead p{margin:0;max-width:720px}.table.compact th,.table.compact td{padding:7px 8px;font-size:11px}.volume-unused{margin-top:10px;display:flex;gap:7px;align-items:center;flex-wrap:wrap}.volume-note{margin-top:8px}.tagx{border:0;background:transparent;color:inherit;padding:0 0 0 5px;cursor:pointer;font:inherit}.compose-subsection{border-top:1px solid var(--line);padding-top:12px}.empty.small{padding:12px}
/* v9.5.1 UI polish: stable compose controls, readable service metadata, inventory tables */
.compose-scalar input,.compose-scalar select,.compose-scalar textarea,.compose-addrow input,.compose-addrow select{
width:100%;min-width:0;background:#0e131b;border:1px solid var(--line);color:var(--text);border-radius:5px;padding:6px 8px;outline:0;
}
.compose-scalar input:focus,.compose-scalar select:focus,.compose-scalar textarea:focus,.compose-addrow input:focus,.compose-addrow select:focus{
border-color:#6658d8;box-shadow:0 0 0 2px #6d5dfc18;
}
.compose-scalar .typeswitch{width:88px}.compose-arrayitem>.compose-scalar{min-width:0}.compose-arrayitem input{min-width:0}
:root[data-theme="light"] .compose-scalar input,:root[data-theme="light"] .compose-scalar select,:root[data-theme="light"] .compose-scalar textarea,:root[data-theme="light"] .compose-addrow input,:root[data-theme="light"] .compose-addrow select{background:#fff;color:var(--text)}
.servicecards{grid-template-columns:repeat(auto-fill,minmax(min(320px,100%),460px));justify-content:start;align-items:start}
.servicecard{min-width:0}.servicecard .svcmeta{grid-template-columns:68px minmax(0,1fr);align-items:start;row-gap:6px}.servicecard .svcmeta>span,.servicecard .svcmeta>code{min-width:0}
.svcvalue{display:block;color:#c7d5e8;white-space:normal;overflow-wrap:anywhere;word-break:break-word;line-height:1.4}
:root[data-theme="light"] .svcvalue{color:#334155}
.inventory-tablewrap{width:100%;max-width:100%;overflow-x:auto;overflow-y:visible;scrollbar-gutter:stable}
.inventory-table{min-width:920px;table-layout:fixed}
.inventory-table th,.inventory-table td{min-width:0}
.inventory-table [data-col="Name"],.inventory-table [data-col="Names"],.inventory-table [data-col="Repository"]{width:220px}
.inventory-table [data-col="Driver"],.inventory-table [data-col="Tag"],.inventory-table [data-col="State"],.inventory-table [data-col="Scope"],.inventory-table [data-col="IPv6"],.inventory-table [data-col="Internal"]{width:95px}
.inventory-table [data-col="Mountpoint"]{width:250px}
.inventory-table [data-col="Size"],.inventory-table [data-col="CreatedSince"]{width:110px}
.inventory-table [data-col="ID"]{width:180px}
.inventory-table [data-col="Image"]{width:230px}
.inventory-table [data-col="Status"]{width:210px}
.inventory-table [data-col="Ports"]{width:260px}
.inventory-table [data-col="Labels"]{width:auto;min-width:300px}
.inventory-ellipsis{display:block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.inventory-labels{font:10px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace}
.inventory-actions-cell{width:155px;min-width:155px;position:sticky;right:0;z-index:2;background:var(--panel);box-shadow:-10px 0 16px -14px #000}
.inventory-table thead .inventory-actions-cell{z-index:3;background:#10151e}
.inventory-table tbody tr:hover .inventory-actions-cell{background:#151b26}
.inventory-actions{display:flex;justify-content:flex-end;align-items:center;gap:5px;flex-wrap:wrap;white-space:nowrap}
.inventory-containers{min-width:1260px}.inventory-containers .inventory-actions-cell{width:380px;min-width:380px}
.inventory-images .inventory-actions-cell,.inventory-networks .inventory-actions-cell,.inventory-volumes .inventory-actions-cell{width:155px;min-width:155px}
:root[data-theme="light"] .inventory-actions-cell{background:var(--panel)}:root[data-theme="light"] .inventory-table thead .inventory-actions-cell{background:#f7f9fc}:root[data-theme="light"] .inventory-table tbody tr:hover .inventory-actions-cell{background:#f3f6fb}
@media(max-width:1050px){.inventory-tablewrap{margin:0}.inventory-table{min-width:880px}.servicecards{grid-template-columns:1fr}}
.right{white-space:nowrap}
/* v9.5.2 UI polish: firewall overflow + complete light-theme surfaces */
.modal-wide{width:min(1040px,calc(100vw - 40px))}
.modal-firewall{overflow-x:hidden}
.modal-firewall .modalbody,.modal-firewall .panel,.modal-firewall .panel.inset,.modal-firewall .fwRuntime{min-width:0;max-width:100%}
.modal-firewall .notice,.modal-firewall .fwExisting,.modal-firewall code,.modal-firewall .muted{overflow-wrap:anywhere;word-break:break-word}
.modal-firewall .fwExisting{max-width:100%;overflow-x:auto;white-space:pre-wrap;word-break:break-word}
.modal-firewall #fwRules{min-width:0;max-width:100%}
.modal-firewall .fwRule{
grid-template-columns:minmax(84px,.8fr) minmax(72px,.7fr) minmax(112px,.95fr) minmax(150px,1.35fr) minmax(130px,1.1fr) 30px 30px 30px;
width:100%;max-width:100%;min-width:0;
}
.modal-firewall .fwRule>*{min-width:0;max-width:100%}
.modal-firewall .fwRule input,.modal-firewall .fwRule select{width:100%;max-width:100%;overflow:hidden;text-overflow:ellipsis}
.modal-firewall .fwRule .iconbtn{width:30px;min-width:30px;max-width:30px}
@media(max-width:900px){
.modalback{padding:10px}
.modal-wide{width:min(100%,calc(100vw - 20px))}
.modal-firewall .fwRule{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 30px 30px 30px;grid-template-areas:"action proto up down remove" "port port port port port" "source source source source source" "comment comment comment comment comment";padding:8px;border:1px solid var(--line);border-radius:7px;background:var(--panel2)}
.modal-firewall .fwAction{grid-area:action}.modal-firewall .fwProto{grid-area:proto}.modal-firewall .fwPort{grid-area:port}.modal-firewall .fwSource{grid-area:source}.modal-firewall .fwComment{grid-area:comment}.modal-firewall .fwUp{grid-area:up}.modal-firewall .fwDown{grid-area:down}.modal-firewall .fwRemove{grid-area:remove}
}
/* Make arbitrary long security/provider output stay inside its card/modal. */
.securityCard,.securityCardHead,.securityFinding,.securityFinding>div,.securityDetail,.securityVersion,.notice,.tip,.panelhead{min-width:0;max-width:100%}
.securityCardHead p,.securityFinding p,.securityFinding small,.securityDetail,.securityVersion,.notice,.tip{overflow-wrap:anywhere;word-break:break-word}
.securityDetail{white-space:pre-wrap;overflow-x:auto}
/* Light theme: replace the remaining dark-only hard-coded surfaces. */
:root[data-theme="light"] body{color:var(--text)}
:root[data-theme="light"] .topbar{background:#f8fafce8}
:root[data-theme="light"] .crumb{color:#475569}
:root[data-theme="light"] nav button{color:#475569}
:root[data-theme="light"] nav button span{color:#64748b}
:root[data-theme="light"] nav button em{background:#eef2f7;color:#64748b}
:root[data-theme="light"] nav button:hover{background:#f1f5f9;color:#0f172a}
:root[data-theme="light"] nav button.active{background:#ede9fe;color:#312e81}
:root[data-theme="light"] nav button.active span{color:#6d5dfc}
:root[data-theme="light"] .avatar{background:#eef2f7;border-color:#d7dee9;color:#334155}
:root[data-theme="light"] .iconbtn{background:#fff;color:#64748b;border-color:#d7dee9}
:root[data-theme="light"] .iconbtn:hover{background:#f8fafc;color:#334155}
:root[data-theme="light"] .btn{background:#fff;color:#334155;border-color:#cbd5e1}
:root[data-theme="light"] .btn:hover{background:#f8fafc;border-color:#94a3b8}
:root[data-theme="light"] .btn.primary{background:#6d5dfc;border-color:#6d5dfc;color:#fff}
:root[data-theme="light"] .btn.success{background:#ecfdf5;border-color:#a7f3d0;color:#047857}
:root[data-theme="light"] .btn.danger{background:#fff1f2;border-color:#fecdd3;color:#be123c}
:root[data-theme="light"] .search,
:root[data-theme="light"] .listhead input,
:root[data-theme="light"] .field input,
:root[data-theme="light"] .field select,
:root[data-theme="light"] .field textarea,
:root[data-theme="light"] .secretrow input,
:root[data-theme="light"] .consolebar input,
:root[data-theme="light"] .consolebar select,
:root[data-theme="light"] .resourcebar input,
:root[data-theme="light"] .fwRule input,
:root[data-theme="light"] .fwRule select,
:root[data-theme="light"] .securityFormRow input,
:root[data-theme="light"] .securityFormRow select{background:#fff;color:#1e293b;border-color:#cbd5e1}
:root[data-theme="light"] input::placeholder,:root[data-theme="light"] textarea::placeholder{color:#94a3b8}
:root[data-theme="light"] .table th{background:#f8fafc;color:#64748b}
:root[data-theme="light"] .table td{border-bottom-color:#e5eaf1}
:root[data-theme="light"] .table tbody tr:hover{background:#f8fafc}
:root[data-theme="light"] .cube{background:#f1f5f9;border-color:#d8e0ea;color:#64748b}
:root[data-theme="light"] .tag{background:#f1f5f9;border-color:#d8e0ea;color:#64748b}
:root[data-theme="light"] .stackitem,:root[data-theme="light"] .monitoritem{border-bottom-color:#e5eaf1}
:root[data-theme="light"] .stackitem:hover,:root[data-theme="light"] .monitoritem:hover{background:#f8fafc}
:root[data-theme="light"] .stackitem.active,:root[data-theme="light"] .monitoritem.active{background:#f1efff}
:root[data-theme="light"] .tabs{background:#f8fafc}
:root[data-theme="light"] .tabs button{color:#64748b}
:root[data-theme="light"] .tabs button.active{color:#312e81}
:root[data-theme="light"] .codebox textarea{background:#fbfdff;color:#243247;border-color:#d8e0ea}
:root[data-theme="light"] .editoraside,:root[data-theme="light"] .servicecard,:root[data-theme="light"] .mini{background:#f8fafc}
:root[data-theme="light"] .tip{background:#f1f5f9;border-color:#d8e0ea;color:#64748b}
:root[data-theme="light"] .chart{background:#fbfdff}
:root[data-theme="light"] .notice{background:#fffbeb;border-color:#fde68a;color:#92400e}
:root[data-theme="light"] .draftnotice{background:#fffbeb;border-color:#fde68a;color:#92400e}
:root[data-theme="light"] .dangerNotice{background:#fff1f2!important;border-color:#fecdd3!important;color:#9f1239!important}
:root[data-theme="light"] .warnNotice{background:#fffbeb!important;border-color:#fde68a!important;color:#92400e!important}
:root[data-theme="light"] .modalback{background:#0f172a66}
:root[data-theme="light"] .modal{background:#fff;border-color:#cbd5e1}
:root[data-theme="light"] .modalhead,:root[data-theme="light"] .modalfoot{background:#fff}
:root[data-theme="light"] .panel.inset,:root[data-theme="light"] .securitySafety>div,:root[data-theme="light"] .securityFacts span,:root[data-theme="light"] .securityIcon,:root[data-theme="light"] .securityDetail,:root[data-theme="light"] .securityCountdown,:root[data-theme="light"] .volume-reconcile{background:#f8fafc}
:root[data-theme="light"] .securityHero{background:linear-gradient(135deg,#fff,#f8fafc)}
:root[data-theme="light"] .tag.oktag{background:#ecfdf5;border-color:#a7f3d0;color:#047857}
:root[data-theme="light"] .securityFinding.sev-high>span{background:#fff1f2;color:#be123c}
:root[data-theme="light"] .securityFinding.sev-medium>span{background:#fffbeb;color:#b45309}
:root[data-theme="light"] .securityFinding.sev-ok>span{background:#ecfdf5;color:#047857}
:root[data-theme="light"] #toast{background:#fff;border-color:#cbd5e1;color:#1e293b}
:root[data-theme="light"] code{color:#334155}
:root[data-theme="light"] kbd{background:#f1f5f9;border-color:#cbd5e1;color:#334155}
:root[data-theme="light"] .inventory-actions-cell{box-shadow:-10px 0 16px -14px #64748b55}
:root[data-theme="light"] .modal-firewall .fwRule{background:#f8fafc}
/* v9.5.2 light-mode contrast + compose designer surfaces */
:root[data-theme="light"]{--green:#059669;--red:#e11d48;--amber:#b45309;--blue:#2563eb;--purple:#6d5dfc}
.modalhead h2,.modalfoot,.switch,.fieldgrid>*{min-width:0}
.modalhead h2{overflow-wrap:anywhere}.modalfoot{flex-wrap:wrap}.switch{flex-wrap:wrap}
:root[data-theme="light"] .status.running,:root[data-theme="light"] .status.up{color:#047857}
:root[data-theme="light"] .status.down,:root[data-theme="light"] .status.exited{color:#be123c}
:root[data-theme="light"] .status.maintenance{color:#b45309}
:root[data-theme="light"] .status.pending,:root[data-theme="light"] .status.unknown,:root[data-theme="light"] .status.paused{color:#64748b}
:root[data-theme="light"] .compose-livebar,
:root[data-theme="light"] .compose-service,
:root[data-theme="light"] .compose-object,
:root[data-theme="light"] .compose-help span,
:root[data-theme="light"] .compose-sections button{background:#f8fafc}
:root[data-theme="light"] .compose-service-head,
:root[data-theme="light"] .compose-object>summary{background:#f1f5f9}
:root[data-theme="light"] .compose-fieldrow{background:#fff;border-color:#dbe3ed}
:root[data-theme="light"] .compose-fieldrow.complex{background:#fbfdff}
:root[data-theme="light"] .compose-sections button.active{background:#eef2ff;border-color:#818cf8;color:#3730a3}
:root[data-theme="light"] .compose-arrayitem{border-left-color:#cbd5e1}
:root[data-theme="light"] .compose-preserved{background:#fffbeb;border-color:#fde68a;color:#92400e}
:root[data-theme="light"] .compose-parseerror{background:#fff1f2;border-color:#fecdd3;color:#9f1239}
:root[data-theme="light"] .compose-parseerror p{color:#be123c}
:root[data-theme="light"] .placeholder{background:#f8fafc;color:#94a3b8}
:root[data-theme="light"] .environment{background:#fff}
:root[data-theme="light"] .sidebarFooter{background:#fff}