diff --git a/README.md b/README.md index 39e1110..6042b6e 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/web/app.js b/web/app.js index 16d8ff6..92f6478 100644 --- a/web/app.js +++ b/web/app.js @@ -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 ${usages.length}${missing.length?` · ${missing.length}!`:''}`;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes',volumeLabel],['configs','Configs'],['secrets','Secrets'],['models','Models'],['include','Include']];host.innerHTML=items.map(([k,l])=>``).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})} function sectionValue(){const m=composeVisualModel||{};if(composeSection==='project'){const x={};for(const k of Object.keys(m))if(!['services','networks','volumes','configs','secrets','models','include'].includes(k))x[k]=m[k];return x}return m[composeSection]??(composeSection==='include'?[]:{})} function sectionPath(){return composeSection==='project'?[]:[composeSection]} -function renderComposeVisual(){const box=$('#composeVisual');if(!box||!composeVisualModel)return;const v=sectionValue(),base=sectionPath();let body='';if(composeSection==='project'){body=composeMapEditor(v,base,{rootProject:true})}else if(composeSection==='services'){body=composeNamedObjectEditor(v,base,'service',COMPOSE_SERVICE_FIELDS)}else if(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||'
Nothing configured in this section.
';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||'
Nothing configured in this section.
';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])=>`
${esc(k)} ${esc(label)}
${composeValueEditor(val,[...path,k],k,{serviceRoot:label==='service',suggestions})}
`).join('');return `${rows}
`} function splitComposeMountShort(raw){const s=String(raw??''),cuts=[];let brace=0;for(let i=0;i0){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 `
${v.map((x,i)=>`
#${i+1}
${composeValueEditor(x,[...path,String(i)],label)}
`).join('')}
`;if(t==='boolean')return `
${typeSwitcher(path,t)}
`;if(t==='null')return `
null${typeSwitcher(path,t)}
`;if(t==='number')return `
${typeSwitcher(path,t)}
`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `
${multiline?``:``}${typeSwitcher(path,'string')}
`} function typeSwitcher(path,t){return ``} function newValueForType(t){return t==='map'?{}:t==='array'?[]:t==='boolean'?false:t==='number'?0:t==='null'?null:''} -function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v)};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Enter a field name.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Enter a name.');queueComposePatch([...p,k],{})})} +function 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 `
`} function secretsTab(st){return `
Secret values are written with mode 0600. Use Compose secrets: with file: ./secrets/name. Existing values are visible only to operators who can edit the stack.
${(st.secrets||[]).map(secretRow).join('')}
`} @@ -87,7 +88,9 @@ function secretRow(s={}){return `
${title} · ${help} Managed files are validated together with the stack.
${files.map(f=>managedFileRow(kind,f)).join('')}
`} function managedFileRow(kind,f={}){const cls=kind==='envfile'?'envfilerow':'configrow';return `
`} function collectManaged(sel){return $$(sel).map(r=>({name:r.querySelector('.managedName').value.trim(),content:r.querySelector('.managedContent').value})).filter(f=>f.name)} -function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
This stack has no running containers yet.
';return `
${sv.map(v=>`
${esc(v.service||v.name)}${badge(v.state||v.status)}
Image${esc(v.image||'—')}Ports${esc(v.ports||'—')}Command${esc(v.command||'—')}
`).join('')}
`} +function 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 `${esc(shown)}`} +function servicesTab(st){const sv=st.services||[];if(!sv.length)return'
This stack has no running containers yet.
';return `
${sv.map(v=>`
${esc(v.service||v.name)}${badge(v.state||v.status)}
Image${svcMetaValue(v.image)}Ports${svcMetaValue(v.ports,{ports:true})}Command${svcMetaValue(v.command)}
`).join('')}
`} function logsTab(){return `
idle
Select “Load” or “Follow”.
`} function graphTab(){return `
Load the normalized Compose dependency graph.
`} function permissionsTab(){return `
Checks expected application UID/GID, host ownership, mode bits and ACL hints.
Run the analysis to review writable bind mounts for every created service.
`} @@ -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='';if(k==='images')create='';if(k==='volumes')create='';if(k==='networks')create=''}const prune=roleOK()&&k!=='containers'?'':'';$('#content').innerHTML=`${pageHead(n,`${nodeName()} · Docker Engine`,`${create}${prune}`)}
Loading ${n.toLowerCase()}…
`;$('#invRefresh').onclick=()=>loadInventory(k);$('#inventorySearch').oninput=()=>renderInventoryRows(k,window.__inventoryRows||[]);$('#resourceCreate')?.addEventListener('click',()=>resourceCreateModal(k));$('#registryLogin')?.addEventListener('click',registryLoginModal);$('#registryLogout')?.addEventListener('click',registryLogoutModal);$('#identityAudit')?.addEventListener('click',containerIdentityAudit);$('#resourcePrune')?.addEventListener('click',()=>dockerResourceAction(k,'prune',{},true));loadInventory(k)} function dockerID(r){return r.ID||r.Id||r.ImageID||r.Name||''} async function loadInventory(kind){try{const rows=await api(`/api/docker/${kind}${qnode()}`);window.__inventoryRows=Array.isArray(rows)?rows:[];renderInventoryRows(kind,window.__inventoryRows)}catch(e){const p=$('#inventoryPanel');if(p)p.innerHTML=`
${esc(e.message)}
`}} -function renderInventoryRows(kind,all){const p=$('#inventoryPanel');if(!p)return;const q=$('#inventorySearch')?.value.toLowerCase()||'',rows=q?all.filter(r=>JSON.stringify(r).toLowerCase().includes(q)):all;const count=$('#inventoryCount');if(count)count.textContent=`${rows.length} / ${all.length}`;if(!rows.length){p.innerHTML='
No matching items found.
';return}const defs={containers:[['Names','Name'],['Image','Image'],['State','State'],['Status','Status'],['Ports','Ports']],images:[['Repository','Repository'],['Tag','Tag'],['ID','Image ID'],['Size','Size'],['CreatedSince','Created']],volumes:[['Name','Name'],['Driver','Driver'],['Mountpoint','Mountpoint'],['Labels','Labels']],networks:[['Name','Name'],['Driver','Driver'],['Scope','Scope'],['IPv6','IPv6'],['Internal','Internal']]};const cols=defs[kind]||Object.keys(rows[0]).slice(0,5).map(k=>[k,k]);p.innerHTML=`${cols.map(c=>``).join('')}${rows.map(r=>{const idx=all.indexOf(r);return `${cols.map((c,i)=>``).join('')}`}).join('')}
${esc(c[1])}Actions
${i===0?`
⬡${esc(r[c[0]]||'—')}
`:esc(r[c[0]]||'—')}
${resourceActions(kind,r,idx)}
`;wireResourceRows(kind)} +function 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 `
⬡${esc(raw)}
`;if(key==='Labels')return `${esc(inventoryLabelSummary(value))}`;if(['Mountpoint','Ports','Status','ID'].includes(key))return `${esc(raw)}`;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='
No matching items found.
';return}const defs={containers:[['Names','Name'],['Image','Image'],['State','State'],['Status','Status'],['Ports','Ports']],images:[['Repository','Repository'],['Tag','Tag'],['ID','Image ID'],['Size','Size'],['CreatedSince','Created']],volumes:[['Name','Name'],['Driver','Driver'],['Mountpoint','Mountpoint'],['Labels','Labels']],networks:[['Name','Name'],['Driver','Driver'],['Scope','Scope'],['IPv6','IPv6'],['Internal','Internal']]};const cols=defs[kind]||Object.keys(rows[0]).slice(0,5).map(k=>[k,k]);p.innerHTML=`
${cols.map(c=>``).join('')}${rows.map(r=>{const idx=all.indexOf(r);return `${cols.map((c,i)=>``).join('')}`}).join('')}
${esc(c[1])}Actions
${inventoryCell(kind,c[0],r[c[0]],i===0)}
${resourceActions(kind,r,idx)}
`;wireResourceRows(kind)} + function resourceActions(kind,r,idx){const inspect=roleOK()?``:'';if(kind==='containers')return `${inspect}${roleOK()?` `:''}`;if(!roleOK())return'';return `${inspect} `} function wireResourceRows(kind){$$('[data-ract]').forEach(b=>b.onclick=()=>{const r=window.__inventoryRows[Number(b.dataset.row)]||{},a=b.dataset.ract;let payload={};if(kind==='containers')payload={id:r.ID||r.Names||r.Name,force:a==='remove'};else if(kind==='images')payload={id:r.ID,name:[r.Repository,r.Tag].filter(Boolean).join(':')};else payload={name:r.Name};dockerResourceAction(kind,a,payload,a==='remove')});$$('[data-inspect]').forEach(b=>b.onclick=()=>inspectDockerResource(kind,window.__inventoryRows[Number(b.dataset.inspect)]));$$('[data-identity]').forEach(b=>b.onclick=()=>inspectContainerIdentity(window.__inventoryRows[Number(b.dataset.identity)]))} async function dockerResourceAction(kind,action,payload,confirmFirst=false){if(confirmFirst&&!confirm(`${action} ${kind}? This can delete Docker resources.`))return;try{const r=await api(`/api/docker/${kind}/actions/${action}${qnode()}`,{method:'POST',body:JSON.stringify(payload||{})});toast(`${resourceTitle(kind)}: ${action} completed`);if(r?.output)showOutput(`${resourceTitle(kind)} · ${action}`,r.output);await loadInventory(kind)}catch(e){toast(e.message)}} @@ -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=>``).join('')} function firewallRuntimeHTML(preview={}){const b=preview.backend||{},r=preview.runtime||{},available=asArray(b.available),active=asArray(b.active);return `

Detected host firewall

selected: ${esc(b.selected||'—')}
Available${esc(available.join(', ')||'none')}Active${esc(active.join(', ')||'none')}Default${esc(r.default_inbound||'—')}Zone${esc(r.zone||b.default_zone||'—')}
${b.reason?`
${esc(b.reason)}
`:''}
${esc(r.raw||'No runtime rules reported.')}
`} -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(`

Host firewall · ${esc(selected)}

Provider model: Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs ufw reset or nft flush ruleset.
${selected==='firewalld'?`
`:''}
${firewallRuntimeHTML(preview)}

Dockwatch-managed port rules

${rules.map(firewallRuleRow).join('')}
Lockout protection: 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.
${!installed&&selected!=='auto'?``:''}
`);$('#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(`

Host firewall · ${esc(selected)}

Provider model: Auto uses active UFW or firewalld when present and falls back to native nftables. Dockwatch preserves rules it does not own and never runs ufw reset or nft flush ruleset.
${selected==='firewalld'?`
`:''}
${firewallRuntimeHTML(preview)}

Dockwatch-managed port rules

${rules.map(firewallRuleRow).join('')}
Lockout protection: 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.
${!installed&&selected!=='auto'?``:''}
`,'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 `
`} 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 `
b.onclick=()=>b.closest('.audWatch').remove())} window.addEventListener('resize',terminalResize); -function modal(html){$('#modalRoot').innerHTML=`
`;$$('[data-close]').forEach(b=>b.onclick=closeModal);$('.modalback').onclick=e=>{if(e.target.classList.contains('modalback'))closeModal()};setTimeout(()=>$('.modal input:not([type=hidden]),.modal select,.modal button')?.focus(),0)} +function modal(html,className=''){const classes=['modal',...String(className||'').split(/\s+/).filter(Boolean)].join(' ');$('#modalRoot').innerHTML=`
`;$$('[data-close]').forEach(b=>b.onclick=closeModal);$('.modalback').onclick=e=>{if(e.target.classList.contains('modalback'))closeModal()};setTimeout(()=>$('.modal input:not([type=hidden]),.modal select,.modal button')?.focus(),0)} function closeModal(){$('#modalRoot').innerHTML=''} init().catch(e=>toast(e.message)); diff --git a/web/index.html b/web/index.html index 881c542..7a52db3 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ Dockwatch - +
@@ -39,5 +39,5 @@
- + diff --git a/web/styles.css b/web/styles.css index 5069c21..8a63c81 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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}