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.
.html file. Open it in any modern browser. The only external resource is Google Fonts loaded via CDN — optional and easily removed..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.fetch() call. Everything else stays unchanged.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.
| Layer | What it does |
|---|---|
| Mock functions | Return hardcoded realistic data. Wrapped in a 380–420ms artificial delay to simulate network latency. Replace with real fetch() to go live. |
| Fetch wrappers | fetchSecurityData() and fetchWorkloadData() — the only functions that change when going live. |
| Renderers | Pure functions that receive data and write .innerHTML or text content to named DOM targets. No state, no side effects. |
| Load functions | loadSecurityPage() 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. |
| Bootstrap | Last two lines of the script: updateClock() runs immediately, then switchPage('workload') sets the initial page and triggers the first data load. |
| Zone | Height / sizing | Description |
|---|---|---|
| Nav Bar | 40px, sticky | Logo · 2 page tabs · live clock. Fixed at top, always visible. |
| Page Header | auto, flex-shrink:0 | Page title + subtitle on the left, spinner + "Auto-refresh 30s" on the right. Separated from content by a border-bottom. |
| KPI Strip | auto, flex-shrink:0 | 5-column CSS grid of KPI cards. Skeleton shimmer on first load. Replaced by renderXxxKPIs(). |
| Live Ticker | 28px, flex-shrink:0 | Horizontally scrolling incident/ticket feed. Labeled panel on left, animated content track on right. Sits between KPI strip and main content. |
| Main Content | flex:1, overflow-y:auto | Security: grid-2-1 (2fr left + 1fr right) + full-width timeline below. Workload: two equal columns + full-width throughput below. |
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.Exo 2, 12px, 900 weight, 3.5px letter-spacing. No link — purely branding.switchPage('security') on click. Active state: color:var(--acc3) with a 2px bottom border in var(--accent).switchPage('workload') is called in bootstrap. Same active styling as above.#last-refresh updates every second via updateClock(). Format: HH:MM:SS.#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.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.
| Class | Accent color | Border tint | Typical use |
|---|---|---|---|
| .kpi.danger | --danger | Red 20% | Active threats, aged SLA tickets |
| .kpi.warn | --warn | Yellow 20% | Firewall events, overloaded engineers |
| .kpi.ok | --ok | Green 20% | Tools online, closed today |
| .kpi.info | --acc3 | None | Clients affected, open tickets, throughput |
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.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.
.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.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.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).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).loadXxxPage() functions — they re-render every 30 seconds with the same data cycle as the rest of the page.| KPI | Source field | Logic |
|---|---|---|
| Active Threats | kpis.activeThreats | Count of threats[] entries. Class: danger if >3, else warn. |
| Firewall Events | kpis.fwEvents | Sum of all fwEvents[].count values. Always warn. |
| IPS Alerts | kpis.ipsAlerts | Sum of count for events where type === 'IPS Alert'. Class: danger if >40, else warn. |
| Clients Affected | kpis.clientsAffected | Count of topClients[] entries. Always info. |
| Tools Online | kpis.toolsOnline | Count of platforms[] where status === 'ok'. Always ok. |
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.
| Field | Displayed as |
|---|---|
| type | Alert title — main bold text |
| platform | Alert meta line (e.g. SentinelOne) |
| host | Alert meta line (e.g. ACME-WS-042) |
| client | Alert meta line, highlighted in var(--mid) |
| severity | CSS class on .alert-item: critical / high / medium / low — controls left border color and dot glow |
| time | Right-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.
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.
| Column | Source field |
|---|---|
| Type | e.type — e.g. "IPS Alert", "Malware Block" |
| Source | e.source — IP address or "Multiple" |
| Client | e.client |
| Count | e.count — event occurrences in window |
| Severity | e.severity rendered as a .sev badge |
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).
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.
| Field | Description |
|---|---|
| name | Platform name (SentinelOne, Huntress, FortiGate, etc.) |
| icon | Emoji rendered in a square icon box |
| status | 'ok' → Online pill (green) · 'warn' → Degraded pill (yellow) · 'err' → Offline pill (red) |
| events | Event 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.
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.
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).| Field | Rendered as |
|---|---|
| sev | CSS class on .tl-item: critical / high / medium / low — sets --tl-color for the dot background and glow |
| time | Small monospace timestamp above the event text |
| text | Event description — main readable line |
| client | Client name below the description in muted monospace |
| KPI | Source | Logic |
|---|---|---|
| Open Tickets | kpis.openTickets | Sum of all engineers[].open. Always info. |
| Overloaded | kpis.overloaded | Count of engineers where pct > 100. Class: danger if >1, else warn. |
| Aged 24h+ | kpis.aged24 | The count from the aging[] entry with label === '24h+'. Always danger. |
| Closed Today | kpis.closedToday | Last element of weekTrend.closed[] (Sunday). Always ok. |
| Throughput | kpis.throughput | Sunday closed ÷ Sunday opened × 100, as a percentage string. Always info. |
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.
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 field | Description |
|---|---|
| name | Display name shown in first column |
| open | Current open ticket count |
| capacity | Maximum ticket capacity (15 in mock data) |
| pct | Utilization percentage — open / capacity × 100. Drives bar color and status badge. Can exceed 100. |
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).
| Column | Source | Logic |
|---|---|---|
| Client | c.name | Full client name in var(--text) |
| Open | c.open | Current open ticket count |
| High | c.high | High-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 |
| Trend | c.trend[] | 7-point SVG polyline (60×20px) in var(--accent). Min/max scaled to available height. |
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.
| Bucket | Class | Color |
|---|---|---|
| 0–2h | age-fresh | --ok green |
| 2–8h | age-warm | --warn yellow |
| 8–24h | age-hot | --orange orange |
| 24h+ | age-stale | --danger red |
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.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 field | Description |
|---|---|
| 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.
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.
| Percentage | Color | Interpretation |
|---|---|---|
| ≥90% | --ok | Team is keeping up — closing more than they open |
| 70–89% | --warn | Falling slightly behind — monitor engineer capacity |
| <70% | --danger | Backlog 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[].
The two mock functions define the full expected shape. Any real API must return objects matching these structures.
Going live requires changing exactly two functions. Everything else — renderers, layout, tickers, auto-refresh — stays unchanged.
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.renderSecurityKPIs(): change activeThreats > 3 and ipsAlerts > 40. In renderWorkloadKPIs(): change overloaded > 1. In renderThroughput(): change the 90 and 70 percentage breakpoints.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.
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.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.updateClock() runs on its own 1-second interval separate from the data refresh cycle. It never interacts with the data or renderers.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.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.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:".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.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.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.mockSecurityData() and mockWorkloadData() to match your real MSP environment for more useful demos.KB · MSP Command Center — Security & Workload · v1.0