KNOWLEDGE BASE // OPSCORE MSP CONSOLE
v1.0 · 2026
KNOWLEDGE BASE // OPSCORE MSP CONSOLE
OpsCore MSP Console
A single-pane-of-glass operations dashboard for MSP environments. Aggregates live data from ConnectWise, SentinelOne, Huntress, FortiGate, Cisco Umbrella, and ESET into three purpose-driven views: Ticket Workload, Security Operations, and SOC Insights.
3 Console Pages
6 Integrations
Self-Contained HTML · No Build Required
01 Overview

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.

Design Philosophy
Every stat card, panel, and table on all three pages uses a unified component vocabulary — the same stat-card, panel-card, and panel-bar patterns repeat throughout. This means any engineer can read any page without re-learning a new layout.
Primary Use Cases

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.

Who Uses It

L1–L3 engineers on active shifts, SOC analysts monitoring security event queues, team leads reviewing engineer workload balance, and managers assessing shift performance.

02 Console Pages

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

🎫 Ticket & Workload
ConnectWise

The ticket view is the default landing page. It answers the core shift question: where is the work piling up?

ComponentPurposeThreshold Logic
Open TicketsTotal tickets in queue from ConnectWiseInfo color (neutral)
Aging 24h+Tickets open longer than 24 hoursWarn at >2, Crit at >5
Closed TodayResolved count from DEMO.closedToday (replace with live CW query)OK color (positive)
Avg AgeMean ticket age in hours across the queueInfo color
Overloaded Eng.Engineers at or above 10 open ticketsOK at 0, Warn at 1+, Crit at 3+
Engineer WorkloadPer-engineer bar chart — OK <8, Warn 8–11, Crit 12+Dynamic bar fill color
Aging Distribution4-bucket grid (0–2h, 2–8h, 8–24h, 24h+) with inline bar chartBucket color matches severity
Tickets by ClientTop 10 clients sorted by open ticket countBar width proportional to max
Throughput (7-day)Daily opened vs. closed countsVisual only — no threshold
Volume Trend (30-day)Line chart comparing opened and closed over 30 daysVisual only

Page 2 — Security Operations

🛡 Security Operations
Multi-Source

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?

ComponentData SourceNotes
Security KPI RowAll sourcesCritical Active, High Active, Total Alerts, Affected Clients, Sources Active (of 5)
Alert Severity BreakdownAll sources4-cell grid: Crit / High / Med / Low with contextual sub-labels
Active Endpoint ThreatsSentinelOne, Huntress, ESETFull-width scrollable table, max 20 rows, sorted newest first
Firewall Security EventsFortiGateEvents grouped and counted by client + type. 2/3 width in layout.
Top Affected ClientsAll sourcesBar chart weighted: endpoint threats count 1.0, forti events count 0.5
Security Event TimelineAll sourcesFull-width, chronological feed. Umbrella DNS blocks included. Max 15 events.

Page 3 — SOC Insights

📊 SOC Insights
Shift Review

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.

ComponentPurpose
Est. MTTRMean time to respond, sourced from DEMO.insights.mttr_min — replace with calculated value from ticket timestamps
Weekly DetectionsSum of all threat and firewall events across the 7-day window
Unresolved 24h+Re-uses ticket aging data — tickets open past SLA window
Checklist OpenCount of shift review items not yet marked done — drives tab badge color
Alert Volume by SourceHorizontal 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 Status5-cell grid showing Online / Partial / Offline status per integration
SOC Shift Review ChecklistOrdered list of shift tasks with Done / In Progress / Open states. Drives the tab badge count.
03 KPIs & Metrics

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.

Important
The Overloaded Engineers card is the only stat card on the Tickets page with dynamic class assignment. All others are static severity colors. If you add new logic, follow the pattern in 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.

ClassColorMeaningThreshold Examples
.ok --ok #34d399 Healthy / resolved0 overloaded engineers, all tools online
.info --accent #38bdf8Neutral / informationalOpen ticket count, avg age
.warn --warn #fbbf24Elevated / needs attentionAging 2–24h, medium severity alerts
.fail --danger #fb7185High severityHigh threats, 8–24h aging
.crit --crit #ff4d4f Critical / immediate actionCritical 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.

Tickets tabShows open count. Badge class: ok if aging24 ≤ 2, warn if ≤ 5, crit if > 5.
Security tabShows total alerts. Badge class: crit if any critical, warn if any high, else ok.
Insights tabShows open checklist item count. Class: warn if > 3 open, info if any open, ok if complete.
04 Integrations

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.

Proxy Note
Several of these APIs do not support direct browser-to-API calls due to CORS restrictions. You will need a lightweight server-side proxy or a CORS-enabled gateway. The console is designed to be dropped behind any web server — a simple Express or NGINX proxy layer is sufficient.
PlatformFunctionAuth MethodKey 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.

EXPECTED DATA SHAPES // JavaScript
// 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'
}
05 Configuration

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.

CONFIG OBJECT // JavaScript · line ~605
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',
}
Security Warning
Do not store production API keys directly in a publicly accessible HTML file. These credentials should be injected at runtime via environment variables through a proxy layer, or the console should be served from an authenticated internal host only.

Tunable Thresholds

Workload severity thresholds are hardcoded in renderTickets(). These are the values to adjust per your team's SLA definitions.

VariableDefaultLocationDescription
overloaded threshold≥ 10 ticketsrenderTickets()Engineer considered overloaded
Bar fill .warn≥ 8 ticketsrenderTickets()Eng bar turns yellow
Bar fill .crit≥ 12 ticketsrenderTickets()Eng bar turns red
Aging .warn tab badge> 2 aging ticketsrenderTickets()Tickets tab badge turns yellow
Aging .crit tab badge> 5 aging ticketsrenderTickets()Tickets tab badge turns red
Auto-refresh interval300,000 ms (5 min)Bottom of <script>setInterval(refreshAll, 300000)
Threat table max rows20renderSecurity()allThreats.slice(0,20)
Timeline max events15renderSecurity()allEvents.slice(0,15)
Firewall table max rows15renderSecurity()fwRows.slice(0,15)
06 API Reference

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.

ConnectWise — GET /service/tickets
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();
SentinelOne — GET /threats
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
Huntress — GET /incidents
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
FortiManager — POST /jsonrpc
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 || [];
Cisco Umbrella — GET /security-activity
// 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 || [];
ESET PROTECT — POST /detections/list
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 || [];
07 Demo Mode

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 Data Shape
The demo data is designed to match the exact shape expected by each render function. This means you can use the demo objects as integration test fixtures when writing your production fetch functions.

Demo Object Structure

DEMO.closedTodayInteger. Replace with a live CW query: GET /service/tickets?conditions=closedDate>[today]
DEMO.ticketsArray of 87 synthetic CW ticket objects generated on page load with random ages up to 48 hours.
DEMO.throughput7-element array of {day, opened, closed} for the throughput chart.
DEMO.trend3030-day rolling ticket volume with {labels, opened, closed} arrays generated at load time.
DEMO.s1_threats3 synthetic SentinelOne threat objects including a ransomware and a malicious Trojan detection.
DEMO.huntress_incidents3 incidents: Persistent Footholds (critical), Suspicious Process (high), Rogue User Account (high).
DEMO.forti_events10 FortiGate events spanning Nmap IPS alerts, Emotet C2 block, VPN auth failures, IOC outbound connections, and a CVE exploit attempt.
DEMO.umbrella_events3 DNS blocks: malware C2, phishing login, and cryptominer pool.
DEMO.eset_detections2 detections: Win32 high severity and JS adware low severity.
DEMO.insightsInsights-specific object containing: 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>.

08 SOC Insights Page

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:

StateVisualDefinition
doneGreen checkmark boxTask completed this shift
pendingYellow ellipsis boxIn progress or partially complete
openEmpty dark boxNot 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').

Checklist Item Shape
{
  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:

Series 1Endpoint threats — rgba(251,113,133,.9) (red/danger)
Series 2Firewall events — 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).

09 Deployment Notes

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

  1. 01
    Configure the API object. Populate all base URLs and credentials in the const API block. Verify each integration is reachable from the host where the file will be served.
  2. 02
    Replace demo return stmts. In each fetchXxx() function, remove return DEMO.xxx; and uncomment the fetch() call below it. Keep the demo data object in place — it serves as a fallback and type reference.
  3. 03
    Address 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.
  4. 04
    Remove 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.
  5. 05
    Adjust 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.
  6. 06
    Tune thresholds. Review the values in the configuration table in Section 05 and align them with your team's SLA definitions before going live.
  7. 07
    Secure 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.
  8. 08
    Verify canvas sizing. The chart canvases use canvas.offsetWidth to 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.
Do Not Do This
Do not serve this file from a public-facing web server without authentication. The API credentials in the CONFIG object will be visible in the browser source. Always serve from an authenticated internal host, VPN-gated URL, or proxy the credentials server-side.
10 Extending the Console

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

  1. 01
    Add a <button class="page-tab"> to the .page-tabs div with a unique id="tab-mypage" and onclick="switchPage('mypage')".
  2. 02
    Add a <div class="page" id="page-mypage"> block below the existing pages. Start it with a stat-row of stat-card elements.
  3. 03
    The switchPage() function handles activation automatically — no changes needed there.
  4. 04
    Wire a renderMyPage() function and call it from refreshAll() and from the switchPage() handler for your tab name.

Adding a New Integration

  1. 01
    Add credentials to the const API object.
  2. 02
    Write a async function fetchMySourceData() following the same pattern as the existing six functions — stub it with return DEMO.mySource; first.
  3. 03
    Add it to the Promise.allSettled() array in refreshAll().
  4. 04
    Destructure its value in the then block and pass to the appropriate render function.
  5. 05
    Add a src-mySource CSS class to the source pill color system following the existing .src-pill pattern.
  6. 06
    Update 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() Signature
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)
)
11 FAQ
Q The charts render at the wrong size or are blank.

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.

Q The Overloaded Engineers card is always yellow even when no engineers are overloaded.

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.

Q How do I change the refresh interval?

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.

Q Can I run this without a proxy for CORS?

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.

Q How do I add a new tool to the Tool Coverage grid on the Insights page?

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.

Q FortiGate events are grouped — how does the grouping work?

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().

Q The Insights page shows wrong numbers right after initial load.

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.