diff --git a/.gitignore b/.gitignore index ae2593a..362232c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ bin/nssm/*.exe # Node backend/node_modules/ +# Environment variables (contains secrets) +backend/.env + # OS files Thumbs.db Desktop.ini diff --git a/CLAUDE.md b/CLAUDE.md index 1a79827..1ade06e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,31 +9,71 @@ A web-based status feed aggregator for a K-12 school district IT department. Pro | Vendor | Type | Status Source | |---|---|---| | Microsoft 365 | Productivity suite | Service Communications API (Graph API) | -| SpamTitan | Email security | TBD — likely status page scrape or synthetic check | -| PowerSchool | Student Information System | TBD — status page scrape | -| Classlink | SSO / Identity | TBD — status page or API | -| Apple | Device ecosystem | Apple System Status page (JSON feed) | -| DRC | Assessment / Testing | TBD — status page scrape | -| FinalSite | School website CMS | TBD — status page scrape or synthetic check | -| Google Workspace | Productivity suite | Google Workspace Status Dashboard (JSON feed) | -| Follett | Library management | TBD — status page scrape or synthetic check | -| EdInsight | Data analytics (Harris Education Solutions) | TBD — status page or synthetic check | -| Raptor | Visitor management | TBD — status page scrape | -| SchoolMessenger | Communication platform | TBD — status page scrape | -| McGraw Hill | Curriculum / assessment | status.mcgrawhill.com (JS-rendered status page) | -| Fortinet | Network security | TBD — FortiCloud API or status page | -| Local Infrastructure | On-prem hardware | Direct monitoring (SNMP, API, or synthetic checks) | +| SpamTitan | Email security | Synthetic check — district appliance (mailportal.nhsd.net) | +| PowerSchool | Student Information System | Atlassian Statuspage API (status.powerschool.com) | +| Classlink | SSO / Identity | Atlassian Statuspage API (status.classlink.com) | +| Apple | Device ecosystem | Apple System Status JSON feed | +| DRC | Assessment / Testing | Synthetic check — PA INSIGHT portal (status page is JS-rendered Angular app) | +| FinalSite | School website CMS | Atlassian Statuspage API (status.finalsite.com) | +| Google Workspace | Productivity suite | Google Workspace Status Dashboard JSON feed | +| Follett | Library management | Synthetic check — district Destiny instance (northhills.follettdestiny.com) | +| EdInsight | Data analytics (Harris Education Solutions) | Synthetic check — no public status page found | +| Raptor | Visitor management | Status.io API (status.raptortech.com) | +| SchoolMessenger | Communication platform | Atlassian Statuspage API (PowerSchool status page, SchoolMessenger components filtered) | +| McGraw Hill | Curriculum / assessment | Synthetic check — ConnectED portal (status.mcgrawhill.com is JS-rendered) | +| Fortinet | Network security | Atlassian Statuspage API (FortiGate Cloud — status.fortigate.forticloud.com) | +| 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. 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 - **Web server**: Caddy - **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) - **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 @@ -43,32 +83,40 @@ New vendors should be added incrementally, not speculatively. - **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. +## 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 ``` infrastructure-monitoring-dashboard/ ├── CLAUDE.md +├── README.md ├── .gitignore ├── bin/ -│ ├── caddy/ # Drop caddy.exe here (git-ignored) -│ └── nssm/ # Drop nssm.exe here (git-ignored) +│ ├── caddy/ # Drop caddy.exe here (git-ignored) +│ └── nssm/ # Drop nssm.exe here (git-ignored) ├── config/ -│ └── Caddyfile # Caddy server configuration +│ └── Caddyfile # Caddy server configuration ├── frontend/ │ ├── index.html -│ ├── css/ -│ └── js/ +│ ├── css/style.css +│ └── js/app.js ├── backend/ │ ├── package.json │ ├── server.js -│ └── providers/ # One module per vendor -└── scripts/ # NSSM service install/uninstall helpers +│ ├── fortigate-throughput.js # WAN throughput poller +│ ├── 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 - Keep it simple. This is a status board, not a monitoring platform. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3760ac --- /dev/null +++ b/README.md @@ -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/.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. diff --git a/backend/fortigate-health.js b/backend/fortigate-health.js new file mode 100644 index 0000000..8d30c87 --- /dev/null +++ b/backend/fortigate-health.js @@ -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 + ); +} diff --git a/backend/fortigate-throughput.js b/backend/fortigate-throughput.js new file mode 100644 index 0000000..f1aa306 --- /dev/null +++ b/backend/fortigate-throughput.js @@ -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 + ); +} diff --git a/backend/package-lock.json b/backend/package-lock.json index 18c21c0..bb71747 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "cors": "^2.8.5", + "dotenv": "^16.4.7", "express": "^4.21.2" } }, @@ -174,6 +175,18 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 1e19426..aa12359 100644 --- a/backend/package.json +++ b/backend/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "cors": "^2.8.5", + "dotenv": "^16.4.7", "express": "^4.21.2" } } diff --git a/backend/providers/amazon-aws.js b/backend/providers/amazon-aws.js new file mode 100644 index 0000000..4221621 --- /dev/null +++ b/backend/providers/amazon-aws.js @@ -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 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(), + }; +} diff --git a/backend/providers/apple.js b/backend/providers/apple.js index 59481ca..412f01e 100644 --- a/backend/providers/apple.js +++ b/backend/providers/apple.js @@ -1,10 +1,58 @@ 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() { + 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 { name, - status: "operational", - message: "All services running normally.", + status: overall, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/classdojo.js b/backend/providers/classdojo.js new file mode 100644 index 0000000..06b4ac7 --- /dev/null +++ b/backend/providers/classdojo.js @@ -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(), + }; +} diff --git a/backend/providers/classkick.js b/backend/providers/classkick.js new file mode 100644 index 0000000..b3b84f5 --- /dev/null +++ b/backend/providers/classkick.js @@ -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(), + }; +} diff --git a/backend/providers/classlink.js b/backend/providers/classlink.js index 720adb1..903de00 100644 --- a/backend/providers/classlink.js +++ b/backend/providers/classlink.js @@ -1,10 +1,40 @@ 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() { + 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 { name, - status: "operational", - message: "All services running normally.", + status, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/cloudflare.js b/backend/providers/cloudflare.js new file mode 100644 index 0000000..633e932 --- /dev/null +++ b/backend/providers/cloudflare.js @@ -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(), + }; +} diff --git a/backend/providers/drc.js b/backend/providers/drc.js index b26a435..9dfde5b 100644 --- a/backend/providers/drc.js +++ b/backend/providers/drc.js @@ -1,10 +1,17 @@ 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() { + const res = await fetch(PROBE_URL, { method: "HEAD" }); + return { name, - status: "outage", - message: "INSIGHT portal is currently unavailable. DRC is aware of the issue.", + status: "operational", + message: `PA INSIGHT portal responding (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/edinsight.js b/backend/providers/edinsight.js index edc05eb..6d118f8 100644 --- a/backend/providers/edinsight.js +++ b/backend/providers/edinsight.js @@ -1,10 +1,18 @@ 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() { + const res = await fetch(PROBE_URL, { method: "HEAD" }); + return { name, status: "operational", - message: "Data analytics platform operational.", + message: `Portal responding (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/finalsite.js b/backend/providers/finalsite.js index a5ea6ca..5c05627 100644 --- a/backend/providers/finalsite.js +++ b/backend/providers/finalsite.js @@ -1,10 +1,40 @@ 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() { + 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 { name, - status: "operational", - message: "All services running normally.", + status, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/follett.js b/backend/providers/follett.js index 6ead2c1..a520e7e 100644 --- a/backend/providers/follett.js +++ b/backend/providers/follett.js @@ -1,10 +1,17 @@ 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() { + const res = await fetch(PROBE_URL, { method: "HEAD" }); + return { name, status: "operational", - message: "All services running normally.", + message: `Destiny portal responding (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/fortinet.js b/backend/providers/fortinet.js index 6e311fe..4a490c1 100644 --- a/backend/providers/fortinet.js +++ b/backend/providers/fortinet.js @@ -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 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() { + 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 { name, - status: "operational", - message: "FortiGuard and FortiCloud services operational.", + status, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/google-workspace.js b/backend/providers/google-workspace.js index 1c27403..0e3648a 100644 --- a/backend/providers/google-workspace.js +++ b/backend/providers/google-workspace.js @@ -1,10 +1,66 @@ 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() { + 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 { name, - status: "operational", - message: "All services running normally.", + status: overall, + message: descriptions.join(" | "), lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/index.js b/backend/providers/index.js index 18dae3e..a187d2c 100644 --- a/backend/providers/index.js +++ b/backend/providers/index.js @@ -13,6 +13,17 @@ import * as schoolmessenger from "./schoolmessenger.js"; import * as fortinet from "./fortinet.js"; import * as mcgrawHill from "./mcgraw-hill.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 = [ microsoft365, @@ -30,4 +41,15 @@ export const providers = [ fortinet, mcgrawHill, localInfrastructure, + amazonAws, + cloudflare, + sherpadesk, + studyIsland, + classkick, + classdojo, + savvas, + schoolDismissalManager, + smartpass, + promethean, + razKids, ]; diff --git a/backend/providers/local-infrastructure.js b/backend/providers/local-infrastructure.js index f2506ae..d775d64 100644 --- a/backend/providers/local-infrastructure.js +++ b/backend/providers/local-infrastructure.js @@ -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() { + 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 { name, - status: "operational", - message: "Firewall uptime 42d. WAN throughput nominal.", + status, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/mcgraw-hill.js b/backend/providers/mcgraw-hill.js index 87a1294..ea069d9 100644 --- a/backend/providers/mcgraw-hill.js +++ b/backend/providers/mcgraw-hill.js @@ -1,10 +1,17 @@ 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() { + const res = await fetch(PROBE_URL, { method: "HEAD" }); + return { name, status: "operational", - message: "All platform services operational.", + message: `ConnectED portal responding (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/microsoft365.js b/backend/providers/microsoft365.js index 806ddd1..aec36bb 100644 --- a/backend/providers/microsoft365.js +++ b/backend/providers/microsoft365.js @@ -1,10 +1,128 @@ 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() { + 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 { name, - status: "operational", - message: "All services running normally.", + status: overall, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/powerschool.js b/backend/providers/powerschool.js index b29ac9b..4a76c38 100644 --- a/backend/providers/powerschool.js +++ b/backend/providers/powerschool.js @@ -1,10 +1,40 @@ 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() { + 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 { name, - status: "degraded", - message: "Slow response times reported. PowerSchool is investigating.", + status, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/promethean.js b/backend/providers/promethean.js new file mode 100644 index 0000000..a556fa4 --- /dev/null +++ b/backend/providers/promethean.js @@ -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(), + }; +} diff --git a/backend/providers/raptor.js b/backend/providers/raptor.js index 4c6a08c..52b921b 100644 --- a/backend/providers/raptor.js +++ b/backend/providers/raptor.js @@ -1,10 +1,50 @@ 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() { + 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 { name, - status: "operational", - message: "Visitor management online.", + status, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/raz-kids.js b/backend/providers/raz-kids.js new file mode 100644 index 0000000..2ea1f93 --- /dev/null +++ b/backend/providers/raz-kids.js @@ -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(), + }; +} diff --git a/backend/providers/savvas.js b/backend/providers/savvas.js new file mode 100644 index 0000000..0f6d03a --- /dev/null +++ b/backend/providers/savvas.js @@ -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(), + }; +} diff --git a/backend/providers/school-dismissal-manager.js b/backend/providers/school-dismissal-manager.js new file mode 100644 index 0000000..90f43b4 --- /dev/null +++ b/backend/providers/school-dismissal-manager.js @@ -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(), + }; +} diff --git a/backend/providers/schoolmessenger.js b/backend/providers/schoolmessenger.js index 8168366..386e32b 100644 --- a/backend/providers/schoolmessenger.js +++ b/backend/providers/schoolmessenger.js @@ -1,10 +1,62 @@ 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() { + 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 { name, - status: "operational", - message: "Messaging services operational.", + status: overall, + message, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/sherpadesk.js b/backend/providers/sherpadesk.js new file mode 100644 index 0000000..d6ce7ea --- /dev/null +++ b/backend/providers/sherpadesk.js @@ -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(), + }; +} diff --git a/backend/providers/smartpass.js b/backend/providers/smartpass.js new file mode 100644 index 0000000..ae05bc8 --- /dev/null +++ b/backend/providers/smartpass.js @@ -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(), + }; +} diff --git a/backend/providers/spamtitan.js b/backend/providers/spamtitan.js index f432a34..58aa113 100644 --- a/backend/providers/spamtitan.js +++ b/backend/providers/spamtitan.js @@ -1,10 +1,47 @@ +import https from "https"; + 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() { + const statusCode = await probe(HOST); + return { name, status: "operational", - message: "Email filtering operational.", + message: `Mail portal responding (HTTP ${statusCode}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/study-island.js b/backend/providers/study-island.js new file mode 100644 index 0000000..35e7c71 --- /dev/null +++ b/backend/providers/study-island.js @@ -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(), + }; +} diff --git a/backend/server.js b/backend/server.js index 6488d8b..60ca9ab 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,9 @@ +import "dotenv/config"; import express from "express"; import cors from "cors"; 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 PORT = 3000; @@ -22,6 +25,9 @@ function safeCheck(provider) { status: "unknown", message: `Check failed: ${err.message}`, lastUpdated: new Date().toISOString(), + })).then((result) => ({ + url: provider.url ?? null, + ...result, })); } @@ -46,11 +52,23 @@ app.get("/api/status", (_req, res) => { 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) => { res.json({ ok: true, providers: providers.length }); }); // Poll immediately, then on interval +startThroughputPolling(); +startHealthPolling(); await pollAll(); setInterval(pollAll, POLL_INTERVAL); diff --git a/config/Caddyfile b/config/Caddyfile index 88ecb6d..dbcdde1 100644 --- a/config/Caddyfile +++ b/config/Caddyfile @@ -1,13 +1,13 @@ { - admin localhost:2020 + admin localhost:2021 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 # Static frontend - root * ../frontend + root * C:/Users/kleins/projects/infrastructure-monitoring-dashboard/frontend file_server # Proxy API requests to Node backend diff --git a/frontend/css/style.css b/frontend/css/style.css index b62e85a..adc06b0 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -158,6 +158,150 @@ header { 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 { @@ -269,6 +413,17 @@ header { /* --- 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 { font-family: var(--font-display); font-size: 1.35rem; diff --git a/frontend/index.html b/frontend/index.html index 196fd29..f44a3e9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,6 +21,58 @@ </div> </header> <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> <script src="js/app.js"></script> </body> diff --git a/frontend/js/app.js b/frontend/js/app.js index 59dce03..d73e793 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,6 +1,9 @@ // How often to fetch status (ms) 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) 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 function renderGrid(vendors) { - const sorted = [...vendors].sort((a, b) => - (SEVERITY[b.status] || 0) - (SEVERITY[a.status] || 0) - ); + const sorted = [...vendors].sort((a, b) => { + 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"); grid.innerHTML = sorted.map((v, i) => { const animClass = initialLoad ? "animate-in" : ""; 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}> <div class="card-header"> <div class="vendor-identity"> <span class="status-indicator ${esc(v.status)}"></span> - <span class="vendor-name">${esc(v.name)}</span> + <span class="vendor-name">${nameHtml}</span> </div> <span class="status-badge ${esc(v.status)}">${esc(v.status)}</span> </div> @@ -216,8 +225,178 @@ async function refresh() { 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 updateClock(); setInterval(updateClock, 1000); refresh(); setInterval(refresh, REFRESH_INTERVAL); +refreshThroughput(); +setInterval(refreshThroughput, THROUGHPUT_INTERVAL); +refreshFgHealth(); +setInterval(refreshFgHealth, 2 * 60 * 1000);