@@ -4,7 +4,7 @@
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
< title > Admin Operations Center< / title >
< link rel = "stylesheet" href = "/style.css?v=13 " / >
< link rel = "stylesheet" href = "/style.css?v=15 " / >
< script defer src = "https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" > < / script >
< script src = "https://alcdn.msauth.net/browser/2.37.0/js/msal-browser.min.js" crossorigin = "anonymous" > < / script >
< / head >
@@ -56,8 +56,11 @@
< / header >
< section class = "panel" >
< h3 > S ource Health< / h3 >
< div class = "s ource-h ealth" >
< div class = "panel-header panel-header--collapsible" @ click = "togglePanel('s ourceHealth')" >
< h3 > S ource H ealth< / h3 >
< span class = "panel-toggle" :class = "panelState.sourceHealth ? 'panel-toggle--open' : ''" > ▸< / span >
< / div >
< div x-show = "panelState.sourceHealth" >
< template x-for = "src in sourceHealth" :key = "src.source" >
< div class = "health-card" >
< strong x-text = "src.source" > < / strong >
@@ -70,12 +73,16 @@
< / div >
< / section >
< section class = "panel" x-show = "alertSummary.total_open > 0 || alerts.length > 0" >
< div class = "panel-header" >
< section class = "panel" >
< div class = "panel-header panel-header--collapsible" @ click = "togglePanel('alerts') " >
< h3 > Alerts< / h3 >
< span x-text = "`${alertSummary.total_open} open`" class = "alert-open-count" > < / span >
< div style = "display:flex;align-items:center;gap:10px;" >
< span x-text = "`${alertSummary.total_open} open`" class = "alert-open-count" > < / span >
< span class = "panel-toggle" :class = "panelState.alerts ? 'panel-toggle--open' : ''" > ▸< / span >
< / div >
< / div >
< div class = "alert-filter s">
< div x-show = "panelState.alert s">
< div class = "alert-filters" >
< select x-model = "alertsFilter.status" @ change = "alertsPage = 1; loadAlerts()" >
< option value = "" > All statuses< / option >
< option value = "open" > Open< / option >
@@ -90,7 +97,7 @@
< option value = "low" > Low< / option >
< / select >
< / div >
< div class = "alerts-list" >
< div class = "alerts-list" x-show = "alerts.length > 0" >
< template x-for = "alert in alerts" :key = "alert._id || alert.event_id" >
< div class = "alert-card" :class = "'alert-card--' + alert.severity" >
< div class = "alert-card__meta" >
@@ -109,15 +116,118 @@
< / div >
< / template >
< / div >
< div class = "alerts-empty" x-show = "alerts.length === 0" >
< p > No alerts match the current filters. Alerts appear here when rules trigger during event ingestion.< / p >
< / div >
< div class = "pagination" x-show = "alertsTotal > 20" >
< button type = "button" :disabled = "alertsPage === 1" @ click = "alertsPage--; loadAlerts()" > Prev< / button >
< span x-text = "`Page ${alertsPage}`" > < / span >
< button type = "button" :disabled = "alertsPage * 20 >= alertsTotal" @ click = "alertsPage++; loadAlerts()" > Next< / button >
< / div >
< / div >
< / section >
< section class = "panel" >
< form id = "filters" class = "filters" @ submit . prevent = "resetPagination(); loadEvents( )">
< div class = "panel-header panel-header--collapsible" @ click = "togglePanel('rules' )">
< h3 > Alert Rules< / h3 >
< div style = "display:flex;align-items:center;gap:10px;" >
< button type = "button" class = "btn--compact" @ click . stop = "openRuleEditor()" > + Add rule< / button >
< span class = "panel-toggle" :class = "panelState.rules ? 'panel-toggle--open' : ''" > ▸< / span >
< / div >
< / div >
< div x-show = "panelState.rules" >
< div class = "rules-list" >
< template x-for = "rule in rules" :key = "rule.id" >
< div class = "rule-card" :class = "rule.enabled ? '' : 'rule-card--disabled'" >
< div class = "rule-card__meta" >
< span class = "pill" :class = "rule.severity === 'high' ? 'pill--err' : (rule.severity === 'medium' ? 'pill--warn' : '')" x-text = "rule.severity" > < / span >
< label class = "toggle-label" >
< input type = "checkbox" :checked = "rule.enabled" @ change = "toggleRule(rule.id, $event.target.checked)" >
< span x-text = "rule.enabled ? 'On' : 'Off'" > < / span >
< / label >
< / div >
< strong x-text = "rule.name" > < / strong >
< p x-text = "rule.message" > < / p >
< div class = "rule-card__conditions" >
< template x-for = "(cond, idx) in rule.conditions" :key = "idx" >
< span class = "pill pill--tag" x-text = "`${cond.field} ${cond.op} ${cond.value}`" > < / span >
< / template >
< / div >
< div class = "rule-card__actions" >
< button type = "button" class = "ghost btn--compact" @ click = "openRuleEditor(rule)" > Edit< / button >
< button type = "button" class = "ghost btn--compact" @ click = "deleteRule(rule.id)" > Delete< / button >
< / div >
< / div >
< / template >
< / div >
< div class = "rules-empty" x-show = "rules.length === 0" >
< p > No custom rules yet. Pre-built admin-ops rules are active by default. Add your own rules to detect specific patterns.< / p >
< / div >
< / div >
< div id = "ruleModal" class = "modal hidden" role = "dialog" aria-modal = "true" :class = "{ 'hidden': !ruleModalOpen }" >
< div class = "modal__content" style = "max-width: 600px;" >
< div class = "modal__header" >
< h3 x-text = "ruleEditId ? 'Edit Rule' : 'New Rule'" > < / h3 >
< button type = "button" class = "ghost" @ click = "ruleModalOpen = false" > Close< / button >
< / div >
< form class = "rule-form" @ submit . prevent = "saveRule()" >
< label >
Name
< input type = "text" x-model = "ruleEdit.name" placeholder = "e.g. Failed CA Policy" required / >
< / label >
< label >
Severity
< select x-model = "ruleEdit.severity" >
< option value = "low" > Low< / option >
< option value = "medium" > Medium< / option >
< option value = "high" > High< / option >
< / select >
< / label >
< label >
Message
< textarea x-model = "ruleEdit.message" placeholder = "What should the alert say?" rows = "2" > < / textarea >
< / label >
< div class = "rule-conditions" >
< span > Conditions (all must match)< / span >
< template x-for = "(cond, idx) in ruleEdit.conditions" :key = "idx" >
< div class = "condition-row" >
< input type = "text" x-model = "cond.field" placeholder = "field" list = "ruleFieldOptions" required / >
< select x-model = "cond.op" >
< option value = "eq" > equals< / option >
< option value = "neq" > not equals< / option >
< option value = "contains" > contains< / option >
< option value = "in" > in list< / option >
< option value = "after_hours" > after hours< / option >
< / select >
< input type = "text" x-model = "cond.value" placeholder = "value" :required = "cond.op !== 'after_hours'" / >
< button type = "button" class = "ghost btn--compact" @ click = "ruleEdit.conditions.splice(idx, 1)" > − < / button >
< / div >
< / template >
< button type = "button" class = "ghost btn--compact" @ click = "ruleEdit.conditions.push({field:'', op:'eq', value:''})" > + Add condition< / button >
< / div >
< datalist id = "ruleFieldOptions" >
< option value = "service" > < / option >
< option value = "operation" > < / option >
< option value = "result" > < / option >
< option value = "actor_display" > < / option >
< option value = "timestamp" > < / option >
< / datalist >
< div class = "rule-form__actions" >
< button type = "submit" > Save< / button >
< button type = "button" class = "ghost" @ click = "ruleModalOpen = false" > Cancel< / button >
< / div >
< / form >
< / div >
< / div >
< / section >
< section class = "panel" >
< div class = "panel-header panel-header--collapsible" @ click = "togglePanel('filters')" >
< h3 > Filters< / h3 >
< span class = "panel-toggle" :class = "panelState.filters ? 'panel-toggle--open' : ''" > ▸< / span >
< / div >
< form id = "filters" class = "filters" @ submit . prevent = "resetPagination(); loadEvents()" x-show = "panelState.filters" >
< div class = "filter-row" >
< label >
User (name/UPN)
@@ -211,8 +321,11 @@
< / section >
< section class = "panel" x-show = "aiFeaturesEnabled" >
< h3 > Ask a question< / h3 >
< form class = "ask-form" @ submit . prevent = "askQuestion()" >
< div class = "panel-header panel-header--collapsible" @ click = "togglePanel('ask')" >
< h3 > Ask a question< / h3 >
< span class = "panel-toggle" :class = "panelState.ask ? 'panel-toggle--open' : ''" > ▸< / span >
< / div >
< form class = "ask-form" @ submit . prevent = "askQuestion()" x-show = "panelState.ask" >
< div class = "ask-row" >
< input
type = "text"
@@ -254,11 +367,15 @@
< / section >
< section class = "panel" >
< div class = "panel-header" >
< div class = "panel-header panel-header--collapsible" @ click = "togglePanel('events') " >
< h2 > Events< / h2 >
< span id = "count" x-text = "countText" > < / span >
< div style = "display:flex;align-items:center;gap:10px;" >
< span id = "count" x-text = "countText" > < / span >
< span class = "panel-toggle" :class = "panelState.events ? 'panel-toggle--open' : ''" > ▸< / span >
< / div >
< / div >
< div id = "status" class = "status" aria-live = "polite" x-text = "statusText" > < / div >
< div x-show = "panelState.events" >
< div id = "status" class = "status" aria-live = "polite" x-text = "statusText" > < / div >
< div id = "events" class = "events" >
< template x-for = "(evt, idx) in events" :key = "evt._id || evt.id || idx" >
< article class = "event" >
@@ -298,6 +415,7 @@
< span x-text = "`Page ${cursorStack.length + 1}`" > < / span >
< button type = "button" id = "nextPage" :disabled = "!nextCursor" @ click = "goNext()" > Next< / button >
< / div >
< / div >
< / section >
< div id = "modal" class = "modal hidden" role = "dialog" aria-modal = "true" aria-labelledby = "modalTitle" :class = "{ 'hidden': !modalOpen }" >
@@ -359,6 +477,7 @@
filters : {
actor : '' , selectedServices : [ ] , search : '' , operation : '' , result : '' , start : '' , end : '' , limit : 24 , includeTags : '' , excludeTags : '' ,
} ,
panelState : { sourceHealth : true , alerts : true , rules : true , filters : true , ask : true , events : true } ,
options : { actors : [ ] , services : [ ] , operations : [ ] , results : [ ] } ,
savedSearches : [ ] ,
appVersion : '' ,
@@ -370,6 +489,10 @@
alertsTotal : 0 ,
alertsPage : 1 ,
alertsFilter : { status : 'open' , severity : '' } ,
rules : [ ] ,
ruleModalOpen : false ,
ruleEditId : null ,
ruleEdit : { name : '' , enabled : true , severity : 'medium' , message : '' , conditions : [ ] } ,
askQuestionText : '' ,
askLoading : false ,
askAnswer : '' ,
@@ -382,12 +505,14 @@
await this . loadVersion ( ) ;
await this . initAuth ( ) ;
this . loadSavedFilters ( ) ;
this . loadPanelState ( ) ;
if ( ! this . authConfig ? . auth _enabled || this . accessToken ) {
await this . loadFilterOptions ( ) ;
await this . loadSavedSearches ( ) ;
await this . loadSourceHealth ( ) ;
await this . loadAlertSummary ( ) ;
await this . loadAlerts ( ) ;
await this . loadRules ( ) ;
await this . loadEvents ( ) ;
}
} ,
@@ -410,12 +535,33 @@
} catch { }
} ,
loadPanelState ( ) {
try {
const saved = localStorage . getItem ( 'aoc_panels' ) ;
if ( saved ) {
const parsed = JSON . parse ( saved ) ;
Object . keys ( parsed ) . forEach ( ( k ) => { if ( this . panelState [ k ] !== undefined ) this . panelState [ k ] = parsed [ k ] ; } ) ;
}
} catch { }
} ,
savePanelState ( ) {
try {
localStorage . setItem ( 'aoc_panels' , JSON . stringify ( this . panelState ) ) ;
} catch { }
} ,
togglePanel ( key ) {
this . panelState [ key ] = ! this . panelState [ key ] ;
this . savePanelState ( ) ;
} ,
async loadVersion ( ) {
try {
const res = await fetch ( '/api/version' ) ;
if ( res . ok ) {
const body = await res . json ( ) ;
this . appVersion = body . version || '' ;
this . appVersion = ( body . version || '' ) . replace ( /^v/ , '' ) ;
}
} catch { }
} ,
@@ -787,6 +933,73 @@
} catch { }
} ,
async loadRules ( ) {
try {
const res = await fetch ( '/api/rules' , { headers : this . authHeader ( ) } ) ;
if ( ! res . ok ) return ;
this . rules = await res . json ( ) ;
} catch { }
} ,
openRuleEditor ( rule ) {
if ( rule ) {
this . ruleEditId = rule . id ;
this . ruleEdit = {
name : rule . name ,
enabled : rule . enabled ,
severity : rule . severity ,
message : rule . message ,
conditions : JSON . parse ( JSON . stringify ( rule . conditions ) ) ,
} ;
} else {
this . ruleEditId = null ;
this . ruleEdit = { name : '' , enabled : true , severity : 'medium' , message : '' , conditions : [ ] } ;
}
this . ruleModalOpen = true ;
} ,
async saveRule ( ) {
const payload = { ... this . ruleEdit } ;
try {
const url = this . ruleEditId ? ` /api/rules/ ${ this . ruleEditId } ` : '/api/rules' ;
const method = this . ruleEditId ? 'PUT' : 'POST' ;
const res = await fetch ( url , {
method ,
headers : { 'Content-Type' : 'application/json' , ... this . authHeader ( ) } ,
body : JSON . stringify ( payload ) ,
} ) ;
if ( ! res . ok ) throw new Error ( await res . text ( ) ) ;
this . ruleModalOpen = false ;
await this . loadRules ( ) ;
} catch ( err ) {
alert ( 'Failed to save rule: ' + err . message ) ;
}
} ,
async toggleRule ( ruleId , enabled ) {
try {
const rule = this . rules . find ( ( r ) => r . id === ruleId ) ;
if ( ! rule ) return ;
const res = await fetch ( ` /api/rules/ ${ ruleId } ` , {
method : 'PUT' ,
headers : { 'Content-Type' : 'application/json' , ... this . authHeader ( ) } ,
body : JSON . stringify ( { ... rule , enabled } ) ,
} ) ;
if ( res . ok ) await this . loadRules ( ) ;
} catch { }
} ,
async deleteRule ( ruleId ) {
if ( ! confirm ( 'Delete this rule?' ) ) return ;
try {
const res = await fetch ( ` /api/rules/ ${ ruleId } ` , {
method : 'DELETE' ,
headers : this . authHeader ( ) ,
} ) ;
if ( res . ok ) await this . loadRules ( ) ;
} catch { }
} ,
async askQuestion ( ) {
const q = this . askQuestionText . trim ( ) ;
if ( ! q ) return ;