2026-03-04 19:42:38 -06:00
<!DOCTYPE html>
< html lang = "en" >
< head >
2026-03-04 22:58:00 -06:00
< meta charset = "UTF-8" >
2026-03-04 19:42:38 -06:00
< title > UniFi Access Attendance</ title >
2026-03-04 22:58:00 -06:00
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
2026-03-04 19:42:38 -06:00
< style >
2026-03-04 22:58:00 -06:00
*, * :: before , * :: after { box-sizing : border-box ; margin : 0 ; padding : 0 ; }
2026-03-04 19:42:38 -06:00
: root {
2026-03-04 22:58:00 -06:00
--bg : #050508 ; --bg-card : #111113 ; --gold : #d4af37 ; --gold-soft : #b89630 ;
--text : #f5f5f5 ; --muted : #888 ; --danger : #ff4d4f ; --success : #2ecc71 ;
2026-05-28 00:39:46 -05:00
--warn : #f39c12 ; --border : #222 ; --blue : #64b4ff ;
2026-03-04 19:42:38 -06:00
}
body {
2026-03-04 22:58:00 -06:00
background : radial-gradient ( circle at top , #18181c 0 % , #050508 55 % );
2026-03-04 19:42:38 -06:00
color : var ( -- text );
font-family : system-ui , - apple-system , BlinkMacSystemFont , "Segoe UI" , sans-serif ;
2026-03-04 22:58:00 -06:00
min-height : 100 vh ; display : flex ; justify-content : center ; padding : 32 px 16 px ;
2026-03-04 19:42:38 -06:00
}
. app-shell {
2026-03-04 19:53:39 -06:00
width : 100 % ; max-width : 1280 px ;
2026-03-04 19:42:38 -06:00
background : rgba ( 5 , 5 , 8 , 0.96 );
2026-03-04 22:58:00 -06:00
border-radius : 20 px ; border : 1 px solid rgba ( 212 , 175 , 55 , 0.3 );
2026-03-04 19:42:38 -06:00
box-shadow : 0 32 px 80 px rgba ( 0 , 0 , 0 , 0.7 );
padding : 24 px 24 px 32 px ;
}
2026-03-04 22:58:00 -06:00
header { display : flex ; justify-content : space-between ; align-items : center ; margin-bottom : 20 px ; gap : 16 px ; }
2026-03-04 19:42:38 -06:00
. title-block { display : flex ; flex-direction : column ; gap : 4 px ; }
2026-03-04 19:53:39 -06:00
h1 { font-size : 1.6 rem ; letter-spacing : 0.08 em ; text-transform : uppercase ; color : var ( -- gold ); }
2026-03-04 19:42:38 -06:00
. subtitle { font-size : 0.9 rem ; color : var ( -- muted ); }
. badge {
2026-03-04 22:58:00 -06:00
border-radius : 999 px ; border : 1 px solid rgba ( 212 , 175 , 55 , 0.5 );
padding : 4 px 10 px ; font-size : 0.75 rem ; text-transform : uppercase ;
letter-spacing : 0.12 em ; color : var ( -- gold - soft );
background : linear-gradient ( 90 deg , rgba ( 212 , 175 , 55 , 0.1 ), rgba ( 212 , 175 , 55 , 0.02 ));
2026-03-04 19:42:38 -06:00
}
2026-03-04 22:58:00 -06:00
. controls { display : flex ; flex-wrap : wrap ; gap : 12 px ; margin-bottom : 18 px ; align-items : center ; }
2026-03-04 19:42:38 -06:00
. control-group { display : flex ; flex-wrap : wrap ; gap : 10 px ; align-items : center ; }
2026-03-04 19:53:39 -06:00
. spacer { flex : 1 ; }
2026-03-04 19:42:38 -06:00
label { font-size : 0.8 rem ; letter-spacing : 0.1 em ; text-transform : uppercase ; color : var ( -- muted ); }
2026-05-28 00:39:46 -05:00
input , select {
2026-03-04 22:58:00 -06:00
background : var ( -- bg - card ); border-radius : 999 px ; border : 1 px solid var ( -- border );
padding : 8 px 14 px ; font-size : 0.9 rem ; color : var ( -- text ); outline : none ; min-width : 130 px ;
2026-03-04 19:42:38 -06:00
}
2026-05-28 00:39:46 -05:00
input : focus , select : focus { border-color : var ( -- gold - soft ); box-shadow : 0 0 0 1 px rgba ( 212 , 175 , 55 , 0.4 ); }
2026-03-04 19:42:38 -06:00
button {
2026-03-04 22:58:00 -06:00
border-radius : 999 px ; border : 1 px solid rgba ( 212 , 175 , 55 , 0.7 );
background : radial-gradient ( circle at top , rgba ( 212 , 175 , 55 , 0.35 ), rgba ( 2 , 2 , 4 , 0.95 ));
color : var ( -- text ); padding : 8 px 18 px ; font-size : 0.85 rem ;
letter-spacing : 0.1 em ; text-transform : uppercase ; cursor : pointer ;
transition : transform 0.08 s , box-shadow 0.08 s ; white-space : nowrap ;
2026-03-04 19:42:38 -06:00
}
button : hover { transform : translateY ( -1 px ); box-shadow : 0 8 px 24 px rgba ( 0 , 0 , 0 , 0.5 ); }
2026-03-04 19:53:39 -06:00
button : active { transform : translateY ( 1 px ); box-shadow : none ; }
button : disabled { opacity : 0.45 ; cursor : default ; transform : none ; }
2026-05-28 00:39:46 -05:00
. sync-btn { border-color : rgba ( 100 , 180 , 255 , 0.6 ); background : radial-gradient ( circle at top , rgba ( 100 , 180 , 255 , 0.18 ), rgba ( 2 , 2 , 4 , 0.95 )); }
. controllers-btn { border-color : rgba ( 160 , 120 , 255 , 0.6 ); background : radial-gradient ( circle at top , rgba ( 160 , 120 , 255 , 0.18 ), rgba ( 2 , 2 , 4 , 0.95 )); }
. reset-btn { border-color : rgba ( 255 , 100 , 100 , 0.6 ); background : radial-gradient ( circle at top , rgba ( 255 , 80 , 80 , 0.18 ), rgba ( 2 , 2 , 4 , 0.95 )); }
. small-btn { padding : 6 px 12 px ; font-size : 0.75 rem ; }
2026-03-04 22:58:00 -06:00
. summary-row { display : flex ; flex-wrap : wrap ; gap : 12 px ; margin-bottom : 16 px ; font-size : 0.85 rem ; }
. summary-pill { display : inline-flex ; align-items : center ; gap : 8 px ; background : var ( -- bg - card ); border-radius : 999 px ; padding : 6 px 14 px ; border : 1 px solid var ( -- border ); color : var ( -- muted ); }
2026-03-04 19:42:38 -06:00
. dot { width : 9 px ; height : 9 px ; border-radius : 50 % ; }
. dot . on { background : var ( -- success ); box-shadow : 0 0 10 px rgba ( 46 , 204 , 113 , 0.7 ); }
. dot . off { background : var ( -- danger ); box-shadow : 0 0 10 px rgba ( 255 , 77 , 79 , 0.7 ); }
. dot . all { background : var ( -- gold - soft ); box-shadow : 0 0 10 px rgba ( 184 , 150 , 48 , 0.5 ); }
. table-card {
2026-03-04 22:58:00 -06:00
background : linear-gradient ( 135 deg , rgba ( 17 , 17 , 19 , 0.98 ), rgba ( 8 , 8 , 10 , 0.98 ));
border-radius : 14 px ; border : 1 px solid rgba ( 255 , 255 , 255 , 0.04 ); overflow : hidden ;
2026-03-04 19:42:38 -06:00
}
table { width : 100 % ; border-collapse : collapse ; font-size : 0.9 rem ; }
2026-03-04 22:58:00 -06:00
thead { background : radial-gradient ( circle at top left , rgba ( 212 , 175 , 55 , 0.22 ), rgba ( 10 , 10 , 12 , 0.9 )); }
th , td { padding : 10 px 16 px ; text-align : left ; border-bottom : 1 px solid rgba ( 255 , 255 , 255 , 0.04 ); white-space : nowrap ; }
2026-03-04 19:53:39 -06:00
th { font-size : 0.78 rem ; text-transform : uppercase ; letter-spacing : 0.12 em ; color : var ( -- muted ); }
2026-03-04 19:42:38 -06:00
tbody tr : last-child td { border-bottom : none ; }
tbody tr : hover { background : rgba ( 212 , 175 , 55 , 0.04 ); }
2026-03-04 22:58:00 -06:00
. name-cell { font-weight : 500 ; color : var ( -- text ); }
. muted-cell { color : var ( -- muted ); font-size : 0.82 rem ; }
2026-05-28 00:39:46 -05:00
. source-chip {
display : inline-block ; padding : 3 px 10 px ; border-radius : 999 px ;
font-size : 0.72 rem ; letter-spacing : 0.08 em ; text-transform : uppercase ;
color : var ( -- blue ); background : rgba ( 100 , 180 , 255 , 0.08 );
border : 1 px solid rgba ( 100 , 180 , 255 , 0.35 );
}
2026-03-04 19:42:38 -06:00
. align-center { text-align : center ; }
2026-03-04 22:58:00 -06:00
. time-first { color : var ( -- text ); font-weight : 500 ; }
2026-03-04 19:53:39 -06:00
. time-latest { color : var ( -- muted ); font-size : 0.85 rem ; }
2026-03-04 22:58:00 -06:00
. same-badge { color : #555 ; font-size : 0.82 rem ; font-style : italic ; }
2026-03-04 19:42:38 -06:00
. status-chip {
2026-03-04 19:53:39 -06:00
display : inline-flex ; align-items : center ; justify-content : center ;
min-width : 88 px ; padding : 5 px 12 px ; border-radius : 999 px ;
2026-03-04 22:58:00 -06:00
font-size : 0.78 rem ; font-weight : 600 ; text-transform : uppercase ; letter-spacing : 0.1 em ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
. status-on { background : rgba ( 46 , 204 , 113 , 0.1 ); color : #c9f7dc ; border : 1 px solid rgba ( 46 , 204 , 113 , 0.65 ); box-shadow : 0 0 14 px rgba ( 46 , 204 , 113 , 0.2 ); }
. status-off { background : rgba ( 255 , 77 , 79 , 0.1 ); color : #ffd6d7 ; border : 1 px solid rgba ( 255 , 77 , 79 , 0.75 ); box-shadow : 0 0 14 px rgba ( 255 , 77 , 79 , 0.2 ); }
. chip-dot { width : 7 px ; height : 7 px ; border-radius : 50 % ; margin-right : 6 px ; background : currentColor ; }
. empty-state { padding : 28 px 16 px ; text-align : center ; color : var ( -- muted ); font-size : 0.9 rem ; }
. empty-state span { color : var ( -- gold - soft ); }
2026-05-28 00:39:46 -05:00
2026-03-04 22:58:00 -06:00
. modal-overlay { display : none ; position : fixed ; inset : 0 ; background : rgba ( 0 , 0 , 0 , 0.75 ); backdrop-filter : blur ( 4 px ); z-index : 100 ; align-items : center ; justify-content : center ; }
2026-03-04 19:53:39 -06:00
. modal-overlay . open { display : flex ; }
2026-05-28 00:39:46 -05:00
. modal { background : var ( -- bg - card ); border : 1 px solid rgba ( 212 , 175 , 55 , 0.3 ); border-radius : 16 px ; padding : 24 px ; max-width : 720 px ; width : 92 % ; box-shadow : 0 24 px 60 px rgba ( 0 , 0 , 0 , 0.8 ); max-height : 90 vh ; overflow-y : auto ; }
. modal . danger { border-color : rgba ( 255 , 100 , 100 , 0.5 ); max-width : 420 px ; text-align : center ; }
. modal h2 { color : var ( -- gold ); font-size : 1.05 rem ; margin-bottom : 14 px ; text-transform : uppercase ; letter-spacing : 0.08 em ; }
. modal . danger h2 { color : var ( -- danger ); }
. modal p { color : var ( -- muted ); font-size : 0.88 rem ; margin-bottom : 16 px ; line-height : 1.55 ; }
2026-03-04 19:53:39 -06:00
. modal p strong { color : var ( -- text ); }
2026-05-28 00:39:46 -05:00
. modal-actions { display : flex ; gap : 12 px ; justify-content : flex-end ; margin-top : 16 px ; }
. modal . danger . modal-actions { justify-content : center ; }
2026-03-04 22:58:00 -06:00
. modal-cancel { border-color : rgba ( 255 , 255 , 255 , 0.15 ); background : rgba ( 255 , 255 , 255 , 0.05 ); }
2026-03-04 19:53:39 -06:00
. modal-confirm { border-color : rgba ( 255 , 100 , 100 , 0.6 ); background : rgba ( 255 , 80 , 80 , 0.18 ); }
2026-05-28 00:39:46 -05:00
. ctrl-list { display : flex ; flex-direction : column ; gap : 10 px ; margin-bottom : 18 px ; }
. ctrl-row {
display : grid ; grid-template-columns : 1 fr auto ;
gap : 8 px 16 px ; padding : 12 px 14 px ;
background : rgba ( 255 , 255 , 255 , 0.02 );
border : 1 px solid var ( -- border ); border-radius : 10 px ; align-items : center ;
}
. ctrl-meta { display : flex ; flex-direction : column ; gap : 3 px ; min-width : 0 ; }
. ctrl-name { font-weight : 600 ; color : var ( -- text ); font-size : 0.95 rem ; }
. ctrl-name . disabled-tag { color : var ( -- muted ); font-size : 0.7 rem ; margin-left : 6 px ; font-weight : 400 ; }
. ctrl-sub { font-size : 0.78 rem ; color : var ( -- muted ); font-family : ui-monospace , monospace ; overflow : hidden ; text-overflow : ellipsis ; }
. ctrl-actions { display : flex ; gap : 6 px ; flex-wrap : wrap ; justify-content : flex-end ; }
. ctrl-actions button { padding : 5 px 10 px ; font-size : 0.7 rem ; letter-spacing : 0.08 em ; }
. form-grid { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 10 px 14 px ; margin-bottom : 14 px ; }
. form-grid . full { grid-column : 1 / -1 ; }
. form-grid label { display : block ; margin-bottom : 4 px ; }
. form-grid input { width : 100 % ; min-width : 0 ; }
. form-hint { font-size : 0.78 rem ; color : var ( -- muted ); margin-top : -4 px ; margin-bottom : 10 px ; }
. form-error { color : var ( -- danger ); font-size : 0.82 rem ; margin-bottom : 10 px ; min-height : 1.1 em ; }
2026-03-04 19:42:38 -06:00
. toast {
2026-03-04 22:58:00 -06:00
position : fixed ; bottom : 24 px ; right : 24 px ; background : var ( -- bg - card );
border : 1 px solid rgba ( 212 , 175 , 55 , 0.5 ); border-radius : 12 px ; padding : 12 px 18 px ;
font-size : 0.85 rem ; color : var ( -- gold ); opacity : 0 ; transform : translateY ( 12 px );
transition : opacity 0.25 s , transform 0.25 s ; pointer-events : none ; z-index : 200 ;
2026-05-28 00:39:46 -05:00
max-width : 360 px ;
2026-03-04 19:42:38 -06:00
}
. toast . show { opacity : 1 ; transform : translateY ( 0 ); }
2026-05-28 00:39:46 -05:00
. toast . error { border-color : rgba ( 255 , 100 , 100 , 0.6 ); color : #ffd6d7 ; }
2026-03-04 19:53:39 -06:00
@ media ( max-width : 800px ) {
2026-03-04 19:42:38 -06:00
header { flex-direction : column ; align-items : flex-start ; }
. controls { flex-direction : column ; align-items : stretch ; }
2026-05-28 00:39:46 -05:00
input , select , button { width : 100 % ; }
th : nth-child ( 5 ), td : nth-child ( 5 ) { display : none ; }
. form-grid { grid-template-columns : 1 fr ; }
. ctrl-row { grid-template-columns : 1 fr ; }
. ctrl-actions { justify-content : flex-start ; }
2026-03-04 19:42:38 -06:00
}
</ style >
</ head >
< body >
2026-03-04 19:53:39 -06:00
< div class = "app-shell" >
< header >
< div class = "title-block" >
< h1 > Building Access</ h1 >
2026-03-04 22:58:00 -06:00
< div class = "subtitle" > Daily badge-in attendance — powered by UniFi Access</ div >
2026-03-04 19:53:39 -06:00
</ div >
< div class = "badge" > LIVE ATTENDANCE DASHBOARD</ div >
</ header >
< section class = "controls" >
< div class = "control-group" >
< label for = "date" > Date</ label >
2026-03-04 22:58:00 -06:00
< input type = "date" id = "date" >
2026-03-04 19:53:39 -06:00
</ div >
< div class = "control-group" >
< label for = "cutoff" > Badged in by</ label >
2026-03-04 22:58:00 -06:00
< input type = "time" id = "cutoff" value = "09:00" >
2026-03-04 19:53:39 -06:00
</ div >
2026-05-28 00:39:46 -05:00
< div class = "control-group" >
< label for = "controller-filter" > Controller</ label >
< select id = "controller-filter" >< option value = "" > All</ option ></ select >
</ div >
2026-03-04 19:53:39 -06:00
< div class = "spacer" ></ div >
< div class = "control-group" >
< button id = "refresh-btn" > ↻ Refresh</ button >
< button class = "sync-btn" id = "sync-btn" > ↻ Sync Users</ button >
2026-05-28 00:39:46 -05:00
< button class = "controllers-btn" id = "open-controllers-btn" > ⚙ Controllers</ button >
2026-03-04 19:53:39 -06:00
< button class = "reset-btn" id = "reset-btn" > ✕ Reset Day</ button >
</ div >
</ section >
< section class = "summary-row" >
2026-03-04 22:58:00 -06:00
< div class = "summary-pill" >< div class = "dot on" ></ div >< span id = "on-time-count" > 0</ span > on time</ div >
< div class = "summary-pill" >< div class = "dot off" ></ div >< span id = "late-count" > 0</ span > late</ div >
< div class = "summary-pill" >< div class = "dot all" ></ div >< span id = "total-count" > 0</ span > total</ div >
2026-03-04 19:53:39 -06:00
</ section >
< section class = "table-card" >
< table >
< thead >
< tr >
< th > #</ th >
< th > Name</ th >
2026-05-28 00:39:46 -05:00
< th > Source</ th >
2026-03-04 19:53:39 -06:00
< th > First Badge In</ th >
< th > Latest Badge In</ th >
< th > Actor ID</ th >
< th class = "align-center" > Status</ th >
</ tr >
</ thead >
< tbody id = "table-body" >
2026-05-28 00:39:46 -05:00
< tr >< td colspan = "7" class = "empty-state" > No data yet. < span > Badge into a door</ span > and press Refresh.</ td ></ tr >
2026-03-04 19:53:39 -06:00
</ tbody >
</ table >
</ section >
</ div >
<!-- Reset confirmation modal -->
< div class = "modal-overlay" id = "reset-modal" >
2026-05-28 00:39:46 -05:00
< div class = "modal danger" >
2026-03-04 19:53:39 -06:00
< h2 > ⚠ Reset Day</ h2 >
2026-03-04 22:58:00 -06:00
< p > This will permanently delete all badge-in records for < strong id = "modal-date-label" ></ strong > .< br > Use this for testing only.</ p >
2026-03-04 19:53:39 -06:00
< div class = "modal-actions" >
< button class = "modal-cancel" id = "modal-cancel" > Cancel</ button >
< button class = "modal-confirm" id = "modal-confirm" > Yes, Reset</ button >
</ div >
2026-03-04 19:42:38 -06:00
</ div >
2026-03-04 19:53:39 -06:00
</ div >
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
<!-- Controllers management modal -->
< div class = "modal-overlay" id = "controllers-modal" >
< div class = "modal" >
< h2 > Controllers</ h2 >
< p > Each controller is a UniFi Access instance reachable from this server.
Adding one will register a webhook on that controller automatically.</ p >
< div class = "ctrl-list" id = "ctrl-list" >
< div class = "empty-state" > No controllers configured.</ div >
</ div >
< h2 style = "margin-top: 8px;" > Add Controller</ h2 >
< div class = "form-grid" >
< div class = "full" >
< label for = "add-name" > Name</ label >
< input type = "text" id = "add-name" placeholder = "e.g. Main Office" >
</ div >
< div >
< label for = "add-host" > Host / IP</ label >
< input type = "text" id = "add-host" placeholder = "10.0.0.1" >
</ div >
< div >
< label for = "add-port" > Port</ label >
< input type = "number" id = "add-port" value = "12445" >
</ div >
< div class = "full" >
< label for = "add-token" > Developer API Token</ label >
< input type = "password" id = "add-token" placeholder = "paste token from UniFi Access" >
</ div >
</ div >
< div class = "form-hint" >
The dashboard registers its webhook URL with the controller using
< span id = "base-url-hint" style = "color: var(--gold-soft);" ></ span > .
Set the < code > DASHBOARD_BASE_URL</ code > env var if the controller can't reach that address.
</ div >
< div class = "form-error" id = "add-error" ></ div >
< div class = "modal-actions" >
< button class = "modal-cancel" id = "controllers-close" > Close</ button >
< button id = "add-controller-btn" > Add Controller</ button >
</ div >
</ div >
</ div >
2026-03-04 19:53:39 -06:00
< div class = "toast" id = "toast" ></ div >
2026-03-04 19:42:38 -06:00
2026-03-04 19:53:39 -06:00
< script >
2026-05-28 00:39:46 -05:00
function isoToday () { return new Date (). toISOString (). slice ( 0 , 10 ); }
2026-03-04 19:53:39 -06:00
2026-05-28 00:39:46 -05:00
function showToast ( msg , isError = false , duration = 3500 ) {
2026-03-04 19:53:39 -06:00
const t = document . getElementById ( 'toast' );
t . textContent = msg ;
2026-05-28 00:39:46 -05:00
t . classList . toggle ( 'error' , isError );
2026-03-04 19:53:39 -06:00
t . classList . add ( 'show' );
clearTimeout ( t . _timer );
t . _timer = setTimeout (() => t . classList . remove ( 'show' ), duration );
}
2026-05-28 00:39:46 -05:00
async function loadControllerList () {
const sel = document . getElementById ( 'controller-filter' );
const prev = sel . value ;
try {
const res = await fetch ( '/api/controllers' );
const items = await res . json ();
sel . innerHTML = '<option value="">All controllers</option>' +
items . map ( c => `<option value=" ${ c . id } "> ${ escapeHtml ( c . name ) }${ c . enabled ? '' : ' (disabled)' } </option>` ). join ( '' );
if ( prev && items . some ( c => c . id === prev )) sel . value = prev ;
return items ;
} catch {
return [];
}
}
2026-03-04 19:53:39 -06:00
async function loadData () {
const date = document . getElementById ( 'date' ). value || isoToday ();
const cutoff = document . getElementById ( 'cutoff' ). value || '09:00' ;
2026-05-28 00:39:46 -05:00
const controllerId = document . getElementById ( 'controller-filter' ). value ;
2026-03-04 19:53:39 -06:00
const params = new URLSearchParams ({ date , cutoff });
2026-05-28 00:39:46 -05:00
if ( controllerId ) params . set ( 'controller_id' , controllerId );
2026-03-04 19:53:39 -06:00
2026-03-04 22:58:00 -06:00
let data ;
2026-03-04 19:53:39 -06:00
try {
const res = await fetch ( '/api/first-badge-status?' + params . toString ());
data = await res . json ();
} catch {
2026-05-28 00:39:46 -05:00
showToast ( 'Could not load data' , true );
2026-03-04 19:53:39 -06:00
return ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
const tbody = document . getElementById ( 'table-body' );
tbody . innerHTML = '' ;
if ( ! data . length ) {
2026-05-28 00:39:46 -05:00
tbody . innerHTML = '<tr><td colspan="7" class="empty-state">No badge-in records for this day.</td></tr>' ;
2026-03-04 19:53:39 -06:00
[ 'on-time-count' , 'late-count' , 'total-count' ]. forEach ( id => {
2026-03-04 22:58:00 -06:00
document . getElementById ( id ). textContent = '0' ;
2026-03-04 19:53:39 -06:00
});
return ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
let onTime = 0 , late = 0 ;
data . forEach (( row , i ) => {
const tr = document . createElement ( 'tr' );
const numTd = document . createElement ( 'td' );
numTd . className = 'muted-cell' ;
numTd . textContent = i + 1 ;
tr . appendChild ( numTd );
2026-03-04 19:42:38 -06:00
2026-03-04 19:53:39 -06:00
const nameTd = document . createElement ( 'td' );
nameTd . className = 'name-cell' ;
2026-03-04 22:58:00 -06:00
nameTd . textContent = row . name ;
2026-03-04 19:53:39 -06:00
tr . appendChild ( nameTd );
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
const sourceTd = document . createElement ( 'td' );
const chip = document . createElement ( 'span' );
chip . className = 'source-chip' ;
chip . textContent = row . source || '—' ;
sourceTd . appendChild ( chip );
tr . appendChild ( sourceTd );
2026-03-04 19:53:39 -06:00
const firstTd = document . createElement ( 'td' );
firstTd . className = 'time-first' ;
2026-03-04 22:58:00 -06:00
firstTd . textContent = row . first_ts || '—' ;
2026-03-04 19:53:39 -06:00
tr . appendChild ( firstTd );
const latestTd = document . createElement ( 'td' );
2026-03-04 22:58:00 -06:00
if ( ! row . latest_ts ) {
2026-03-04 19:53:39 -06:00
latestTd . className = 'same-badge' ;
latestTd . textContent = '— same' ;
} else {
latestTd . className = 'time-latest' ;
2026-03-04 22:58:00 -06:00
latestTd . textContent = row . latest_ts ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
tr . appendChild ( latestTd );
2026-03-04 19:42:38 -06:00
2026-03-04 19:53:39 -06:00
const idTd = document . createElement ( 'td' );
idTd . className = 'muted-cell' ;
2026-03-04 22:58:00 -06:00
idTd . textContent = row . actor_id ? row . actor_id . slice ( 0 , 8 ) + '...' : '—' ;
2026-03-04 19:53:39 -06:00
tr . appendChild ( idTd );
2026-03-04 19:42:38 -06:00
2026-03-04 19:53:39 -06:00
const statusTd = document . createElement ( 'td' );
statusTd . className = 'align-center' ;
2026-05-28 00:39:46 -05:00
const statusChip = document . createElement ( 'div' );
2026-03-04 22:58:00 -06:00
const isOnTime = row . status === 'ON TIME' ;
2026-05-28 00:39:46 -05:00
statusChip . className = 'status-chip ' + ( isOnTime ? 'status-on' : 'status-off' );
statusChip . innerHTML = '<span class="chip-dot"></span>' + ( isOnTime ? 'ON TIME' : 'LATE' );
statusTd . appendChild ( statusChip );
2026-03-04 19:53:39 -06:00
tr . appendChild ( statusTd );
tbody . appendChild ( tr );
2026-03-04 22:58:00 -06:00
isOnTime ? onTime ++ : late ++ ;
2026-03-04 19:53:39 -06:00
});
2026-03-04 22:58:00 -06:00
document . getElementById ( 'on-time-count' ). textContent = onTime ;
document . getElementById ( 'late-count' ). textContent = late ;
document . getElementById ( 'total-count' ). textContent = onTime + late ;
2026-03-04 19:53:39 -06:00
}
async function syncUsers () {
const btn = document . getElementById ( 'sync-btn' );
2026-05-28 00:39:46 -05:00
const orig = btn . innerHTML ;
btn . textContent = 'Syncing…' ; btn . disabled = true ;
2026-03-04 19:53:39 -06:00
try {
await fetch ( '/api/sync-users' );
2026-05-28 00:39:46 -05:00
showToast ( 'User list synced from all controllers' );
2026-03-04 19:53:39 -06:00
await loadData ();
} catch {
2026-05-28 00:39:46 -05:00
showToast ( 'Sync failed — check server logs' , true );
2026-03-04 19:53:39 -06:00
} finally {
2026-05-28 00:39:46 -05:00
btn . innerHTML = orig ; btn . disabled = false ;
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
}
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
// -------- Reset day modal --------
2026-03-04 19:53:39 -06:00
document . getElementById ( 'reset-btn' ). addEventListener ( 'click' , () => {
const date = document . getElementById ( 'date' ). value || isoToday ();
document . getElementById ( 'modal-date-label' ). textContent = date ;
document . getElementById ( 'reset-modal' ). classList . add ( 'open' );
});
document . getElementById ( 'modal-cancel' ). addEventListener ( 'click' , () => {
document . getElementById ( 'reset-modal' ). classList . remove ( 'open' );
});
document . getElementById ( 'modal-confirm' ). addEventListener ( 'click' , async () => {
document . getElementById ( 'reset-modal' ). classList . remove ( 'open' );
const date = document . getElementById ( 'date' ). value || isoToday ();
2026-05-28 00:39:46 -05:00
const controllerId = document . getElementById ( 'controller-filter' ). value ;
const params = new URLSearchParams ({ date });
if ( controllerId ) params . set ( 'controller_id' , controllerId );
2026-03-04 19:53:39 -06:00
try {
2026-05-28 00:39:46 -05:00
const res = await fetch ( '/api/reset-day?' + params . toString (), { method : 'DELETE' });
2026-03-04 19:53:39 -06:00
const json = await res . json ();
2026-03-04 22:58:00 -06:00
showToast ( `Reset complete — ${ json . deleted } record(s) deleted for ${ date } ` );
2026-03-04 19:53:39 -06:00
await loadData ();
} catch {
2026-05-28 00:39:46 -05:00
showToast ( 'Reset failed — check server logs' , true );
2026-03-04 19:42:38 -06:00
}
2026-03-04 19:53:39 -06:00
});
document . getElementById ( 'reset-modal' ). addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'reset-modal' ))
document . getElementById ( 'reset-modal' ). classList . remove ( 'open' );
});
2026-03-04 19:42:38 -06:00
2026-05-28 00:39:46 -05:00
// -------- Controllers modal --------
function escapeHtml ( s ) {
return String ( s ). replace ( /[&<>"']/g , c => ({
'&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : '''
}[ c ]));
}
function fmtDate ( iso ) {
if ( ! iso ) return 'never' ;
const d = new Date ( iso );
return isNaN ( d ) ? iso : d . toLocaleString ();
}
async function renderControllers () {
const list = document . getElementById ( 'ctrl-list' );
list . innerHTML = '<div class="empty-state">Loading…</div>' ;
let items = [];
try {
const res = await fetch ( '/api/controllers' );
items = await res . json ();
} catch {
list . innerHTML = '<div class="empty-state">Failed to load controllers.</div>' ;
return ;
}
if ( ! items . length ) {
list . innerHTML = '<div class="empty-state">No controllers configured. Add one below to start receiving badge events.</div>' ;
return ;
}
list . innerHTML = '' ;
items . forEach ( c => {
const row = document . createElement ( 'div' );
row . className = 'ctrl-row' ;
row . innerHTML = `
<div class="ctrl-meta">
<div class="ctrl-name">
${ escapeHtml ( c . name ) }
${ c . enabled ? '' : '<span class="disabled-tag">(disabled)</span>' }
</div>
<div class="ctrl-sub"> ${ escapeHtml ( c . host ) } : ${ c . port } · last sync ${ fmtDate ( c . last_sync_at ) }${ c . has_webhook ? '' : ' · <span style="color:var(--danger)">no webhook</span>' } </div>
</div>
<div class="ctrl-actions">
<button class="small-btn sync-btn" data-act="test" data-id=" ${ c . id } ">Test</button>
<button class="small-btn sync-btn" data-act="sync" data-id=" ${ c . id } ">Sync</button>
<button class="small-btn" data-act="toggle" data-id=" ${ c . id } "> ${ c . enabled ? 'Disable' : 'Enable' } </button>
<button class="small-btn reset-btn" data-act="delete" data-id=" ${ c . id } " data-name=" ${ escapeHtml ( c . name ) } ">Remove</button>
</div>
` ;
list . appendChild ( row );
});
}
document . getElementById ( 'open-controllers-btn' ). addEventListener ( 'click' , async () => {
document . getElementById ( 'base-url-hint' ). textContent = window . location . origin ;
document . getElementById ( 'add-error' ). textContent = '' ;
document . getElementById ( 'controllers-modal' ). classList . add ( 'open' );
await renderControllers ();
});
document . getElementById ( 'controllers-close' ). addEventListener ( 'click' , () => {
document . getElementById ( 'controllers-modal' ). classList . remove ( 'open' );
});
document . getElementById ( 'controllers-modal' ). addEventListener ( 'click' , e => {
if ( e . target === document . getElementById ( 'controllers-modal' ))
document . getElementById ( 'controllers-modal' ). classList . remove ( 'open' );
});
document . getElementById ( 'ctrl-list' ). addEventListener ( 'click' , async e => {
const btn = e . target . closest ( 'button[data-act]' );
if ( ! btn ) return ;
const id = btn . dataset . id ;
const act = btn . dataset . act ;
btn . disabled = true ;
try {
if ( act === 'test' ) {
const r = await fetch ( `/api/controllers/ ${ id } /test` , { method : 'POST' });
const j = await r . json ();
showToast ( j . ok
? `Connected — ${ j . user_count } users on controller`
: `Test failed: ${ j . message } ` ,
! j . ok );
} else if ( act === 'sync' ) {
const r = await fetch ( `/api/controllers/ ${ id } /sync` , { method : 'POST' });
const j = await r . json ();
showToast ( `Synced ${ j . synced } users` );
await renderControllers ();
await loadData ();
} else if ( act === 'toggle' ) {
const isEnabling = btn . textContent . trim (). toLowerCase () === 'enable' ;
await fetch ( `/api/controllers/ ${ id } ` , {
method : 'PATCH' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ({ enabled : isEnabling }),
});
await renderControllers ();
await loadControllerList ();
} else if ( act === 'delete' ) {
const name = btn . dataset . name ;
if ( ! confirm ( `Remove controller " ${ name } "?\n\nThis deletes its webhook from the controller and removes all its badge events from the dashboard. This cannot be undone.` ))
return ;
const r = await fetch ( `/api/controllers/ ${ id } ` , { method : 'DELETE' });
if ( ! r . ok ) {
const j = await r . json (). catch (() => ({}));
showToast ( `Remove failed: ${ j . error || r . status } ` , true );
return ;
}
showToast ( `Removed ${ name } ` );
await renderControllers ();
await loadControllerList ();
await loadData ();
}
} catch ( err ) {
showToast ( `Action failed: ${ err . message || err } ` , true );
} finally {
btn . disabled = false ;
}
});
document . getElementById ( 'add-controller-btn' ). addEventListener ( 'click' , async () => {
const btn = document . getElementById ( 'add-controller-btn' );
const err = document . getElementById ( 'add-error' );
err . textContent = '' ;
const body = {
name : document . getElementById ( 'add-name' ). value . trim (),
host : document . getElementById ( 'add-host' ). value . trim (),
port : parseInt ( document . getElementById ( 'add-port' ). value , 10 ) || 12445 ,
api_token : document . getElementById ( 'add-token' ). value . trim (),
};
if ( ! body . name || ! body . host || ! body . api_token ) {
err . textContent = 'Name, host, and API token are required.' ;
return ;
}
btn . disabled = true ;
btn . textContent = 'Adding…' ;
try {
const r = await fetch ( '/api/controllers' , {
method : 'POST' ,
headers : { 'Content-Type' : 'application/json' },
body : JSON . stringify ( body ),
});
const j = await r . json ();
if ( ! r . ok ) {
err . textContent = j . error || 'Failed to add controller.' ;
if ( j . response ) err . textContent += ` — ${ String ( j . response ). slice ( 0 , 200 ) } ` ;
return ;
}
document . getElementById ( 'add-name' ). value = '' ;
document . getElementById ( 'add-host' ). value = '' ;
document . getElementById ( 'add-token' ). value = '' ;
showToast ( `Added ${ j . name } ` );
await renderControllers ();
await loadControllerList ();
await loadData ();
} catch ( e ) {
err . textContent = `Network error: ${ e . message || e } ` ;
} finally {
btn . disabled = false ;
btn . textContent = 'Add Controller' ;
}
});
// -------- Wire up --------
2026-03-04 19:53:39 -06:00
document . getElementById ( 'refresh-btn' ). addEventListener ( 'click' , loadData );
document . getElementById ( 'sync-btn' ). addEventListener ( 'click' , syncUsers );
2026-05-28 00:39:46 -05:00
document . getElementById ( 'controller-filter' ). addEventListener ( 'change' , loadData );
2026-03-04 19:53:39 -06:00
2026-05-28 00:39:46 -05:00
window . addEventListener ( 'load' , async () => {
2026-03-04 19:53:39 -06:00
const dateInput = document . getElementById ( 'date' );
if ( ! dateInput . value ) dateInput . value = isoToday ();
2026-05-28 00:39:46 -05:00
await loadControllerList ();
await loadData ();
2026-03-04 19:53:39 -06:00
});
</ script >
2026-03-04 19:42:38 -06:00
</ body >
</ html >