The Admin Insights console is a two-page single-file HTML dashboard. The Security Operations page gives NOC/SOC engineers a unified threat view across all security platforms β active endpoint detections, firewall events, IPS alerts, alert severity breakdown, and a threat timeline. The Ticket & Workload page gives service desk managers visibility into engineer capacity, ticket aging buckets, per-client ticket volume, and weekly throughput.
The two pages are mutually exclusive β only one is active at a time. Switching pages clears the auto-refresh interval for the previous page and starts a new 30-second interval for the newly active one. Each page loads independently, so the Security page can be left open for wall display while workload data loads on demand.
fetchSecurityData() and fetchWorkloadData() now call real proxy endpoints via apiFetch(). If either endpoint is unreachable, times out, or returns a non-200 response, the call silently falls back to the static mock generator. The dashboard always renders.
The console is a self-contained single HTML file with no framework or CDN dependencies β all rendering is pure DOM manipulation, all charts are hand-drawn SVG. The data engine uses two fetch functions that call your proxy, each with a 6-second timeout and silent fallback.
Every vendor referenced in the dashboard was audited against live API documentation before wiring. The following table documents the findings β what the dashboard shows, what real API backs it, and any constraints you need to know before building the proxy layer.
| VENDOR | DASHBOARD USE | REAL API STATUS | ENDPOINT | AUTH |
|---|---|---|---|---|
| SentinelOne | Threat detections β type, host, severity, time | β Confirmed | GET /web/api/v2.1/threats |
Authorization: ApiToken <token> |
| Huntress | Threat detections β incident reports, host, severity | β Confirmed | GET https://api.huntress.io/v1/incident_reports |
Basic auth β Base64(apiKey:apiSecret) |
| FortiGate | Firewall events, IPS alerts, source IPs, counts | β Confirmed | GET /api/v2/log/disk/ips (IPS)GET /api/v2/log/disk/traffic (FW) |
Authorization: Bearer <token> or URL param |
| Cisco Umbrella | Platform status, event count ("Phishing Link Clicked" event type) | β οΈ Exists β OAuth2 required | GET https://reports.api.umbrella.com/v2/activity |
OAuth2 client_credentials β requires token endpoint first |
| ESET | Platform status, "Malware Blocked" event type | β οΈ Webhook β not pollable | ESET PROTECT REST API is device-management only β no threat event poll endpoint | Events delivered via syslog/webhook only. See C5. |
| ConnectWise PSA | Open tickets, engineer open/capacity, ticket aging, weekly trend | β Confirmed | GET /v4_6_release/apis/3.0/service/ticketsGET /v4_6_release/apis/3.0/system/members |
Basic auth β Base64(companyId+publicKey:privateKey) + clientId header |
/api/security response. If your environment does not use ESET, remove it from the platforms array in mockSecurityData() to avoid confusion in the demo view.
Five KPI tiles across the top of the Security Operations page, rendered by renderSecurityKPIs(kpis). Each tile has a label, large value, sub-label, and a color class (danger/warn/ok/info) that sets its border accent.
kpis.activeThreats. Class: danger if >3, else warn. Live: threats.length from combined SentinelOne + Huntress response.kpis.fwEvents. Always warn class. Live: fwEvents.reduce((a,e)=>a+e.count,0).kpis.ipsAlerts. Class: danger if >40, else warn. Live: filter FortiGate events where type is IPS and sum counts.kpis.clientsAffected. Always info class. Live: topClients.length.'ok'. Source: kpis.toolsOnline. Always ok class. Live: platforms.filter(p=>p.status==='ok').length.Rendered by renderThreats(threats). Shows a card per threat event with a colored severity dot, threat type, platform source, host name, client name, and time-ago string. Sorted by recency.
threatInfo.threatName or Huntress incident report title.agentDetectionInfo.agentComputerName or Huntress agent_name.'critical', 'high', 'medium', 'low' β lowercase exact strings. Used for CSS class on the severity dot.createdAt timestamp in your proxy.Rendered by renderFWEvents(events). A five-column table showing event type, source IP, client, event count, and severity badge. Data comes from FortiGate's log endpoints β your proxy aggregates and groups log entries by source IP and event type.
logid or subtype field.srcip field, or "Multiple" if aggregated across many sources.'critical', 'high', 'medium', 'low'. Map from FortiGate level field: emergency/alert/critical β critical, error β high, warning β medium, information/notification β low.Rendered by renderAlertSummary(summary). Four horizontal bar gauges showing the proportion of Critical / High / Medium / Low alerts. Each bar fills proportionally to its share of the total. Source: alertSummary: {critical, high, medium, low} β integer counts from your proxy.
In production these counts are the aggregate of all cross-platform alerts β SentinelOne threats + Huntress incidents + FortiGate events β bucketed into the four severity levels. Your proxy normalizes vendor-specific severity naming before returning.
Rendered by renderTopClients(clients). A three-column table: client name, total alert count, and a severity badge for their highest-severity active alert. Shows the top 5 clients by alert count descending.
'critical', 'high', 'medium', or 'low'.Rendered by renderPlatforms(platforms). A card per security platform showing icon, name, event count, and an Online/Degraded/Offline status pill. The status pill is green for 'ok', amber for 'warn', and red for 'err'.
| PLATFORM | STATUS LOGIC | EVENT COUNT SOURCE |
|---|---|---|
| SentinelOne | ok if API responds within timeout; warn if latency high; err on failure | Count of active threats from GET /web/api/v2.1/threats |
| Huntress | ok if API responds; err on auth failure or timeout | Count of open incident reports from GET /v1/incident_reports |
| FortiGate | ok if log API responds; warn if degraded; err on failure | Total log entry count from IPS + traffic endpoints combined |
| Cisco Umbrella | ok if OAuth token valid and activity API responds | Count of activity events from GET /v2/activity |
| ESET | ok if webhook receiver has received events recently; warn if stale; err if no events in threshold window | Count of stored ESET webhook events β NOT polled |
Rendered by renderTimeline(timeline). A vertical timeline of recent threat events with severity color coding, HH:MM timestamp, event description, and client name. Items are displayed most-recent first. The timeline wraps in a .timeline CSS class that draws a vertical spine line down the left side.
'critical', 'high', 'medium', 'low' β controls the dot color and left border accent on each timeline item."14:02". Compute from event timestamp in your proxy.threatInfo.threatName + action, Huntress incident summary, FortiGate msg field.Five KPI tiles rendered by renderWorkloadKPIs(kpis). All five source from ConnectWise PSA in production.
kpis.openTickets. Live: GET /service/tickets?conditions=status/name!="Closed" β use X-Total-Count response header to avoid fetching all records.kpis.overloaded. Class: danger if >1, else warn. Live: count of engineers where open > capacity.kpis.aged24. Always danger class. Live: CW tickets where dateEntered < [now - 24h] and status is open.kpis.closedToday. Always ok class. Live: CW tickets where dateResolved >= [today 00:00].kpis.throughput. e.g. "91%".Rendered by renderEngineers(engineers). A four-column table: engineer name, open ticket count, a visual capacity bar, and a status badge. The capacity bar fills to Math.min(pct, 100)% β it caps visually at 100% even if overloaded, but the percentage label shows the real value.
firstName + ' ' + lastName or identifier.GET /service/tickets?conditions=owner/id={memberId} AND status/name!="Closed".Math.round(open/capacity*100). Threshold: β₯100% β danger (Overload) Β· β₯80% β warn (Near cap) Β· below 80% β ok (Available).Rendered by renderAging(aging). A four-bucket bar chart showing ticket age distribution. Each bar fills proportionally to its count relative to the max bucket. Colors: 0-2h green, 2-8h yellow, 8-24h orange, 24h+ red.
| BUCKET | CSS CLASS | COLOR | CW FILTER |
|---|---|---|---|
| 0-2h | age-fresh | Green | dateEntered >= [now-2h] |
| 2-8h | age-warm | Yellow | dateEntered between [now-8h] and [now-2h] |
| 8-24h | age-hot | Orange | dateEntered between [now-24h] and [now-8h] |
| 24h+ | age-stale | Red | dateEntered < [now-24h] |
The label field must match exactly one of the four values above β the aging renderer references label==='24h+' directly when computing the KPI Aged 24h+ value.
Rendered by renderClientWorkload(clients). A five-column table: client name, open count, high-priority count, stale (24h+) count, and a 7-point SVG sparkline of the client's ticket trend over the past 7 days.
company/name = client and status open.Rendered by renderVolumeTrend(weekTrend). A two-line SVG area chart (hand-drawn, no Chart.js dependency) showing opened vs closed tickets per day over the current week. Blue line for opened, green for closed, with gradient fill areas. Day labels render along the bottom axis.
['Mon','Tue','Wed','Thu','Fri','Sat','Sun']. Fixed β do not change these.dateEntered day.dateResolved day.Rendered by renderThroughput(kpis, weekTrend). Shows the weekly closed/opened ratio as a large percentage with color coding (green β₯90%, yellow β₯70%, red below 70%), plus two horizontal bar gauges for total opened and total closed with their raw counts.
The percentage is computed client-side: Math.round(closed/opened*100) where closed and opened are the sums of weekTrend.closed and weekTrend.opened arrays. The proxy does not need to compute this β just provide accurate arrays and the render function handles it.
Your proxy must return objects matching these shapes exactly. Field names are hardcoded throughout all renderer functions.
| VALUE | CSS VAR | USED IN |
|---|---|---|
'critical' | --red | Threat dots, FW event badge, alert summary bar, timeline dot, top client badge |
'high' | --accent-orange | Same as above, amber/orange rendering |
'medium' | --yellow | Yellow rendering across all severity displays |
'low' | --green | Green rendering β informational/healthy |
'ok' | --green | Platform pill, KPI class, engineer status |
'warn' | --yellow | Platform pill degraded state |
'err' | --red | Platform pill offline state |
class="sev ${e.severity}" and class="plat-pill ${p.status}". Any capitalization difference will produce an unstyled element.
/api/security and /api/workload as aggregation endpoints.s1-api-token.huntress-api-key and huntress-api-secret.fortigate-token and fortigate-host.umbrella-client-id and umbrella-client-secret.cw-company-id, cw-public-key, cw-private-key, and register for a Client ID at developer.connectwise.com.PROXY_BASE as an empty string. No CORS configuration needed. Set to the full Function App URL only when the dashboard and proxy are on different origins.
Your proxy's GET /api/security handler must fan out to all security vendors, normalize the results to the shapes in Section 17, and return a single JSON object. Build incrementally β start with SentinelOne and ConnectWise, let the others fall back to mock until ready.
- 1SentinelOne threatsCall
GET https://<instance>.sentinelone.net/web/api/v2.1/threats?resolved=false&limit=20withAuthorization: ApiToken <token>. Map each threat to the threat object shape:threatInfo.threatNameβ type,agentDetectionInfo.agentComputerNameβ host,agentRealtimeInfo.accountNameβ client,threatInfo.confidenceLevelβ severity. - 2Huntress incidentsCall
GET https://api.huntress.io/v1/incident_reports?limit=10with Basic auth (base64 apiKey:apiSecret). Map each incident:summaryβ type,agent_nameβ host,organization_nameβ client. Huntress usescritical/high/lowβ maplowtomediumin your proxy. - 3FortiGate eventsCall
GET https://<fortigate-ip>/api/v2/log/disk/ips?rows=50and/api/v2/log/disk/traffic?rows=100withAuthorization: Bearer <token>. Group bysrcipandtype. Map FortiGatelevelfield: emergency/alert/critical β critical Β· error β high Β· warning β medium Β· information β low. - 4Cisco UmbrellaFirst call
POST https://api.umbrella.com/auth/v2/tokenwith client credentials to get a bearer token. Then callGET https://reports.api.umbrella.com/v2/activity?limit=20. Map activity type to the threat/event objects. OAuth token typically expires in 3600s β cache it in your proxy. - 5ESET (webhook-based)See C5. Configure ESET PROTECT to forward threat events via syslog or webhook to your proxy. Your proxy stores recent events and serves them from an in-memory store when
/api/securityis called. - 6Aggregate and normalizeMerge all threat arrays, compute
alertSummarycounts by severity, buildtopClientssorted by alert count, computeplatformsstatus based on each vendor's API response health, and buildtimelinesorted by most recent.
Your proxy's GET /api/workload handler aggregates from ConnectWise PSA only. All authentication uses Basic auth with Base64 encoded companyId+publicKey:privateKey and a clientId header.
- 1Engineers and open ticketsCall
GET /v4_6_release/apis/3.0/system/members?conditions=inactiveFlag=falseto get all active members. Then for each member, callGET /service/tickets?conditions=owner/id={id} AND status/name!="Closed"&fields=idusingX-Total-Countheader to get open ticket count without fetching all records. - 2Ticket aging bucketsCall
GET /service/tickets?conditions=status/name!="Closed"&fields=id,dateEnteredwith pagination. Compute each ticket's age fromdateEnteredand bucket into the four age ranges. - 3Per-client ticket tableCall
GET /service/tickets?conditions=status/name!="Closed"&fields=id,company/name,priority/name,dateEnteredgrouped bycompany/name. For each client computeopen,high(priority "High" or "Critical"), andstale(age > 24h). For the 7-day trend, query tickets opened each day of the current week filtered by company. - 4Weekly volume trendQuery tickets with
dateEntered between [start-of-week] and [now]grouped by day for opened, anddateResolved between [start-of-week] and [now]grouped by day for closed. Return 7 values per array (MonβSun), with 0 for future days.
ESET PROTECT's REST API is designed for device and policy management β it does not expose a queryable threat event feed. This is not a gap in the dashboard design; it requires a different integration pattern than the other four security vendors.
GET /threats endpoint you can poll.
To include ESET threat data on the Security page, configure your proxy as follows:
- 1Enable syslog forwarding in ESET PROTECTIn the ESET PROTECT console, go to More β Settings β Advanced Settings. Under Logging, enable Syslog and point it to your proxy server's IP and port. Set format to JSON if available.
- 2Receive and store events in your proxyAdd a UDP/TCP syslog listener to your Function App or a sidecar service. Parse incoming ESET JSON payloads and store the last N threat events in a fast store (Redis, Cosmos DB, or even an in-memory array if freshness tolerance allows).
- 3Serve from /api/securityWhen
/api/securityis called, include stored ESET events in thethreatsarray and set the ESET platform object'sstatusbased on whether new events have arrived within the expected window (e.g. if no events in 5 minutes during business hours, setwarn). - 4If not using ESETRemove ESET from the
platformsarray inmockSecurityData()and remove any ESET entries from thethreatsmock array. This prevents ESET showing as "Online" with fake events in the demo view.
- βSet DEMO_MODE = false while testing. Proxy failures log to browser console only when
DEMO_MODE = false. In its defaulttruestate, fallbacks are completely silent β you will see demo data with no indication anything failed. - βCheck DevTools Network tab. With
DEMO_MODE = false, open DevTools β Network, switch pages, and watch the calls to/api/securityand/api/workload. Confirm 200 status and valid JSON response body before checking rendered output. - βSeverity values are lowercase. All severity strings must be
'critical','high','medium','low','ok','warn', or'err'β exact lowercase. Capitalized versions will produce unstyled elements with no error. - βAging label '24h+' must match exactly. The KPI computation uses
aging.find(a=>a.label==='24h+'). If your proxy returns'24h'or'24h +'the Aged 24h+ KPI will show undefined and potentially crash the renderer. - βweekTrend arrays must have exactly 7 items. The SVG chart uses array length to compute x-step spacing. Arrays shorter or longer than 7 will produce misaligned labels.
- βClient trend arrays must have exactly 7 items. The sparkline function also expects 7 points. Fewer produces a compressed sparkline; more produces one that overflows the SVG bounds.
- βAPI credentials in Key Vault, not in proxy code. Verify Function App Application Settings show Key Vault reference strings, not raw credential values.
| SYMPTOM | CAUSE | FIX |
|---|---|---|
| Dashboard shows mock data despite proxy being live | apiFetch() caught an error and fell back silently | Set DEMO_MODE = false, reload, check browser console for the error message. |
| Platform pills all show "Offline" | status field not matching 'ok'/'warn'/'err' | Log your proxy's platforms array. Check for capitalization issues or typos. |
| Aged 24h+ KPI shows NaN or 0 | aging array missing the '24h+' label bucket | Confirm your proxy returns all four aging buckets with exact label strings. |
| Throughput chart renders but numbers are 0 | weekTrend.opened or weekTrend.closed is empty or all zeros | Verify CW date range query is using correct timezone. CW stores dates in UTC. |
| Security page loads but threat list is empty | Proxy returning empty threats array | Call SentinelOne /threats directly via curl with your token to verify active threats exist. Check if resolved=false filter is applied. |
| Auto-refresh stops after switching pages | switchPage() should clear both intervals β check console for errors | Any error in a renderer function can break the subsequent setInterval call. Check console for uncaught errors on page switch. |