The OpsCore MSP Console is a self-contained single-file HTML dashboard designed for MSP service operations teams. It requires no build system, no backend server, and no external dependencies beyond a browser. All logic runs client-side via vanilla JavaScript.
The console is structured around three operational views that map directly to how a shift is actually run: what tickets need attention right now, what security events need response, and whether the shift is being reviewed and closed out correctly.
stat-card, panel-card, and panel-bar patterns repeat throughout. This means any engineer can read any page without re-learning a new layout.
Morning shift briefings and handoffs, live ticket queue triage, real-time security event monitoring, SOC shift close-out review, and client-facing status checks during incidents.
L1–L3 engineers on active shifts, SOC analysts monitoring security event queues, team leads reviewing engineer workload balance, and managers assessing shift performance.
The console is organized into three tab-navigated pages. Each page opens with a 5-card KPI stat row that gives the shift-level summary before any detail panels. Tab badges reflect live severity state so the most critical view is always visually flagged even when another tab is active.
Page 1 — Ticket & Workload
The ticket view is the default landing page. It answers the core shift question: where is the work piling up?
| Component | Purpose | Threshold Logic |
|---|---|---|
| Open Tickets | Total tickets in queue from ConnectWise | Info color (neutral) |
| Aging 24h+ | Tickets open longer than 24 hours | Warn at >2, Crit at >5 |
| Closed Today | Resolved count from DEMO.closedToday (replace with live CW query) | OK color (positive) |
| Avg Age | Mean ticket age in hours across the queue | Info color |
| Overloaded Eng. | Engineers at or above 10 open tickets | OK at 0, Warn at 1+, Crit at 3+ |
| Engineer Workload | Per-engineer bar chart — OK <8, Warn 8–11, Crit 12+ | Dynamic bar fill color |
| Aging Distribution | 4-bucket grid (0–2h, 2–8h, 8–24h, 24h+) with inline bar chart | Bucket color matches severity |
| Tickets by Client | Top 10 clients sorted by open ticket count | Bar width proportional to max |
| Throughput (7-day) | Daily opened vs. closed counts | Visual only — no threshold |
| Volume Trend (30-day) | Line chart comparing opened and closed over 30 days | Visual only |
Page 2 — Security Operations
The security view aggregates endpoint threats, firewall events, DNS blocks, and network intrusions into a single triage surface. It answers: is anything actively happening right now that needs a response?
| Component | Data Source | Notes |
|---|---|---|
| Security KPI Row | All sources | Critical Active, High Active, Total Alerts, Affected Clients, Sources Active (of 5) |
| Alert Severity Breakdown | All sources | 4-cell grid: Crit / High / Med / Low with contextual sub-labels |
| Active Endpoint Threats | SentinelOne, Huntress, ESET | Full-width scrollable table, max 20 rows, sorted newest first |
| Firewall Security Events | FortiGate | Events grouped and counted by client + type. 2/3 width in layout. |
| Top Affected Clients | All sources | Bar chart weighted: endpoint threats count 1.0, forti events count 0.5 |
| Security Event Timeline | All sources | Full-width, chronological feed. Umbrella DNS blocks included. Max 15 events. |
Page 3 — SOC Insights
The Insights page is purpose-built for shift close-out and operational review. It does not repeat raw event data — instead it surfaces patterns, volume trends, and checklist compliance. The tab badge reflects the number of open checklist items.
| Component | Purpose |
|---|---|
| Est. MTTR | Mean time to respond, sourced from DEMO.insights.mttr_min — replace with calculated value from ticket timestamps |
| Weekly Detections | Sum of all threat and firewall events across the 7-day window |
| Unresolved 24h+ | Re-uses ticket aging data — tickets open past SLA window |
| Checklist Open | Count of shift review items not yet marked done — drives tab badge color |
| Alert Volume by Source | Horizontal bar chart per tool showing total events + critical sub-count. Uses src_extra for 7-day accumulation context. |
| Detection Trend (7-day) | Line chart with separate series for endpoint threats vs. firewall events. Distinct color palette from the Tickets trend chart. |
| Tool Coverage Status | 5-cell grid showing Online / Partial / Offline status per integration |
| SOC Shift Review Checklist | Ordered list of shift tasks with Done / In Progress / Open states. Drives the tab badge count. |
Every page opens with a unified stat-row of 4–5 stat cards. Each card has a color-coded top accent bar that communicates severity state at a glance. The color is applied dynamically in JavaScript — it is not hardcoded in the HTML.
renderTickets() where kpi-overloaded-card.className is set based on the live count.
Severity Color Mapping
The same four severity states are used consistently across stat cards, aging buckets, sev pills, timeline dots, tab badges, and panel count badges.
| Class | Color | Meaning | Threshold Examples |
|---|---|---|---|
.ok | --ok #34d399 | Healthy / resolved | 0 overloaded engineers, all tools online |
.info | --accent #38bdf8 | Neutral / informational | Open ticket count, avg age |
.warn | --warn #fbbf24 | Elevated / needs attention | Aging 2–24h, medium severity alerts |
.fail | --danger #fb7185 | High severity | High threats, 8–24h aging |
.crit | --crit #ff4d4f | Critical / immediate action | Critical alerts, aging 24h+ |
Tab Badge Logic
Tab badges are computed in each render function and applied to the badge <span> element dynamically. The badge class controls both color and border.
The console pulls from six platforms via REST APIs. All fetch functions are stubbed in the file — in demo mode they return hardcoded DEMO objects. To go live, replace the return DEMO.xxx; line in each function with the appropriate fetch() call.
| Platform | Function | Auth Method | Key Data Pulled |
|---|---|---|---|
| ConnectWise Manage | fetchCWTickets() |
Basic base64 credentials + clientId header |
Open service tickets: id, summary, dateEntered, owner, company, priority, status |
| SentinelOne | fetchS1Threats() |
ApiToken bearer header |
Unresolved threats: site name, computer name, threat name, confidence level, created timestamp |
| Huntress | fetchHuntressIncidents() |
HTTP Basic (API key : secret) | Open incidents: account name, host, incident type, severity, created timestamp |
| FortiGate / FortiManager | fetchFortiEvents() |
JSON-RPC POST with bearer token | Security events: client, event type, detail, severity, timestamp |
| Cisco Umbrella | fetchUmbrellaEvents() |
OAuth2 client credentials (key + secret) | DNS security activity: client, blocked domain, category, timestamp |
| ESET PROTECT | fetchEsetDetections() |
HTTP Basic (user + pass) | Detections: company, computer, threat name, severity, occurrence time |
Data Shapes
Each fetch function must return an array of objects matching the shape below. The render functions destructure these directly. If your API returns differently structured data, normalize it inside the fetch function before returning — do not modify the render logic.
// ConnectWise ticket shape { id: 1234, summary: 'User cannot login', dateEntered: '2026-03-22T08:30:00.000Z', owner: { name: 'J. Hartwell' }, company: { name: 'Acme Corp' }, priority: { name: 'High' }, status: { name: 'Open' } } // SentinelOne threat shape { agentRealtimeInfo: { siteName: 'Acme Corp', computerName: 'LAPTOP-047' }, threatInfo: { threatName: 'Trojan.GenericKD', confidenceLevel: 'malicious', createdAt: '2026-03-22T06:00:00.000Z' } } // Huntress incident shape { account_name: 'Lakeside Legal', host: 'LL-WS-09', incident_type: 'Persistent Footholds', severity: 'critical', created_at: '2026-03-22T09:45:00.000Z' } // FortiGate event shape { client: 'Summit Finance', type: 'Malware Blocked', detail: 'Emotet C2 traffic blocked', sev: 'crit', // 'crit' | 'high' | 'medium' | 'low' count: 1, time: '2026-03-22T09:00:00.000Z' } // Umbrella event shape { client: 'Vertex Analytics', domain: 'malware-c2.ru', category: 'Malware', time: '2026-03-22T08:00:00.000Z' } // ESET detection shape { company: 'Greenfield Mfg', computer: 'GF-PC-22', threatName: 'Win32/Delf.NNT', severity: 'high', occurrenceTime: '2026-03-22T04:00:00.000Z' }
All endpoint URLs and API credentials are stored in a single const API object at the top of the <script> block. Locate and replace the placeholder values before deploying to production.
const API = { // ConnectWise Manage REST API CW_BASE: 'https://YOUR-CW-INSTANCE.connectwise.com/v4_6_release/apis/3.0', CW_CLIENT_ID: 'YOUR_CLIENT_ID', CW_AUTH: 'Basic YOUR_BASE64_CREDS', // base64(company+user:pass) // SentinelOne S1_BASE: 'https://YOUR-TENANT.sentinelone.net/web/api/v2.1', S1_TOKEN: 'YOUR_S1_API_TOKEN', // Huntress HUNTRESS_BASE: 'https://api.huntress.io/v1', HUNTRESS_KEY: 'YOUR_HUNTRESS_API_KEY', HUNTRESS_SECRET: 'YOUR_HUNTRESS_SECRET', // FortiGate / FortiManager JSON-RPC FORTI_BASE: 'https://YOUR-FORTIMANAGER/jsonrpc', FORTI_TOKEN: 'YOUR_FORTI_TOKEN', // Cisco Umbrella UMBRELLA_BASE: 'https://reports.api.umbrella.com/v2', UMBRELLA_KEY: 'YOUR_UMBRELLA_KEY', UMBRELLA_SECRET: 'YOUR_UMBRELLA_SECRET', // ESET PROTECT ESET_BASE: 'https://YOUR-ESET-SERVER:8443/era/v1', ESET_USER: 'YOUR_ESET_USER', ESET_PASS: 'YOUR_ESET_PASS', }
Tunable Thresholds
Workload severity thresholds are hardcoded in renderTickets(). These are the values to adjust per your team's SLA definitions.
| Variable | Default | Location | Description |
|---|---|---|---|
overloaded threshold | ≥ 10 tickets | renderTickets() | Engineer considered overloaded |
Bar fill .warn | ≥ 8 tickets | renderTickets() | Eng bar turns yellow |
Bar fill .crit | ≥ 12 tickets | renderTickets() | Eng bar turns red |
Aging .warn tab badge | > 2 aging tickets | renderTickets() | Tickets tab badge turns yellow |
Aging .crit tab badge | > 5 aging tickets | renderTickets() | Tickets tab badge turns red |
| Auto-refresh interval | 300,000 ms (5 min) | Bottom of <script> | setInterval(refreshAll, 300000) |
| Threat table max rows | 20 | renderSecurity() | allThreats.slice(0,20) |
| Timeline max events | 15 | renderSecurity() | allEvents.slice(0,15) |
| Firewall table max rows | 15 | renderSecurity() | fwRows.slice(0,15) |
Below are the production-ready fetch() call stubs for each integration. These are commented out in the file — uncomment and remove the return DEMO.xxx; line above each block to activate live data.
const res = await fetch( `${API.CW_BASE}/service/tickets?conditions=status/name!="Closed"&pageSize=1000`, { headers: { 'Authorization': API.CW_AUTH, 'clientId': API.CW_CLIENT_ID, 'Content-Type': 'application/json' }} ); if (!res.ok) throw new Error('CW tickets: ' + res.status); return res.json();
const res = await fetch( `${API.S1_BASE}/threats?resolved=false&limit=100`, { headers: { 'Authorization': 'ApiToken ' + API.S1_TOKEN }} ); if (!res.ok) throw new Error('S1: ' + res.status); const j = await res.json(); return j.data; // S1 wraps results in a .data array
const b64 = btoa(API.HUNTRESS_KEY + ':' + API.HUNTRESS_SECRET); const res = await fetch( `${API.HUNTRESS_BASE}/incidents?status=open`, { headers: { 'Authorization': 'Basic ' + b64 }} ); if (!res.ok) throw new Error('Huntress: ' + res.status); const j = await res.json(); return j.incidents || j; // normalize response envelope
const res = await fetch(API.FORTI_BASE, { method: 'POST', headers: { 'Authorization': 'Bearer ' + API.FORTI_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 1, method: 'get', params: [{ url: '/logview/adom/root/logfiles/list', filter: { logtype: 'traffic' }}] }) }); if (!res.ok) throw new Error('FortiManager: ' + res.status); const j = await res.json(); return j.result?.[0]?.data || [];
// Step 1: Get OAuth2 token const tokenRes = await fetch('https://api.umbrella.com/auth/v2/token', { method: 'POST', headers: { 'Authorization': 'Basic ' + btoa(API.UMBRELLA_KEY + ':' + API.UMBRELLA_SECRET), 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'grant_type=client_credentials' }); const token = (await tokenRes.json()).access_token; // Step 2: Pull security activity const res = await fetch( `${API.UMBRELLA_BASE}/security-activity?from=-1days&limit=500`, { headers: { 'Authorization': 'Bearer ' + token }} ); return (await res.json()).data || [];
const b64 = btoa(API.ESET_USER + ':' + API.ESET_PASS); const res = await fetch(`${API.ESET_BASE}/detections/list`, { method: 'POST', headers: { 'Authorization': 'Basic ' + b64, 'Content-Type': 'application/json' }, body: JSON.stringify({ filter: { resolvedTime: 'UNRESOLVED' }, pagination: { pageSize: 200 } }) }); return (await res.json()).detections || [];
The console ships with a fully populated const DEMO object that simulates realistic MSP data across all six integrations. All fetch functions return from this object by default. A yellow demo bar is shown at the top of the page when loaded in demo mode.
Demo Object Structure
GET /service/tickets?conditions=closedDate>[today]{day, opened, closed} for the throughput chart.{labels, opened, closed} arrays generated at load time.mttr_min, weekly (7-day detection series), src_extra (accumulated counts per tool), and checklist (shift review items).The demo bar is rendered via a fixed-position #ktc-demo-bar div at the top of the body, styled with the cyan glow button and a yellow centered warning notice. To suppress it in production, remove the entire <div id="ktc-demo-bar">...</div> block and the associated #ktc-demo-bar style block from the <head>.
The Insights page was designed to fill a gap the other two views leave: they tell you what is happening right now, but Insights tells you how the shift performed and whether operational review was completed correctly before handoff.
Shift Review Checklist
The checklist drives the Insights tab badge count. Items have three states:
| State | Visual | Definition |
|---|---|---|
| done | Green checkmark box | Task completed this shift |
| pending | Yellow ellipsis box | In progress or partially complete |
| open | Empty dark box | Not yet started |
To customize the checklist, edit the DEMO.insights.checklist array. Each item requires three fields: title, sub (description), and status ('done' | 'pending' | 'open').
{ title: 'Review all Critical / High alerts', sub: 'Confirm disposition or open a ticket for each finding', status: 'done' // 'done' | 'pending' | 'open' }
Alert Volume by Source
The source breakdown bars use both live data (current session events) and accumulated demo data from DEMO.insights.src_extra. The src_extra object simulates the difference between what is currently active vs. what occurred over the past 7 days. In production, replace src_extra with a historical query result from each platform's events API filtered to the last 7 days.
Detection Trend Chart
The 7-day trend chart uses the updated drawLineChart() function which now accepts color and fill parameters. It uses a distinct palette from the Tickets page trend:
rgba(251,113,133,.9) (red/danger)rgba(251,191,36,.9) (yellow/warn)To change these, pass different hex or rgba strings to drawLineChart() as the 4th–7th arguments (c1, f1, c2, f2 = line color, fill color for each series).
The console is a single self-contained HTML file with no external JavaScript dependencies. All rendering uses vanilla JS and the HTML Canvas API for charts.
Go-Live Checklist
- 01Configure the API object. Populate all base URLs and credentials in the
const APIblock. Verify each integration is reachable from the host where the file will be served. - 02Replace demo return stmts. In each
fetchXxx()function, removereturn DEMO.xxx;and uncomment thefetch()call below it. Keep the demo data object in place — it serves as a fallback and type reference. - 03Address CORS. Test each API call in the browser console. Any cross-origin failure needs a proxy route. Recommended: NGINX reverse proxy with a
/proxy/cw,/proxy/s1, etc. path structure that rewrites to the upstream API with the necessary auth headers injected server-side. - 04Remove the demo bar. Delete the
<div id="ktc-demo-bar">block and the<style id="ktc-home-bar-style">block from the HTML head. These are purely cosmetic for showcase environments. - 05Adjust refresh interval. The default auto-refresh is 5 minutes. Change
setInterval(refreshAll, 300000)at the bottom of the script to your preferred polling frequency. Do not go below 60 seconds on shared API accounts. - 06Tune thresholds. Review the values in the configuration table in Section 05 and align them with your team's SLA definitions before going live.
- 07Secure the file. Since the HTML may contain API credentials or be served to a shared internal host, protect access with HTTP basic auth at the NGINX/IIS layer or restrict by IP/VPN.
- 08Verify canvas sizing. The chart canvases use
canvas.offsetWidthto determine their draw area. If the console is loaded in a viewport narrower than the panel, charts may render at incorrect sizes. Test at 1280px, 1440px, and 1920px widths.
The console is intentionally structured so that adding a new page, a new data source, or a new panel is a predictable operation. The component vocabulary is small and consistent.
Adding a New Tab Page
- 01Add a
<button class="page-tab">to the.page-tabsdiv with a uniqueid="tab-mypage"andonclick="switchPage('mypage')". - 02Add a
<div class="page" id="page-mypage">block below the existing pages. Start it with astat-rowofstat-cardelements. - 03The
switchPage()function handles activation automatically — no changes needed there. - 04Wire a
renderMyPage()function and call it fromrefreshAll()and from theswitchPage()handler for your tab name.
Adding a New Integration
- 01Add credentials to the
const APIobject. - 02Write a
async function fetchMySourceData()following the same pattern as the existing six functions — stub it withreturn DEMO.mySource;first. - 03Add it to the
Promise.allSettled()array inrefreshAll(). - 04Destructure its value in the
thenblock and pass to the appropriate render function. - 05Add a
src-mySourceCSS class to the source pill color system following the existing.src-pillpattern. - 06Update the Tool Coverage grid on the Insights page to include the new source.
Chart Customization
Both chart helpers (drawBarChart and drawLineChart) accept color arrays or individual color parameters. The line chart accepts up to 7 arguments:
drawLineChart( ctx, // CanvasRenderingContext2D labels, // string[] — x-axis labels series1, // number[] — primary line data series2, // number[] — secondary line data c1, // string — series1 line color (default: cyan) f1, // string — series1 fill color (default: cyan translucent) c2, // string — series2 line color (default: green) f2 // string — series2 fill color (default: green translucent) )
Canvas elements size themselves based on offsetWidth at the time they are drawn. If the chart is in a hidden tab when the page first loads, it will have an offsetWidth of 0. This is why switchPage() calls a setTimeout(() => renderTickets(...), 50) to redraw canvases after the page becomes visible. If you add new canvas elements, apply the same pattern in your tab switch handler.
This was a known bug in earlier versions. The current version dynamically sets document.getElementById('kpi-overloaded-card').className in renderTickets(). Verify you are running the current version — the card class should read ok when overloaded count is 0.
Find the last line of the <script> block: setInterval(refreshAll, 300000);. Replace 300000 with your desired interval in milliseconds. Note that API rate limits vary — ConnectWise allows ~1000 requests/hour per credential, and SentinelOne limits are per-tenant. Do not poll faster than the rate limits permit.
Only if all six APIs you are calling have CORS headers that allow browser requests from your serving domain. Most vendor APIs do not. In practice, you will need at minimum a simple pass-through proxy for ConnectWise and SentinelOne. A four-line NGINX location block per API is sufficient for internal deployments.
Add a new .cov-box div inside the .coverage-grid on the Insights page. Use the online, partial, or offline class on the box, and add a matching src-myTool CSS class to the source pill system. Then update the grid column count in the CSS: .coverage-grid { grid-template-columns: repeat(6, 1fr); } if adding a sixth tool.
The renderSecurity() function groups FortiGate events by a composite key of client + '|' + type. This means multiple events of the same type from the same client are collapsed into one row with an incrementing count. The row inherits the severity from the first event in that group. If your FortiGate events contain subtly different type names for the same event (e.g. case variations), normalize them before returning from fetchFortiEvents().
renderInsights() depends on window._lastSec which is set by renderSecurity(). Both are called in refreshAll() in sequence. If Insights is rendering before Security has populated _lastSec, it will show zero values. This should not occur under normal conditions because both calls are in the same try block. If it does occur, check whether a fetch function is throwing an uncaught exception that exits the try block early.