// Knowledge Base · MSP True-Up Console
MSP True-Up Console Reference Guide
Complete reference for the MSP True-Up Console — a 20-vendor billing true-up and operational health dashboard. Tracks seat counts, license vs actual usage, $ deltas, coverage gaps, backup job health, and operational metrics across four categories: Security, Backup & DR, Network & Endpoint, and Business Ops. Proxy-ready: DEMO flag, fetchWithFallback(), loadDashboard(), and 30-second auto-refresh are all in place.
Vendors: 20 across 4 categories
Clients: 150 in demo dataset
Status: Wired — proxy is the remaining dependency
Refresh: Auto every 30 seconds
01 //What Is This Tool

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.

Single File — Proxy Architecture
All HTML, CSS, and JS in one file. Unlike other tools in the suite that call vendor APIs directly, this dashboard is designed to route all credential handling through a backend proxy. Credentials are entered in the form drawer, captured to memory, and sent to the proxy — the browser never calls vendor APIs directly. This is the correct pattern for CORS and security reasons.
Proxy-Ready Wiring in Place
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.
150-Client Demo Dataset
The demo uses 150 named mock clients. Each client gets a stable endpoint count (20–150) seeded once at load, and all 20 vendors compute seats, jobs, and billing from that count — so totals are internally consistent across tabs and vendors.
02 //API Audit Results
Seven issues found and fixed — dashboard is now proxy-ready
The original file called all 20 vendor APIs simultaneously from the browser via 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.
IssueOriginal stateFixed state
Live data pipeline missingconnectAPIs() fired fetches, discarded results, still called loadDemoData()fetchWithFallback() + fetchVendorData() + fetchAllVendors() — proxy responses replace mock data per-vendor
NinjaRMM OAuth: Bearer placeholderAuthorization: Bearer placeholder — always 401Documented: 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 placeholderNew credential field cw-clientid added to form; value read via gi('cw-clientid')
Single failure killed all 20 connectionsOne 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 disconnectingdisconnectAPIs() resets DEMO=true, clears LIVE_CREDS, reverts mode pip
clearCreds() didn't revert to demo modeClearing the form left DEMO=false and mode as LIVEclearCreds() calls disconnectAPIs() if live
No suite pattern wiringNo DEMO flag, no loadDashboard(), no startAutoRefresh()Full suite pattern implemented
03 //Architecture
DEMO = true // mock generators, no network calls (default) DEMO = false // proxy fetch per vendor, mock fallback on error loadDashboard() ├─ DEMO=true → loadDemoData() all 20 vendors from generators └─ DEMO=false → fetchAllVendors() 20 parallel proxy calls └─ fetchVendorData(bucket, vid) └─ fetchWithFallback('/api/vendor/{vid}/{bucket}?client={c}') ├─ success → ST.data[bucket][vid] = result.rows └─ failure → mock fallback for that vendor only → buildClientSelect() → renderAll() renderAll() ├─ renderMasterHero() ← aggregates all 20 vendors ├─ renderSectionDeltaBar() ← active tab + master total ├─ renderSec() ← Security tab ├─ renderBak() ← Backup tab ├─ renderNet() ← Network tab └─ renderBiz() ← BizOps tab startAutoRefresh(30000) ← 30s interval, skips if tab hidden
FunctionRole
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 / bizSumAggregate a vendor's rows into a single summary object for display in the table and hero.
04 //UI Layout

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.

ZoneHeightDescription
Topbar42px stickyBrand, client filter select, mode pip (DEMO/LIVE), Connect/Disconnect buttons, Refresh, Export CSV, hotkey toggle
Credential Drawer0 → 600px animated4 collapsible vendor category sections — Security (8), Backup (6), Network (4), BizOps (6). "Connect All 20 Sources" button at bottom.
Master Revenue Hero110px maxTotal revenue delta (all 20 vendors), plus per-tab delta cells with badge counts. Clicking a cell switches the tab.
Tab Nav38px4 color-coded tabs: Security (red), Backup (orange), Network (green), BizOps (purple). Tab count badge shows vendor count + ⚠ if issues.
Section Delta Bar72pxActive section delta (big), master total (smaller), proportional bar showing all 4 sections' contribution. Updates on tab switch.
KPI Stripauto5–7 KPI tiles at top of each tab. Colors and values specific to each tab's metrics.
Data TableflexOne row per vendor. Sortable columns. Click row to open inline drill-down. Action buttons on qualifying rows.
Drill-Down0 → 400px animatedPer-client breakdown for the selected vendor. Shows same columns as main table but at client level.
Status Line28px sticky bottomProblem count, last sync time, hotkey hint.
05 //Topbar
Client Filter
Dropdown of all 150 clients + "All Clients". 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.
Mode Pip
Yellow blinking dot = DEMO mode. Green solid dot = LIVE mode. Set by connectAPIs() / disconnectAPIs().
Connect 20 Sources
Opens the credential drawer (toggleCreds()). Hidden when live.
Disconnect
Calls disconnectAPIs() — resets to DEMO mode, clears credentials from memory, reverts all UI indicators. Hidden when in demo mode.
Refresh
Calls loadDashboard(). Also triggered by hotkey R and on the 30-second auto-refresh interval.
Export CSV
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.
06 //Credential Drawer

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.

Credentials go to the proxy, not directly to vendors
The proxy receives credentials via 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.
CategoryVendorsCredential fields
SecuritySentinelOne, Huntress, RocketCyber, Duo, Keeper, Mimecast, Cisco Umbrella, ESETVaries — see Section 15 for per-vendor field names
Backup & DRAcronis, Cove, Axcient x360Cloud, Axcient x360Recover, Datto, StorageCraftVaries — see Section 15
Network & EndpointNinjaRMM, Auvik, Cisco Meraki, FortiGate/FortiManagerVaries — see Section 15
Business OpsConnectWise PSA, Dialpad, 8x8, Hudu, Liongard, ScalePadVaries — CW PSA requires Company ID, Public Key, Private Key, Site URL, and Client ID (new field added in audit)
07 //Master Revenue Hero

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.

Master Delta
Sum of all vendor tD (actual minus billed). Negative = under-billing. Positive = over-billing. Color: red if below -$200, green if above +$200.
Sub-label
"Under-billing by $X across 20 vendors — review agreements" or "Over-billed by $X — credit or consume".
Per-tab cells
Each cell shows the section delta with direction arrow, plus two status badges (gaps/alerts for Security, unprotected/warnings for Backup, offline/alerts for Network, to-review count for BizOps).
08 //Tab Navigation

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.

09 //Section Delta Bar

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.

10 //Security Tab

8 vendors: SentinelOne, Huntress, RocketCyber, Duo, Keeper, Mimecast, Cisco Umbrella, ESET.

KPI Strip
Coverage Gaps (red), Seat Overage (orange), Active Alerts (yellow), Total Licensed (cyan), Monthly Billing (green)
Table columns
Vendor, Licensed, Active, Δ Seats, Gaps, Alerts, Billed, Actual, $ Delta, % Delta, Utilization bar, Action button
Action buttons
"Fix Billing" on rows with delta below -$50. "Deploy Agent" on rows with coverage gaps. "Investigate" on rows with active alerts. Buttons are styled red (fix class) for urgent items.
Drill-down
Per-client: Licensed, Active, Gaps, Alerts, Billed, Actual, $ Delta, Status pill. Row color-codes by alert severity.
Sort options
$ Delta (default, worst-first), Gaps, Vendor. Columns also click-sortable.
11 //Backup & DR Tab

6 vendors: Acronis, Cove, Axcient x360Cloud, Axcient x360Recover, Datto, StorageCraft.

KPI Strip
Protected Workloads (cyan), Unprotected (red), Jobs Healthy (green), Jobs Warning (yellow), Storage Used in TB (orange), Monthly Billing (green)
Table columns
Vendor, Workloads, Unprotected, Jobs OK, Jobs Warn, Storage, Licensed, Active, Billed, Actual, $ Delta, % Delta
Drill-down
Per-client: Workloads, Unprotected, Storage, Billed, Actual, $ Delta, last-job status pill (OK / Warn / Fail).
Sort options
$ Delta (default), Unprotected, Vendor.
12 //Network & Endpoint Tab

4 vendors: NinjaRMM (endpoints), Auvik (sites), Cisco Meraki (networks), FortiGate/FortiManager (firewalls). Each vendor uses a different "managed unit" depending on the type.

KPI Strip
Managed Devices (NinjaRMM total, cyan), Monitored Sites (Auvik total, green), Active Alerts (yellow), Devices Offline (red), Monthly Billing (green)
Table columns
Vendor, Managed, Offline, Alerts, Uptime %, Licensed, Billed, Actual, $ Delta, % Delta
Unit mapping
NinjaRMM = endpoints per client; Auvik = sites (1 per ~20 endpoints); Meraki = networks (1 per ~25 endpoints); FortiGate = firewalls (1 per ~50 endpoints)
Sort options
$ Delta (default), Offline, Vendor.
13 //Business Ops Tab

6 vendors: ConnectWise PSA (agreements), Dialpad (users), 8x8 (users), Hudu (assets/doc coverage), Liongard (inspectors), ScalePad (devices/EoL count).

KPI Strip
Agreements (CW PSA count, cyan), Total Users (Dialpad+8x8, purple), Doc Coverage % (Hudu avg, green), Assets Tracked (ScalePad, yellow), EoL Assets (red), Monthly Billing (green)
Table columns
Vendor, Active, Licensed, Δ Seats, Billed, Actual, $ Delta, % Delta, Metric (doc coverage % or EoL count), Status pill
Vendor-specific metrics
Hudu shows doc coverage %. ScalePad shows EoL asset count. ConnectWise PSA shows agreement count (not user seats). Liongard shows inspector count.
14 //Proxy Endpoints

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.

MethodPathResponse shape
POST/api/connectReceives 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}]}
15 //Vendor API Map — All 20
VendorCred fieldsAuthKey endpoint(s) proxy calls
SentinelOneConsole URL, API TokenApiToken headerGET /web/api/v2.1/agents
HuntressAPI Key, API SecretBasic base64(key:secret)GET /v1/agents, GET /v1/organizations
RocketCyberAPI Token, Account IDBearer tokenGET /v3/account/{id}/agent
Duo SecurityIntegration Key, Secret Key, API HostnameBasic HMAC-SHA1 (ikey:skey)GET /admin/v1/users
KeeperAPI Key, Node IDBearer tokenGET /api/rest/admin/v1/node/{id}/users
MimecastClient ID, Client SecretOAuth2 client_credentialsPOST /oauth/tokenGET /api/awareness-training/...
Cisco UmbrellaAPI Key, API Secret, Org IDBasic base64(key:secret)GET /admin/v2/organizations/{orgId}/roamingcomputers
ESETConsole URL, Username, PasswordBasic base64(user:pass)GET /era/v1/groups/0/computers
AcronisData Center URL, Client ID, Client SecretOAuth2 client_credentialsPOST /api/2/idp/tokenGET /api/2/resources
Cove (N-able)Username, Password, Partner NameJSON-RPC session tokenPOST https://backup.management/jsonapi (Login → ListPartnerCustomers)
Axcient x360CloudAPI Key, Partner IDx-api-key headerGET /x360cloud/partner/{partnerId}/organizations
Axcient x360RecoverAPI Key, Base URLx-api-key headerGET /api/devices
DattoAPI Key, Secret KeyBasic base64(apiKey:secretKey)GET /v1/bcdr/device
StorageCraftAccount Email, API TokenBearer tokenGET /api/v2/accounts
NinjaRMMClient ID, Client Secret, RegionOAuth2 client_credentialsPOST /ws/oauth/tokenGET /v2/devices-detailed
AuvikUsername (email), API KeyBasic base64(email:apiKey)GET /v1/inventory/device/info
Cisco MerakiAPI Key, Org ID (optional)X-Cisco-Meraki-API-Key headerGET /api/v1/organizations, GET /api/v1/organizations/{id}/devices
FortiGate/FortiManagerFortiManager Host, API Token, ADOMJSON-RPC session tokenPOST /jsonrpc {method:get, url:/dvmdb/adom/{adom}/device}
ConnectWise PSACompany ID, Public Key, Private Key, Site URL, Client IDBasic base64(companyId+pub:priv) + clientId headerGET /v4_6_release/apis/3.0/finance/agreements
DialpadAPI KeyBearer tokenGET /api/v2/users
16 //Data Shapes
// Security row (ST.data.sec[vid][i]) { client, seatsLicensed, seatsActive, gaps, alerts, costPerSeat, billed, actual, delta, utilization, status } // Backup row (ST.data.bak[vid][i]) { client, workloads, unprotected, jobsOk, jobsWarn, storageUsed, storageAlloc, seatsLicensed, seatsActive, costPerSeat, billed, actual, delta, lastJob } // Network row (ST.data.net[vid][i]) { client, managed, offline, alerts, seatsLic, costPerUnit, billed, actual, delta, status, uptime } // BizOps row (ST.data.biz[vid][i]) { client, active, licensed, costPerUnit, billed, actual, delta, status, // extras per vendor: openTickets?, slaBreaches?, // cw activeLines?, // dialpad, 8x8 docCoverage?, // hudu (0–100) alertCount?, // liongard eolCount? // scalepad }
17 //Auth Quirks — What the Proxy Must Handle
NinjaRMM — OAuth2 Token Exchange Required First
NinjaRMM does not accept API key auth for data endpoints. The proxy must first 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.
Mimecast — OAuth2 Token Exchange Required First
POST to 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.
Acronis — OAuth2 Token Exchange Required First
POST to 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.
Cove — JSON-RPC Session Required First
POST 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.
Duo Security — HMAC-SHA1 Signature Required
Duo Admin API does not use simple Basic auth. Requests must be HMAC-SHA1 signed with the secret key. The proxy must implement Duo's signature scheme (date header + canonical request string + HMAC). The credential form collects ikey and skey for this purpose.
ConnectWise PSA — clientId Is Mandatory
Every CW Manage REST API request requires a 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.
18 //Activation Checklist
1
Build and start the proxy
The proxy must implement: 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.
2
Handle multi-step auth vendors at proxy level
NinjaRMM, Mimecast, Acronis, and Cove all require a token/session exchange before data calls. Implement these as proxy-side middleware — cache tokens and refresh on expiry. Duo requires HMAC-SHA1 request signing.
3
Register CW Manage Client ID
Go to 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.
4
Open dashboard, click "Connect 20 Sources"
Enter credentials in the drawer. Click "Connect All 20 Sources". The mode pip turns green when live. The credential status shows "Live mode active". All 20 vendor data calls fire through the proxy.
5
Verify per-vendor data loads
Check each tab's table — rows should show real client names and real seat counts. If a vendor row shows mock-looking data, check the proxy log for that vendor's fetch — it may have fallen back to mock due to a credential or auth error.
6
Test client filter
Select a specific client from the dropdown. All four tabs, all KPI strips, and the hero should filter to that client instantly. The ?client= query parameter is passed to the proxy on every fetch — the proxy must filter its vendor API response to that client's data.
19 //Keyboard Shortcuts
KeyAction
RRefresh — calls loadDashboard()
FFocus client filter dropdown
1–4Switch to Security / Backup / Network / BizOps tab
SSort active tab by $ Delta (worst-first)
KToggle credential drawer open/closed
?Toggle hotkey hint panel
EscClose hotkey hint, close credential drawer, close all drill-downs
20 //Troubleshooting
Dashboard shows mock data after clicking Connect
The proxy is not running or 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.
NinjaRMM showing mock data only
The original file had a hardcoded 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.
ConnectWise PSA returning 401
Ensure the proxy is sending both the 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.
Hero delta shows incorrect total after client filter
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.
Drill-down shows no rows for a vendor
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.
Auto-refresh stops when tab is hidden
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.
Export CSV only exports the active tab
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