Implement all vendor integrations, WAN graphs, and FortiGate health panel

- Wire up 26 vendor providers: Atlassian Statuspage API, Status.io, Instatus,
  AWS RSS feeds, Apple/Google JSON feeds, M365 Graph API, and synthetic checks
- Add 11 new providers: AWS, Cloudflare, SmartPass, School Dismissal Manager,
  SherpaDesk, Classkick, ClassDojo, Savvas, Study Island, Promethean, RAZ-Kids
- Rename Local Infrastructure → Internet (TCP check to 8.8.8.8:53)
- Add WAN throughput graph section: dual-link canvas graphs (Crown Castle +
  Comcast) polling FortiGate REST API every 30s with 30-min rolling history
- Add FortiGate health card: uptime, CPU %, memory % from FortiOS API
- Add /api/throughput and /api/fortigate-health endpoints
- Add README with setup instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Klein
2026-02-20 13:46:13 -05:00
parent 7d8cde8f92
commit 51eb3bf7c8
39 changed files with 1776 additions and 59 deletions
+3
View File
@@ -5,6 +5,9 @@ bin/nssm/*.exe
# Node # Node
backend/node_modules/ backend/node_modules/
# Environment variables (contains secrets)
backend/.env
# OS files # OS files
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
+73 -25
View File
@@ -9,31 +9,71 @@ A web-based status feed aggregator for a K-12 school district IT department. Pro
| Vendor | Type | Status Source | | Vendor | Type | Status Source |
|---|---|---| |---|---|---|
| Microsoft 365 | Productivity suite | Service Communications API (Graph API) | | Microsoft 365 | Productivity suite | Service Communications API (Graph API) |
| SpamTitan | Email security | TBD — likely status page scrape or synthetic check | | SpamTitan | Email security | Synthetic check — district appliance (mailportal.nhsd.net) |
| PowerSchool | Student Information System | TBD — status page scrape | | PowerSchool | Student Information System | Atlassian Statuspage API (status.powerschool.com) |
| Classlink | SSO / Identity | TBD — status page or API | | Classlink | SSO / Identity | Atlassian Statuspage API (status.classlink.com) |
| Apple | Device ecosystem | Apple System Status page (JSON feed) | | Apple | Device ecosystem | Apple System Status JSON feed |
| DRC | Assessment / Testing | TBD — status page scrape | | DRC | Assessment / Testing | Synthetic check — PA INSIGHT portal (status page is JS-rendered Angular app) |
| FinalSite | School website CMS | TBD — status page scrape or synthetic check | | FinalSite | School website CMS | Atlassian Statuspage API (status.finalsite.com) |
| Google Workspace | Productivity suite | Google Workspace Status Dashboard (JSON feed) | | Google Workspace | Productivity suite | Google Workspace Status Dashboard JSON feed |
| Follett | Library management | TBD — status page scrape or synthetic check | | Follett | Library management | Synthetic check — district Destiny instance (northhills.follettdestiny.com) |
| EdInsight | Data analytics (Harris Education Solutions) | TBD — status page or synthetic check | | EdInsight | Data analytics (Harris Education Solutions) | Synthetic check — no public status page found |
| Raptor | Visitor management | TBD — status page scrape | | Raptor | Visitor management | Status.io API (status.raptortech.com) |
| SchoolMessenger | Communication platform | TBD — status page scrape | | SchoolMessenger | Communication platform | Atlassian Statuspage API (PowerSchool status page, SchoolMessenger components filtered) |
| McGraw Hill | Curriculum / assessment | status.mcgrawhill.com (JS-rendered status page) | | McGraw Hill | Curriculum / assessment | Synthetic check — ConnectED portal (status.mcgrawhill.com is JS-rendered) |
| Fortinet | Network security | TBD — FortiCloud API or status page | | Fortinet | Network security | Atlassian Statuspage API (FortiGate Cloud — status.fortigate.forticloud.com) |
| Local Infrastructure | On-prem hardware | Direct monitoring (SNMP, API, or synthetic checks) | | SherpaDesk | Helpdesk / ticketing | Synthetic check — app portal (no public status API) |
| Study Island | Instructional practice | Atlassian Statuspage API (Edmentum — status.edmentum.com, Study Island component filtered) |
| Classkick | Classroom assessment | Synthetic check — app portal (StatusCast API requires auth token) |
| ClassDojo | Classroom communication | Synthetic check — app portal (no machine-readable status feed) |
| Savvas K-12 | Curriculum / learning platform | Atlassian Statuspage API (status.savvas.com) |
| Amazon AWS | Cloud infrastructure | RSS feed polling (EC2 us-east-1/2, S3, CloudFront, Route 53) |
| Cloudflare | CDN / DNS | Atlassian Statuspage API (cloudflarestatus.com) |
| SmartPass | Hall pass management | Instatus JSON API (smartpass.instatus.com) |
| School Dismissal Manager | Dismissal management | Synthetic check — admin portal (status page redirects to StatusGator) |
| Promethean | Interactive displays | Synthetic check — prometheanworld.com (panels used in standalone mode; no cloud features) |
| RAZ-Kids | Reading platform | Synthetic check — Learning A-Z login portal (browser UA required; behind Cloudflare bot detection) |
| Internet | Connectivity | TCP check to 8.8.8.8:53 |
Note: Exchange Online is intentionally excluded — it is a component of M365 Service Health and would be redundant. Note: Exchange Online is intentionally excluded — it is a component of M365 Service Health and would be redundant.
New vendors should be added incrementally, not speculatively. New vendors should be added incrementally, not speculatively.
## FortiGate Dashboard Features
In addition to the vendor status cards, the dashboard includes two FortiGate-specific panels that sit above the vendor grid:
### WAN Throughput Graph
- Two side-by-side canvas graphs, one per WAN link (Crown Castle on port25, Comcast on port8)
- Polls `GET /api/v2/monitor/system/interface` on the FortiGate every 30 seconds
- Computes Mbps from cumulative byte counter deltas
- Stores a 30-minute rolling history (60 points at 30s intervals)
- Frontend fetches `/api/throughput` and renders using HTML5 Canvas
### FortiGate Health Card
- Shows hostname, firmware version, uptime, CPU %, and memory %
- Polls `GET /api/v2/monitor/system/status` and `GET /api/v2/monitor/system/resource/usage`
- Updates every 2 minutes
- CPU/memory values turn amber at ≥75% and red at ≥90%
- Frontend fetches `/api/fortigate-health`
Both panels use the built-in Node.js `https` module with `rejectUnauthorized: false` to handle the FortiGate's self-signed management certificate.
## Credentials / Environment
`backend/.env` is gitignored and contains:
- `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` — Microsoft 365 Graph API
- `FORTIGATE_HOST`, `FORTIGATE_API_TOKEN` — FortiGate REST API
- `FORTIGATE_WAN1_INTERFACE`, `FORTIGATE_WAN1_LABEL` — Crown Castle WAN (port25)
- `FORTIGATE_WAN2_INTERFACE`, `FORTIGATE_WAN2_LABEL` — Comcast WAN (port8)
## Hosting ## Hosting
- **Web server**: Caddy - **Web server**: Caddy
- **URL**: https://status.nhsd.net:8443 (port 8443 to avoid conflict with existing Caddy instance on this machine) - **URL**: https://status.nhsd.net:8443 (port 8443 to avoid conflict with existing Caddy instance on this machine)
- **Access**: Local network only (DNS A record points to the host machine) - **Access**: Local network only (DNS A record points to the host machine)
- **TLS**: Caddy internal TLS (self-signed). IT staff only — browser cert warnings are acceptable. - **TLS**: Caddy internal TLS (self-signed). IT staff only — browser cert warnings are acceptable.
- **Important**: There is a separate, pre-existing Caddy instance already running on this machine (unrelated to this project). This project runs its own dedicated Caddy instance using `config/Caddyfile`. Do not confuse the two — always start/stop the dashboard Caddy explicitly with that Caddyfile.
## Architecture ## Architecture
@@ -43,32 +83,40 @@ New vendors should be added incrementally, not speculatively.
- **Services**: NSSM runs both Caddy and the Node backend as Windows services. - **Services**: NSSM runs both Caddy and the Node backend as Windows services.
- **Data flow**: Backend polls vendors → caches to local store → frontend fetches from backend API → auto-refreshes on interval. - **Data flow**: Backend polls vendors → caches to local store → frontend fetches from backend API → auto-refreshes on interval.
## API Endpoints
| Endpoint | Description | Poll interval |
|---|---|---|
| `GET /api/status` | All vendor status cards | 2 minutes |
| `GET /api/throughput` | WAN throughput history (60 points) | 30 seconds |
| `GET /api/fortigate-health` | FortiGate system health | 2 minutes |
| `GET /api/health` | Backend liveness check | On demand |
## Directory Structure ## Directory Structure
``` ```
infrastructure-monitoring-dashboard/ infrastructure-monitoring-dashboard/
├── CLAUDE.md ├── CLAUDE.md
├── README.md
├── .gitignore ├── .gitignore
├── bin/ ├── bin/
│ ├── caddy/ # Drop caddy.exe here (git-ignored) │ ├── caddy/ # Drop caddy.exe here (git-ignored)
│ └── nssm/ # Drop nssm.exe here (git-ignored) │ └── nssm/ # Drop nssm.exe here (git-ignored)
├── config/ ├── config/
│ └── Caddyfile # Caddy server configuration │ └── Caddyfile # Caddy server configuration
├── frontend/ ├── frontend/
│ ├── index.html │ ├── index.html
│ ├── css/ │ ├── css/style.css
│ └── js/ │ └── js/app.js
├── backend/ ├── backend/
│ ├── package.json │ ├── package.json
│ ├── server.js │ ├── server.js
── providers/ # One module per vendor ── fortigate-throughput.js # WAN throughput poller
└── scripts/ # NSSM service install/uninstall helpers │ ├── fortigate-health.js # FortiGate system health poller
│ └── providers/ # One module per vendor
└── scripts/ # NSSM service install/uninstall helpers
``` ```
## Development Approach
Frontend-first with mock data. Build the dashboard UI and layout with realistic fake data, then replace mocks with real vendor integrations one at a time.
## Design Principles ## Design Principles
- Keep it simple. This is a status board, not a monitoring platform. - Keep it simple. This is a status board, not a monitoring platform.
+111
View File
@@ -0,0 +1,111 @@
# NHSD Service Status Dashboard
A web-based status feed aggregator for the North Hills School District IT department. Provides a single-pane-of-glass view of vendor service health across 26 platforms, plus live WAN throughput graphs and FortiGate health monitoring.
## Features
- **Vendor status cards** — real-time health for all district platforms, color-coded and sortable by severity
- **WAN throughput graphs** — live RX/TX graphs for Crown Castle and Comcast WAN links, pulled from the FortiGate API
- **FortiGate health panel** — hostname, firmware version, uptime, CPU, and memory utilization
- **Auto-refresh** — vendor status refreshes every 2 minutes; throughput every 30 seconds
- **Glanceable design** — optimized for a wall-mounted monitor; status obvious from across the room
## Prerequisites
- **Node.js** v18 or later
- **Caddy** v2 — place `caddy.exe` in `bin/caddy/`
- **NSSM** (optional, for running as Windows services) — place `nssm.exe` in `bin/nssm/`
## Setup
### 1. Install backend dependencies
```bash
cd backend
npm install
```
### 2. Configure credentials
Copy the template and fill in your values:
```bash
cp backend/.env.example backend/.env
```
Edit `backend/.env`:
```env
# Microsoft 365 — Azure AD app registration
AZURE_TENANT_ID=your-tenant-id
AZURE_CLIENT_ID=your-client-id
AZURE_CLIENT_SECRET=your-client-secret
# FortiGate — WAN throughput and system health
FORTIGATE_HOST=10.1.20.1
FORTIGATE_API_TOKEN=your-api-token
FORTIGATE_WAN1_INTERFACE=port25
FORTIGATE_WAN1_LABEL=Crown Castle
FORTIGATE_WAN2_INTERFACE=port8
FORTIGATE_WAN2_LABEL=Comcast
```
**Microsoft 365**: Create an app registration in Azure AD with `ServiceHealth.Read.All` permission (application, not delegated).
**FortiGate**: Create a read-only API token under **System → Administrators → Create New → REST API Admin**. Only Monitor access is required — no configuration access needed.
### 3. Start the backend
```bash
cd backend
npm start
```
The backend listens on `http://localhost:3000`.
### 4. Start Caddy
```bash
bin/caddy/caddy.exe run --config config/Caddyfile
```
The dashboard is then available at `https://status.nhsd.net:8443`.
> **Note:** There is a separate, pre-existing Caddy instance on this machine. Always use the `--config config/Caddyfile` flag to target the dashboard Caddy specifically.
## Running as Windows Services (NSSM)
Use the helper scripts in `scripts/` to install the backend and Caddy as NSSM services so they start automatically with Windows.
## API Endpoints
| Endpoint | Description |
|---|---|
| `GET /api/status` | All vendor status (array) |
| `GET /api/throughput` | WAN throughput history (array of 30-min data points) |
| `GET /api/fortigate-health` | FortiGate hostname, version, uptime, CPU, memory |
| `GET /api/health` | Backend liveness check |
## Adding a New Vendor
1. Create `backend/providers/<vendor>.js` exporting `name`, `url`, and `checkStatus()`
2. Add it to `backend/providers/index.js`
See any existing provider for the expected return shape:
```js
export const name = "Vendor Name";
export const url = "https://status.vendor.com/";
export async function checkStatus() {
// ...
return {
name,
status, // "operational" | "degraded" | "outage" | "unknown"
message, // short human-readable description
lastUpdated: new Date().toISOString(),
};
}
```
If a vendor check throws, the backend catches it and returns `status: "unknown"` — the dashboard degrades gracefully.
+97
View File
@@ -0,0 +1,97 @@
import https from "https";
const HOST = process.env.FORTIGATE_HOST;
const TOKEN = process.env.FORTIGATE_API_TOKEN;
const POLL_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
let cached = null;
function fetchJson(urlStr, headers) {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const req = https.request(
{
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: "GET",
headers,
rejectUnauthorized: false,
},
(res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode}`));
} else {
try { resolve(JSON.parse(body)); }
catch { reject(new Error("Invalid JSON in response")); }
}
});
}
);
req.on("error", reject);
req.end();
});
}
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
async function poll() {
const headers = { Authorization: `Bearer ${TOKEN}` };
const base = `https://${HOST}`;
const [statusData, resourceData] = await Promise.all([
fetchJson(`${base}/api/v2/monitor/system/status`, headers),
fetchJson(`${base}/api/v2/monitor/system/resource/usage`, headers),
]);
const r = statusData.results ?? {};
const nowSec = Math.floor(Date.now() / 1000);
const uptimeSec = r.utc_last_reboot
? nowSec - r.utc_last_reboot
: null;
const cpuPct = resourceData.results?.cpu?.[0]?.current ?? null;
const memPct = resourceData.results?.mem?.[0]?.current ?? null;
cached = {
hostname: r.hostname ?? "FortiGate",
model: r.model_number ?? "1800F",
version: statusData.version ?? "—",
uptimeSeconds: uptimeSec,
uptimeLabel: uptimeSec !== null ? formatUptime(uptimeSec) : "—",
cpuPct,
memPct,
lastUpdated: new Date().toISOString(),
};
}
export function getHealth() {
return cached;
}
export function startPolling() {
if (!HOST || !TOKEN) {
console.warn("[fg-health] FORTIGATE_HOST or FORTIGATE_API_TOKEN not set — health polling disabled.");
return;
}
poll().catch((err) =>
console.error("[fg-health] Initial poll failed:", err.message)
);
setInterval(
() => poll().catch((err) =>
console.error("[fg-health] Poll failed:", err.message)
),
POLL_INTERVAL_MS
);
}
+118
View File
@@ -0,0 +1,118 @@
import https from "https";
const HOST = process.env.FORTIGATE_HOST;
const TOKEN = process.env.FORTIGATE_API_TOKEN;
const WAN1_IF = process.env.FORTIGATE_WAN1_INTERFACE;
const WAN1_LABEL = process.env.FORTIGATE_WAN1_LABEL ?? WAN1_IF;
const WAN2_IF = process.env.FORTIGATE_WAN2_INTERFACE;
const WAN2_LABEL = process.env.FORTIGATE_WAN2_LABEL ?? WAN2_IF;
const POLL_INTERVAL_MS = 30_000;
const HISTORY_POINTS = 60; // 30 minutes at 30s intervals
let history = [];
let prevReading = null;
// Use the https module directly so we can disable rejectUnauthorized —
// FortiGate management interface uses a self-signed cert.
function fetchJson(urlStr, headers) {
return new Promise((resolve, reject) => {
const url = new URL(urlStr);
const req = https.request(
{
hostname: url.hostname,
port: url.port || 443,
path: url.pathname + url.search,
method: "GET",
headers,
rejectUnauthorized: false,
},
(res) => {
let body = "";
res.on("data", (chunk) => (body += chunk));
res.on("end", () => {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(new Error(`HTTP ${res.statusCode}`));
} else {
try { resolve(JSON.parse(body)); }
catch (e) { reject(new Error("Invalid JSON in response")); }
}
});
}
);
req.on("error", reject);
req.end();
});
}
function getIfBytes(data, ifname) {
const results = data?.results;
if (!results) return null;
// FortiOS may return results as an object keyed by interface name or as an array
const iface = Array.isArray(results)
? results.find((i) => i.name === ifname)
: results[ifname];
if (!iface) return null;
return { rx: iface.rx_bytes ?? 0, tx: iface.tx_bytes ?? 0 };
}
async function poll() {
const data = await fetchJson(
`https://${HOST}/api/v2/monitor/system/interface`,
{ Authorization: `Bearer ${TOKEN}` }
);
const now = Date.now();
const wan1 = getIfBytes(data, WAN1_IF);
const wan2 = getIfBytes(data, WAN2_IF);
if (!wan1 || !wan2) {
console.warn("[throughput] Interface not found in FortiGate response — check interface names in .env");
return;
}
if (prevReading) {
const elapsed = (now - prevReading.timestamp) / 1000; // seconds
const mbps = (curr, prev) =>
Math.max(0, ((curr - prev) * 8) / elapsed / 1_000_000);
const point = {
timestamp: now,
wan1: {
label: WAN1_LABEL,
rxMbps: mbps(wan1.rx, prevReading.wan1.rx),
txMbps: mbps(wan1.tx, prevReading.wan1.tx),
},
wan2: {
label: WAN2_LABEL,
rxMbps: mbps(wan2.rx, prevReading.wan2.rx),
txMbps: mbps(wan2.tx, prevReading.wan2.tx),
},
};
history.push(point);
if (history.length > HISTORY_POINTS) history.shift();
}
prevReading = { timestamp: now, wan1, wan2 };
}
export function getHistory() {
return history;
}
export function startPolling() {
if (!HOST || !TOKEN) {
console.warn("[throughput] FORTIGATE_HOST or FORTIGATE_API_TOKEN not set — throughput polling disabled.");
return;
}
poll().catch((err) =>
console.error("[throughput] Initial poll failed:", err.message)
);
setInterval(
() => poll().catch((err) =>
console.error("[throughput] Poll failed:", err.message)
),
POLL_INTERVAL_MS
);
}
+13
View File
@@ -9,6 +9,7 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2" "express": "^4.21.2"
} }
}, },
@@ -174,6 +175,18 @@
"npm": "1.2.8000 || >= 1.4.16" "npm": "1.2.8000 || >= 1.4.16"
} }
}, },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+1
View File
@@ -8,6 +8,7 @@
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2" "express": "^4.21.2"
} }
} }
+77
View File
@@ -0,0 +1,77 @@
export const name = "Amazon AWS";
export const url = "https://health.aws.amazon.com/health/status";
// Core services in both Pittsburgh-adjacent regions + global services.
// Expand this list if you discover specific services your vendors rely on.
const FEEDS = [
{ url: "https://status.aws.amazon.com/rss/ec2-us-east-1.rss", label: "EC2 (N. Virginia)" },
{ url: "https://status.aws.amazon.com/rss/ec2-us-east-2.rss", label: "EC2 (Ohio)" },
{ url: "https://status.aws.amazon.com/rss/s3-us-east-1.rss", label: "S3 (N. Virginia)" },
{ url: "https://status.aws.amazon.com/rss/cloudfront.rss", label: "CloudFront" },
{ url: "https://status.aws.amazon.com/rss/route53.rss", label: "Route 53" },
];
const SEVERITY_ORDER = ["operational", "degraded", "outage"];
function worstStatus(a, b) {
return SEVERITY_ORDER.indexOf(a) >= SEVERITY_ORDER.indexOf(b) ? a : b;
}
// Extract the text of the first <title> inside the first <item>.
// Returns null if there are no items (clean feed = all clear).
function parseFirstItemTitle(xml) {
const itemMatch = xml.match(/<item[\s>][\s\S]*?<\/item>/i);
if (!itemMatch) return null;
const titleMatch = itemMatch[0].match(/<title>([\s\S]*?)<\/title>/i);
return titleMatch ? titleMatch[1].trim() : null;
}
function titleToStatus(title) {
if (!title) return "operational"; // no items = clean feed
const t = title.toLowerCase();
if (t.includes("operating normally") || t.includes("informational")) return "operational";
if (t.includes("performance issues") || t.includes("degraded")) return "degraded";
if (t.includes("service disruption") || t.includes("disruption")) return "outage";
return "degraded"; // unknown incident title — assume degraded
}
async function checkFeed({ url, label }) {
const res = await fetch(url);
if (!res.ok) throw new Error(`${label}: HTTP ${res.status}`);
const xml = await res.text();
const title = parseFirstItemTitle(xml);
const status = titleToStatus(title);
return { label, status, title };
}
export async function checkStatus() {
const results = await Promise.allSettled(FEEDS.map(checkFeed));
let overall = "operational";
const issues = [];
for (const result of results) {
if (result.status === "rejected") {
overall = worstStatus(overall, "degraded");
issues.push(`Check failed: ${result.reason?.message ?? "unknown error"}`);
continue;
}
const { label, status, title } = result.value;
overall = worstStatus(overall, status);
if (status !== "operational") {
issues.push(`${label}: ${title ?? "unknown issue"}`);
}
}
const message =
issues.length > 0 ? issues.join(" | ") : "All monitored services operating normally.";
return {
name,
status: overall,
message,
lastUpdated: new Date().toISOString(),
};
}
+50 -2
View File
@@ -1,10 +1,58 @@
export const name = "Apple"; export const name = "Apple";
export const url = "https://www.apple.com/support/systemstatus/";
const STATUS_URL =
"https://www.apple.com/support/systemstatus/data/system_status_en_US.js";
// eventStatus values that mean the event is no longer active
const RESOLVED_STATUSES = new Set(["resolved", "completed"]);
// statusType → dashboard status (Performance issues = degraded, outages = outage)
function mapStatusType(statusType) {
const t = (statusType ?? "").toLowerCase();
if (t === "outage") return "outage";
return "degraded"; // Performance, Maintenance, or anything else
}
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`Apple status request failed (${res.status})`);
}
const data = await res.json();
const services = data.services ?? [];
// Collect services with active (unresolved) events
const affected = [];
let overall = "operational";
for (const svc of services) {
const activeEvents = (svc.events ?? []).filter(
(e) => !RESOLVED_STATUSES.has((e.eventStatus ?? "").toLowerCase())
);
if (activeEvents.length === 0) continue;
for (const event of activeEvents) {
const mapped = mapStatusType(event.statusType);
if (mapped === "outage" || overall === "operational") {
overall = mapped;
}
affected.push(`${svc.serviceName}: ${event.message ?? event.statusType}`);
}
}
const message =
affected.length > 0
? affected.join(" | ")
: "All services operating normally.";
return { return {
name, name,
status: "operational", status: overall,
message: "All services running normally.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+17
View File
@@ -0,0 +1,17 @@
export const name = "ClassDojo";
export const url = "https://status.classdojo.com/";
// No public JSON status API — status page is a manually-updated static HTML file.
// Synthetic check against the main app; link to status page for incident details.
const PROBE_URL = "https://www.classdojo.com/";
export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return {
name,
status: "operational",
message: `App portal responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(),
};
}
+17
View File
@@ -0,0 +1,17 @@
export const name = "Classkick";
export const url = "https://classkick.statuscast.com/";
// No public JSON status API — StatusCast requires a Bearer token.
// Synthetic check against the app; link to StatusCast page for incident details.
const PROBE_URL = "https://app.classkick.com/";
export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return {
name,
status: "operational",
message: `App portal responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(),
};
}
+32 -2
View File
@@ -1,10 +1,40 @@
export const name = "Classlink"; export const name = "Classlink";
export const url = "https://status.classlink.com/";
const STATUS_URL = "https://status.classlink.com/api/v2/summary.json";
const STATUS_MAP = {
none: "operational",
minor: "degraded",
major: "degraded",
critical: "outage",
};
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`Classlink status request failed (${res.status})`);
}
const data = await res.json();
const indicator = data.status?.indicator ?? "unknown";
const status = STATUS_MAP[indicator] ?? "unknown";
const incidents = data.incidents ?? [];
const activeIncidents = incidents.filter((i) => i.status !== "resolved");
let message;
if (activeIncidents.length > 0) {
message = activeIncidents.map((i) => i.name).join("; ");
} else {
message = data.status?.description ?? "Status unavailable.";
}
return { return {
name, name,
status: "operational", status,
message: "All services running normally.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+42
View File
@@ -0,0 +1,42 @@
export const name = "Cloudflare";
export const url = "https://www.cloudflarestatus.com/";
const STATUS_URL = "https://www.cloudflarestatus.com/api/v2/summary.json";
// Atlassian Statuspage indicator → dashboard status
const STATUS_MAP = {
none: "operational",
minor: "degraded",
major: "degraded",
critical: "outage",
};
export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`Cloudflare status request failed (${res.status})`);
}
const data = await res.json();
const indicator = data.status?.indicator ?? "unknown";
const status = STATUS_MAP[indicator] ?? "unknown";
// Build message from active incidents, fall back to Statuspage description
const incidents = data.incidents ?? [];
const activeIncidents = incidents.filter((i) => i.status !== "resolved");
let message;
if (activeIncidents.length > 0) {
message = activeIncidents.map((i) => i.name).join("; ");
} else {
message = data.status?.description ?? "Status unavailable.";
}
return {
name,
status,
message,
lastUpdated: new Date().toISOString(),
};
}
+9 -2
View File
@@ -1,10 +1,17 @@
export const name = "DRC"; export const name = "DRC";
export const url = "https://wbte.drcedirect.com/PA/portals/pa";
// status.drcedirect.com is a JS-rendered Angular app with no accessible API.
// Synthetic check against the PA INSIGHT portal instead.
const PROBE_URL = "https://wbte.drcedirect.com/PA/portals/pa";
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return { return {
name, name,
status: "outage", status: "operational",
message: "INSIGHT portal is currently unavailable. DRC is aware of the issue.", message: `PA INSIGHT portal responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+9 -1
View File
@@ -1,10 +1,18 @@
export const name = "EdInsight"; export const name = "EdInsight";
export const url = "https://myedinsight.com";
// TODO: No public status page found for Harris Education Solutions. Synthetic
// check only — investigate whether Harris offers a status API or webhook feed.
const PROBE_URL = "https://myedinsight.com";
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return { return {
name, name,
status: "operational", status: "operational",
message: "Data analytics platform operational.", message: `Portal responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+32 -2
View File
@@ -1,10 +1,40 @@
export const name = "FinalSite"; export const name = "FinalSite";
export const url = "https://status.finalsite.com/";
const STATUS_URL = "https://status.finalsite.com/api/v2/summary.json";
const STATUS_MAP = {
none: "operational",
minor: "degraded",
major: "degraded",
critical: "outage",
};
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`FinalSite status request failed (${res.status})`);
}
const data = await res.json();
const indicator = data.status?.indicator ?? "unknown";
const status = STATUS_MAP[indicator] ?? "unknown";
const incidents = data.incidents ?? [];
const activeIncidents = incidents.filter((i) => i.status !== "resolved");
let message;
if (activeIncidents.length > 0) {
message = activeIncidents.map((i) => i.name).join("; ");
} else {
message = data.status?.description ?? "Status unavailable.";
}
return { return {
name, name,
status: "operational", status,
message: "All services running normally.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+8 -1
View File
@@ -1,10 +1,17 @@
export const name = "Follett"; export const name = "Follett";
export const url = "https://northhills.follettdestiny.com";
// status.follettsoftware.com has no public API or RSS feed — JS-rendered only.
// Synthetic check against the district's Destiny instance instead.
const PROBE_URL = "https://northhills.follettdestiny.com";
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return { return {
name, name,
status: "operational", status: "operational",
message: "All services running normally.", message: `Destiny portal responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+36 -2
View File
@@ -1,10 +1,44 @@
// Monitors FortiGate Cloud specifically — the most relevant FortiCloud service
// for a school district. Other FortiCloud products have separate status pages
// at status.forticlient.forticloud.com, status.fortiedge.forticloud.com, etc.
export const name = "Fortinet"; export const name = "Fortinet";
export const url = "https://status.fortigate.forticloud.com/";
const STATUS_URL =
"https://status.fortigate.forticloud.com/api/v2/summary.json";
const STATUS_MAP = {
none: "operational",
minor: "degraded",
major: "degraded",
critical: "outage",
};
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`Fortinet status request failed (${res.status})`);
}
const data = await res.json();
const indicator = data.status?.indicator ?? "unknown";
const status = STATUS_MAP[indicator] ?? "unknown";
const incidents = data.incidents ?? [];
const activeIncidents = incidents.filter((i) => i.status !== "resolved");
let message;
if (activeIncidents.length > 0) {
message = activeIncidents.map((i) => i.name).join("; ");
} else {
message = data.status?.description ?? "Status unavailable.";
}
return { return {
name, name,
status: "operational", status,
message: "FortiGuard and FortiCloud services operational.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+58 -2
View File
@@ -1,10 +1,66 @@
export const name = "Google Workspace"; export const name = "Google Workspace";
export const url = "https://workspace.google.com/status/";
const INCIDENTS_URL =
"https://www.google.com/appsstatus/dashboard/incidents.json";
// status_impact → dashboard status
const STATUS_MAP = {
SERVICE_OUTAGE: "outage",
SERVICE_DISRUPTION: "degraded",
};
const IMPACT_LABEL = {
SERVICE_OUTAGE: "outage",
SERVICE_DISRUPTION: "service disruption",
};
const SEVERITY_ORDER = ["operational", "degraded", "outage"];
function worstStatus(a, b) {
return SEVERITY_ORDER.indexOf(a) >= SEVERITY_ORDER.indexOf(b) ? a : b;
}
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(INCIDENTS_URL);
if (!res.ok) {
throw new Error(`Google Workspace status request failed (${res.status})`);
}
const incidents = await res.json();
// Active incidents have no end timestamp
const active = incidents.filter((i) => !i.end);
if (active.length === 0) {
return {
name,
status: "operational",
message: "All services operating normally.",
lastUpdated: new Date().toISOString(),
};
}
let overall = "operational";
const descriptions = [];
for (const incident of active) {
const mapped = STATUS_MAP[incident.status_impact] ?? "degraded";
overall = worstStatus(overall, mapped);
const services = (incident.affected_products ?? [])
.map((p) => p.title)
.join(", ");
const label = services || incident.service_name || "Unknown service";
const impact = IMPACT_LABEL[incident.status_impact] ?? "incident";
descriptions.push(`${label}: ${impact}`);
}
return { return {
name, name,
status: "operational", status: overall,
message: "All services running normally.", message: descriptions.join(" | "),
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+22
View File
@@ -13,6 +13,17 @@ import * as schoolmessenger from "./schoolmessenger.js";
import * as fortinet from "./fortinet.js"; import * as fortinet from "./fortinet.js";
import * as mcgrawHill from "./mcgraw-hill.js"; import * as mcgrawHill from "./mcgraw-hill.js";
import * as localInfrastructure from "./local-infrastructure.js"; import * as localInfrastructure from "./local-infrastructure.js";
import * as amazonAws from "./amazon-aws.js";
import * as cloudflare from "./cloudflare.js";
import * as sherpadesk from "./sherpadesk.js";
import * as studyIsland from "./study-island.js";
import * as classkick from "./classkick.js";
import * as classdojo from "./classdojo.js";
import * as savvas from "./savvas.js";
import * as schoolDismissalManager from "./school-dismissal-manager.js";
import * as smartpass from "./smartpass.js";
import * as promethean from "./promethean.js";
import * as razKids from "./raz-kids.js";
export const providers = [ export const providers = [
microsoft365, microsoft365,
@@ -30,4 +41,15 @@ export const providers = [
fortinet, fortinet,
mcgrawHill, mcgrawHill,
localInfrastructure, localInfrastructure,
amazonAws,
cloudflare,
sherpadesk,
studyIsland,
classkick,
classdojo,
savvas,
schoolDismissalManager,
smartpass,
promethean,
razKids,
]; ];
+42 -3
View File
@@ -1,10 +1,49 @@
export const name = "Local Infrastructure"; import { createConnection } from "net";
export const name = "Internet";
export const url = null;
// TCP connect to Google DNS port 53 — more reliable than ICMP ping in
// managed Windows environments where outbound ICMP may be blocked.
const CHECK_HOST = "8.8.8.8";
const CHECK_PORT = 53;
const TIMEOUT_MS = 5000;
function tcpCheck(host, port) {
return new Promise((resolve) => {
const start = Date.now();
const socket = createConnection({ host, port }, () => {
const latencyMs = Date.now() - start;
socket.destroy();
resolve({ reachable: true, latencyMs });
});
socket.setTimeout(TIMEOUT_MS);
socket.on("timeout", () => { socket.destroy(); resolve({ reachable: false, latencyMs: null }); });
socket.on("error", () => { resolve({ reachable: false, latencyMs: null }); });
});
}
export async function checkStatus() { export async function checkStatus() {
const { reachable, latencyMs } = await tcpCheck(CHECK_HOST, CHECK_PORT);
if (!reachable) {
return {
name,
status: "outage",
message: `No response from ${CHECK_HOST}:${CHECK_PORT} — internet may be down.`,
lastUpdated: new Date().toISOString(),
};
}
const status = latencyMs >= 100 ? "degraded" : "operational";
const message = latencyMs >= 100
? `High latency to ${CHECK_HOST}: ${latencyMs}ms.`
: `Internet reachable — ${CHECK_HOST} responded in ${latencyMs}ms.`;
return { return {
name, name,
status: "operational", status,
message: "Firewall uptime 42d. WAN throughput nominal.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+8 -1
View File
@@ -1,10 +1,17 @@
export const name = "McGraw Hill"; export const name = "McGraw Hill";
export const url = "https://connected.mcgraw-hill.com/connected/permLinkLogin.do";
// status.mcgrawhill.com is a JS-rendered page with no accessible API.
// Synthetic check against the ConnectED portal instead.
const PROBE_URL = "https://connected.mcgraw-hill.com/connected/permLinkLogin.do";
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return { return {
name, name,
status: "operational", status: "operational",
message: "All platform services operational.", message: `ConnectED portal responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+120 -2
View File
@@ -1,10 +1,128 @@
export const name = "Microsoft 365"; export const name = "Microsoft 365";
export const url = "https://admin.microsoft.com/Adminportal/Home#/servicehealth";
const GRAPH_HEALTH_URL =
"https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/healthOverviews";
// Token cache
let cachedToken = null;
// Graph serviceStatus → dashboard status
const STATUS_MAP = {
serviceOperational: "operational",
serviceRestored: "operational",
postIncidentReviewPublished: "operational",
verifyingService: "operational",
falsePositive: "operational",
serviceDegradation: "degraded",
investigating: "degraded",
restoringService: "degraded",
extendedRecovery: "degraded",
investigationSuspended: "degraded",
serviceInterruption: "outage",
};
// Graph serviceStatus → human-readable label for the message
const STATUS_LABEL = {
serviceDegradation: "service degradation",
investigating: "investigating",
restoringService: "restoring service",
extendedRecovery: "extended recovery",
investigationSuspended: "investigation suspended",
serviceInterruption: "service interruption",
};
const SEVERITY_ORDER = ["operational", "degraded", "outage", "unknown"];
function mapStatus(graphStatus) {
return STATUS_MAP[graphStatus] || "unknown";
}
function worstStatus(a, b) {
return SEVERITY_ORDER.indexOf(a) >= SEVERITY_ORDER.indexOf(b) ? a : b;
}
async function getToken() {
const { AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET } =
process.env;
if (!AZURE_TENANT_ID || !AZURE_CLIENT_ID || !AZURE_CLIENT_SECRET) {
throw new Error(
"Missing Azure credentials (AZURE_TENANT_ID, AZURE_CLIENT_ID, AZURE_CLIENT_SECRET)"
);
}
// Return cached token if still valid (with 5-minute buffer)
if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
return cachedToken.accessToken;
}
const tokenUrl = `https://login.microsoftonline.com/${AZURE_TENANT_ID}/oauth2/v2.0/token`;
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: AZURE_CLIENT_ID,
client_secret: AZURE_CLIENT_SECRET,
scope: "https://graph.microsoft.com/.default",
});
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Token request failed (${res.status}): ${text}`);
}
const data = await res.json();
cachedToken = {
accessToken: data.access_token,
expiresAt: Date.now() + data.expires_in * 1000,
};
return cachedToken.accessToken;
}
export async function checkStatus() { export async function checkStatus() {
const token = await getToken();
const res = await fetch(GRAPH_HEALTH_URL, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Graph API request failed (${res.status}): ${text}`);
}
const data = await res.json();
const services = data.value || [];
let overall = "operational";
const issues = [];
for (const svc of services) {
const mapped = mapStatus(svc.status);
overall = worstStatus(overall, mapped);
if (mapped !== "operational") {
const label = STATUS_LABEL[svc.status] ?? svc.status;
issues.push(`${svc.service}: ${label}`);
}
}
const message =
issues.length > 0
? issues.join(", ")
: "All services running normally.";
return { return {
name, name,
status: "operational", status: overall,
message: "All services running normally.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+32 -2
View File
@@ -1,10 +1,40 @@
export const name = "PowerSchool"; export const name = "PowerSchool";
export const url = "https://status.powerschool.com/";
const STATUS_URL = "https://status.powerschool.com/api/v2/summary.json";
const STATUS_MAP = {
none: "operational",
minor: "degraded",
major: "degraded",
critical: "outage",
};
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`PowerSchool status request failed (${res.status})`);
}
const data = await res.json();
const indicator = data.status?.indicator ?? "unknown";
const status = STATUS_MAP[indicator] ?? "unknown";
const incidents = data.incidents ?? [];
const activeIncidents = incidents.filter((i) => i.status !== "resolved");
let message;
if (activeIncidents.length > 0) {
message = activeIncidents.map((i) => i.name).join("; ");
} else {
message = data.status?.description ?? "Status unavailable.";
}
return { return {
name, name,
status: "degraded", status,
message: "Slow response times reported. PowerSchool is investigating.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+17
View File
@@ -0,0 +1,17 @@
export const name = "Promethean";
export const url = "https://www.prometheanworld.com/";
// No cloud features in use — panels run in standalone mode. No public status
// page exists. Synthetic check confirms basic web reachability only.
const PROBE_URL = "https://www.prometheanworld.com/";
export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return {
name,
status: "operational",
message: `Site responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(),
};
}
+42 -2
View File
@@ -1,10 +1,50 @@
export const name = "Raptor"; export const name = "Raptor";
export const url = "https://status.raptortech.com/";
const API_URL =
"https://status.raptortech.com/1.0/status/6501d5abfab4ce052d52e315";
// Status.io status_code → dashboard status
function mapStatusCode(code) {
if (code === 100) return "operational";
if (code === 200) return "degraded"; // planned maintenance
if (code >= 300 && code < 500) return "degraded"; // degraded / partial disruption
if (code >= 500) return "outage"; // service disruption / security event
return "unknown";
}
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(API_URL);
if (!res.ok) {
throw new Error(`Raptor status request failed (${res.status})`);
}
const data = await res.json();
const result = data.result ?? {};
const overall = result.status_overall ?? {};
const status = mapStatusCode(overall.status_code);
const incidents = result.incidents ?? [];
let message;
if (incidents.length > 0) {
message = incidents
.map((i) => {
const components = (i.containers_affected ?? [])
.map((c) => c.name)
.join(", ");
return components ? `${i.name} (${components})` : i.name;
})
.join(" | ");
} else {
message = overall.status ?? "All services operational.";
}
return { return {
name, name,
status: "operational", status,
message: "Visitor management online.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+26
View File
@@ -0,0 +1,26 @@
export const name = "RAZ-Kids";
export const url = "https://www.raz-kids.com/";
// No public status page. Synthetic check against the teacher login portal.
// Learning A-Z properties are behind Cloudflare bot detection — requires a
// browser User-Agent to avoid 403 challenges.
const PROBE_URL =
"https://accounts.learninga-z.com/ng/member/login?siteAbbr=rk";
const HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
};
export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "GET", headers: HEADERS });
return {
name,
status: res.ok ? "operational" : "degraded",
message: res.ok
? `Login portal responding (HTTP ${res.status}).`
: `Unexpected response from login portal (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(),
};
}
+40
View File
@@ -0,0 +1,40 @@
export const name = "Savvas K-12";
export const url = "https://status.savvas.com/";
const STATUS_URL = "https://status.savvas.com/api/v2/summary.json";
const STATUS_MAP = {
none: "operational",
minor: "degraded",
major: "degraded",
critical: "outage",
};
export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`Savvas status request failed (${res.status})`);
}
const data = await res.json();
const indicator = data.status?.indicator ?? "unknown";
const status = STATUS_MAP[indicator] ?? "unknown";
const incidents = data.incidents ?? [];
const activeIncidents = incidents.filter((i) => i.status !== "resolved");
let message;
if (activeIncidents.length > 0) {
message = activeIncidents.map((i) => i.name).join("; ");
} else {
message = data.status?.description ?? "Status unavailable.";
}
return {
name,
status,
message,
lastUpdated: new Date().toISOString(),
};
}
@@ -0,0 +1,17 @@
export const name = "School Dismissal Manager";
export const url = "https://www.schooldismissalmanager.com/SchoolAdmin/";
// No owned status page — systemstatus.schooldismissalmanager.com redirects
// to StatusGator. Synthetic check against the school admin portal instead.
const PROBE_URL = "https://www.schooldismissalmanager.com/SchoolAdmin/";
export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return {
name,
status: "operational",
message: `Admin portal responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(),
};
}
+54 -2
View File
@@ -1,10 +1,62 @@
export const name = "SchoolMessenger"; export const name = "SchoolMessenger";
export const url = "https://status.powerschool.com/";
// SchoolMessenger was acquired by PowerSchool and lives on their status page
// as a group of components. We filter specifically for those rather than
// duplicating the overall PowerSchool check.
const COMPONENTS_URL =
"https://status.powerschool.com/api/v2/components.json";
// Atlassian component status → dashboard status
const STATUS_MAP = {
operational: "operational",
under_maintenance: "degraded",
degraded_performance: "degraded",
partial_outage: "degraded",
major_outage: "outage",
};
const SEVERITY_ORDER = ["operational", "degraded", "outage"];
function worstStatus(a, b) {
return SEVERITY_ORDER.indexOf(a) >= SEVERITY_ORDER.indexOf(b) ? a : b;
}
export async function checkStatus() { export async function checkStatus() {
const res = await fetch(COMPONENTS_URL);
if (!res.ok) {
throw new Error(`SchoolMessenger status request failed (${res.status})`);
}
const data = await res.json();
const components = (data.components ?? []).filter((c) =>
c.name.startsWith("SchoolMessenger")
);
if (components.length === 0) {
throw new Error("No SchoolMessenger components found on status page");
}
let overall = "operational";
const issues = [];
for (const c of components) {
const mapped = STATUS_MAP[c.status] ?? "unknown";
overall = worstStatus(overall, mapped);
if (mapped !== "operational") {
issues.push(`${c.name}: ${c.status.replace(/_/g, " ")}`);
}
}
const message =
issues.length > 0 ? issues.join(" | ") : "All services operational.";
return { return {
name, name,
status: "operational", status: overall,
message: "Messaging services operational.", message,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+17
View File
@@ -0,0 +1,17 @@
export const name = "SherpaDesk";
export const url = "https://app.sherpadesk.com/new/login/";
// No usable public status API — status.sherpadesk.com is a Pingdom uptime
// report page with an invalid cert. Synthetic check against the app portal.
const PROBE_URL = "https://app.sherpadesk.com/new/login/";
export async function checkStatus() {
const res = await fetch(PROBE_URL, { method: "HEAD" });
return {
name,
status: "operational",
message: `App portal responding (HTTP ${res.status}).`,
lastUpdated: new Date().toISOString(),
};
}
+36
View File
@@ -0,0 +1,36 @@
export const name = "SmartPass";
export const url = "https://smartpass.instatus.com";
const STATUS_URL = "https://smartpass.instatus.com/summary.json";
// Instatus page.status → dashboard status
const STATUS_MAP = {
UP: "operational",
HASISSUES: "degraded",
UNDERMAINTENANCE: "degraded",
DOWN: "outage",
};
export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`SmartPass status request failed (${res.status})`);
}
const data = await res.json();
const pageStatus = data.page?.status ?? "UNKNOWN";
const status = STATUS_MAP[pageStatus] ?? "unknown";
const message =
status === "operational"
? "All services operating normally."
: `Service status: ${pageStatus}`;
return {
name,
status,
message,
lastUpdated: new Date().toISOString(),
};
}
+38 -1
View File
@@ -1,10 +1,47 @@
import https from "https";
export const name = "SpamTitan"; export const name = "SpamTitan";
export const url = "https://mailportal.nhsd.net";
const HOST = "mailportal.nhsd.net";
const TIMEOUT_MS = 8000;
// TODO: TitanHQ has extensive API docs — investigate using the SpamTitan API
// for richer status data (queue depth, filtering stats, etc.) rather than
// a simple synthetic check.
// Synthetic check — hit the appliance directly and see if it responds.
// Uses https.request so we can skip self-signed cert verification.
function probe(host) {
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: host,
path: "/",
method: "HEAD",
rejectUnauthorized: false, // local appliance may have self-signed cert
timeout: TIMEOUT_MS,
},
(res) => resolve(res.statusCode)
);
req.on("timeout", () => {
req.destroy();
reject(new Error("Connection timed out"));
});
req.on("error", reject);
req.end();
});
}
export async function checkStatus() { export async function checkStatus() {
const statusCode = await probe(HOST);
return { return {
name, name,
status: "operational", status: "operational",
message: "Email filtering operational.", message: `Mail portal responding (HTTP ${statusCode}).`,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
}; };
} }
+51
View File
@@ -0,0 +1,51 @@
export const name = "Study Island";
export const url = "https://status.edmentum.com/";
// Study Island is a component on Edmentum's parent status page.
const STATUS_URL = "https://status.edmentum.com/api/v2/summary.json";
const STATUS_MAP = {
operational: "operational",
degraded_performance: "degraded",
partial_outage: "degraded",
major_outage: "outage",
};
export async function checkStatus() {
const res = await fetch(STATUS_URL);
if (!res.ok) {
throw new Error(`Edmentum status request failed (${res.status})`);
}
const data = await res.json();
const component = (data.components ?? []).find(
(c) => c.name === "Study Island"
);
if (!component) {
throw new Error("Study Island component not found in Edmentum status feed.");
}
const status = STATUS_MAP[component.status] ?? "unknown";
const incidents = data.incidents ?? [];
const activeIncidents = incidents.filter((i) => i.status !== "resolved");
let message;
if (activeIncidents.length > 0) {
message = activeIncidents.map((i) => i.name).join("; ");
} else {
message = component.status === "operational"
? "All Systems Operational"
: component.status.replace(/_/g, " ");
}
return {
name,
status,
message,
lastUpdated: new Date().toISOString(),
};
}
+18
View File
@@ -1,6 +1,9 @@
import "dotenv/config";
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import { providers } from "./providers/index.js"; import { providers } from "./providers/index.js";
import { startPolling as startThroughputPolling, getHistory as getThroughputHistory } from "./fortigate-throughput.js";
import { startPolling as startHealthPolling, getHealth as getFgHealth } from "./fortigate-health.js";
const app = express(); const app = express();
const PORT = 3000; const PORT = 3000;
@@ -22,6 +25,9 @@ function safeCheck(provider) {
status: "unknown", status: "unknown",
message: `Check failed: ${err.message}`, message: `Check failed: ${err.message}`,
lastUpdated: new Date().toISOString(), lastUpdated: new Date().toISOString(),
})).then((result) => ({
url: provider.url ?? null,
...result,
})); }));
} }
@@ -46,11 +52,23 @@ app.get("/api/status", (_req, res) => {
res.json(cachedStatuses); res.json(cachedStatuses);
}); });
app.get("/api/throughput", (_req, res) => {
res.json(getThroughputHistory());
});
app.get("/api/fortigate-health", (_req, res) => {
const health = getFgHealth();
if (!health) return res.status(503).json({ error: "Health data not yet available." });
res.json(health);
});
app.get("/api/health", (_req, res) => { app.get("/api/health", (_req, res) => {
res.json({ ok: true, providers: providers.length }); res.json({ ok: true, providers: providers.length });
}); });
// Poll immediately, then on interval // Poll immediately, then on interval
startThroughputPolling();
startHealthPolling();
await pollAll(); await pollAll();
setInterval(pollAll, POLL_INTERVAL); setInterval(pollAll, POLL_INTERVAL);
+3 -3
View File
@@ -1,13 +1,13 @@
{ {
admin localhost:2020 admin localhost:2021
auto_https disable_redirects auto_https disable_redirects
} }
https://status.nhsd.net:8443, https://localhost:8443 { https://status.nhsd.net:8443, https://localhost:8443, https://10.11.203.17:8443, https://AD-TECH-K0404:8443 {
tls internal tls internal
# Static frontend # Static frontend
root * ../frontend root * C:/Users/kleins/projects/infrastructure-monitoring-dashboard/frontend
file_server file_server
# Proxy API requests to Node backend # Proxy API requests to Node backend
+155
View File
@@ -158,6 +158,150 @@ header {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* ===== WAN THROUGHPUT ===== */
#wan-section {
padding: 1.5rem 2.5rem 0;
}
#wan-graphs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.25rem;
}
.wan-panel {
background: var(--surface);
border: 1px solid var(--border);
border-top: 3px solid #388bfd;
border-radius: 8px;
padding: 1.25rem 1.5rem 1rem;
overflow: hidden;
}
.wan-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.wan-name {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 800;
color: var(--text-1);
letter-spacing: 0.03em;
line-height: 1;
}
.wan-legend {
display: flex;
gap: 1.25rem;
}
.wan-legend-item {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: var(--font-mono);
font-size: 0.68rem;
color: var(--text-2);
}
.wan-legend-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.wan-legend-dot.rx { background: #58a6ff; box-shadow: 0 0 5px rgba(88, 166, 255, 0.5); }
.wan-legend-dot.tx { background: #3fb950; box-shadow: 0 0 5px rgba(63, 185, 80, 0.5); }
.wan-canvas {
display: block;
width: 100%;
height: 90px;
}
/* ===== FORTIGATE HEALTH CARD ===== */
#fg-health-card {
display: flex;
align-items: center;
gap: 2rem;
margin-top: 1.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-top: 3px solid #388bfd;
border-radius: 8px;
padding: 1rem 1.5rem;
}
.fg-identity {
display: flex;
align-items: center;
gap: 0.65rem;
flex-shrink: 0;
}
.fg-model {
font-family: var(--font-display);
font-size: 1.1rem;
font-weight: 800;
color: var(--text-1);
letter-spacing: 0.03em;
line-height: 1;
}
.fg-metrics {
display: flex;
gap: 2rem;
flex-wrap: wrap;
}
.fg-metric {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.fg-metric-label {
font-family: var(--font-mono);
font-size: 0.6rem;
font-weight: 400;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-3);
}
.fg-metric-value {
font-family: var(--font-mono);
font-size: 0.85rem;
font-weight: 400;
color: var(--text-1);
}
.fg-metric-value.warn { color: var(--warn); }
.fg-metric-value.crit { color: var(--crit); }
@media (max-width: 640px) {
#wan-section {
padding: 1.25rem 1.5rem 0;
}
#wan-graphs {
grid-template-columns: 1fr;
}
#fg-health-card {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
}
/* ===== VENDOR GRID ===== */ /* ===== VENDOR GRID ===== */
#vendor-grid { #vendor-grid {
@@ -269,6 +413,17 @@ header {
/* --- Vendor name --- */ /* --- Vendor name --- */
.vendor-name a.vendor-link {
color: inherit;
text-decoration: none;
}
.vendor-name a.vendor-link:hover {
text-decoration: underline;
text-decoration-color: var(--text-3);
text-underline-offset: 3px;
}
.vendor-name { .vendor-name {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 1.35rem; font-size: 1.35rem;
+52
View File
@@ -21,6 +21,58 @@
</div> </div>
</header> </header>
<div id="summary-bar"></div> <div id="summary-bar"></div>
<section id="wan-section">
<div id="wan-graphs">
<div class="wan-panel">
<div class="wan-panel-header">
<span class="wan-name" id="wan1-name">Crown Castle</span>
<div class="wan-legend">
<span class="wan-legend-item"><span class="wan-legend-dot rx"></span><span id="wan1-rx">— Mbps ↓</span></span>
<span class="wan-legend-item"><span class="wan-legend-dot tx"></span><span id="wan1-tx">— Mbps ↑</span></span>
</div>
</div>
<canvas id="wan1-canvas" class="wan-canvas"></canvas>
</div>
<div class="wan-panel">
<div class="wan-panel-header">
<span class="wan-name" id="wan2-name">Comcast</span>
<div class="wan-legend">
<span class="wan-legend-item"><span class="wan-legend-dot rx"></span><span id="wan2-rx">— Mbps ↓</span></span>
<span class="wan-legend-item"><span class="wan-legend-dot tx"></span><span id="wan2-tx">— Mbps ↑</span></span>
</div>
</div>
<canvas id="wan2-canvas" class="wan-canvas"></canvas>
</div>
</div>
<div id="fg-health-card">
<div class="fg-identity">
<span class="status-indicator operational" id="fg-status-dot"></span>
<span class="fg-model" id="fg-model">FortiGate 1800F</span>
</div>
<div class="fg-metrics">
<div class="fg-metric">
<span class="fg-metric-label">Hostname</span>
<span class="fg-metric-value" id="fg-hostname"></span>
</div>
<div class="fg-metric">
<span class="fg-metric-label">Version</span>
<span class="fg-metric-value" id="fg-version"></span>
</div>
<div class="fg-metric">
<span class="fg-metric-label">Uptime</span>
<span class="fg-metric-value" id="fg-uptime"></span>
</div>
<div class="fg-metric">
<span class="fg-metric-label">CPU</span>
<span class="fg-metric-value" id="fg-cpu"></span>
</div>
<div class="fg-metric">
<span class="fg-metric-label">Memory</span>
<span class="fg-metric-value" id="fg-mem"></span>
</div>
</div>
</div>
</section>
<main id="vendor-grid"></main> <main id="vendor-grid"></main>
<script src="js/app.js"></script> <script src="js/app.js"></script>
</body> </body>
+183 -4
View File
@@ -1,6 +1,9 @@
// How often to fetch status (ms) // How often to fetch status (ms)
const REFRESH_INTERVAL = 60_000; const REFRESH_INTERVAL = 60_000;
// How often to fetch WAN throughput (ms) — matches backend poll rate
const THROUGHPUT_INTERVAL = 30_000;
// Status severity for sorting (higher = more severe = shown first) // Status severity for sorting (higher = more severe = shown first)
const SEVERITY = { outage: 3, degraded: 2, unknown: 1, operational: 0 }; const SEVERITY = { outage: 3, degraded: 2, unknown: 1, operational: 0 };
@@ -169,20 +172,26 @@ function renderSummary(vendors) {
// Render vendor cards into the grid, sorted by severity // Render vendor cards into the grid, sorted by severity
function renderGrid(vendors) { function renderGrid(vendors) {
const sorted = [...vendors].sort((a, b) => const sorted = [...vendors].sort((a, b) => {
(SEVERITY[b.status] || 0) - (SEVERITY[a.status] || 0) const severityDiff = (SEVERITY[b.status] || 0) - (SEVERITY[a.status] || 0);
); if (severityDiff !== 0) return severityDiff;
return a.name.localeCompare(b.name);
});
const grid = document.getElementById("vendor-grid"); const grid = document.getElementById("vendor-grid");
grid.innerHTML = sorted.map((v, i) => { grid.innerHTML = sorted.map((v, i) => {
const animClass = initialLoad ? "animate-in" : ""; const animClass = initialLoad ? "animate-in" : "";
const animStyle = initialLoad ? `style="--d: ${i * 0.07}s"` : ""; const animStyle = initialLoad ? `style="--d: ${i * 0.07}s"` : "";
const nameHtml = v.url
? `<a href="${esc(v.url)}" target="_blank" rel="noopener" class="vendor-link">${esc(v.name)}</a>`
: esc(v.name);
return `<div class="vendor-card ${esc(v.status)} ${animClass}" ${animStyle}> return `<div class="vendor-card ${esc(v.status)} ${animClass}" ${animStyle}>
<div class="card-header"> <div class="card-header">
<div class="vendor-identity"> <div class="vendor-identity">
<span class="status-indicator ${esc(v.status)}"></span> <span class="status-indicator ${esc(v.status)}"></span>
<span class="vendor-name">${esc(v.name)}</span> <span class="vendor-name">${nameHtml}</span>
</div> </div>
<span class="status-badge ${esc(v.status)}">${esc(v.status)}</span> <span class="status-badge ${esc(v.status)}">${esc(v.status)}</span>
</div> </div>
@@ -216,8 +225,178 @@ async function refresh() {
render(vendors); render(vendors);
} }
// ===== WAN THROUGHPUT GRAPH =====
async function fetchThroughput() {
try {
const res = await fetch("/api/throughput");
if (!res.ok) throw new Error(res.statusText);
return await res.json();
} catch {
return [];
}
}
// Round up to the nearest "nice" scale value for the Y axis
function niceMax(val) {
const steps = [10, 25, 50, 100, 200, 500, 1000, 2000, 5000, 10000];
return steps.find((s) => s >= val) ?? Math.ceil(val * 1.2);
}
function fmtMbps(mbps) {
if (mbps >= 1000) return `${(mbps / 1000).toFixed(1)} Gbps`;
if (mbps >= 100) return `${Math.round(mbps)} Mbps`;
if (mbps >= 10) return `${mbps.toFixed(1)} Mbps`;
return `${mbps.toFixed(2)} Mbps`;
}
function drawGraph(canvas, points, wanKey) {
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth;
const H = canvas.offsetHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext("2d");
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, W, H);
if (points.length < 2) {
ctx.fillStyle = "#484f58";
ctx.font = "11px Consolas, monospace";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Waiting for data…", W / 2, H / 2);
return;
}
const PAD = { top: 6, right: 6, bottom: 4, left: 42 };
const plotW = W - PAD.left - PAD.right;
const plotH = H - PAD.top - PAD.bottom;
const allVals = points.flatMap((p) => [p[wanKey].rxMbps, p[wanKey].txMbps]);
const maxVal = niceMax(Math.max(...allVals, 1));
// Grid lines and Y-axis labels
ctx.font = "9px Consolas, monospace";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
[0, 0.5, 1].forEach((frac) => {
const y = PAD.top + plotH * (1 - frac);
ctx.strokeStyle = "#21262d";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD.left, y);
ctx.lineTo(PAD.left + plotW, y);
ctx.stroke();
if (frac > 0) {
ctx.fillStyle = "#484f58";
ctx.fillText(fmtMbps(maxVal * frac), PAD.left - 5, y);
}
});
// Draw a line with a fill underneath
function drawLine(getValue, strokeColor, fillColor) {
const n = points.length;
const pts = points.map((p, i) => ({
x: PAD.left + (i / (n - 1)) * plotW,
y: PAD.top + plotH * (1 - Math.max(0, getValue(p)) / maxVal),
}));
// Fill
ctx.beginPath();
ctx.moveTo(pts[0].x, PAD.top + plotH);
pts.forEach((pt) => ctx.lineTo(pt.x, pt.y));
ctx.lineTo(pts[n - 1].x, PAD.top + plotH);
ctx.closePath();
ctx.fillStyle = fillColor;
ctx.fill();
// Stroke
ctx.beginPath();
pts.forEach((pt, i) => (i === 0 ? ctx.moveTo(pt.x, pt.y) : ctx.lineTo(pt.x, pt.y)));
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 1.5;
ctx.lineJoin = "round";
ctx.stroke();
}
drawLine((p) => p[wanKey].rxMbps, "#58a6ff", "rgba(88, 166, 255, 0.10)");
drawLine((p) => p[wanKey].txMbps, "#3fb950", "rgba(63, 185, 80, 0.10)");
}
function renderThroughput(points) {
if (points.length === 0) return;
const latest = points[points.length - 1];
// Update labels from the data (picks up names from .env via backend)
document.getElementById("wan1-name").textContent = latest.wan1.label;
document.getElementById("wan2-name").textContent = latest.wan2.label;
document.getElementById("wan1-rx").textContent = `${fmtMbps(latest.wan1.rxMbps)}`;
document.getElementById("wan1-tx").textContent = `${fmtMbps(latest.wan1.txMbps)}`;
document.getElementById("wan2-rx").textContent = `${fmtMbps(latest.wan2.rxMbps)}`;
document.getElementById("wan2-tx").textContent = `${fmtMbps(latest.wan2.txMbps)}`;
drawGraph(document.getElementById("wan1-canvas"), points, "wan1");
drawGraph(document.getElementById("wan2-canvas"), points, "wan2");
}
async function refreshThroughput() {
const points = await fetchThroughput();
renderThroughput(points);
}
// ===== FORTIGATE HEALTH CARD =====
async function fetchFgHealth() {
try {
const res = await fetch("/api/fortigate-health");
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
function renderFgHealth(health) {
if (!health) return;
document.getElementById("fg-model").textContent =
`FortiGate ${health.model}`;
document.getElementById("fg-hostname").textContent = health.hostname;
document.getElementById("fg-version").textContent = health.version;
document.getElementById("fg-uptime").textContent = health.uptimeLabel;
const cpuEl = document.getElementById("fg-cpu");
cpuEl.textContent = health.cpuPct !== null ? `${health.cpuPct}%` : "—";
cpuEl.className = "fg-metric-value" +
(health.cpuPct >= 90 ? " crit" : health.cpuPct >= 75 ? " warn" : "");
const memEl = document.getElementById("fg-mem");
memEl.textContent = health.memPct !== null ? `${health.memPct}%` : "—";
memEl.className = "fg-metric-value" +
(health.memPct >= 90 ? " crit" : health.memPct >= 75 ? " warn" : "");
const dot = document.getElementById("fg-status-dot");
const stressed = health.cpuPct >= 90 || health.memPct >= 90;
dot.className = `status-indicator ${stressed ? "degraded" : "operational"}`;
}
async function refreshFgHealth() {
const health = await fetchFgHealth();
renderFgHealth(health);
}
// Initialize // Initialize
updateClock(); updateClock();
setInterval(updateClock, 1000); setInterval(updateClock, 1000);
refresh(); refresh();
setInterval(refresh, REFRESH_INTERVAL); setInterval(refresh, REFRESH_INTERVAL);
refreshThroughput();
setInterval(refreshThroughput, THROUGHPUT_INTERVAL);
refreshFgHealth();
setInterval(refreshFgHealth, 2 * 60 * 1000);