@@ -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' , 'volume s', '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 \n APP_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 ) } }