DEMO flag, fetchWithFallback(), loadDashboard(), and 30-second auto-refresh are all in place.The MSP True-Up Console is a monthly billing true-up and operational health console. Its core purpose is to identify where an MSP is under-billing or over-billing clients across 20 vendor subscriptions — showing licensed vs active seats, the resulting dollar delta, coverage gaps, and operational health metrics all in one screen. It is not a monitoring or ticketing tool. It is a revenue reconciliation and vendor governance tool.
DEMO flag, fetchWithFallback(), fetchVendorData(), fetchAllVendors(), loadDashboard(), startAutoRefresh(30000), and disconnectAPIs() are all implemented. Demo mode uses local mock generators. Live mode fetches per-vendor data from the proxy. Both modes feed identical render functions.Promise.all(), discarded all results, then still rendered mock data. Credentials were never used. Five additional structural gaps were corrected. No genuine vendor API limitations were found — all 20 vendors have documented REST or JSON-RPC APIs that support the data the dashboard displays.| Issue | Original state | Fixed state |
|---|---|---|
| Live data pipeline missing | connectAPIs() fired fetches, discarded results, still called loadDemoData() | fetchWithFallback() + fetchVendorData() + fetchAllVendors() — proxy responses replace mock data per-vendor |
| NinjaRMM OAuth: Bearer placeholder | Authorization: Bearer placeholder — always 401 | Documented: proxy must do OAuth2 client_credentials token exchange at /ws/oauth/token before calling /v2/devices-detailed |
| CW PSA: hardcoded clientId | 'clientId': 'YOUR_CLIENT_ID' — required header was hardcoded placeholder | New credential field cw-clientid added to form; value read via gi('cw-clientid') |
| Single failure killed all 20 connections | One catch block wrapped entire Promise.all() | Per-vendor fetchWithFallback() — each vendor fails independently, mock fallback on error |
| Disconnect button called toggleCreds() | Clicking Disconnect re-opened the credential form instead of disconnecting | disconnectAPIs() resets DEMO=true, clears LIVE_CREDS, reverts mode pip |
| clearCreds() didn't revert to demo mode | Clearing the form left DEMO=false and mode as LIVE | clearCreds() calls disconnectAPIs() if live |
| No suite pattern wiring | No DEMO flag, no loadDashboard(), no startAutoRefresh() | Full suite pattern implemented |
| Function | Role |
|---|---|
| fetchWithFallback(path, fn) | GET to PROXY_BASE + path with x-api-key header, 10s timeout, silent fallback on any error. |
| fetchVendorData(bucket, vid) | Fetches one vendor's data from /api/vendor/{vid}/{bucket}, writes to ST.data[bucket][vid]. |
| fetchAllVendors() | Fires all 20 fetchVendorData() calls via Promise.all() — each fails independently. |
| loadDashboard() | Main entry point. Branches on DEMO, loads data, builds client selector, renders all tabs. |
| connectAPIs() | Captures credentials from form to LIVE_CREDS, validates, sets DEMO=false, calls loadDashboard(). |
| disconnectAPIs() | Resets DEMO=true, clears LIVE_CREDS, reverts topbar to DEMO state, re-renders with mock data. |
| rows(bucket, vid) | Returns ST.data[bucket][vid] filtered to active client (or all if client='all'). |
| secSum / bakSum / netSum / bizSum | Aggregate a vendor's rows into a single summary object for display in the table and hero. |
The dashboard is a single vertically-scrolling page. No popups or drawers occupy full screen — the credential drawer slides down below the topbar, the drill-down rows expand inline under each vendor row.
| Zone | Height | Description |
|---|---|---|
| Topbar | 42px sticky | Brand, client filter select, mode pip (DEMO/LIVE), Connect/Disconnect buttons, Refresh, Export CSV, hotkey toggle |
| Credential Drawer | 0 → 600px animated | 4 collapsible vendor category sections — Security (8), Backup (6), Network (4), BizOps (6). "Connect All 20 Sources" button at bottom. |
| Master Revenue Hero | 110px max | Total revenue delta (all 20 vendors), plus per-tab delta cells with badge counts. Clicking a cell switches the tab. |
| Tab Nav | 38px | 4 color-coded tabs: Security (red), Backup (orange), Network (green), BizOps (purple). Tab count badge shows vendor count + ⚠ if issues. |
| Section Delta Bar | 72px | Active section delta (big), master total (smaller), proportional bar showing all 4 sections' contribution. Updates on tab switch. |
| KPI Strip | auto | 5–7 KPI tiles at top of each tab. Colors and values specific to each tab's metrics. |
| Data Table | flex | One row per vendor. Sortable columns. Click row to open inline drill-down. Action buttons on qualifying rows. |
| Drill-Down | 0 → 400px animated | Per-client breakdown for the selected vendor. Shows same columns as main table but at client level. |
| Status Line | 28px sticky bottom | Problem count, last sync time, hotkey hint. |
onClientChange() sets ST.client, closes open drills, and calls renderAll(). All tables, KPIs, and the hero filter to the selected client instantly. Ctrl+F / hotkey F focuses this field.connectAPIs() / disconnectAPIs().toggleCreds()). Hidden when live.disconnectAPIs() — resets to DEMO mode, clears credentials from memory, reverts all UI indicators. Hidden when in demo mode.loadDashboard(). Also triggered by hotkey R and on the 30-second auto-refresh interval.exportActive() — exports the active tab's vendor summary as a CSV file. Format: Vendor, Licensed/Managed, Active, Billed, Actual, $ Delta, % Delta. Filename: trueup-{tab}-{date}.csv.A collapsible drawer below the topbar organized into 4 vendor categories. Each category is independently collapsible. All credential values are captured to the LIVE_CREDS object in memory when "Connect All 20 Sources" is clicked — they are never persisted to localStorage or sent anywhere except the proxy.
POST /api/connect and uses them to authenticate vendor API calls server-side. The browser does not call SentinelOne, Huntress, NinjaRMM, etc. directly. This is intentional — browser-direct calls would be blocked by CORS on every vendor.| Category | Vendors | Credential fields |
|---|---|---|
| Security | SentinelOne, Huntress, RocketCyber, Duo, Keeper, Mimecast, Cisco Umbrella, ESET | Varies — see Section 15 for per-vendor field names |
| Backup & DR | Acronis, Cove, Axcient x360Cloud, Axcient x360Recover, Datto, StorageCraft | Varies — see Section 15 |
| Network & Endpoint | NinjaRMM, Auvik, Cisco Meraki, FortiGate/FortiManager | Varies — see Section 15 |
| Business Ops | ConnectWise PSA, Dialpad, 8x8, Hudu, Liongard, ScalePad | Varies — CW PSA requires Company ID, Public Key, Private Key, Site URL, and Client ID (new field added in audit) |
The hero bar at the top aggregates all 20 vendors across all tabs into a single total revenue delta. The left cell shows the master number. The four right cells show per-tab totals with operational badges. Clicking any right cell switches to that tab.
tD (actual minus billed). Negative = under-billing. Positive = over-billing. Color: red if below -$200, green if above +$200.switchTab(tab) updates ST.activeTab, toggles active state on nav buttons and hero cells, calls renderTab() and renderSectionDeltaBar(). Hotkeys 1–4 switch tabs. The tab count badge shows vendor count with a red ⚠ suffix when issues exist.
Below the tab nav — shows the active section's dollar delta (large), the master total across all 20 vendors (smaller), the section's percentage contribution to total exposure, and a proportional stacked bar with all 4 sections. Active section is full opacity; others are 30% dimmed. Updates on every tab switch and data refresh.
8 vendors: SentinelOne, Huntress, RocketCyber, Duo, Keeper, Mimecast, Cisco Umbrella, ESET.
fix class) for urgent items.6 vendors: Acronis, Cove, Axcient x360Cloud, Axcient x360Recover, Datto, StorageCraft.
4 vendors: NinjaRMM (endpoints), Auvik (sites), Cisco Meraki (networks), FortiGate/FortiManager (firewalls). Each vendor uses a different "managed unit" depending on the type.
6 vendors: ConnectWise PSA (agreements), Dialpad (users), 8x8 (users), Hudu (assets/doc coverage), Liongard (inspectors), ScalePad (devices/EoL count).
The proxy (PROXY_BASE, default localhost:3001) must implement these endpoints. The dashboard sends an x-api-key header matching PROXY_KEY in the config.
| Method | Path | Response shape |
|---|---|---|
| POST | /api/connect | Receives LIVE_CREDS object. Returns {vendors: {s1:'ok', ninja:'err:OAuth failed', ...}} |
| GET | /api/vendor/:vid/sec?client=all | {rows: [{client, seatsLicensed, seatsActive, gaps, alerts, billed, actual, delta, ...}]} |
| GET | /api/vendor/:vid/bak?client=all | {rows: [{client, workloads, unprotected, jobsOk, jobsWarn, storageUsed, billed, actual, delta, ...}]} |
| GET | /api/vendor/:vid/net?client=all | {rows: [{client, managed, offline, alerts, seatsLic, billed, actual, delta, uptime, ...}]} |
| GET | /api/vendor/:vid/biz?client=all | {rows: [{client, active, licensed, billed, actual, delta, status, ...extras}]} |
| Vendor | Cred fields | Auth | Key endpoint(s) proxy calls |
|---|---|---|---|
| SentinelOne | Console URL, API Token | ApiToken header | GET /web/api/v2.1/agents |
| Huntress | API Key, API Secret | Basic base64(key:secret) | GET /v1/agents, GET /v1/organizations |
| RocketCyber | API Token, Account ID | Bearer token | GET /v3/account/{id}/agent |
| Duo Security | Integration Key, Secret Key, API Hostname | Basic HMAC-SHA1 (ikey:skey) | GET /admin/v1/users |
| Keeper | API Key, Node ID | Bearer token | GET /api/rest/admin/v1/node/{id}/users |
| Mimecast | Client ID, Client Secret | OAuth2 client_credentials | POST /oauth/token → GET /api/awareness-training/... |
| Cisco Umbrella | API Key, API Secret, Org ID | Basic base64(key:secret) | GET /admin/v2/organizations/{orgId}/roamingcomputers |
| ESET | Console URL, Username, Password | Basic base64(user:pass) | GET /era/v1/groups/0/computers |
| Acronis | Data Center URL, Client ID, Client Secret | OAuth2 client_credentials | POST /api/2/idp/token → GET /api/2/resources |
| Cove (N-able) | Username, Password, Partner Name | JSON-RPC session token | POST https://backup.management/jsonapi (Login → ListPartnerCustomers) |
| Axcient x360Cloud | API Key, Partner ID | x-api-key header | GET /x360cloud/partner/{partnerId}/organizations |
| Axcient x360Recover | API Key, Base URL | x-api-key header | GET /api/devices |
| Datto | API Key, Secret Key | Basic base64(apiKey:secretKey) | GET /v1/bcdr/device |
| StorageCraft | Account Email, API Token | Bearer token | GET /api/v2/accounts |
| NinjaRMM | Client ID, Client Secret, Region | OAuth2 client_credentials | POST /ws/oauth/token → GET /v2/devices-detailed |
| Auvik | Username (email), API Key | Basic base64(email:apiKey) | GET /v1/inventory/device/info |
| Cisco Meraki | API Key, Org ID (optional) | X-Cisco-Meraki-API-Key header | GET /api/v1/organizations, GET /api/v1/organizations/{id}/devices |
| FortiGate/FortiManager | FortiManager Host, API Token, ADOM | JSON-RPC session token | POST /jsonrpc {method:get, url:/dvmdb/adom/{adom}/device} |
| ConnectWise PSA | Company ID, Public Key, Private Key, Site URL, Client ID | Basic base64(companyId+pub:priv) + clientId header | GET /v4_6_release/apis/3.0/finance/agreements |
| Dialpad | API Key | Bearer token | GET /api/v2/users |
POST /ws/oauth/token with grant_type=client_credentials, client_id, and client_secret to get a Bearer token, then use that token for GET /v2/devices-detailed. Token expires — proxy should cache and refresh.https://api.services.mimecast.com/oauth/token with client_credentials grant to get Bearer token. Use Bearer for subsequent data calls. Token lifetime is short — proxy should refresh on expiry.https://{dc}.acronis.com/api/2/idp/token with client_credentials to get access token. Then use Bearer for resource queries. Data center URL varies by region.Login method to https://backup.management/jsonapi to get a session token. Then use that session in subsequent ListPartnerCustomers and ListDevices JSON-RPC calls. The session has a timeout — proxy must re-login on session expiry.clientId header containing a GUID registered at developer.connectwise.com. This header is separate from the Basic Auth credential and was previously hardcoded as YOUR_CLIENT_ID. A new input field has been added to the credential form for this value.POST /api/connect (credential validation), and GET /api/vendor/:vid/:bucket?client= for each of the 20 vendors across the 4 buckets. Update PROXY_BASE and PROXY_KEY in the dashboard script block.developer.connectwise.com → My Apps → Add Application. Copy the GUID — this is the value for the new "Client ID" field in the credential drawer's ConnectWise section.?client= query parameter is passed to the proxy on every fetch — the proxy must filter its vendor API response to that client's data.| Key | Action |
|---|---|
| R | Refresh — calls loadDashboard() |
| F | Focus client filter dropdown |
| 1–4 | Switch to Security / Backup / Network / BizOps tab |
| S | Sort active tab by $ Delta (worst-first) |
| K | Toggle credential drawer open/closed |
| ? | Toggle hotkey hint panel |
| Esc | Close hotkey hint, close credential drawer, close all drill-downs |
PROXY_BASE is wrong. fetchWithFallback() falls back to mock on any network error. Open DevTools → Network and look for /api/vendor/ calls. If they're missing entirely, DEMO is still true — check that connectAPIs() completed successfully and set DEMO = false.Bearer placeholder for NinjaRMM — this has been removed. NinjaRMM requires a proxy-side OAuth2 token exchange at /ws/oauth/token. If the proxy doesn't implement this step, the data call will 401 and fall back to mock.Authorization: Basic base64(companyId+pub:priv) header AND the clientId header. Missing either will cause 401. The Client ID field was added to the credential form in the audit — enter the GUID from developer.connectwise.com.renderMasterHero() calls secSum(), bakSum() etc. which in turn call rows(bucket, vid) which filters by ST.client. If the hero looks wrong after filtering, verify that ST.data was populated for all 20 vendors — a vendor that fell back to mock will produce random values that don't match the filter's scope.openDrill(tab, vid) reads from rows(tab, vid). If ST.data[tab][vid] is empty or undefined, the drill-down will be blank. Check that fetchVendorData() ran for that vendor and check the proxy log for that specific /api/vendor/{vid}/{tab} call.startAutoRefresh() uses document.hidden to skip refreshes when the browser tab is not visible. This is intentional — it prevents unnecessary API load when the dashboard is backgrounded. Refreshes resume as soon as the tab becomes active again.exportActive() exports the tab currently showing. Switch tabs before exporting to get the correct section. The filename includes the tab name and date for easy identification.KB · MSP True-Up Console · dashboard-msp-master-dashboard.html · v1.0 — Wired & Proxy-Ready