2026-03-01 23:03:18 -05:00
'use strict' ;
2026-11-04 23:40:17 -05:00
/* exported initDeviceSearch */
/* global DEVICE_NAME */
2026-03-01 23:03:18 -05:00
2026-03-17 20:43:08 -04:00
// ── Auto-redirect on auth timeout ─────────────────────────────────────
2026-04-18 23:46:44 -04:00
// Wraps fetch so a 401 (Authelia session expired) forces a full reload.
// lt.api uses fetch internally, so this covers all API calls too.
2026-03-17 20:43:08 -04:00
( function () {
const _fetch = window . fetch ;
window . fetch = async function (... args ) {
const resp = await _fetch (... args );
2026-04-19 23:35:02 -04:00
if ( resp . status === 401 ) {
window . location . reload ();
throw new Error ( 'Session expired — reloading' );
}
2026-03-17 20:43:08 -04:00
return resp ;
};
})();
2026-04-18 23:46:44 -04:00
// ── Toast notifications — thin wrapper over lt.toast ──────────────────
2026-03-01 23:03:18 -05:00
function showToast ( msg , type = 'success' ) {
2026-03-14 21:40:20 -04:00
if ( type === 'error' ) return lt . toast . error ( msg );
if ( type === 'warning' ) return lt . toast . warning ( msg );
if ( type === 'info' ) return lt . toast . info ( msg );
return lt . toast . success ( msg );
2025-02-07 21:03:31 -05:00
}
2026-04-18 23:46:44 -04:00
// ── Normalise UTC timestamp string for Date() parsing ─────────────────
// Server returns "2026-03-14 14:14:21 UTC"; Date() needs ISO 8601.
function _toIso ( s ) {
if ( ! s ) return s ;
return s . replace ( ' UTC' , 'Z' ). replace ( ' ' , 'T' );
}
2026-03-01 23:03:18 -05:00
// ── Dashboard auto-refresh ────────────────────────────────────────────
async function refreshAll () {
2026-04-19 23:35:02 -04:00
const refreshBtn = document . querySelector ( '[data-action="refresh"]' );
2026-04-30 21:09:56 -04:00
const spinner = document . getElementById ( 'refresh-spinner' );
2026-04-19 23:35:02 -04:00
if ( refreshBtn ) refreshBtn . classList . add ( 'is-loading' );
2026-04-30 21:09:56 -04:00
if ( spinner ) spinner . style . display = '' ;
2026-03-01 23:03:18 -05:00
try {
2026-04-19 23:35:02 -04:00
const [ netResult , statusResult ] = await Promise . allSettled ([
2026-04-18 23:46:44 -04:00
lt . api . get ( '/api/network' ),
lt . api . get ( '/api/status' ),
2026-11-04 23:40:17 -05:00
refreshModem (),
2026-03-01 23:03:18 -05:00
]);
2026-04-19 23:35:02 -04:00
if ( netResult . status === 'fulfilled' ) {
const net = netResult . value ;
updateHostGrid ( net . hosts || {});
updateUnifiTable ( net . unifi || []);
updateTopology ( net . hosts || {});
} else {
2026-04-29 17:50:00 -04:00
showToast ( 'Network data unavailable' , 'warning' );
2026-04-19 23:35:02 -04:00
}
if ( statusResult . status === 'fulfilled' ) {
const status = statusResult . value ;
updateEventsTable ( status . events || [], status . total_active );
updateStatusBar ( status . summary || {}, status . last_check || '' , status . daemon_ok );
2026-04-30 21:33:02 -04:00
if ( typeof window . gandalfNotifUpdate === 'function' ) window . gandalfNotifUpdate ( status . events || []);
2026-04-19 23:35:02 -04:00
} else {
2026-04-29 17:50:00 -04:00
showToast ( 'Status data unavailable' , 'warning' );
2026-04-19 23:35:02 -04:00
}
} finally {
if ( refreshBtn ) refreshBtn . classList . remove ( 'is-loading' );
2026-04-30 21:09:56 -04:00
if ( spinner ) spinner . style . display = 'none' ;
2026-03-01 23:03:18 -05:00
}
2025-02-08 00:32:25 -05:00
}
2026-04-19 23:35:02 -04:00
function updateStatusBar ( summary , lastCheck , daemonOk ) {
2026-03-01 23:03:18 -05:00
const bar = document . querySelector ( '.status-chips' );
if ( ! bar ) return ;
const chips = [];
2026-04-19 23:35:02 -04:00
if ( daemonOk === false ) chips . push ( '<span class="chip chip-critical">⚠ MONITOR OFFLINE</span>' );
2026-03-02 12:43:11 -05:00
if ( summary . critical ) chips . push ( `<span class="chip chip-critical">● ${ summary . critical } CRITICAL</span>` );
if ( summary . warning ) chips . push ( `<span class="chip chip-warning">● ${ summary . warning } WARNING</span>` );
2026-04-19 23:35:02 -04:00
if ( ! summary . critical && ! summary . warning && daemonOk !== false ) chips . push ( '<span class="chip chip-ok">✔ ALL SYSTEMS NOMINAL</span>' );
2026-03-01 23:03:18 -05:00
bar . innerHTML = chips . join ( '' );
const lc = document . getElementById ( 'last-check' );
2026-03-02 12:43:11 -05:00
if ( lc && lastCheck ) lc . textContent = lastCheck ;
2026-03-14 21:35:32 -04:00
const critCount = summary . critical || 0 ;
const warnCount = summary . warning || 0 ;
2026-04-18 23:46:44 -04:00
if ( critCount ) document . title = `( ${ critCount } CRIT) GANDALF` ;
else if ( warnCount ) document . title = `( ${ warnCount } WARN) GANDALF` ;
else document . title = 'GANDALF' ;
2026-03-14 21:35:32 -04:00
2026-04-30 21:09:56 -04:00
const alertBadge = document . getElementById ( 'alert-count-badge' );
if ( alertBadge ) {
const total = critCount + warnCount ;
alertBadge . textContent = total ;
alertBadge . style . display = total ? '' : 'none' ;
}
2026-04-30 22:19:50 -04:00
// Update stat cards
const scCrit = document . getElementById ( 'stat-critical-val' );
const scWarn = document . getElementById ( 'stat-warning-val' );
2026-05-07 18:36:57 -04:00
const scRes = document . getElementById ( 'stat-resolved-val' );
2026-04-30 22:19:50 -04:00
if ( scCrit ) scCrit . textContent = critCount ;
if ( scWarn ) scWarn . textContent = warnCount ;
2026-05-10 22:42:40 -04:00
if ( scRes && summary . resolved_24h !== null && summary . resolved_24h !== undefined ) scRes . textContent = summary . resolved_24h ;
2026-04-30 22:19:50 -04:00
const statCritCard = document . getElementById ( 'stat-critical' );
if ( statCritCard ) statCritCard . classList . toggle ( 'lt-stat-card--alert' , critCount > 0 );
2026-03-14 21:35:32 -04:00
// Stale data banner: warn if last_check is older than 15 minutes
let staleBanner = document . getElementById ( 'stale-banner' );
if ( lastCheck ) {
2026-04-18 23:46:44 -04:00
const checkAge = ( Date . now () - new Date ( _toIso ( lastCheck ))) / 1000 ;
if ( checkAge > 900 ) {
2026-03-14 21:35:32 -04:00
if ( ! staleBanner ) {
staleBanner = document . createElement ( 'div' );
staleBanner . id = 'stale-banner' ;
2026-04-29 23:37:47 -04:00
staleBanner . className = 'lt-alert lt-alert--warning' ;
staleBanner . innerHTML = '<span class="lt-alert-icon">⚠</span><div class="lt-alert-body"><div class="lt-alert-msg"></div></div>' ;
2026-04-18 21:01:20 -04:00
document . querySelector ( '.lt-main' ). prepend ( staleBanner );
2026-03-14 21:35:32 -04:00
}
const mins = Math . floor ( checkAge / 60 );
2026-04-29 23:37:47 -04:00
staleBanner . querySelector ( '.lt-alert-msg' ). textContent =
`Monitoring data is stale — last check was ${ mins } minute ${ mins !== 1 ? 's' : '' } ago. The monitor daemon may be down.` ;
2026-03-14 21:35:32 -04:00
staleBanner . style . display = '' ;
} else if ( staleBanner ) {
staleBanner . style . display = 'none' ;
}
}
2025-02-07 23:54:28 -05:00
}
2026-03-01 23:03:18 -05:00
function updateHostGrid ( hosts ) {
2026-04-30 22:19:50 -04:00
const scHosts = document . getElementById ( 'stat-hosts-val' );
if ( scHosts ) scHosts . textContent = Object . keys ( hosts ). length ;
2026-03-01 23:03:18 -05:00
for ( const [ name , host ] of Object . entries ( hosts )) {
const card = document . querySelector ( `.host-card[data-host=" ${ CSS . escape ( name ) } "]` );
if ( ! card ) continue ;
card . className = card . className . replace ( /host-card-(up|down|degraded|unknown)/g , '' );
card . classList . add ( `host-card- ${ host . status } ` );
const dot = card . querySelector ( '.host-status-dot' );
if ( dot ) dot . className = `host-status-dot dot- ${ host . status } ` ;
2025-02-07 23:54:28 -05:00
2026-03-01 23:03:18 -05:00
const ifaceList = card . querySelector ( '.iface-list' );
if ( ifaceList && host . interfaces && Object . keys ( host . interfaces ). length > 0 ) {
ifaceList . innerHTML = Object . entries ( host . interfaces )
. sort (([ a ], [ b ]) => a . localeCompare ( b ))
. map (([ iface , state ]) => `
<div class="iface-row">
<span class="iface-dot dot- ${ state } "></span>
2026-04-18 23:46:44 -04:00
<span class="iface-name"> ${ lt . escHtml ( iface ) } </span>
2026-03-01 23:03:18 -05:00
<span class="iface-state state- ${ state } "> ${ state } </span>
</div>
` ). join ( '' );
2025-02-07 23:54:28 -05:00
}
2026-03-01 23:03:18 -05:00
}
2025-02-07 23:54:28 -05:00
}
2026-03-01 23:03:18 -05:00
function updateTopology ( hosts ) {
document . querySelectorAll ( '.topo-host' ). forEach ( node => {
const name = node . dataset . host ;
const host = hosts [ name ];
if ( ! host ) return ;
2026-03-14 22:22:19 -04:00
node . className = node . className . replace ( /topo-v2-status-(up|down|degraded|unknown)/g , '' );
node . classList . add ( `topo-v2-status- ${ host . status } ` );
2026-03-01 23:03:18 -05:00
const badge = node . querySelector ( '.topo-badge' );
if ( badge ) {
badge . className = `topo-badge topo-badge- ${ host . status } ` ;
badge . textContent = host . status ;
}
2026-04-29 23:37:47 -04:00
// Animate the 10G drop-wire red+dashed when host is down
document . querySelectorAll ( `.topo-v2-wire-10g[data-host=" ${ CSS . escape ( name ) } "]` ). forEach ( wire => {
wire . classList . toggle ( 'wire-down' , host . status === 'down' );
});
2026-03-01 23:03:18 -05:00
});
2025-02-07 21:28:54 -05:00
}
2026-11-04 23:40:17 -05:00
// ── Device search ─────────────────────────────────────────────────────
function deviceUrl ( name ) {
return `/device/ ${ encodeURIComponent ( name ) } ` ;
}
// All words must match somewhere; the device's name matching ranks first.
function rankDevices ( devices , query ) {
const words = query . trim (). toLowerCase (). split ( /\s+/ ). filter ( Boolean );
if ( ! words . length ) return [];
const scored = [];
for ( const d of devices ) {
if ( ! words . every ( w => d . search . includes ( w ))) continue ;
const name = d . name . toLowerCase ();
let score = 3 ;
if ( name === words [ 0 ]) score = 0 ;
else if ( name . startsWith ( words [ 0 ])) score = 1 ;
else if ( name . includes ( words [ 0 ]) || ( d . ip || '' ). startsWith ( words [ 0 ])) score = 2 ;
scored . push ({ d , score });
}
return scored
. sort (( a , b ) => a . score - b . score || a . d . name . localeCompare ( b . d . name ))
. slice ( 0 , 12 )
. map ( s => s . d );
}
function initDeviceSearch () {
const input = document . getElementById ( 'device-search-input' );
const results = document . getElementById ( 'device-search-results' );
if ( ! input || ! results ) return ;
let devices = [];
let matches = [];
let active = 0 ;
async function loadDevices () {
try {
devices = ( await lt . api . get ( '/api/devices' )). devices || [];
} catch ( err ) {
console . warn ( 'Device list unavailable' , err );
}
}
function close () {
results . hidden = true ;
input . setAttribute ( 'aria-expanded' , 'false' );
input . removeAttribute ( 'aria-activedescendant' );
}
function render () {
matches = rankDevices ( devices , input . value );
if ( ! input . value . trim ()) { close (); return ; }
active = Math . min ( active , Math . max ( matches . length - 1 , 0 ));
results . hidden = false ;
input . setAttribute ( 'aria-expanded' , 'true' );
if ( ! matches . length ) {
results . innerHTML = '<div class="device-search-empty">No devices match.</div>' ;
input . removeAttribute ( 'aria-activedescendant' );
return ;
}
results . innerHTML = matches . map (( d , i ) => `
<a class="device-search-row ${ i === active ? ' is-active' : '' } " id="device-opt- ${ i } " role="option"
aria-selected=" ${ i === active } " href=" ${ lt . escHtml ( deviceUrl ( d . name )) } ">
<span class="dot- ${ lt . escHtml ( d . status ) } "></span>
<span class="device-search-name"> ${ lt . escHtml ( d . name ) } </span>
<span class="device-search-kind"> ${ lt . escHtml ( d . kind ) } </span>
<span class="device-search-meta"> ${ lt . escHtml ( d . ip || '' ) } </span>
<span class="device-search-meta"> ${ lt . escHtml ( d . model || '' ) } </span>
</a>` ). join ( '' );
input . setAttribute ( 'aria-activedescendant' , `device-opt- ${ active } ` );
}
input . addEventListener ( 'input' , () => { active = 0 ; render (); });
input . addEventListener ( 'focus' , () => { loadDevices (). then ( render ); });
input . addEventListener ( 'keydown' , e => {
if ( e . key === 'Escape' ) { close (); return ; }
if ( ! matches . length ) return ;
if ( e . key === 'ArrowDown' ) { e . preventDefault (); active = ( active + 1 ) % matches . length ; render (); }
if ( e . key === 'ArrowUp' ) { e . preventDefault (); active = ( active - 1 + matches . length ) % matches . length ; render (); }
if ( e . key === 'Enter' ) { e . preventDefault (); window . location . href = deviceUrl ( matches [ active ]. name ); }
});
document . addEventListener ( 'click' , e => {
if ( ! e . target . closest ( '#device-search' )) close ();
});
document . addEventListener ( 'keydown' , e => {
const typing = e . target . closest ( 'input, textarea, select, [contenteditable]' );
if ( e . key === '/' && ! typing && ! e . ctrlKey && ! e . metaKey ) {
e . preventDefault ();
input . focus ();
}
});
loadDevices ();
}
// ── WAN modem / fiber panel ───────────────────────────────────────────
async function refreshModem () {
try {
updateModem ( await lt . api . get ( '/api/modem' ));
} catch ( err ) {
console . warn ( 'Modem data unavailable' , err );
}
}
function _modemUptime ( seconds ) {
if ( seconds === null || seconds === undefined ) return '– ' ;
const d = Math . floor ( seconds / 86400 );
const h = Math . floor (( seconds % 86400 ) / 3600 );
const m = Math . floor (( seconds % 3600 ) / 60 );
return d ? ` ${ d } d ${ h } h` : ` ${ h } h ${ m } m` ;
}
function _modemRate ( bytesPerSec ) {
if ( bytesPerSec === null || bytesPerSec === undefined ) return '– ' ;
const mbps = bytesPerSec * 8 / 1e6 ;
return mbps >= 1000 ? ` ${ ( mbps / 1000 ). toFixed ( 2 ) } Gbps` : ` ${ mbps . toFixed ( 1 ) } Mbps` ;
}
function _modemStat ( label , value , cls , note ) {
return `
<div class="modem-stat">
<span class="modem-stat-label"> ${ lt . escHtml ( label ) } </span>
<span class="modem-stat-value ${ cls || '' } "> ${ lt . escHtml ( String ( value )) } </span>
${ note ? `<span class="modem-stat-note"> ${ lt . escHtml ( note ) } </span>` : '' }
</div>` ;
}
function _modemOptic ( label , metric , status , unit ) {
if ( ! metric || metric . value === null || metric . value === undefined ) return _modemStat ( label , '– ' );
const cls = { alarm : 'lt-text-red' , warning : 'lt-text-amber' , ok : 'lt-text-green' }[ status ] || '' ;
return _modemStat ( label , ` ${ metric . value } ${ unit } ` , cls , `warn ${ metric . warn_low } to ${ metric . warn_high } ` );
}
function _modemChart ( history ) {
const points = ( history || []). filter ( p => p . rx_avg !== null && p . rx_avg !== undefined );
if ( points . length < 2 ) {
return '<div class="modem-chart-empty">Rx power trend appears after a few polls.</div>' ;
}
const W = 600 , H = 70 , PAD = 4 ;
const values = points . map ( p => p . rx_avg );
const low = Math . min (... values ) - 0.5 , high = Math . max (... values ) + 0.5 ;
const t0 = points [ 0 ]. ts , tSpan = ( points [ points . length - 1 ]. ts - t0 ) || 1 ;
const coord = p => ` ${ (( p . ts - t0 ) / tSpan * W ). toFixed ( 1 ) } ,` +
` ${ ( PAD + ( high - p . rx_avg ) / ( high - low ) * ( H - 2 * PAD )). toFixed ( 1 ) } ` ;
// Break the line where samples are missing rather than drawing across an outage
const gaps = points . slice ( 1 ). map (( p , i ) => p . ts - points [ i ]. ts ). sort (( a , b ) => a - b );
const maxGap = gaps [ Math . floor ( gaps . length / 2 )] * 3 ;
const segments = [[]];
points . forEach (( p , i ) => {
if ( i && p . ts - points [ i - 1 ]. ts > maxGap ) segments . push ([]);
segments [ segments . length - 1 ]. push ( coord ( p ));
});
const lines = segments
. map ( s => `<polyline points=" ${ s . join ( ' ' ) } " fill="none" vector-effect="non-scaling-stroke"/>` )
. join ( '' );
const summary = `range ${ Math . min (... values ) } to ${ Math . max (... values ) } dBm` ;
return `
<div class="modem-chart">
<div class="modem-chart-title">Rx power, last 7 days <span> ${ lt . escHtml ( summary ) } </span></div>
<svg viewBox="0 0 ${ W } ${ H } " preserveAspectRatio="none" role="img"
aria-label="Rx power trend, ${ lt . escHtml ( summary ) } "> ${ lines } </svg>
</div>` ;
}
function _modemWhen ( epochSeconds ) {
return new Date ( epochSeconds * 1000 ). toLocaleString ([], { dateStyle : 'short' , timeStyle : 'short' });
}
function _modemLastRestart ( history ) {
const restarts = ( history && history . restarts ) || [];
if ( ! restarts . length ) return '' ;
const last = restarts [ restarts . length - 1 ];
return `restarted ${ _modemWhen ( last . at ) }${ last . by ? ` by ${ last . by } ` : '' } ` ;
}
function _modemDuration ( seconds ) {
if ( seconds < 60 ) return ` ${ Math . round ( seconds ) } s` ;
if ( seconds < 3600 ) return ` ${ Math . floor ( seconds / 60 ) } m ${ Math . round ( seconds % 60 ) } s` ;
return ` ${ Math . floor ( seconds / 3600 ) } h ${ Math . floor (( seconds % 3600 ) / 60 ) } m` ;
}
function _modemOutages ( history ) {
const outages = (( history && history . outages ) || []). slice ( - 5 ). reverse ();
if ( ! outages . length ) return '' ;
const rows = outages . map ( o => {
const length = o . restored_at === null || o . restored_at === undefined
? '<span class="lt-text-red">ongoing</span>'
: lt . escHtml ( _modemDuration ( o . restored_at - o . failed_at ));
return `<li><span> ${ lt . escHtml ( _modemWhen ( o . failed_at )) } </span><span> ${ length } </span></li>` ;
}). join ( '' );
return `
<div class="modem-outages">
<div class="modem-chart-title">Recent WAN outages</div>
<ul> ${ rows } </ul>
</div>` ;
}
function updateModem ( data ) {
const section = document . getElementById ( 'modem-section' );
const body = document . getElementById ( 'modem-body' );
if ( ! section || ! body ) return ;
section . hidden = ! data . configured ;
if ( ! data . configured ) return ;
// On the dashboard, link to the modem's page. On that page, it would link to itself.
const detailsLink = document . getElementById ( 'modem-device-link' );
if ( detailsLink && typeof DEVICE_NAME === 'undefined' && data . name ) {
detailsLink . href = deviceUrl ( data . name );
detailsLink . hidden = false ;
}
const rxLabel = document . getElementById ( 'topo-modem-rx' );
const topo = document . getElementById ( 'topo-modem' );
const title = document . getElementById ( 'modem-title' );
if ( ! data . reachable ) {
if ( rxLabel ) rxLabel . textContent = 'unreachable' ;
if ( topo ) topo . classList . add ( 'topo-v2-status-down' );
body . innerHTML = `
<div class="modem-error">Modem status unavailable: ${ lt . escHtml ( data . error || 'unknown error' ) }
<span class="modem-stat-note">Last attempt ${ lt . escHtml ( data . updated || '– ' ) } </span></div>
${ _modemChart ( data . history ) } ` ;
return ;
}
const problems = data . wan_problems || [];
const optics = data . optics || {};
const status = data . optics_status || {};
const counters = data . counters || {};
const rates = data . rates || {};
const rx = optics . rx_power ? optics . rx_power . value : null ;
if ( topo ) topo . classList . toggle ( 'topo-v2-status-down' , problems . length > 0 );
if ( rxLabel ) rxLabel . textContent = ( rx === null || rx === undefined ) ? 'Rx – ' : `Rx ${ rx } dBm` ;
if ( title ) {
title . textContent = [ data . model || 'AT&T Gateway' , data . firmware && `fw ${ data . firmware } ` ]
. filter ( Boolean ). join ( ' · ' );
}
const errs = ( counters . rx_errors || 0 ) + ( counters . tx_errors || 0 );
const drops = ( counters . rx_drops || 0 ) + ( counters . tx_drops || 0 );
body . innerHTML = `
${ problems . length ? `<div class="modem-error">WAN down: ${ lt . escHtml ( problems . join ( '; ' )) } </div>` : '' }
<div class="modem-stats">
${ _modemStat ( 'WAN' , problems . length ? 'DOWN' : 'UP' , problems . length ? 'lt-text-red' : 'lt-text-green' ,
data . wan_ip || '' ) }
${ _modemStat ( 'PON' , data . pon_state || '– ' , '' , data . wavelength || '' ) }
${ _modemOptic ( 'Rx power' , optics . rx_power , status . rx_power , 'dBm' ) }
${ _modemOptic ( 'Tx power' , optics . tx_power , status . tx_power , 'dBm' ) }
${ _modemOptic ( 'Optics temp' , optics . temperature , status . temperature , '°C' ) }
${ _modemStat ( 'Throughput' , `↓ ${ _modemRate ( rates . rx_bytes ) } ` , '' , `↑ ${ _modemRate ( rates . tx_bytes ) } ` ) }
${ _modemStat ( 'Errors / drops' , ` ${ errs } / ${ drops } ` , errs ? 'lt-text-amber' : '' , 'since modem reboot' ) }
${ _modemStat ( 'Uptime' , _modemUptime ( data . uptime_s ), '' , _modemLastRestart ( data . wan_history )) }
</div>
${ _modemChart ( data . history ) }
${ _modemOutages ( data . wan_history ) }
<div class="modem-stat-note modem-updated">Updated ${ lt . escHtml ( data . updated || '– ' ) } </div>` ;
}
2026-03-01 23:03:18 -05:00
function updateUnifiTable ( devices ) {
const tbody = document . querySelector ( '#unifi-table tbody' );
if ( ! tbody || ! devices . length ) return ;
tbody . innerHTML = devices . map ( d => {
const statusClass = d . connected ? '' : 'row-critical' ;
const dotClass = d . connected ? 'dot-up' : 'dot-down' ;
2026-04-30 21:09:56 -04:00
const statusText = d . connected ? 'ONLINE' : 'OFFLINE' ;
2026-03-01 23:03:18 -05:00
const suppressBtn = ! d . connected
2026-04-18 21:01:20 -04:00
? `<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
2026-03-03 15:39:48 -05:00
data-sup-type="unifi_device"
2026-04-18 23:46:44 -04:00
data-sup-name=" ${ lt . escHtml ( d . name ) } "
2026-04-29 17:50:00 -04:00
data-sup-detail=""
aria-label="Suppress alerts for ${ lt . escHtml ( d . name ) } ">🔕 Suppress</button>`
2026-03-01 23:03:18 -05:00
: '' ;
return `
<tr class=" ${ statusClass } ">
<td><span class=" ${ dotClass } "></span> ${ statusText } </td>
2026-11-04 23:40:17 -05:00
<td><a class="device-link" href=" ${ lt . escHtml ( deviceUrl ( d . name )) } "><strong> ${ lt . escHtml ( d . name ) } </strong></a></td>
2026-04-18 23:46:44 -04:00
<td> ${ lt . escHtml ( d . type ) } </td>
<td> ${ lt . escHtml ( d . model ) } </td>
<td> ${ lt . escHtml ( d . ip ) } </td>
2026-03-01 23:03:18 -05:00
<td> ${ suppressBtn } </td>
</tr>` ;
}). join ( '' );
2025-01-04 01:42:16 -05:00
}
2026-03-17 20:32:32 -04:00
function updateEventsTable ( events , totalActive ) {
2026-03-01 23:03:18 -05:00
const wrap = document . getElementById ( 'events-table-wrap' );
if ( ! wrap ) return ;
2026-04-29 17:50:00 -04:00
const active = ( events || []). filter ( e => e . severity !== 'info' );
2026-03-01 23:03:18 -05:00
if ( ! active . length ) {
2026-04-30 21:09:56 -04:00
wrap . innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-icon">✔</div><div class="lt-empty-state-title">No active alerts</div></div>' ;
2026-03-01 23:03:18 -05:00
return ;
}
2026-05-10 22:42:40 -04:00
const truncated = totalActive !== null && totalActive !== undefined && totalActive > active . length ;
2026-03-17 20:32:32 -04:00
const countNotice = truncated
? `<div class="pagination-notice">Showing ${ active . length } of ${ totalActive } active alerts — <a href="/api/events?limit=1000">view all via API</a></div>`
: '' ;
2026-03-01 23:03:18 -05:00
const rows = active . map ( e => {
const supType = e . event_type === 'unifi_device_offline' ? 'unifi_device'
: e . event_type === 'interface_down' ? 'interface'
: 'host' ;
2026-03-14 14:31:57 -04:00
const ticketBase = ( typeof GANDALF_CONFIG !== 'undefined' && GANDALF_CONFIG . ticket_web_url )
? GANDALF_CONFIG . ticket_web_url : 'http://t.lotusguild.org/ticket/' ;
2026-03-01 23:03:18 -05:00
const ticket = e . ticket_id
2026-04-18 23:46:44 -04:00
? `<a href=" ${ lt . escHtml ( ticketBase ) }${ lt . escHtml ( String ( e . ticket_id )) } " target="_blank"
2026-05-11 23:02:09 -04:00
class="ticket-link"># ${ lt . escHtml ( String ( e . ticket_id )) } </a>`
2026-03-01 23:03:18 -05:00
: '– ' ;
2026-05-10 23:11:15 -04:00
const supBadge = e . is_suppressed
? `<span class="lt-badge badge-suppressed" title="Alert suppressed">🔕 sup</span>`
: '' ;
2026-03-01 23:03:18 -05:00
return `
2026-05-10 23:11:15 -04:00
<tr class="row- ${ e . severity }${ e . is_suppressed ? ' row-suppressed' : '' } ">
<td><span class="lt-badge badge- ${ e . severity } "> ${ e . severity } </span> ${ supBadge } </td>
2026-04-18 23:46:44 -04:00
<td> ${ lt . escHtml ( e . event_type . replace ( /_/g , ' ' )) } </td>
<td><strong> ${ lt . escHtml ( e . target_name ) } </strong></td>
<td> ${ lt . escHtml ( e . target_detail || '– ' ) } </td>
<td class="desc-cell" title=" ${ lt . escHtml ( e . description || '' ) } "> ${ lt . escHtml (( e . description || '' ). substring ( 0 , 60 )) }${ ( e . description || '' ). length > 60 ? '…' : '' } </td>
<td class="ts-cell" title=" ${ lt . escHtml ( e . first_seen || '' ) } "> ${ lt . time . ago ( _toIso ( e . first_seen )) } </td>
<td class="ts-cell" title=" ${ lt . escHtml ( e . last_seen || '' ) } "> ${ lt . time . ago ( _toIso ( e . last_seen )) } </td>
2026-03-01 23:03:18 -05:00
<td> ${ e . consecutive_failures } </td>
<td> ${ ticket } </td>
<td>
2026-04-18 21:01:20 -04:00
<button class="lt-btn lt-btn-ghost lt-btn-sm btn-suppress"
2026-04-18 23:46:44 -04:00
data-sup-type=" ${ lt . escHtml ( supType ) } "
data-sup-name=" ${ lt . escHtml ( e . target_name ) } "
2026-04-29 17:50:00 -04:00
data-sup-detail=" ${ lt . escHtml ( e . target_detail || '' ) } "
aria-label="Suppress alert for ${ lt . escHtml ( e . target_name ) } ">🔕</button>
2026-03-01 23:03:18 -05:00
</td>
</tr>` ;
}). join ( '' );
wrap . innerHTML = `
2026-03-17 20:32:32 -04:00
${ countNotice }
2026-04-18 21:01:20 -04:00
<div class="lt-table-wrap">
<table class="lt-table" id="events-table">
<caption class="lt-sr-only">Active network alerts</caption>
2026-03-02 12:43:11 -05:00
<thead>
<tr>
<th>Sev</th><th>Type</th><th>Target</th><th>Detail</th>
2026-03-14 21:46:11 -04:00
<th>Description</th><th>First Seen</th><th>Last Seen</th><th>Failures</th><th>Ticket</th><th>Actions</th>
2026-03-02 12:43:11 -05:00
</tr>
</thead>
<tbody> ${ rows } </tbody>
</table>
</div>` ;
2025-02-07 20:55:38 -05:00
}
2026-04-18 23:46:44 -04:00
// ── Suppression modal ─────────────────────────────────────────────────
2026-03-01 23:03:18 -05:00
function openSuppressModal ( type , name , detail ) {
const modal = document . getElementById ( 'suppress-modal' );
if ( ! modal ) return ;
2026-04-18 23:46:44 -04:00
document . getElementById ( 'sup-type' ). value = type ;
document . getElementById ( 'sup-name' ). value = name ;
document . getElementById ( 'sup-detail' ). value = detail ;
document . getElementById ( 'sup-reason' ). value = '' ;
2026-03-01 23:03:18 -05:00
document . getElementById ( 'sup-expires' ). value = '' ;
updateSuppressForm ();
2026-04-18 23:46:44 -04:00
lt . modal . open ( 'suppress-modal' );
2026-03-01 23:03:18 -05:00
2026-05-10 23:34:16 -04:00
document . querySelectorAll ( '#suppress-modal .pill' ). forEach ( p => {
p . classList . remove ( 'active' );
p . setAttribute ( 'aria-pressed' , 'false' );
});
2026-03-01 23:03:18 -05:00
const manualPill = document . querySelector ( '#suppress-modal .pill-manual' );
2026-05-10 23:34:16 -04:00
if ( manualPill ) { manualPill . classList . add ( 'active' ); manualPill . setAttribute ( 'aria-pressed' , 'true' ); }
2026-03-01 23:03:18 -05:00
const hint = document . getElementById ( 'duration-hint' );
if ( hint ) hint . textContent = 'Suppression will persist until manually removed.' ;
}
function closeSuppressModal () {
2026-04-18 23:46:44 -04:00
lt . modal . close ( 'suppress-modal' );
2026-03-01 23:03:18 -05:00
}
function updateSuppressForm () {
const type = document . getElementById ( 'sup-type' ). value ;
const nameGrp = document . getElementById ( 'sup-name-group' );
const detailGrp = document . getElementById ( 'sup-detail-group' );
2026-05-11 12:58:32 -04:00
const nameInput = document . getElementById ( 'sup-name' );
const detailInput = document . getElementById ( 'sup-detail' );
2026-03-01 23:03:18 -05:00
if ( nameGrp ) nameGrp . style . display = ( type === 'all' ) ? 'none' : '' ;
if ( detailGrp ) detailGrp . style . display = ( type === 'interface' ) ? '' : 'none' ;
2026-05-11 12:58:32 -04:00
if ( nameInput ) {
const req = ( type !== 'all' );
nameInput . required = req ;
nameInput . setAttribute ( 'aria-required' , String ( req ));
}
if ( detailInput ) {
const req = ( type === 'interface' );
detailInput . required = req ;
detailInput . setAttribute ( 'aria-required' , String ( req ));
}
2026-03-01 23:03:18 -05:00
}
2026-05-11 12:58:32 -04:00
function setDuration ( mins , el , opts ) {
const o = opts || {};
const expiresEl = document . getElementById ( o . expiresId || 'sup-expires' );
const pillSel = o . pillSel || '#suppress-modal .pill' ;
const hint = document . getElementById ( o . hintId || 'duration-hint' );
if ( expiresEl ) expiresEl . value = mins || '' ;
document . querySelectorAll ( pillSel ). forEach ( p => {
2026-05-10 23:34:16 -04:00
p . classList . remove ( 'active' );
p . setAttribute ( 'aria-pressed' , 'false' );
});
if ( el ) { el . classList . add ( 'active' ); el . setAttribute ( 'aria-pressed' , 'true' ); }
2026-03-01 23:03:18 -05:00
if ( hint ) {
if ( mins ) {
const h = Math . floor ( mins / 60 ), m = mins % 60 ;
hint . textContent = `Expires in ${ h ? h + 'h ' : '' }${ m ? m + 'm' : '' } .` ;
} else {
hint . textContent = 'Suppression will persist until manually removed.' ;
}
}
2025-02-07 20:55:38 -05:00
}
2026-03-01 23:03:18 -05:00
async function submitSuppress ( e ) {
e . preventDefault ();
const type = document . getElementById ( 'sup-type' ). value ;
const name = document . getElementById ( 'sup-name' ). value ;
const detail = document . getElementById ( 'sup-detail' ). value ;
const reason = document . getElementById ( 'sup-reason' ). value ;
const expires = document . getElementById ( 'sup-expires' ). value ;
if ( ! reason . trim ()) { showToast ( 'Reason is required' , 'error' ); return ; }
if ( type !== 'all' && ! name . trim ()) { showToast ( 'Target name is required' , 'error' ); return ; }
2025-02-07 20:55:38 -05:00
2026-03-01 23:03:18 -05:00
try {
2026-04-18 23:46:44 -04:00
await lt . api . post ( '/api/suppressions' , {
target_type : type ,
target_name : name ,
target_detail : detail ,
reason ,
expires_minutes : expires ? parseInt ( expires ) : null ,
2026-03-01 23:03:18 -05:00
});
2026-04-18 23:46:44 -04:00
closeSuppressModal ();
showToast ( 'Suppression applied ✔' , 'success' );
setTimeout ( refreshAll , 500 );
2026-03-01 23:03:18 -05:00
} catch ( err ) {
2026-04-18 23:46:44 -04:00
showToast ( err . message || 'Failed to apply suppression' , 'error' );
2026-03-01 23:03:18 -05:00
}
2025-02-07 21:03:31 -05:00
}
2025-02-07 20:55:38 -05:00
2026-05-10 23:07:53 -04:00
// ── Suppress form – wired here so the modal works from any page ──────
document . getElementById ( 'suppress-form' ) ? . addEventListener ( 'submit' , submitSuppress );
document . getElementById ( 'sup-type' ) ? . addEventListener ( 'change' , updateSuppressForm );
2026-04-18 23:46:44 -04:00
// ── Global click delegation ───────────────────────────────────────────
2026-03-01 23:03:18 -05:00
document . addEventListener ( 'click' , e => {
2026-04-18 23:46:44 -04:00
// Refresh button
if ( e . target . closest ( '[data-action="refresh"]' )) {
lt . autoRefresh . now ();
return ;
}
// Duration pills (data-duration="" = manual/forever)
const pill = e . target . closest ( '.pill[data-duration]' );
if ( pill ) {
const val = pill . dataset . duration ;
setDuration ( val ? parseInt ( val ) : null , pill );
return ;
}
2026-03-03 15:39:48 -05:00
2026-04-18 23:46:44 -04:00
// Suppress buttons
2026-03-03 15:39:48 -05:00
const btn = e . target . closest ( '.btn-suppress[data-sup-type]' );
if ( btn ) {
openSuppressModal (
btn . dataset . supType || '' ,
btn . dataset . supName || '' ,
btn . dataset . supDetail || '' ,
);
}
2026-03-01 23:03:18 -05:00
});