Bugfix
release-tag / release-image (push) Successful in 2m23s

This commit is contained in:
2026-09-01 08:11:16 +02:00
parent 97004838a3
commit c609c34f16
7 changed files with 80 additions and 12 deletions
+11 -1
View File
@@ -1,4 +1,4 @@
# Dockwatch v9.4
# Dockwatch v9.4.1
> Go module: `git.send.nrw/sendnrw/dockwatch`
@@ -6,6 +6,16 @@ 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.4.1 fixes
- fixed monitor creation when no monitor is selected (`state.monitor == null`)
- static UI assets are served with `Cache-Control: no-store`, preventing stale frontend code after upgrades
- Compose visual-editor patches now use a real queue so rapid edits on different fields are not lost
- the **Volumes** section now reconciles every service mount with top-level `volumes:` declarations
- named volumes missing a top-level declaration can be added individually or with **Add missing declarations**
- bind mounts, anonymous mounts and dynamic mounts are shown in the reconciliation view but are never silently converted into named volumes
- unused top-level volume declarations are highlighted and can be removed explicitly without deleting the Docker volume itself
## What is included
### Compose / stack management
+28
View File
@@ -29,3 +29,31 @@ func TestArrayPatch(t *testing.T) {
t.Fatal(out)
}
}
func TestAddTopLevelNamedVolumePreservesServiceMounts(t *testing.T) {
src := "services:\n app:\n volumes:\n - db_data:/var/lib/db\n - /srv/app:/data:ro\n x-future: keep\n"
out, err := Apply(src, Patch{Path: []string{"volumes", "db_data"}, Value: map[string]any{}})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"db_data:/var/lib/db", "/srv/app:/data:ro", "x-future: keep", "volumes:", "db_data:"} {
if !strings.Contains(out, want) {
t.Fatalf("expected %q to be preserved/added:\n%s", want, out)
}
}
parsed, err := Parse(out)
if err != nil {
t.Fatal(err)
}
root, ok := parsed.Value.(map[string]any)
if !ok {
t.Fatalf("unexpected parse result %#v", parsed.Value)
}
vols, ok := root["volumes"].(map[string]any)
if !ok {
t.Fatalf("top-level volumes missing: %#v", root["volumes"])
}
if _, ok := vols["db_data"]; !ok {
t.Fatalf("db_data declaration missing: %#v", vols)
}
}
+8 -3
View File
@@ -147,9 +147,14 @@ func New(c config.Config, a *auth.Service, ss *stacks.Service, n *nodes.Manager,
mux.Handle("/api/", a.Middleware(mutationOriginGuard(s.auditMiddleware(api))))
assets, _ := fs.Sub(web.FS, ".")
f := http.FileServer(http.FS(assets))
mux.Handle("GET /app.js", f)
mux.Handle("GET /styles.css", f)
mux.Handle("GET /{$}", f)
staticNoStore := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
f.ServeHTTP(w, r)
})
mux.Handle("GET /app.js", staticNoStore)
mux.Handle("GET /styles.css", staticNoStore)
mux.Handle("GET /{$}", staticNoStore)
return securityHeaders(mux)
}
func (s *Server) agent(m *http.ServeMux) {
+15
View File
@@ -1,6 +1,8 @@
package httpapi
import (
"net/http/httptest"
"strings"
"testing"
"git.send.nrw/sendnrw/dockwatch/internal/audit"
@@ -16,3 +18,16 @@ func TestRouterPatternsDoNotConflict(t *testing.T) {
}()
_ = New(config.Config{Mode: config.ModeStandalone}, &auth.Service{}, nil, nil, nil, (*audit.Service)(nil), nil, nil, nil)
}
func TestStaticAssetsDisableBrowserCaching(t *testing.T) {
h := New(config.Config{Mode: config.ModeStandalone}, &auth.Service{}, nil, nil, nil, (*audit.Service)(nil), nil, nil, nil)
for _, path := range []string{"/", "/app.js", "/styles.css"} {
t.Run(strings.TrimPrefix(path, "/"), func(t *testing.T) {
rr := httptest.NewRecorder()
h.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("Cache-Control for %s = %q, want no-store", path, got)
}
})
}
}
+14 -6
View File
@@ -51,7 +51,7 @@ const COMPOSE_FIELD_HINTS={image:'Container image',build:'Build configuration',c
const COMPOSE_CHILD_FIELDS={build:['context','dockerfile','dockerfile_inline','entitlements','args','ssh','labels','cache_from','cache_to','no_cache','no_cache_filter','additional_contexts','network','provenance','sbom','pull','target','shm_size','extra_hosts','isolation','privileged','secrets','tags','ulimits','platforms'],healthcheck:['test','interval','timeout','retries','start_period','start_interval','disable'],logging:['driver','options'],deploy:['mode','endpoint_mode','replicas','labels','rollback_config','update_config','resources','restart_policy','placement'],resources:['limits','reservations'],limits:['cpus','memory','pids'],reservations:['cpus','memory','generic_resources','devices'],restart_policy:['condition','delay','max_attempts','window'],update_config:['parallelism','delay','failure_action','monitor','max_failure_ratio','order'],rollback_config:['parallelism','delay','failure_action','monitor','max_failure_ratio','order'],placement:['constraints','preferences','max_replicas_per_node'],develop:['watch'],watch:['path','action','target','ignore','exec','initial_sync'],credential_spec:['config','file','registry'],extends:['file','service'],provider:['type','options'],ports:['name','mode','host_ip','target','published','protocol','app_protocol'],volumes:['type','source','target','read_only','consistency','bind','volume','tmpfs','image'],bind:['propagation','create_host_path','selinux','recursive'],volume:['nocopy','subpath'],tmpfs:['size','mode'],image:['subpath'],depends_on:['restart','required','condition'],networks:['aliases','interface_name','ipv4_address','ipv6_address','link_local_ips','mac_address','driver_opts','priority','gw_priority'],secrets:['source','target','uid','gid','mode'],configs:['source','target','uid','gid','mode'],ipam:['driver','config','options'],network:['attachable','driver','driver_opts','enable_ipv4','enable_ipv6','external','ipam','internal','labels','name'],config:['file','environment','content','external','name'],secret:['file','environment','external','name'],model:['model','context_size','runtime_flags'],blkio_config:['device_read_bps','device_read_iops','device_write_bps','device_write_iops','weight','weight_device'],ulimits:['soft','hard'],post_start:['command','user','privileged','working_dir','environment'],pre_start:['command','user','privileged','working_dir','environment'],pre_stop:['command','user','privileged','working_dir','environment']};
function composeSuggestions(path,opt={}){if(opt.serviceRoot)return COMPOSE_SERVICE_FIELDS;const clean=path.filter(x=>!/^\d+$/.test(String(x)));const last=clean[clean.length-1]||'';if(COMPOSE_CHILD_FIELDS[last])return COMPOSE_CHILD_FIELDS[last];if(clean.length===1&&clean[0]==='networks')return COMPOSE_CHILD_FIELDS.network;if(clean.length===1&&clean[0]==='volumes')return ['driver','driver_opts','external','labels','name'];if(clean.length===1&&clean[0]==='configs')return COMPOSE_CHILD_FIELDS.config;if(clean.length===1&&clean[0]==='secrets')return COMPOSE_CHILD_FIELDS.secret;if(clean.length===1&&clean[0]==='models')return COMPOSE_CHILD_FIELDS.model;return []}
let composeVisualModel=null,composeParseSeq=0,composePatchBusy=false,composePendingPatch=null,composeSection='services';
let composeVisualModel=null,composeParseSeq=0,composePatchBusy=false,composePatchQueue=[],composeSection='services';
function pathKey(path){return path.map(String).join('\u001f')}
function pathAttr(path){return encodeURIComponent(JSON.stringify(path))}
function pathFromAttr(v){try{return JSON.parse(decodeURIComponent(v))}catch{return[]}}
@@ -59,11 +59,17 @@ function typeOfValue(v){if(v===null)return'null';if(Array.isArray(v))return'arra
function cloneJSON(v){return v===undefined?undefined:JSON.parse(JSON.stringify(v))}
function composeSetLocal(path,val,del=false){if(!composeVisualModel)return;let cur=composeVisualModel;for(let i=0;i<path.length-1;i++){const k=path[i];if(Array.isArray(cur))cur=cur[Number(k)];else cur=cur[k];if(cur==null)return}const k=path[path.length-1];if(del){if(Array.isArray(cur))cur.splice(Number(k),1);else delete cur[k]}else{if(Array.isArray(cur))cur[Number(k)]=val;else cur[k]=val}}
async function parseComposeVisual(){const box=$('#composeVisual'),ta=$('#composeText'),status=$('#composeSyncState');if(!box||!ta)return;const seq=++composeParseSeq;status.className='muted';status.textContent=' parsing…';try{const r=await api('/api/compose/parse',{method:'POST',body:JSON.stringify({compose:ta.value})});if(seq!==composeParseSeq)return;composeVisualModel=r.value||{};status.className='green';status.textContent=' YAML ↔ Designer synchronized';renderComposeSections();renderComposeVisual()}catch(e){if(seq!==composeParseSeq)return;status.className='red';status.textContent=' Visual sync paused';box.innerHTML=`<div class="compose-parseerror"><b>Visual editor paused</b><p>${esc(e.message)}</p><p>Your YAML source is untouched. Fix the syntax and synchronization resumes automatically.</p></div>`}}
function renderComposeSections(){const host=$('#composeSections');if(!host)return;const items=[['project','Project'],['services','Services'],['networks','Networks'],['volumes','Volumes'],['configs','Configs'],['secrets','Secrets'],['models','Models'],['include','Include']];host.innerHTML=items.map(([k,l])=>`<button class="${composeSection===k?'active':''}" data-csection="${k}">${l}</button>`).join('');$$('[data-csection]').forEach(b=>b.onclick=()=>{composeSection=b.dataset.csection;renderComposeSections();renderComposeVisual()})}
function 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(['networks','volumes','configs','secrets','models'].includes(composeSection)){body=composeNamedObjectEditor(v,base,composeSection.slice(0,-1),[])}else body=composeValueEditor(v,base,'include');box.innerHTML=body||'<div class="empty">Nothing configured in this section.</div>';wireComposeTree()}
function 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 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'}
function composeVolumeUsages(model=composeVisualModel||{}){const out=[];for(const [service,cfg] of Object.entries(model.services||{})){const mounts=Array.isArray(cfg?.volumes)?cfg.volumes:[];mounts.forEach((m,index)=>{if(typeof m==='string'){const x=splitComposeMountShort(m),kind=classifyComposeMountSource(x.source);out.push({service,index,kind,source:x.source,target:x.target,readOnly:(x.options||'').split(',').includes('ro'),raw:m})}else if(m&&typeof m==='object'){const kind=classifyComposeMountSource(m.source,m.type);out.push({service,index,kind,source:m.source||'',target:m.target||'',readOnly:!!m.read_only,raw:m})}})}return out}
function composeVolumeSection(v,path){const usages=composeVolumeUsages(),declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),named=usages.filter(x=>x.kind==='volume'&&x.source),missing=[...new Set(named.map(x=>x.source).filter(x=>!declared.has(x)))],usedNames=new Set(named.map(x=>x.source)),unused=[...declared].filter(x=>!usedNames.has(x)),binds=usages.filter(x=>x.kind==='bind'),other=usages.filter(x=>!['volume','bind'].includes(x.kind));const status=missing.length?`<span class="tag warn">${missing.length} missing declaration${missing.length===1?'':'s'}</span>`:'<span class="tag oktag">named volumes aligned</span>';const rows=usages.map(x=>`<tr><td><b>${esc(x.service)}</b></td><td><span class="tag">${esc(x.kind)}</span></td><td><code>${esc(x.source||'—')}</code></td><td><code>${esc(x.target||'—')}</code></td><td>${x.readOnly?'read-only':'read-write'}</td><td>${x.kind==='volume'&&x.source?(declared.has(x.source)?'<span class="green">declared</span>':`<span class="amber">missing</span> <button class="btn tiny" data-vdeclare="${esc(x.source)}">Declare</button>`):x.kind==='bind'?'<span class="muted">no top-level declaration</span>':'<span class="muted">not applicable</span>'}</td></tr>`).join('');return `<div class="volume-reconcile"><div class="panelhead"><div><h3>Volume reconciliation</h3><p class="muted">Service mounts are compared live with top-level <code>volumes:</code>. Bind mounts are shown here but never turned into named-volume declarations.</p></div><div class="actions">${status}${missing.length?'<button class="btn tiny primary" id="syncVolumeDeclarations">+ Add missing declarations</button>':''}</div></div>${rows?`<div class="tablewrap"><table class="table compact"><thead><tr><th>Service</th><th>Type</th><th>Source</th><th>Target</th><th>Access</th><th>Top-level</th></tr></thead><tbody>${rows}</tbody></table></div>`:'<div class="empty small">No service mounts are currently configured.</div>'}${unused.length?`<div class="volume-unused"><b>Declared but currently unused:</b> ${unused.map(n=>`<span class="tag">${esc(n)} <button class="tagx" data-vremove="${esc(n)}" title="Remove unused declaration">×</button></span>`).join(' ')}</div>`:''}${binds.length?`<div class="muted volume-note">${binds.length} bind mount${binds.length===1?'':'s'} detected. Bind sources belong to the host filesystem and therefore do not appear as top-level named volumes.</div>`:''}${other.length?`<div class="muted volume-note">${other.length} anonymous/dynamic/special mount${other.length===1?'':'s'} preserved without automatic declaration changes.</div>`:''}</div><div class="compose-subsection"><div class="editorlabel">Top-level volumes <span class="muted">Compose declarations</span></div>${composeNamedObjectEditor(v,path,'volume',[])}</div>`}
function wireComposeVolumeReconcile(){if(composeSection!=='volumes')return;$$('[data-vdeclare]').forEach(b=>b.onclick=()=>queueComposePatch(['volumes',b.dataset.vdeclare],{}));$('#syncVolumeDeclarations')?.addEventListener('click',()=>{const declared=new Set(Object.keys((composeVisualModel||{}).volumes||{})),names=[...new Set(composeVolumeUsages().filter(x=>x.kind==='volume'&&x.source&&!declared.has(x.source)).map(x=>x.source))];if(!names.length)return toast('Named volumes are already aligned.');names.forEach(n=>queueComposePatch(['volumes',n],{}));toast(`${names.length} volume declaration${names.length===1?'':'s'} queued.`)});$$('[data-vremove]').forEach(b=>b.onclick=()=>{const n=b.dataset.vremove;if(confirm(`Remove unused top-level volume declaration ${n}? This does not delete a Docker volume.`))queueComposePatch(['volumes',n],null,true)})}
function composeMapEditor(v,path,opt={}){if(v===null||typeof v!=='object'||Array.isArray(v))return composeValueEditor(v,path,'value');const entries=Object.entries(v);const serviceRoot=!!opt.serviceRoot;const suggestions=opt.rootProject?COMPOSE_TOP_FIELDS:composeSuggestions(path,opt);const listId='candidates-'+Math.abs(pathKey(path).split('').reduce((a,c)=>((a<<5)-a+c.charCodeAt(0))|0,0));return `<div class="compose-map">${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}</div><div class="compose-addrow"><input ${suggestions.length?`list="${listId}"`:''} data-cnewkey="${pathAttr(path)}" placeholder="Add field/key"><select data-cnewtype="${pathAttr(path)}"><option value="string">string</option><option value="map">object</option><option value="array">array</option><option value="boolean">boolean</option><option value="number">number</option><option value="null">null</option></select><button class="btn tiny" data-caddkey="${pathAttr(path)}">+ Field</button>${suggestions.length?`<datalist id="${listId}">${suggestions.map(x=>`<option value="${esc(x)}"></option>`).join('')}</datalist>`:''}</div>`}
function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `<div class="compose-fieldrow ${complex?'complex':''}"><div class="compose-fieldhead"><div><code>${esc(k)}</code>${hint?`<small>${esc(hint)}</small>`:''}${k.startsWith('x-')?'<span class="tag">extension</span>':''}</div><div class="summary-actions"><span class="typepill">${t}</span><button class="iconbtn" data-cdelete="${pathAttr(path)}" title="Remove">×</button></div></div>${composeValueEditor(val,path,k,{serviceRoot:false})}</div>`}
function composeValueEditor(v,path,label,opt={}){const t=typeOfValue(v),pa=pathAttr(path);if(t==='map')return composeMapEditor(v,path,opt);if(t==='array')return `<div class="compose-array">${v.map((x,i)=>`<div class="compose-arrayitem"><div class="arrayindex">#${i+1}<button class="iconbtn" data-cdelete="${pathAttr([...path,String(i)])}" title="Remove">×</button></div>${composeValueEditor(x,[...path,String(i)],label)}</div>`).join('')}<div class="compose-addrow"><select data-carraytype="${pa}"><option value="string">string</option><option value="map">object</option><option value="array">array</option><option value="boolean">boolean</option><option value="number">number</option><option value="null">null</option></select><button class="btn tiny" data-carrayadd="${pa}">+ Item</button></div></div>`;if(t==='boolean')return `<div class="compose-scalar"><select data-cscalar="${pa}" data-ctype="boolean"><option value="true" ${v?'selected':''}>true</option><option value="false" ${!v?'selected':''}>false</option></select>${typeSwitcher(path,t)}</div>`;if(t==='null')return `<div class="compose-scalar"><span class="nullvalue">null</span>${typeSwitcher(path,t)}</div>`;if(t==='number')return `<div class="compose-scalar"><input data-cscalar="${pa}" data-ctype="number" type="number" step="any" value="${esc(v)}">${typeSwitcher(path,t)}</div>`;const multiline=String(v??'').includes('\n')||String(v??'').length>100;return `<div class="compose-scalar">${multiline?`<textarea data-cscalar="${pa}" data-ctype="string">${esc(v??'')}</textarea>`:`<input data-cscalar="${pa}" data-ctype="string" value="${esc(v??'')}">`}${typeSwitcher(path,'string')}</div>`}
@@ -71,7 +77,9 @@ function typeSwitcher(path,t){return `<select class="typeswitch" data-ctypeswitc
function newValueForType(t){return t==='map'?{}:t==='array'?[]:t==='boolean'?false:t==='number'?0:t==='null'?null:''}
function wireComposeTree(){$$('[data-cscalar]').forEach(el=>{const fn=()=>{const p=pathFromAttr(el.dataset.cscalar),t=el.dataset.ctype;let v=el.value;if(t==='boolean')v=v==='true';else if(t==='number')v=Number(v);queueComposePatch(p,v)};el.addEventListener('change',fn);if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',debounceFn(fn,180))});$$('[data-ctypeswitch]').forEach(el=>el.onchange=()=>queueComposePatch(pathFromAttr(el.dataset.ctypeswitch),newValueForType(el.value)));$$('[data-cdelete]').forEach(b=>b.onclick=e=>{e.preventDefault();e.stopPropagation();queueComposePatch(pathFromAttr(b.dataset.cdelete),null,true)});$$('[data-caddkey]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddkey),inp=document.querySelector(`[data-cnewkey="${CSS.escape(b.dataset.caddkey)}"]`),typ=document.querySelector(`[data-cnewtype="${CSS.escape(b.dataset.caddkey)}"]`);const k=inp?.value.trim();if(!k)return toast('Enter a field name.');queueComposePatch([...p,k],newValueForType(typ?.value||'string'))});$$('[data-carrayadd]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.carrayadd),typ=document.querySelector(`[data-carraytype="${CSS.escape(b.dataset.carrayadd)}"]`);queueComposePatch([...p,'-'],newValueForType(typ?.value||'string'))});$$('[data-caddnamed]').forEach(b=>b.onclick=()=>{const p=pathFromAttr(b.dataset.caddnamed),inp=document.querySelector(`[data-cnewname="${CSS.escape(b.dataset.caddnamed)}"]`),k=inp?.value.trim();if(!k)return toast('Enter a name.');queueComposePatch([...p,k],{})})}
function debounceFn(fn,ms){let t;return(...a)=>{clearTimeout(t);t=setTimeout(()=>fn(...a),ms)}}
async function queueComposePatch(path,value,del=false){composePendingPatch={path,value,delete:del};if(composePatchBusy)return;while(composePendingPatch){const p=composePendingPatch;composePendingPatch=null;composePatchBusy=true;try{const ta=$('#composeText');if(!ta)break;const r=await api('/api/compose/patch',{method:'POST',body:JSON.stringify({compose:ta.value,path:p.path,value:p.value,delete:p.delete})});ta.value=r.compose;composeSetLocal(p.path,p.value,p.delete);setDirty(true);saveDraft();await parseComposeVisual()}catch(e){toast('Compose patch failed: '+e.message);await parseComposeVisual()}finally{composePatchBusy=false}}}
function 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 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>`}
@@ -113,8 +121,8 @@ function latencyChart(checks){const a=[...checks].reverse().slice(-60);if(!a.len
function monitorDetailHTML(m,c){const avg=c?.length?Math.round(c.reduce((a,x)=>a+x.latency_ms,0)/c.length):0,ok=c?.filter(x=>x.ok).length||0,ratio=c?.length?ok/c.length*100:0;return `<div class="detailhead"><div class="detailtitle"><span class="cube">♡</span><div><h2>${esc(m.name)}</h2><small>${esc(m.target)}</small></div>${badge(m.status)}</div><div class="actions">${roleOK()?`<button class="btn primary" id="checkMon">↻ Check now</button><button class="btn" id="editMon">Edit</button>${m.status==='paused'?'<button class="btn success" id="resumeMon">Resume</button>':'<button class="btn" id="pauseMon">Pause</button>'}${m.status==='maintenance'?'<button class="btn" id="clearMaint">End maintenance</button>':'<button class="btn" id="maintMon">Maintenance</button>'}`:''}</div></div><div class="monitorhero"><div><span class="muted">24 hour uptime</span><div class="uptimebig ${m.status==='down'?'red':'green'}">${Number(m.uptime_24h||0).toFixed(3)}%</div></div><div>${badge(m.status)}</div></div><div class="tabbody"><div class="hb">${heartbeatBars(c,m.status,64)}</div><div class="monitorstats"><div class="mini"><small>Last heartbeat</small><b>${fmtAgo(m.last_checked_at)}</b></div><div class="mini"><small>Last latency</small><b>${m.last_latency_ms||0} ms</b></div><div class="mini"><small>Average latency</small><b>${avg} ms</b></div><div class="mini"><small>Recent success</small><b>${ratio.toFixed(1)}%</b></div><div class="mini"><small>Interval</small><b>${m.interval_seconds}s</b></div></div>${m.last_message?`<div class="notice ${m.status==='down'?'red':''}" style="margin-bottom:10px"><b>Last result:</b> ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}</div>`:''}<div class="panel"><div class="panelhead"><h2>Response time</h2><span class="muted">last ${c.length} heartbeats</span></div><div class="tabbody"><div class="chart">${latencyChart(c)}</div></div></div>${m.status==='maintenance'?`<div class="notice" style="margin-top:10px">Maintenance active${m.maintenance_until?` until ${esc(fmtTime(m.maintenance_until))}`:' until manually ended'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.</div>`:''}<div class="panel" style="margin-top:10px"><div class="panelhead"><h2>Configuration</h2></div><table class="table"><tbody><tr><td class="muted">Type</td><td>${esc(m.type.toUpperCase())}</td><td class="muted">Method</td><td>${esc(m.method||'GET')}</td></tr><tr><td class="muted">Expected status</td><td>${m.expected_min}–${m.expected_max}</td><td class="muted">Timeout</td><td>${m.timeout_ms} ms</td></tr><tr><td class="muted">Keyword</td><td>${esc(m.keyword||'—')}</td><td class="muted">Probe environment</td><td>${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / local'}</td></tr></tbody></table></div>${roleOK()?'<div class="toolbar" style="margin-top:10px"><button class="btn danger" id="deleteMon">Delete monitor</button></div>':''}</div>`}
function wireMonitorDetail(){$('#checkMon')?.addEventListener('click',checkMonitorNow);$('#editMon')?.addEventListener('click',()=>monitorModal(state.monitor));$('#pauseMon')?.addEventListener('click',()=>monitorAction('pause'));$('#resumeMon')?.addEventListener('click',()=>monitorAction('resume'));$('#maintMon')?.addEventListener('click',()=>maintenanceModal(state.monitor));$('#clearMaint')?.addEventListener('click',()=>clearMaintenance(state.monitor.id));$('#deleteMon')?.addEventListener('click',deleteMonitor)}
async function checkMonitorNow(){const b=$('#checkMon');setBusy(b,true,'Checking');try{const c=await api(`/api/monitors/${state.monitor.id}/check`,{method:'POST'});toast(c.ok?`Check OK · ${c.latency_ms} ms`:`Check failed · ${c.message}`);await refreshData(false);await openMonitor(state.monitor.id)}catch(e){toast(e.message)}finally{setBusy(b,false)}}
function monitorModal(m=null){const isEdit=!!m;modal(`<div class="modalhead"><h2>${isEdit?'Edit monitor':'New monitor'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="mfName" value="${esc(m?.name||'')}"></div><div class="field"><label>Monitor type</label><select id="mfType"><option value="http">HTTP(S)</option><option value="tcp">TCP port</option><option value="dns">DNS hostname</option><option value="docker">Docker container</option></select></div><div class="field full"><label>URL / target / container name or ID</label><input id="mfTarget" list="dockerTargets" value="${esc(m?.target||'')}" placeholder="https://example.com, host:port or container-name"><datalist id="dockerTargets"></datalist></div><div class="field"><label>Service group</label><select id="mfService"><option value="">Ungrouped</option>${state.services.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label>Probe environment</label><select id="mfNode"><option value="">Master / local</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}</select></div><div class="field"><label>Interval (seconds)</label><input id="mfInterval" type="number" min="10" max="86400" step="1" value="${m?.interval_seconds||60}"></div><div class="field"><label>Timeout (ms)</label><input id="mfTimeout" type="number" value="${m?.timeout_ms||5000}"></div><div class="field"><label>HTTP method</label><select id="mfMethod">${['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].map(v=>`<option>${v}</option>`).join('')}</select></div><div class="field"><label>Expected HTTP status</label><div style="display:flex;gap:5px"><input id="mfMin" type="number" value="${m?.expected_min||200}" style="width:50%"><input id="mfMax" type="number" value="${m?.expected_max||399}" style="width:50%"></div></div><div class="field full"><label>Keyword assertion (optional)</label><input id="mfKeyword" value="${esc(m?.keyword||'')}" placeholder="Text that must occur in the response"></div><div class="field full"><label>Request headers as JSON</label><textarea id="mfHeaders" spellcheck="false">${esc(m?.headers_json||'{}')}</textarea></div><div class="field full"><label>Request body</label><textarea id="mfBody" spellcheck="false">${esc(m?.body||'')}</textarea></div><label class="switch"><input id="mfInvert" type="checkbox" ${m?.invert_keyword?'checked':''}> Invert keyword match</label><label class="switch"><input id="mfTLS" type="checkbox" ${m?.ignore_tls?'checked':''}> Ignore TLS certificate errors</label><label class="switch"><input id="mfHealthy" type="checkbox" ${m?.require_healthy?'checked':''}> Docker: require Healthy condition</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveMonitor">${isEdit?'Save changes':'Create monitor'}</button></div>`);$('#mfType').value=m?.type||'http';$('#mfNode').value=m?.node_id||'';$('#mfService').value=m?.service_id||'';$('#mfInterval').value=String(m?.interval_seconds||60);$('#mfMethod').value=m?.method||'GET';const syncFields=()=>{const t=$('#mfType').value,isHTTP=t==='http',isDocker=t==='docker';['mfMethod','mfMin','mfMax','mfKeyword','mfHeaders','mfBody'].forEach(id=>{const e=$('#'+id);if(e?.closest('.field'))e.closest('.field').style.display=isHTTP?'':'none'});['mfInvert','mfTLS'].forEach(id=>{const e=$('#'+id);if(e?.closest('.switch'))e.closest('.switch').style.display=isHTTP?'':'none'});const he=$('#mfHealthy');if(he?.closest('.switch'))he.closest('.switch').style.display=isDocker?'':'none';const target=$('#mfTarget');if(target)target.placeholder=isHTTP?'https://example.com':t==='tcp'?'host:port':t==='dns'?'example.com':'container-name-or-id'};const loadDockerTargets=async()=>{syncFields();if($('#mfType').value!=='docker')return;try{const nid=$('#mfNode').value;const rows=await api('/api/docker/containers'+(nid?`?node_id=${nid}`:''));$('#dockerTargets').innerHTML=asArray(rows).map(x=>`<option value="${esc(x.Names||x.Name||x.ID||x.name||x.id||'')}"></option>`).join('')}catch{}};$('#mfType').onchange=loadDockerTargets;$('#mfNode').onchange=loadDockerTargets;loadDockerTargets();$('#saveMonitor').onclick=()=>saveMonitor(m?.id)}
async function saveMonitor(id){const enabled=state.monitor?.id===id?state.monitor.enabled:true;const body={name:$('#mfName').value,type:$('#mfType').value,target:$('#mfTarget').value,node_id:$('#mfNode').value?Number($('#mfNode').value):null,service_id:$('#mfService').value?Number($('#mfService').value):null,interval_seconds:Number($('#mfInterval').value),timeout_ms:Number($('#mfTimeout').value),expected_min:Number($('#mfMin').value),expected_max:Number($('#mfMax').value),method:$('#mfMethod').value,headers_json:$('#mfHeaders').value||'{}',body:$('#mfBody').value,keyword:$('#mfKeyword').value,invert_keyword:$('#mfInvert').checked,ignore_tls:$('#mfTLS').checked,require_healthy:$('#mfHealthy').checked,enabled};try{const m=await api(id?`/api/monitors/${id}`:'/api/monitors',{method:id?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);state.monitor=m;state.checks=id?await api(`/api/monitors/${id}/checks?limit=80`):[];renderMonitors();toast(id?'Monitor updated.':'Monitor created.')}catch(e){toast(e.message)}}
function monitorModal(m=null){const isEdit=!!m;modal(`<div class="modalhead"><h2>${isEdit?'Edit monitor':'New monitor'}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="fieldgrid"><div class="field"><label>Name</label><input id="mfName" value="${esc(m?.name||'')}"></div><div class="field"><label>Monitor type</label><select id="mfType"><option value="http">HTTP(S)</option><option value="tcp">TCP port</option><option value="dns">DNS hostname</option><option value="docker">Docker container</option></select></div><div class="field full"><label>URL / target / container name or ID</label><input id="mfTarget" list="dockerTargets" value="${esc(m?.target||'')}" placeholder="https://example.com, host:port or container-name"><datalist id="dockerTargets"></datalist></div><div class="field"><label>Service group</label><select id="mfService"><option value="">Ungrouped</option>${state.services.map(x=>`<option value="${x.id}">${esc(x.name)}</option>`).join('')}</select></div><div class="field"><label>Probe environment</label><select id="mfNode"><option value="">Master / local</option>${state.nodes.map(n=>`<option value="${n.id}" ${n.enabled?'':'disabled'}>${esc(n.name)}${n.enabled?'':' · disabled'}</option>`).join('')}</select></div><div class="field"><label>Interval (seconds)</label><input id="mfInterval" type="number" min="10" max="86400" step="1" value="${m?.interval_seconds||60}"></div><div class="field"><label>Timeout (ms)</label><input id="mfTimeout" type="number" value="${m?.timeout_ms||5000}"></div><div class="field"><label>HTTP method</label><select id="mfMethod">${['GET','HEAD','POST','PUT','PATCH','DELETE','OPTIONS'].map(v=>`<option>${v}</option>`).join('')}</select></div><div class="field"><label>Expected HTTP status</label><div style="display:flex;gap:5px"><input id="mfMin" type="number" value="${m?.expected_min||200}" style="width:50%"><input id="mfMax" type="number" value="${m?.expected_max||399}" style="width:50%"></div></div><div class="field full"><label>Keyword assertion (optional)</label><input id="mfKeyword" value="${esc(m?.keyword||'')}" placeholder="Text that must occur in the response"></div><div class="field full"><label>Request headers as JSON</label><textarea id="mfHeaders" spellcheck="false">${esc(m?.headers_json||'{}')}</textarea></div><div class="field full"><label>Request body</label><textarea id="mfBody" spellcheck="false">${esc(m?.body||'')}</textarea></div><label class="switch"><input id="mfInvert" type="checkbox" ${m?.invert_keyword?'checked':''}> Invert keyword match</label><label class="switch"><input id="mfTLS" type="checkbox" ${m?.ignore_tls?'checked':''}> Ignore TLS certificate errors</label><label class="switch"><input id="mfHealthy" type="checkbox" ${m?.require_healthy?'checked':''}> Docker: require Healthy condition</label></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="saveMonitor">${isEdit?'Save changes':'Create monitor'}</button></div>`);$('#mfType').value=m?.type||'http';$('#mfNode').value=m?.node_id||'';$('#mfService').value=m?.service_id||'';$('#mfInterval').value=String(m?.interval_seconds||60);$('#mfMethod').value=m?.method||'GET';const syncFields=()=>{const t=$('#mfType').value,isHTTP=t==='http',isDocker=t==='docker';['mfMethod','mfMin','mfMax','mfKeyword','mfHeaders','mfBody'].forEach(id=>{const e=$('#'+id);if(e?.closest('.field'))e.closest('.field').style.display=isHTTP?'':'none'});['mfInvert','mfTLS'].forEach(id=>{const e=$('#'+id);if(e?.closest('.switch'))e.closest('.switch').style.display=isHTTP?'':'none'});const he=$('#mfHealthy');if(he?.closest('.switch'))he.closest('.switch').style.display=isDocker?'':'none';const target=$('#mfTarget');if(target)target.placeholder=isHTTP?'https://example.com':t==='tcp'?'host:port':t==='dns'?'example.com':'container-name-or-id'};const loadDockerTargets=async()=>{syncFields();if($('#mfType').value!=='docker')return;try{const nid=$('#mfNode').value;const rows=await api('/api/docker/containers'+(nid?`?node_id=${nid}`:''));$('#dockerTargets').innerHTML=asArray(rows).map(x=>`<option value="${esc(x.Names||x.Name||x.ID||x.name||x.id||'')}"></option>`).join('')}catch{}};$('#mfType').onchange=loadDockerTargets;$('#mfNode').onchange=loadDockerTargets;loadDockerTargets();$('#saveMonitor').onclick=()=>saveMonitor(m?.id,m?.enabled??true)}
async function saveMonitor(id,currentEnabled=true){const isEdit=id!==undefined&&id!==null;const enabled=isEdit?Boolean(currentEnabled):true;const body={name:$('#mfName').value,type:$('#mfType').value,target:$('#mfTarget').value,node_id:$('#mfNode').value?Number($('#mfNode').value):null,service_id:$('#mfService').value?Number($('#mfService').value):null,interval_seconds:Number($('#mfInterval').value),timeout_ms:Number($('#mfTimeout').value),expected_min:Number($('#mfMin').value),expected_max:Number($('#mfMax').value),method:$('#mfMethod').value,headers_json:$('#mfHeaders').value||'{}',body:$('#mfBody').value,keyword:$('#mfKeyword').value,invert_keyword:$('#mfInvert').checked,ignore_tls:$('#mfTLS').checked,require_healthy:$('#mfHealthy').checked,enabled};try{const m=await api(id?`/api/monitors/${id}`:'/api/monitors',{method:id?'PUT':'POST',body:JSON.stringify(body)});closeModal();await refreshData(true);state.monitor=m;state.checks=id?await api(`/api/monitors/${id}/checks?limit=80`):[];renderMonitors();toast(id?'Monitor updated.':'Monitor created.')}catch(e){toast(e.message)}}
async function monitorAction(a){try{await api(`/api/monitors/${state.monitor.id}/${a}`,{method:'POST'});await openMonitor(state.monitor.id);toast(a==='pause'?'Monitor paused.':'Monitor resumed.')}catch(e){toast(e.message)}}
function maintenanceModal(m){modal(`<div class="modalhead"><h2>Maintenance · ${esc(m.name)}</h2><button class="closex" data-close>×</button></div><div class="modalbody"><div class="notice" style="margin-bottom:10px">Checks are suppressed during maintenance and the monitor is shown as maintenance instead of down.</div><div class="fieldgrid"><div class="field"><label>End mode</label><select id="maintMode"><option value="manual">Until manually ended</option><option value="1h">1 hour</option><option value="4h">4 hours</option><option value="24h">24 hours</option><option value="custom">Custom date/time</option></select></div><div class="field"><label>Custom end</label><input id="maintUntil" type="datetime-local"></div><div class="field full"><label>Note</label><input id="maintNote" placeholder="Planned deployment / provider maintenance"></div></div></div><div class="modalfoot"><button class="btn" data-close>Cancel</button><button class="btn primary" id="startMaint">Start maintenance</button></div>`);$('#startMaint').onclick=()=>startMaintenance(m.id)}
async function startMaintenance(id){const mode=$('#maintMode').value;let until=null;if(mode==='custom'){const v=$('#maintUntil').value;if(v)until=Math.floor(new Date(v).getTime()/1000)}else if(mode!=='manual'){until=Math.floor(Date.now()/1000)+Number(mode.replace('h',''))*3600}try{await api(`/api/monitors/${id}/maintenance`,{method:'POST',body:JSON.stringify({until,note:$('#maintNote').value})});closeModal();await refreshData(true);await openMonitor(id);toast('Maintenance started.')}catch(e){toast(e.message)}}
+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">
<link rel="stylesheet" href="/styles.css?v=9.4.1">
</head>
<body>
<div id="shell">
@@ -39,5 +39,5 @@
</main>
</div>
<div id="modalRoot"></div><div id="toast"></div>
<script src="/app.js"></script>
<script src="/app.js?v=9.4.1"></script>
</body></html>
+2
View File
@@ -25,3 +25,5 @@ body.sidebar-collapsed #shell{grid-template-columns:64px 1fr}body.sidebar-collap
.securityHero{display:flex;justify-content:space-between;align-items:center;gap:20px;border:1px solid var(--line);border-radius:10px;padding:18px 20px;margin-bottom:12px;background:linear-gradient(135deg,var(--panel),var(--panel2))}.securityHero.securityManage{border-color:#24533f}.securityHero.securityAudit{border-color:#5f512b}.securityHero.securityOff{opacity:.78}.securityHero small{font-size:9px;letter-spacing:.12em;color:var(--muted)}.securityHero p{margin:5px 0 0;color:var(--muted);font-size:11px}.securityScore{font-size:38px;font-weight:750;line-height:1;margin-top:4px}.securityScore span{font-size:13px;color:var(--muted);font-weight:500}.securityCaps{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end}.tag.oktag{border-color:#2b5f49;color:#74d7aa;background:#13261f}.securityGrid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.securityCard{background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:14px;min-width:0}.securityCardHead{display:flex;align-items:flex-start;justify-content:space-between;gap:10px}.securityCardHead>div:first-child{display:flex;gap:10px;align-items:flex-start;min-width:0}.securityCardHead h2{font-size:13px;margin:0}.securityCardHead p{font-size:10px;color:var(--muted);line-height:1.45;margin:4px 0 0}.securityIcon{width:30px;height:30px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);display:grid;place-items:center;font-size:15px;flex:0 0 auto}.securityFacts{display:grid;grid-template-columns:repeat(4,1fr);gap:5px;margin-top:13px}.securityFacts span{background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:7px;min-width:0}.securityFacts small{display:block;color:var(--muted);font-size:8px;text-transform:uppercase;letter-spacing:.06em}.securityFacts b{font-size:10px;display:block;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityVersion{font:9px/1.4 ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--muted);margin-top:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.securityDetail{font:9px/1.45 ui-monospace,SFMono-Regular,Consolas,monospace;background:var(--panel2);border:1px solid var(--line);padding:8px;border-radius:6px;max-height:100px;overflow:auto;white-space:pre-wrap}.securityActions{display:flex;gap:5px;flex-wrap:wrap;margin-top:12px}.securityFindings{display:flex;flex-direction:column}.securityFinding{display:grid;grid-template-columns:24px minmax(0,1fr);gap:9px;padding:10px;border-top:1px solid var(--line)}.securityFinding:first-child{border-top:0}.securityFinding>span{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--panel2);font-weight:700}.securityFinding b{font-size:11px}.securityFinding p{font-size:10px;color:var(--muted);margin:3px 0}.securityFinding small{font-size:9px;color:var(--text)}.securityFinding.sev-high>span{background:var(--red2);color:var(--red)}.securityFinding.sev-medium>span{background:var(--amber2);color:var(--amber)}.securityFinding.sev-ok>span{background:var(--green2);color:var(--green)}.securitySafety{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:10px}.securitySafety>div{border:1px solid var(--line);border-radius:7px;padding:10px;background:var(--panel2)}.securitySafety b{font-size:10px}.securitySafety p{font-size:9px;color:var(--muted);line-height:1.45;margin:4px 0 0}.warnNotice{border-color:#5e4a20!important;background:#2a2414!important;color:#e6ca7c!important}.fwRule{display:grid;grid-template-columns:90px 80px 140px minmax(150px,1fr) minmax(120px,1fr) 28px;gap:6px;margin-bottom:6px;align-items:center}.fwRule input,.fwRule select{min-width:0}.securityFormRow{display:grid;grid-template-columns:110px 130px 100px 100px 105px minmax(140px,1fr) 90px 28px;gap:6px;align-items:center;margin-bottom:6px}.securityFormRow.audit{grid-template-columns:minmax(240px,1fr) 80px minmax(120px,200px) 28px}.securityChecks{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.securityCountdown{display:flex;align-items:flex-end;justify-content:space-between;margin:20px 0;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--panel2)}.securityCountdown small{color:var(--muted)}.securityCountdown strong{font-size:32px}.notice code{font:10px ui-monospace,SFMono-Regular,Consolas,monospace}
@media(max-width:1300px){.securityGrid{grid-template-columns:1fr}.securityFacts{grid-template-columns:repeat(4,1fr)}}
@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}