From c609c34f162fd0e2fb1cdb1e1048b5b6dac494d3 Mon Sep 17 00:00:00 2001 From: groot Date: Tue, 1 Sep 2026 08:11:16 +0200 Subject: [PATCH] Bugfix --- README.md | 12 +++++++++- internal/composeedit/composeedit_test.go | 28 ++++++++++++++++++++++++ internal/httpapi/httpapi.go | 11 +++++++--- internal/httpapi/httpapi_test.go | 15 +++++++++++++ web/app.js | 20 ++++++++++++----- web/index.html | 4 ++-- web/styles.css | 2 ++ 7 files changed, 80 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f34302d..7c31568 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/composeedit/composeedit_test.go b/internal/composeedit/composeedit_test.go index 6423f98..644dbc9 100644 --- a/internal/composeedit/composeedit_test.go +++ b/internal/composeedit/composeedit_test.go @@ -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) + } +} diff --git a/internal/httpapi/httpapi.go b/internal/httpapi/httpapi.go index 02f0f10..bfb405c 100644 --- a/internal/httpapi/httpapi.go +++ b/internal/httpapi/httpapi.go @@ -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) { diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 5d94117..084c65a 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -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) + } + }) + } +} diff --git a/web/app.js b/web/app.js index f968aaa..a585d7b 100644 --- a/web/app.js +++ b/web/app.js @@ -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;iVisual editor paused

${esc(e.message)}

Your YAML source is untouched. Fix the syntax and synchronization resumes automatically.

`}} -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])=>``).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 ${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(['networks','volumes','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()} +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 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'} +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?`${missing.length} missing declaration${missing.length===1?'':'s'}`:'named volumes aligned';const rows=usages.map(x=>`${esc(x.service)}${esc(x.kind)}${esc(x.source||'—')}${esc(x.target||'—')}${x.readOnly?'read-only':'read-write'}${x.kind==='volume'&&x.source?(declared.has(x.source)?'declared':`missing `):x.kind==='bind'?'no top-level declaration':'not applicable'}`).join('');return `

Volume reconciliation

Service mounts are compared live with top-level volumes:. Bind mounts are shown here but never turned into named-volume declarations.

${status}${missing.length?'':''}
${rows?`
${rows}
ServiceTypeSourceTargetAccessTop-level
`:'
No service mounts are currently configured.
'}${unused.length?`
Declared but currently unused: ${unused.map(n=>`${esc(n)} `).join(' ')}
`:''}${binds.length?`
${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.
`:''}${other.length?`
${other.length} anonymous/dynamic/special mount${other.length===1?'':'s'} preserved without automatic declaration changes.
`:''}
Top-level volumes Compose declarations
${composeNamedObjectEditor(v,path,'volume',[])}
`} +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 `
${entries.map(([k,val])=>composeMapEntry(k,val,[...path,k],serviceRoot)).join('')}
${suggestions.length?`${suggestions.map(x=>``).join('')}`:''}
`} function composeMapEntry(k,val,path,serviceRoot=false){const t=typeOfValue(val),complex=t==='map'||t==='array',hint=serviceRoot?COMPOSE_FIELD_HINTS[k]:'';return `
${esc(k)}${hint?`${esc(hint)}`:''}${k.startsWith('x-')?'extension':''}
${t}
${composeValueEditor(val,path,k,{serviceRoot:false})}
`} 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')}
`} @@ -71,7 +77,9 @@ function typeSwitcher(path,t){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('')}
`} @@ -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 `
♡

${esc(m.name)}

${esc(m.target)}
${badge(m.status)}
${roleOK()?`${m.status==='paused'?'':''}${m.status==='maintenance'?'':''}`:''}
24 hour uptime
${Number(m.uptime_24h||0).toFixed(3)}%
${badge(m.status)}
${heartbeatBars(c,m.status,64)}
Last heartbeat${fmtAgo(m.last_checked_at)}
Last latency${m.last_latency_ms||0} ms
Average latency${avg} ms
Recent success${ratio.toFixed(1)}%
Interval${m.interval_seconds}s
${m.last_message?`
Last result: ${esc(m.last_message)}${m.last_status_code?` · HTTP ${m.last_status_code}`:''}
`:''}

Response time

last ${c.length} heartbeats
${latencyChart(c)}
${m.status==='maintenance'?`
Maintenance active${m.maintenance_until?` until ${esc(fmtTime(m.maintenance_until))}`:' until manually ended'}${m.maintenance_note?`: ${esc(m.maintenance_note)}`:''}.
`:''}

Configuration

Type${esc(m.type.toUpperCase())}Method${esc(m.method||'GET')}
Expected status${m.expected_min}–${m.expected_max}Timeout${m.timeout_ms} ms
Keyword${esc(m.keyword||'—')}Probe environment${m.node_id?esc(state.nodes.find(n=>n.id===m.node_id)?.name||m.node_id):'Master / local'}
${roleOK()?'
':''}
`} 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(`

${isEdit?'Edit monitor':'New monitor'}

`);$('#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=>``).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(`

${isEdit?'Edit monitor':'New monitor'}

`);$('#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=>``).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(`

Maintenance · ${esc(m.name)}

Checks are suppressed during maintenance and the monitor is shown as maintenance instead of down.
`);$('#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)}} diff --git a/web/index.html b/web/index.html index aa8a58c..ba3b7a9 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 66d872c..d4d705e 100644 --- a/web/styles.css +++ b/web/styles.css @@ -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}