diff --git a/CLAUDE.md b/CLAUDE.md index 1ade06e..456c9ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,11 +18,11 @@ A web-based status feed aggregator for a K-12 school district IT department. Pro | 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) | +| Raptor | Visitor management | Status.io API (status.raptortech.com); incidents in "Monitoring" state (state ≥ 300) are suppressed from the message | | 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) | +| SherpaDesk | Helpdesk / ticketing | Synthetic check — district portal (nhsd.sherpadesk.com); HEAD not supported, uses GET | | 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) | @@ -32,7 +32,7 @@ A web-based status feed aggregator for a K-12 school district IT department. Pro | 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) | +| RAZ-Kids | Reading platform | Synthetic check — Learning A-Z login portal; always returns `unknown` due to Cloudflare managed challenge blocking server-side fetches | | 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. @@ -41,55 +41,44 @@ 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. +The WAN throughput graphs and FortiGate health card were implemented but are currently disabled due to a FortiGate API access issue. The code has been removed from the frontend and backend pending resolution. The `.env` variables below are placeholders for when this is revisited. ## 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) +- `FORTIGATE_HOST`, `FORTIGATE_API_TOKEN` — FortiGate REST API (not currently used) +- `FORTIGATE_WAN1_INTERFACE`, `FORTIGATE_WAN1_LABEL` — Crown Castle WAN (not currently used) +- `FORTIGATE_WAN2_INTERFACE`, `FORTIGATE_WAN2_LABEL` — Comcast WAN (not currently used) + +## Source Control + +- **Remote**: https://git.canadabot.net/canadabot/infrastructure-monitoring-dashboard (remote name: `gitea`) +- Credentials stored in `C:\users\kleins\.gitea_credentials` ## 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. +- **Web server**: Shared Caddy instance also used by the staff lifecycle portal (`C:\staff-lifecycle-portal\caddy\Caddyfile`) +- **URL**: https://status.nhsd.net (standard HTTPS, no custom port) +- **Access**: All local network devices (no IP restriction on the status block) +- **TLS**: Caddy internal TLS (self-signed). Browser cert warnings are acceptable; distribute `caddy/data/caddy/pki/authorities/local/root.crt` via Group Policy to eliminate them. +- **DNS**: A record `status.nhsd.net → 10.1.20.214` on nhsd-dc-04p.nhsd.net +- **Caddy reload**: `caddy reload --config "C:\staff-lifecycle-portal\caddy\Caddyfile"` ## Architecture - **Frontend**: HTML/CSS/JS dashboard — lightweight, no heavy framework. Designed to work on a wall-mounted monitor or quick browser check. - **Backend**: Node.js service that polls vendor status on a schedule and caches results. -- **Web server**: Caddy reverse-proxies to the backend API and serves the static frontend. -- **Services**: NSSM runs both Caddy and the Node backend as Windows services. +- **Web server**: Caddy reverse-proxies `/api/*` to the Node backend on port 3000 and serves the static frontend directly. +- **Services**: Node backend runs as an NSSM Windows service named `StatusDashboard` (auto-start). Caddy is managed by the staff-lifecycle-portal project. - **Data flow**: Backend polls vendors → caches to local store → frontend fetches from backend API → auto-refreshes on interval. +- **Logs**: `C:\infrastructure-monitoring-dashboard\logs\backend.log` ## 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 @@ -100,10 +89,9 @@ infrastructure-monitoring-dashboard/ ├── README.md ├── .gitignore ├── bin/ -│ ├── caddy/ # Drop caddy.exe here (git-ignored) │ └── nssm/ # Drop nssm.exe here (git-ignored) ├── config/ -│ └── Caddyfile # Caddy server configuration +│ └── Caddyfile # Unused — Caddy config lives in the lifecycle portal project ├── frontend/ │ ├── index.html │ ├── css/style.css @@ -111,9 +99,8 @@ infrastructure-monitoring-dashboard/ ├── backend/ │ ├── package.json │ ├── server.js -│ ├── fortigate-throughput.js # WAN throughput poller -│ ├── fortigate-health.js # FortiGate system health poller │ └── providers/ # One module per vendor +├── logs/ # backend.log (git-ignored) └── scripts/ # NSSM service install/uninstall helpers ``` diff --git a/backend/fortigate-health.js b/backend/fortigate-health.js deleted file mode 100644 index 8d30c87..0000000 --- a/backend/fortigate-health.js +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index f1aa306..0000000 --- a/backend/fortigate-throughput.js +++ /dev/null @@ -1,118 +0,0 @@ -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/providers/classdojo.js b/backend/providers/classdojo.js index 06b4ac7..e042b0c 100644 --- a/backend/providers/classdojo.js +++ b/backend/providers/classdojo.js @@ -10,8 +10,10 @@ export async function checkStatus() { return { name, - status: "operational", - message: `App portal responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `App portal responding (HTTP ${res.status}).` + : `Unexpected response from app portal (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/classkick.js b/backend/providers/classkick.js index b3b84f5..13d23c0 100644 --- a/backend/providers/classkick.js +++ b/backend/providers/classkick.js @@ -10,8 +10,10 @@ export async function checkStatus() { return { name, - status: "operational", - message: `App portal responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `App portal responding (HTTP ${res.status}).` + : `Unexpected response from app portal (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/drc.js b/backend/providers/drc.js index 9dfde5b..e2d86cc 100644 --- a/backend/providers/drc.js +++ b/backend/providers/drc.js @@ -1,17 +1,20 @@ export const name = "DRC"; -export const url = "https://wbte.drcedirect.com/PA/portals/pa"; +export const url = "https://status.drcedirect.com/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"; +// Note: /PA/portals/pa returns 500 — /PA/ is the correct probe path. +const PROBE_URL = "https://wbte.drcedirect.com/PA/"; export async function checkStatus() { const res = await fetch(PROBE_URL, { method: "HEAD" }); return { name, - status: "operational", - message: `PA INSIGHT portal responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `PA INSIGHT portal responding (HTTP ${res.status}).` + : `Unexpected response from PA INSIGHT portal (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/edinsight.js b/backend/providers/edinsight.js index 6d118f8..5c1b536 100644 --- a/backend/providers/edinsight.js +++ b/backend/providers/edinsight.js @@ -11,8 +11,10 @@ export async function checkStatus() { return { name, - status: "operational", - message: `Portal responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `Portal responding (HTTP ${res.status}).` + : `Unexpected response from portal (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/follett.js b/backend/providers/follett.js index a520e7e..6541fde 100644 --- a/backend/providers/follett.js +++ b/backend/providers/follett.js @@ -10,8 +10,10 @@ export async function checkStatus() { return { name, - status: "operational", - message: `Destiny portal responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `Destiny portal responding (HTTP ${res.status}).` + : `Unexpected response from Destiny portal (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/mcgraw-hill.js b/backend/providers/mcgraw-hill.js index ea069d9..e494f6f 100644 --- a/backend/providers/mcgraw-hill.js +++ b/backend/providers/mcgraw-hill.js @@ -10,8 +10,10 @@ export async function checkStatus() { return { name, - status: "operational", - message: `ConnectED portal responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `ConnectED portal responding (HTTP ${res.status}).` + : `Unexpected response from ConnectED portal (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/microsoft365.js b/backend/providers/microsoft365.js index aec36bb..f098e11 100644 --- a/backend/providers/microsoft365.js +++ b/backend/providers/microsoft365.js @@ -1,5 +1,5 @@ export const name = "Microsoft 365"; -export const url = "https://admin.microsoft.com/Adminportal/Home#/servicehealth"; +export const url = "https://status.cloud.microsoft/m365/referrer=serviceStatusRedirect"; const GRAPH_HEALTH_URL = "https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/healthOverviews"; diff --git a/backend/providers/promethean.js b/backend/providers/promethean.js index a556fa4..6596d7a 100644 --- a/backend/providers/promethean.js +++ b/backend/providers/promethean.js @@ -10,8 +10,10 @@ export async function checkStatus() { return { name, - status: "operational", - message: `Site responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `Site responding (HTTP ${res.status}).` + : `Unexpected response from site (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/raptor.js b/backend/providers/raptor.js index 52b921b..b671f07 100644 --- a/backend/providers/raptor.js +++ b/backend/providers/raptor.js @@ -26,10 +26,19 @@ export async function checkStatus() { const status = mapStatusCode(overall.status_code); const incidents = result.incidents ?? []; - let message; - if (incidents.length > 0) { - message = incidents + // Only surface incidents still being actively investigated or identified. + // State 300 = Monitoring (fix deployed), 400 = Resolved — exclude both. + const activeIncidents = incidents.filter((i) => { + const messages = i.messages ?? []; + if (messages.length === 0) return true; + const latestState = messages[messages.length - 1].state; + return latestState < 300; + }); + + let message; + if (activeIncidents.length > 0) { + message = activeIncidents .map((i) => { const components = (i.containers_affected ?? []) .map((c) => c.name) diff --git a/backend/providers/raz-kids.js b/backend/providers/raz-kids.js index 2ea1f93..a61943a 100644 --- a/backend/providers/raz-kids.js +++ b/backend/providers/raz-kids.js @@ -15,6 +15,16 @@ const HEADERS = { export async function checkStatus() { const res = await fetch(PROBE_URL, { method: "GET", headers: HEADERS }); + // Cloudflare managed challenge — JS required, not a real error + if (res.headers.get("cf-mitigated") === "challenge") { + return { + name, + status: "unknown", + message: "Cloudflare challenge blocked synthetic check — status cannot be determined.", + lastUpdated: new Date().toISOString(), + }; + } + return { name, status: res.ok ? "operational" : "degraded", diff --git a/backend/providers/school-dismissal-manager.js b/backend/providers/school-dismissal-manager.js index 90f43b4..cdb84f4 100644 --- a/backend/providers/school-dismissal-manager.js +++ b/backend/providers/school-dismissal-manager.js @@ -10,8 +10,10 @@ export async function checkStatus() { return { name, - status: "operational", - message: `Admin portal responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `Admin portal responding (HTTP ${res.status}).` + : `Unexpected response from admin portal (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/sherpadesk.js b/backend/providers/sherpadesk.js index d6ce7ea..4b63223 100644 --- a/backend/providers/sherpadesk.js +++ b/backend/providers/sherpadesk.js @@ -1,17 +1,19 @@ export const name = "SherpaDesk"; -export const url = "https://app.sherpadesk.com/new/login/"; +export const url = "https://nhsd.sherpadesk.com/"; -// 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/"; +// No usable public status API. Synthetic check against the district portal. +// HEAD returns 404 on this host — use GET. +const PROBE_URL = "https://nhsd.sherpadesk.com/"; export async function checkStatus() { - const res = await fetch(PROBE_URL, { method: "HEAD" }); + const res = await fetch(PROBE_URL, { method: "GET" }); return { name, - status: "operational", - message: `App portal responding (HTTP ${res.status}).`, + status: res.ok ? "operational" : "degraded", + message: res.ok + ? `App portal responding (HTTP ${res.status}).` + : `Unexpected response from app portal (HTTP ${res.status}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/providers/spamtitan.js b/backend/providers/spamtitan.js index 58aa113..ce39668 100644 --- a/backend/providers/spamtitan.js +++ b/backend/providers/spamtitan.js @@ -37,11 +37,14 @@ function probe(host) { export async function checkStatus() { const statusCode = await probe(HOST); + const ok = statusCode >= 200 && statusCode < 400; return { name, - status: "operational", - message: `Mail portal responding (HTTP ${statusCode}).`, + status: ok ? "operational" : "degraded", + message: ok + ? `Mail portal responding (HTTP ${statusCode}).` + : `Unexpected response from mail portal (HTTP ${statusCode}).`, lastUpdated: new Date().toISOString(), }; } diff --git a/backend/server.js b/backend/server.js index 60ca9ab..f888032 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2,8 +2,6 @@ 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; @@ -52,23 +50,11 @@ 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/frontend/index.html b/frontend/index.html index f44a3e9..196fd29 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,58 +21,6 @@
-
-
-
-
- Crown Castle -
- — Mbps ↓ - — Mbps ↑ -
-
- -
-
-
- Comcast -
- — Mbps ↓ - — Mbps ↑ -
-
- -
-
-
-
- - FortiGate 1800F -
-
-
- Hostname - -
-
- Version - -
-
- Uptime - -
-
- CPU - -
-
- Memory - -
-
-
-
diff --git a/frontend/js/app.js b/frontend/js/app.js index d73e793..e7dde95 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,9 +1,6 @@ // 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 }; @@ -225,178 +222,8 @@ 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);