MSP Console v2 is a self-contained single-file HTML dashboard purpose-built for MSP engineers managing a specific client environment. The demo client is "Meridian Financial Group" but every reference is hardcoded and replaceable. All logic runs client-side with no build process required.
The console operates in two modes: Demo Mode (default, all data is static/simulated) and Live Mode (real data via a backend proxy and WebSocket connection). Switching between them requires no code changes beyond toggling Live Mode in Settings and providing a backend URL.
MSP L2/L3 engineers running monthly security reviews, account managers preparing QBR materials, and team leads monitoring live client environments. The AI analyst layer lowers the barrier for L1 engineers interpreting complex security posture data.
Find and replace Meridian Financial Group throughout the file. The client name appears in the header badge, the risk banner label, the PDF report cover, and several hardcoded demo data strings. All other data flows from the state object.
The file loads three CDN scripts and two Google fonts. All other logic is vanilla JavaScript inside a single <script> block. There is no module system, no transpilation, and no state management library.
| Dependency | Version | Source | Required For |
|---|---|---|---|
| Chart.js | latest | cdn.jsdelivr.net/npm/chart.js | Safety score trend line, MFA trend line, radar chart (control coverage). All canvas charts. |
| jsPDF | 2.5.1 | cdnjs.cloudflare.com | PDF report generation. Page layout, text, shapes, and image embedding. |
| jsPDF-AutoTable | 3.8.2 | cdnjs.cloudflare.com | Metrics detail table on Page 3 of the PDF report. Must load after jsPDF. |
| Inter | 400,600,700,800 | Google Fonts | Primary UI font throughout the dashboard. |
| JetBrains Mono | 400,700 | Google Fonts | All metric values, code labels, timestamps, nav badges. |
<script src> paths to relative URLs.
Content Security Policy
A <meta http-equiv="Content-Security-Policy"> tag is embedded in the head. Key directives:
cdn.jsdelivr.net and cdnjs.cloudflare.com for Chart.js and jsPDF. 'unsafe-inline' is required for the inline script block.https://api.anthropic.com only. This is the only domain the AI module may call. If you change AI providers, update this directive or the API call will be blocked.'self' data: — allows the PDF chart export which generates a data: URI from the canvas.Page Structure
The layout is a CSS grid with a 196px fixed sidebar and a fluid content area (main { grid-template-columns: 196px 1fr }). The content area uses overflow-y: auto on a #contentBody div. Navigation switches sections by calling loadSection(name) which writes HTML into #contentBody via the sectionMap registry.
All runtime data lives in a single const state object. This is the source of truth for every render function. The WebSocket handlers, CSV import pipeline, and score calculator all mutate this object and then trigger re-renders.
const state = { score: 88, // 0–100 safety score (recomputed by score heuristic) grade: 'A', // 'A+' | 'A' | 'B' | 'C' | 'D' mfaPct: 88, // % of users with MFA enabled (0–100) nonCompliant: 2, // count of Intune noncompliant devices activeUsers: 142, // total Entra ID licensed users stalePasswords:12, // accounts with passwords 90+ days old lockedOut: 0, // accounts currently locked out (real-time) riskySignIns: 0, // risky sign-ins in current window criticalItems: [ // "Must Fix This Week" list { main: 'MFA disabled on 6 finance/ops accounts', sub: 'T. Williams, A. Patel, M. Chen + 3 others', action: 'https://entra.microsoft.com/...', actionLabel: 'Open Entra MFA →', importSource:'lockouts' // optional — set by CSV import for deduplication } ], warningItems: [ // "Fix This Month" list — same shape as criticalItems ], activityLog: [], // array of { msg, level, time } — max 60 entries, newest first lastSync: new Date(), lastSyncMs: Date.now(), }
state.mfaPct = 92). There is no setter or reactive system. After mutating state, you must manually call the relevant render functions or the UI will not update. The WebSocket handlers in const handlers follow the correct pattern: mutate state, then call animateCount(), renderPriorities(), or addActivity() as appropriate.
The trendData object holds 12-month historical arrays for the safety score and MFA trend charts. These are separate from state because they are chart-only data that the live event handlers never need to mutate.
The Dashboard is the default landing section, rendered by renderDashboard(). It contains five visual zones that give the shift engineer a complete situational picture without needing to open any other section.
| Component | Function | Notes |
|---|---|---|
| Risk Banner | SVG ring gauge with letter grade, score, kill-chain stats, and priority counts | Ring color: green ≥ 85, yellow ≥ 70, red below. Hovering the ring shows a flyout popover with score breakdown. Ring updates live via updateRing(score). |
| Summary Grid | 5 stat cards: MFA Coverage, Non-Compliant Devices, Active Users, Stale Passwords, Locked Out | Cards are sticky at top: 0 so they remain visible while scrolling. Each card is clickable to navigate to its related section. Values animate via animateCount() on live updates. |
| Priority Boxes | Critical (Must Fix This Week) and Warning (Fix This Month) item lists with remediation action links | Items are rendered from state.criticalItems and state.warningItems. Clicking an item toggles expanded state to reveal the action link. Critical box has a pulsing red border animation. Max 10 items displayed per box. |
| Live Event Feed | Chronological log of incoming WebSocket events and system activity | Rendered from state.activityLog by renderActivityFeed(). Max 60 entries stored, top 20 shown. Each entry has a color-coded dot: crit (red), warn (yellow), ok (green), info (cyan). |
| Charts | Safety Score 12-month trend line + Risk Radar (6-axis control coverage) | Both are Chart.js instances. Initialized in initCharts() called via setTimeout(initCharts, 50) after the section renders. The trend chart is also captured as a PNG and embedded in PDF Page 4. |
Score Ring Color Logic
The sidebar has 9 nav items. All sections except Dashboard, Credentials, Import, and Audit Log are rendered by simple functions that return an HTML string. loadSection(name) writes the string to #contentBody and handles the active state on the nav item.
| Section | Key | Render Function | Nav Badge |
|---|---|---|---|
| Dashboard | dashboard | renderDashboard() | None |
| Users | users | renderUsers() | WARN count of users without MFA (hardcoded: 6) |
| Devices | devices | renderDevices() | CRIT count of noncompliant devices — updates via updateBadge('nb_devices', state.nonCompliant) |
| Sign-Ins | signins | renderSignIns() | OK count of risky sign-ins — updates live |
| Trends | trends | renderTrends() | None |
| Infrastructure | infra | renderInfraSection() | ! hidden until live infra data arrives |
| Credentials | credentials | renderCredentials() | count hidden until credentials saved |
| Import CSVs | import | renderImportSection() | count hidden until files queued |
| Audit Log | auditlog | renderAuditLog(body) | Count of stored AI query log entries |
Sign-Ins Section
The Sign-Ins section is unique: it filters state.activityLog for entries that include the strings 'Sign-in' or 'Risky'. This means it is not a separate data store — it is a filtered view of the live feed. In production with real WebSocket data, this section will populate automatically as events arrive.
Infrastructure Section
The Infrastructure section renders four monitoring cards: Active Directory, Server Health, SQL Server, and File Server. In demo mode it uses a hardcoded infraDemo object. In Live Mode it polls GET /infra on the configured backend URL. Each card shows metrics with color-coded thresholds using the valC(value, suffix, goodFn, warnFn) helper. Metrics with no good/warn function default to muted display.
The safety score is a proprietary MSP heuristic, not Microsoft Secure Score or NIST 800-53. It is computed fresh every time the CSV import applies data. The formula deducts points from 100 based on five signals from the state object.
const mfaDeduct = Math.max(0, Math.round((100 - state.mfaPct) * 0.5)); const devDeduct = Math.min(20, state.nonCompliant * 7); const lockedDeduct = Math.min(10, state.lockedOut * 3); const staleDeduct = Math.min(8, Math.round(state.stalePasswords * 0.3)); const riskDeduct = Math.min(10, state.riskySignIns * 2); const rawScore = 100 - mfaDeduct - devDeduct - lockedDeduct - staleDeduct - riskDeduct; const newScore = Math.max(0, Math.min(100, rawScore));
| Signal | Formula | Max Deduction |
|---|---|---|
| MFA Coverage | (100 - mfaPct) * 0.5 | 50 pts (if 0% MFA) |
| Non-Compliant Devices | nonCompliant * 7 | 20 pts cap |
| Locked Out Accounts | lockedOut * 3 | 10 pts cap |
| Stale Passwords | stalePasswords * 0.3 | 8 pts cap |
| Risky Sign-Ins | riskySignIns * 2 | 10 pts cap |
| Grade | Score Range |
|---|---|
| A+ | 90 – 100 |
| A | 80 – 89 |
| B | 70 – 79 |
| C | 60 – 69 |
| D | below 60 |
importApplyToDashboard(). Live WebSocket events update individual state fields and display values but do NOT recompute the score. If you want the score to recompute on live events, add the heuristic formula as a shared recomputeScore() function and call it inside the relevant handlers entries.
The RT object manages the WebSocket connection. In demo mode, RT.connect() calls itself without a URL argument and simulates events via setTimeout. In production, call RT.connect('wss://your-backend.azurewebsites.net').
/* * TO GO LIVE: * 1. Uncomment: <script src="https://cdn.socket.io/4.7.5/socket.io.min.js"> * 2. Change: RT.connect('wss://your-backend.azurewebsites.net'); */ RT.connect(); // currently runs in demo mode
Event Handlers
The const handlers object maps WebSocket event names to handler functions. Each handler mutates the relevant state field, updates the relevant DOM element, and fires a toast notification.
| Event | Payload Fields | What It Does |
|---|---|---|
update:mfa | coveragePct, disabled, userDisplayName, userId | Updates state.mfaPct, animates the MFA stat card, adds a critical item if MFA was disabled on a user. |
update:device | compliance, deviceName, deviceId | Increments or decrements state.nonCompliant, flashes the devices card, updates the nav badge. |
update:riskyUser | userDisplayName, riskLevel | Increments state.riskySignIns, logs to activity feed, updates sign-ins nav badge. |
update:signIn | status, userDisplayName, location | Logs sign-in event to activity feed. Fires a toast only if status is 'failure'. |
update:score | score, grade | Updates state.score and state.grade, animates the score display, re-draws the ring, pulses the client name badge. |
Sync Status Indicator
The header shows a sync dot and timestamp driven by updateSyncStatus(), called every 10 seconds via setInterval. The dot transitions through three states based on time since last markSynced() call:
The AI panel is a fixed bottom-right chat widget labeled "Grok Assistant." It calls the Anthropic API (api.anthropic.com/v1/messages) directly from the browser. The CSP connect-src allows only this domain. The model used is configurable in askAI().
askAI() function as a plain string in the HTML file. Anyone with access to the file can extract it. In production, proxy AI requests through your backend at /ai/ask and inject the key server-side. Never ship this file with a real API key to a client-accessible URL.
Quick Chips
Four quick-access chips send preset queries without typing: mfa gaps, top risks, fix plan, and score. Each chip value is passed to askAI(query) which assembles a context-rich system prompt from the current state object before calling the API.
AI Offline Mode
The header button "AI Offline: OFF" toggles window._aiOffline. When active, askAI() returns immediately without making any API call and shows a local message instead. The state persists to localStorage as xai_msp_ai_offline. Use this during client meetings or any context where external API traffic is inappropriate.
AI Panel Toggle
The "AI Analyst: ON/OFF" header button controls panel visibility. The preference is saved to localStorage under the key AI_PREF_KEY. The panel is hidden on viewports below 680px and accessible instead via the Settings modal.
Context Injection
Every AI query prepends a system prompt built from live state values. This gives the model accurate numbers for the current client session without requiring it to hallucinate or estimate. The system prompt includes: client name, current score and grade, MFA percent, non-compliant device count, locked out count, stale passwords, risky sign-ins, and the text of all critical and warning items.
The Credentials section stores on-premise server credentials used by the backend proxy to connect to AD, SQL, File, and utility servers for the Infrastructure section. The security model is carefully designed to minimize credential exposure risk.
Map (credSession) in memory and are cleared when the tab closes. Only display metadata (name, hostname, username, roles, status) is persisted to localStorage. If a backend is configured, passwords are POST'd over HTTPS to /credentials where they are encrypted AES-256 server-side.
Credential Object Shape
{ id: 'cr_m8k2q9xa', // generated by credUid() name: 'Primary DC', hostname: 'dc01.contoso.local', username: 'DOMAIN\\svc_msp', password: '••••••••', // in memory only — never persisted locally roles: ['dc', 'server'] } // localStorage stores this shape — NO password field: { id: 'cr_m8k2q9xa', name: 'Primary DC', hostname: 'dc01.contoso.local', username: 'DOMAIN\\svc_msp', roles: ['dc', 'server'], addedAt: 1711123456789, lastStatus: 'ok', // 'ok' | 'fail' | null lastTest: 1711123500000 }
Server Roles
Each credential can be tagged with one or more roles that determine which Infrastructure section panels it feeds data to:
Test Connection
testCredConnection(id) calls GET /credentials/{id}/test on the configured backend. The backend attempts a connection using the stored (encrypted) password and returns { reachable: true|false }. The card border and status badge update accordingly. This requires a backend URL to be set in Settings.
The Import CSVs section provides a drag-and-drop interface for applying monthly maintenance script outputs directly to the dashboard. Files are parsed entirely in the browser — no data is transmitted anywhere. This is the primary mechanism for updating state when Live Mode is not connected.
Workflow
- 01Drop or browse CSV files. Multiple files can be queued at once. The drop zone accepts
.csvonly. The queue displays each file name with a size label. - 02Click "Parse and Preview."
importParseAndPreview()reads each file usingFileReader, splits by newlines, parses headers, and counts rows. Delta chips appear below the drop zone showing what will change. - 03Click "Apply to Dashboard."
importApplyToDashboard()maps each file to theCSV_SCHEMAentries, writes row counts tostatefields, recalculates the score, and navigates to the Dashboard.
CSV Schema Map
The CSV_SCHEMA object maps filename patterns to state fields. Filenames are matched case-insensitively. Unknown files are accepted in the queue but produce no state changes.
| Expected Filename | State Field Updated | Special Logic |
|---|---|---|
LockedOutUsers.csv | state.lockedOut | Also rebuilds state.criticalItems entries tagged importSource: 'lockouts'. First 5 usernames shown in the item sub-text. |
Kerberoastable.csv | state (warning item) | Rebuilds state.warningItems entries tagged importSource: 'kerb'. First 3 account names shown. |
OldPasswords.csv | state.stalePasswords | Also rebuilds stale password warning item tagged importSource: 'stale'. |
| Other mapped files | Varies by CSV_SCHEMA config | Row count written to the mapped state.field. No special item logic. |
criticalItems or warningItems, the code filters out any existing item with the same importSource tag. This prevents duplicate entries when you re-import updated files without refreshing the page.
After a successful apply, file names (not content) are saved to localStorage as lastImportNames. This drives the "Last import:" hint text shown inside the drop zone on subsequent visits.
The "PDF Report" header button generates a 4-page A4 PDF using jsPDF and jsPDF-AutoTable. The report is generated entirely client-side from the current state object and immediately downloaded. The filename is MSP-Security-Report-{ClientName}-{Month}-{Year}.pdf.
| Page | Content |
|---|---|
| Page 1 — Cover | Client name, report period, generation date, classification. Large grade letter, score value with progress bar. 4 stat tiles: MFA Coverage, Locked-Out Users, Non-Compliant Devices, Risky Sign-Ins. Confidentiality strip at bottom. |
| Page 2 — Executive Summary | 5-tile KPI row. Introductory narrative paragraph (named client, period, data sources). Red critical items box with all state.criticalItems. Yellow moderate items box with all state.warningItems. Automatic page break if boxes overflow. |
| Page 3 — Metrics Detail | 17-row AutoTable covering Identity/MFA, Active Directory, Endpoint/Intune, M365/Exchange, Conditional Access, and App Registrations. Each row has Category, Metric, Value (live from state), and Status ([OK] or [!] color-coded). Column widths total exactly PW - 2*M = 174mm. |
| Page 4 — Trends and Recommendations | Safety score trend chart embedded as PNG (captured from the live canvas via an off-screen composite). 4-item recommendations table with priority, timeframe, effort, action, and guidance columns. Footer on every page via didDrawPage callback. |
#trendChart canvas element. If you generate the PDF without having visited the Dashboard section first (so the chart was never rendered), the PDF will show a placeholder text line instead of the chart image. Always navigate to Dashboard before generating the PDF.
Chart White-Background Composite
The dark-themed chart canvas is composited onto a white offscreen canvas before embedding. This is done by creating a new canvas at 2x pixel density, drawing a white fillRect, then drawing the live canvas on top. This produces a sharp, print-appropriate chart without modifying the live Chart.js instance or causing any visual flash in the UI.
Every AI query submitted through the chat panel is logged to IndexedDB (msp_console_audit, version 2). The prompt text is encrypted with AES-GCM using a session-only CryptoKey before the write. The key is non-extractable and lives only in memory — it is generated fresh on each page load via the Web Crypto API.
What Is Logged
true if the query was submitted while AI Offline Mode was active (meaning no API call was made).TTL and Auto-Expiry
On every page load, _purgeExpiredLogs() runs an IndexedDB cursor sweep on the by_ts index and deletes any entry with a timestamp older than LOG_TTL_DAYS (default 90 days). To change the retention window, edit const LOG_TTL_DAYS = 90 near the top of the audit section in the script.
Export and Clear
The Audit Log section renders Export CSV and Clear Logs buttons. Export decrypts each entry (prompts decrypt successfully only in the same browser session using the same in-memory key) and generates a CSV download. Entries from a previous session will decrypt as [encrypted — key unavailable] because the key is never persisted. Clear Logs deletes all IndexedDB entries and resets the nav badge.
The Settings modal is opened via the ⚙ header button. It controls four areas. Settings are persisted to localStorage under the liveSettings key.
Live Infrastructure Monitoring
| Setting | ID | Notes |
|---|---|---|
| Live Mode | liveModeToggle | Enables polling the backend for real AD / SQL / server data. Required for Infrastructure section to show live data. |
| Backend URL | backendUrl | Base URL of your backend proxy, e.g. https://your-backend:3000. Used by liveFetch(path) for all infrastructure API calls. |
| API Key | backendApiKey | Added as x-api-key header on every liveFetch call. Stored in liveSettings in localStorage. |
| Poll Interval | pollInterval | Options: 1 min, 5 min (default), 15 min. Controls how often the dashboard pulls a full infra snapshot. |
Source Toggles
Four toggles control which infrastructure data sources are polled: AD, SQL, File Server, and Server Health. Disabled sources show "Disabled" in the relevant Infrastructure card header and do not trigger API calls for that data type.
AI and Privacy
A toggle controls AI panel visibility (same effect as the header button). Settings also contains the AI Offline Mode toggle as an alternative to the header button.
localStorage Keys Reference
liveSettings object (backend URL, API key, poll interval, source toggles).'1' or '0' — AI offline mode preference.'1' or '0' — AI panel visibility preference.The console was built with several specific security and accessibility decisions that are worth understanding before deployment or modification.
Passwords exist only in the credSession in-memory Map. They are cleared on tab close. The CSP connect-src prevents credential data from being sent to any URL except the configured backend. Metadata in localStorage carries no sensitive fields.
AI Offline Mode blocks all Anthropic API calls. The audit log captures every query with AES-GCM encryption. The privacy footer states that all data is processed locally and AI queries are the only external call. The xAI privacy policy link references Anthropic-equivalent terms.
Accessibility Features
a.skip-link becomes visible on keyboard focus. Jumps to #contentBody. Required for keyboard-only users who would otherwise tab through the entire sidebar on every page.:focus-visible shows a 2px cyan outline. :focus is suppressed to avoid ugly rings on mouse clicks. Nav items get a left-bar indicator instead of a full outline.aria-label. The connection pill has role="status". The sync status has aria-live="polite". Nav items have role="button" and tabindex="0".@media (prefers-reduced-motion: reduce) disables the starfield entirely, suppresses all pulsing animations, and kills the toast slide animations. Hover transitions are preserved as they are user-initiated.@media (forced-colors: active) hides the starfield layer to prevent it from interfering with high-contrast mode rendering.--muted: #b8b8b8 is WCAG AA compliant on #000 background. --muted2: #888 is flagged in comments as decorative-only and should not carry readable text.Starfield Performance
50 star elements are generated by JavaScript in initStarfield(). Each star uses CSS custom properties (--star-opacity, --star-dx, --star-dy) to randomize appearance without generating 50 separate keyframe rules. The will-change: transform, opacity declaration promotes each star to its own GPU compositor layer, preventing layout thrash during animation.
Go-Live Checklist
- 01Replace the client name. Find and replace all instances of
Meridian Financial Groupwith your client name. Also update the HTML<title>tag. - 02Self-host the CDN dependencies. Download Chart.js, jsPDF 2.5.1, and jsPDF-AutoTable 3.8.2. Place them alongside the HTML file. Update the three
<script src>tags to relative paths. - 03Update the CSP if changing script hosts. If you host scripts on a different domain, add it to the
script-srcdirective in the CSP meta tag. If you add a backend proxy URL, add it toconnect-src. - 04Move the Anthropic API key to a backend proxy. Replace the direct Anthropic fetch in
askAI()with a POST to/ai/askon your backend. The backend injects the API key. Updateconnect-srcto your backend domain. - 05Set up the backend for Live Mode. The console expects a backend that responds to:
GET /infra,GET /credentials/{id}/test,POST /credentials,DELETE /credentials/{id}. Wire the WebSocket server to push events matching the handler names documented in Section 07. - 06Uncomment the socket.io script tag. The
<script src="https://cdn.socket.io/4.7.5/socket.io.min.js">tag is commented out in the source. Uncomment it and addcdn.socket.ioto the CSPscript-src. - 07Update RT.connect() with your WebSocket URL. Change the boot call at the bottom of the script from
RT.connect()toRT.connect('wss://your-backend.azurewebsites.net'). - 08Update the demo data in state. Replace
state.criticalItemsandstate.warningItemswith the client's actual current findings. ReplacetrendData.safetyandtrendData.mfawith 12-month historical actuals from the client's Microsoft 365 tenant. - 09Serve from an authenticated URL. This file contains API endpoint references, a backend URL, and potentially an API key. It must not be publicly accessible. Serve from an authenticated internal host, VPN-gated URL, or behind HTTP Basic Auth at minimum.
- 10Test PDF generation on the target machine. Navigate to Dashboard, wait for charts to render, then click PDF Report. Verify the chart image appears on Page 4. Test that the AutoTable metrics render correctly with your actual state values.
Chart.js was not loaded from the CDN before initCharts() was called. This usually means the CDN request timed out or failed. Open the browser console — a Chart.js 404 or failed fetch will be visible. Self-host Chart.js as a local file to eliminate this dependency entirely.
The trend chart canvas (#trendChart) must exist in the DOM and be rendered when the PDF button is clicked. The chart only renders when the Dashboard section is active. Navigate to the Dashboard section first, wait for the chart to draw, then click PDF Report. If you clicked PDF from another section, the canvas does not exist in the DOM and the chart capture fails gracefully with the placeholder text.
This is by design. Passwords are memory-only and cleared on tab close. Credential metadata (name, hostname, username, roles) should persist in localStorage across sessions. If metadata is also missing, check that localStorage is not blocked in your browser. In private/incognito mode, localStorage is cleared on tab close — use a regular browser session for the console.
The score heuristic only runs if at least one of the five score signals (mfaPct, nonCompliant, lockedOut, stalePasswords, riskySignIns) was updated by the imported files. If none of your imported files map to those fields in CSV_SCHEMA, the score will not change. Check the toast notification after applying — it shows the count of fields updated and the old/new score values.
The AI system prompt is built from the current state object at the time the query is sent. If the state is still showing demo defaults (score 88, 142 users, etc.) it means no CSV import has been applied and no live WebSocket data has arrived. Apply a CSV import first to update state with real client values, then query the AI again for data-relevant responses.
Live Mode being toggled ON does not immediately trigger a fetch. The first poll runs at the next interval tick or when you manually click Refresh. Click the Refresh button in the header to force an immediate liveFetch('/infra'). If it still fails, check the backend URL in Settings and verify the backend is reachable from your network. The browser console will show the failed fetch with a specific error.
The AES-GCM key is session-only and generated fresh on each page load. Entries from previous sessions cannot be decrypted in a new session — the key that encrypted them no longer exists. Only entries from the current session (same page load, not just same tab) can be decrypted. This is intentional. If you need readable long-term logs, add a server-side logging endpoint and POST queries there with the key stored server-side.
Add a .nav-item div to the <aside> with a data-section="mysection" attribute and any desired nav badge. Then register a render function: sectionMap['mysection'] = () => '<div>...content...</div>'. The loadSection() function will find it automatically via the sectionMap. If your section needs post-render DOM initialization (like the Import section's drop zone), return the HTML string from the map entry and call setTimeout(initMySection, 50) before returning.