// Knowledge Base · MSP Command Center
Security & Workload Dashboard
Complete operational and integration reference for the MSP Command Center — a two-page, single-file HTML dashboard covering Security Operations and Ticket & Workload Management for MSP technicians and NOC leads.
Format: Self-contained .html
Pages: Security Operations · Ticket & Workload
Refresh: Auto every 30 seconds
Data: Mock API — swap two fetch() calls to go live
01 //What Is This Tool

The MSP Command Center is a self-contained single-file HTML dashboard with two pages — Security Operations and Ticket & Workload Management. It is designed to run on a NOC screen or as a daily-use tab for MSP technicians, surfacing security threats, firewall events, engineer capacity, ticket aging, and throughput metrics in one view. No server, build step, or install required.

Single File — Zero Dependencies
All HTML, CSS, JS, and mock data lives in one .html file. Open it in any modern browser. The only external resource is Google Fonts loaded via CDN — optional and easily removed.
Two Isolated Pages, One Navigation Bar
Security Operations and Ticket & Workload are separate .page div elements toggled by switchPage(id). Only one is visible at a time. Each has its own data loader, KPI strip, live ticker, panels, and 30-second auto-refresh interval. Switching pages cancels the outgoing interval and starts a fresh one.
Mock Data Ships Ready — Two Lines to Go Live
Both pages start in demo mode with realistic mock data. Going live requires replacing one line in each of two async fetch functions with a real fetch() call. Everything else stays unchanged.
02 //Architecture

The dashboard is entirely browser-side. Data flows from mock functions (or real API endpoints) through async fetch wrappers into renderer functions that update DOM elements in place. Nothing is stored — every refresh cycle re-renders from scratch.

fetchSecurityData()mockSecurityData() // or real fetch('/api/security') └─ renderSecurityKPIs()#sec-kpis └─ renderThreats()#threat-list └─ renderFWEvents()#fw-tbody └─ renderAlertSummary()#alert-summary └─ renderSecTicker()#sec-ticker-scroll └─ renderTopClients()#client-tbody └─ renderPlatforms()#plat-list └─ renderTimeline()#timeline-list fetchWorkloadData()mockWorkloadData() // or real fetch('/api/workload') └─ renderWorkloadKPIs()#wl-kpis └─ renderEngineers()#eng-tbody └─ renderAging()#aging-bars └─ renderClientWorkload()#client-wl-tbody └─ renderVolumeTrend()#trend-chart └─ renderWlTicker()#wl-ticker-scroll └─ renderThroughput()#throughput-panel
LayerWhat it does
Mock functionsReturn hardcoded realistic data. Wrapped in a 380–420ms artificial delay to simulate network latency. Replace with real fetch() to go live.
Fetch wrappersfetchSecurityData() and fetchWorkloadData() — the only functions that change when going live.
RenderersPure functions that receive data and write .innerHTML or text content to named DOM targets. No state, no side effects.
Load functionsloadSecurityPage() and loadWorkloadPage() — orchestrate the fetch and pass results to all renderers. Show/hide the spinner. Catch errors silently.
switchPage(id)Toggles .active on nav tabs and page divs. Cancels both intervals, then starts the appropriate one.
updateClock()Runs every 1 second via setInterval. Writes current time to #last-refresh in the nav bar.
BootstrapLast two lines of the script: updateClock() runs immediately, then switchPage('workload') sets the initial page and triggers the first data load.
03 //UI Layout
ZoneHeight / sizingDescription
Nav Bar40px, stickyLogo · 2 page tabs · live clock. Fixed at top, always visible.
Page Headerauto, flex-shrink:0Page title + subtitle on the left, spinner + "Auto-refresh 30s" on the right. Separated from content by a border-bottom.
KPI Stripauto, flex-shrink:05-column CSS grid of KPI cards. Skeleton shimmer on first load. Replaced by renderXxxKPIs().
Live Ticker28px, flex-shrink:0Horizontally scrolling incident/ticket feed. Labeled panel on left, animated content track on right. Sits between KPI strip and main content.
Main Contentflex:1, overflow-y:autoSecurity: grid-2-1 (2fr left + 1fr right) + full-width timeline below. Workload: two equal columns + full-width throughput below.
100vh fixed layout
The body is height:100vh; overflow:hidden with the active .page handling its own overflow-y:auto. This prevents double scrollbars and keeps the nav pinned. On screens shorter than ~700px some content may require scrolling within the page area.
04 //Nav Bar & Clock
MSP Command logo
Pulsing dot + "MSP Command" in Exo 2, 12px, 900 weight, 3.5px letter-spacing. No link — purely branding.
Security Operations tab
Calls switchPage('security') on click. Active state: color:var(--acc3) with a 2px bottom border in var(--accent).
Ticket & Workload tab
Default active tab on page load — switchPage('workload') is called in bootstrap. Same active styling as above.
LIVE status + clock
Green "LIVE" label with pulsing green dot. Time display in #last-refresh updates every second via updateClock(). Format: HH:MM:SS.
Spinner
Each page has a spinner element (#sec-spinner, #wl-spinner). Gets class="loading" at the start of each load and removed on completion. Opacity transitions from 0 to 1 so it fades in smoothly.
05 //KPI Strips

Both pages have a 5-column KPI strip rendered as a CSS grid. Each card has a colored 2px top accent strip driven by the --kpi-accent CSS variable, set per card via a class modifier.

Active Threats
5
Endpoint detections
Tools Online
4
Security platforms
Open Tickets
76
Total active
ClassAccent colorBorder tintTypical use
.kpi.danger--dangerRed 20%Active threats, aged SLA tickets
.kpi.warn--warnYellow 20%Firewall events, overloaded engineers
.kpi.ok--okGreen 20%Tools online, closed today
.kpi.info--acc3NoneClients affected, open tickets, throughput
Conditional danger class on security KPIs
renderSecurityKPIs() sets class dynamically: Active Threats uses 'danger' if activeThreats > 3, else 'warn'. IPS Alerts uses 'danger' if ipsAlerts > 40, else 'warn'. Adjust these thresholds to match your environment.
06 //Live Ticker

Each page has a 28px horizontal scrolling ticker between the KPI strip and the main content panels. The ticker is contextually relevant — the Security page feeds incident tickets, the Workload page feeds open PSA tickets.

Live Incidents
INC-4420  critical  Ransomware Detected · Acme Corp · A. Rivera  ◆  FW-0881  high  IPS Alert · 185.220.101.5 · Acme Corp
buildTickerHTML(items)
Formats each item as a .ticker-item span with ID, severity badge, description text, client, optional assignee, and optional age. Duplicates the full list so the CSS animation loops seamlessly without a visible reset.
startTicker(scrollId, items)
Clears animating class, injects HTML, then in a requestAnimationFrame measures actual rendered width (scrollWidth / 2) and sets animation-duration based on 60px/sec scroll speed. Re-adding the class starts the CSS animation at the correct rate for actual content length.
Security ticker source
Built from threats[] (5 items as INC-xxxx with assigned engineer) and the first 3 fwEvents[] (as FW-xxxx with source IP). Called as renderSecTicker(d.threats, d.fwEvents).
Workload ticker source
Built from clients[] and engineers[]. Generates up to 3 representative tickets per client, derives severity from stale and high fields, assigns to engineers round-robin, and sorts critical-first. IDs are sequential TKT-78xx. Called as renderWlTicker(d.clients, d.engineers).
Refresh behavior
Both ticker renderers are called inside their respective loadXxxPage() functions — they re-render every 30 seconds with the same data cycle as the rest of the page.
07 //Security KPIs
KPISource fieldLogic
Active Threatskpis.activeThreatsCount of threats[] entries. Class: danger if >3, else warn.
Firewall Eventskpis.fwEventsSum of all fwEvents[].count values. Always warn.
IPS Alertskpis.ipsAlertsSum of count for events where type === 'IPS Alert'. Class: danger if >40, else warn.
Clients Affectedkpis.clientsAffectedCount of topClients[] entries. Always info.
Tools Onlinekpis.toolsOnlineCount of platforms[] where status === 'ok'. Always ok.
08 //Threat Detection Panel

Rendered by renderThreats(threats) into #threat-list. Each threat becomes an .alert-item row with a colored left border and glowing dot driven by the severity class.

FieldDisplayed as
typeAlert title — main bold text
platformAlert meta line (e.g. SentinelOne)
hostAlert meta line (e.g. ACME-WS-042)
clientAlert meta line, highlighted in var(--mid)
severityCSS class on .alert-item: critical / high / medium / low — controls left border color and dot glow
timeRight-aligned timestamp (e.g. "2m ago")

The panel badge (#threat-count) shows the total event count and is updated by renderThreats() independently of the strip renderer.

09 //Firewall Events Panel

Rendered by renderFWEvents(fwEvents) into #fw-tbody as a standard .data-table. Panel badge is hardcoded to "FortiGate" — update the HTML to match your platform if different.

ColumnSource field
Typee.type — e.g. "IPS Alert", "Malware Block"
Sourcee.source — IP address or "Multiple"
Cliente.client
Counte.count — event occurrences in window
Severitye.severity rendered as a .sev badge
10 //Alert Summary

Rendered by renderAlertSummary(summary) into #alert-summary. Shows four severity levels as horizontal progress bars, each scaled as a percentage of total alert count. The summary object shape is: {critical:3, high:8, medium:14, low:22}. Bar widths are recalculated on every render using Math.round(summary[key] / total * 100).

11 //Security Platforms

Rendered by renderPlatforms(platforms) into #plat-list. Each platform becomes a .plat-card row with an emoji icon box, name, event count, and a status pill.

FieldDescription
namePlatform name (SentinelOne, Huntress, FortiGate, etc.)
iconEmoji rendered in a square icon box
status'ok' → Online pill (green) · 'warn' → Degraded pill (yellow) · 'err' → Offline pill (red)
eventsEvent count shown as "N events" below the platform name

Mock data includes SentinelOne, Huntress, FortiGate (warn), Cisco Umbrella, and ESET. FortiGate is pre-set to warn to demonstrate the degraded state styling.

12 //Threat Timeline

Full-width panel below the two-column grid. Rendered by renderTimeline(timeline) directly into #timeline-list, which already carries class="timeline". Each item is a .tl-item with a colored dot on the left spine line.

Do not wrap in a second .timeline div
The container already has class="timeline". The renderer injects items directly as children. Wrapping in another <div class="timeline"> causes the CSS ::before spine line to draw twice and breaks the dot offset (left:-17px).
FieldRendered as
sevCSS class on .tl-item: critical / high / medium / low — sets --tl-color for the dot background and glow
timeSmall monospace timestamp above the event text
textEvent description — main readable line
clientClient name below the description in muted monospace
13 //Workload KPIs
KPISourceLogic
Open Ticketskpis.openTicketsSum of all engineers[].open. Always info.
Overloadedkpis.overloadedCount of engineers where pct > 100. Class: danger if >1, else warn.
Aged 24h+kpis.aged24The count from the aging[] entry with label === '24h+'. Always danger.
Closed Todaykpis.closedTodayLast element of weekTrend.closed[] (Sunday). Always ok.
Throughputkpis.throughputSunday closed ÷ Sunday opened × 100, as a percentage string. Always info.
14 //Engineer Workload

Rendered by renderEngineers(engineers) into #eng-tbody. Each engineer row shows name, open ticket count, a capacity bar, and a status badge. The panel badge updates the engineer count via #eng-count.

Capacity bar color thresholds
Available
60%
Near cap
80%
Overload
107%

Bar fills to a max of 100% visually (Math.min(e.pct, 100)) even when the engineer is over capacity. The numeric percentage still shows the true value (e.g. 120%). The badge label reads: Available (<80%), Near cap (80–99%), Overload (≥100%).

Engineer fieldDescription
nameDisplay name shown in first column
openCurrent open ticket count
capacityMaximum ticket capacity (15 in mock data)
pctUtilization percentage — open / capacity × 100. Drives bar color and status badge. Can exceed 100.
15 //Tickets by Client

Rendered by renderClientWorkload(clients) into #client-wl-tbody. Each client row includes a SVG sparkline of the 7-day ticket trend built inline by sparkline(data).

ColumnSourceLogic
Clientc.nameFull client name in var(--text)
Openc.openCurrent open ticket count
Highc.highHigh-priority ticket count. Red if >3, yellow if >1, muted if 0–1
24h+c.stale.age-badge class: age-stale if >2, age-hot if >0, age-fresh if 0
Trendc.trend[]7-point SVG polyline (60×20px) in var(--accent). Min/max scaled to available height.
16 //Ticket Aging Distribution

Rendered by renderAging(aging) into #aging-bars. Builds a flex bar chart — each bucket shows count on top, a colored bar scaled proportionally to the max count, and a label below. The skeleton class and inline height are removed before rendering.

BucketClassColor
0–2hage-fresh--ok green
2–8hage-warm--warn yellow
8–24hage-hot--orange orange
24h+age-stale--danger red
Bar height is proportional, not absolute
Each bar height is Math.round(count / max * 72)px with a min-height:4px floor. The max bucket always renders at 72px. Add more buckets to the aging[] array and the chart adapts automatically.
17 //Ticket Volume Trend

Rendered by renderVolumeTrend(weekTrend) into #trend-chart. Produces an inline SVG with two area+line series — opened tickets (blue) and closed tickets (green) — with gradient fills, a 7-day x-axis, and a legend.

weekTrend fieldDescription
labels[]X-axis day labels. Array of 7 strings: ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']
opened[]Tickets opened each day. Must match labels length.
closed[]Tickets closed each day. Must match labels length.

The SVG uses a 400×90 viewBox with 22px padding on each side. Y-scale is computed from the max value across both arrays. preserveAspectRatio="xMidYMid meet" prevents horizontal stretching as the panel resizes.

18 //Ticket Throughput

Full-width panel at the bottom of the Workload page. Rendered by renderThroughput(kpis, weekTrend) into #throughput-panel. Shows the weekly close rate as a large percentage with color thresholds, plus two progress bars for opened vs closed totals.

PercentageColorInterpretation
≥90%--okTeam is keeping up — closing more than they open
70–89%--warnFalling slightly behind — monitor engineer capacity
<70%--dangerBacklog growing — intervention needed

The opened bar always renders at 100% width as the baseline reference. The closed bar renders at pct% of that width. Weekly totals are the sum of all 7 days in weekTrend.opened[] and weekTrend.closed[].

19 //Data Model

The two mock functions define the full expected shape. Any real API must return objects matching these structures.

Security data shape
{ kpis: { activeThreats, fwEvents, ipsAlerts, clientsAffected, toolsOnline }, threats: [{ id, type, client, platform, host, severity: 'critical'|'high'|'medium'|'low', time // relative string, e.g. "2m ago" }], fwEvents: [{ type, source, client, count, severity: 'critical'|'high'|'medium'|'low' }], alertSummary: { critical, high, medium, low }, // counts topClients: [{ name, alerts, severity }], platforms: [{ name, icon, status: 'ok'|'warn'|'err', events }], timeline: [{ sev: 'critical'|'high'|'medium'|'low', time, text, client }] }
Workload data shape
{ kpis: { openTickets, overloaded, aged24, closedToday, throughput }, engineers: [{ name, open, capacity, pct // 0–120+ — open/capacity×100 }], aging: [{ label, // e.g. '0-2h', '2-8h', '8-24h', '24h+' count, cls: 'age-fresh'|'age-warm'|'age-hot'|'age-stale' }], clients: [{ name, open, high, stale, trend: [n, n, n, n, n, n, n] // 7-day array }], weekTrend: { labels: ['Mon',...,'Sun'], opened: [n,...,n], // 7 values closed: [n,...,n] // 7 values } }
20 //Wiring Live APIs

Going live requires changing exactly two functions. Everything else — renderers, layout, tickers, auto-refresh — stays unchanged.

1
Replace fetchSecurityData()
Find the function in the script block and swap out the mock call:
// Before async function fetchSecurityData() { await new Promise(r=>setTimeout(r, 420)); return mockSecurityData(); } // After async function fetchSecurityData() { return fetch('/api/security').then(r=>r.json()); }
2
Replace fetchWorkloadData()
Same pattern as above:
async function fetchWorkloadData() { return fetch('/api/workload').then(r=>r.json()); }
3
Ensure your API response matches the data shape
Return JSON objects matching the structures in section 19. Field names must match exactly — the renderers access fields by name with no mapping layer. Extra fields are ignored.
4
Handle CORS if serving from a different origin
If the dashboard is opened as a file:// URL, browsers block cross-origin fetch. Options: serve the HTML from the same origin as the API, add CORS headers to your API, or use a lightweight local proxy.
5
Adjust KPI thresholds to match your environment
In renderSecurityKPIs(): change activeThreats > 3 and ipsAlerts > 40. In renderWorkloadKPIs(): change overloaded > 1. In renderThroughput(): change the 90 and 70 percentage breakpoints.
21 //Auto-Refresh

switchPage(id) manages two module-level interval references: secInterval and wlInterval. Every page switch clears both, then starts only the one for the newly active page at 30 seconds. This prevents both pages from polling simultaneously when switching tabs.

function switchPage(id) { // Toggle active classes on tabs and pages clearInterval(secInterval); clearInterval(wlInterval); if (id === 'security') { loadSecurityPage(); secInterval = setInterval(loadSecurityPage, 30000); } else { loadWorkloadPage(); wlInterval = setInterval(loadWorkloadPage, 30000); } }
Change refresh rate
Change both setInterval(..., 30000) calls. Minimum recommended: 15000ms. Very fast intervals (under 5s) will thrash the API and may cause visible flicker as the DOM re-renders.
Spinner behavior
loadSecurityPage() adds loading to the spinner at the top of the try block and removes it in the finally-equivalent after the try/catch. If the fetch fails, the spinner still clears — data simply does not update.
Live clock independence
updateClock() runs on its own 1-second interval separate from the data refresh cycle. It never interacts with the data or renderers.
22 //Troubleshooting
Ticker not scrolling after page switch
The ticker animation uses requestAnimationFrame to measure rendered width before setting duration. If the page is hidden (display:none) when startTicker() runs, scrollWidth returns 0. The fix: call renderSecTicker() and renderWlTicker() inside the load functions after data is ready, not before the page becomes visible.
Aging bars not rendering — still shows skeleton
renderAging() removes the skeleton class and clears the inline height style. If you see the skeleton persist, check that renderAging() is being called with a non-empty aging[] array and that the element ID aging-bars exists in the DOM.
KPI values not updating
renderSecurityKPIs() replaces the entire #sec-kpis innerHTML. If values are stuck, check that fetchSecurityData() resolves successfully — open DevTools Network to confirm the mock delay completes or your API responds with data. Errors are caught silently; check the console for "Security data error:".
SVG trend chart distorted on wide screens
The trend SVG uses preserveAspectRatio="xMidYMid meet". If the chart appears letterboxed, check that the containing #trend-chart panel body has a defined height or flex:1. The SVG is set to width="100%" so it fills the panel.
Both pages polling simultaneously after nav click
This happens if switchPage() is not clearing intervals before starting new ones — e.g. if you call loadSecurityPage() manually outside switchPage(). Always use switchPage() as the single entry point for page changes. Never call the load functions directly for scheduled execution.
Opening as file:// blocks real API fetch
Browsers block cross-origin requests from file:// origins. If you plan to use real APIs, serve the HTML through a local HTTP server (python3 -m http.server 8080 or similar), or host it on the same domain as your API endpoints.
Adjust mock data for your environment
The mock data reflects a specific scenario: 6 clients, 6 engineers, 5 security platforms, 5 threats. Replace client names, engineer names, platform names, and counts in mockSecurityData() and mockWorkloadData() to match your real MSP environment for more useful demos.

KB · MSP Command Center — Security & Workload · v1.0