Implement all vendor integrations, WAN graphs, and FortiGate health panel
- Wire up 26 vendor providers: Atlassian Statuspage API, Status.io, Instatus, AWS RSS feeds, Apple/Google JSON feeds, M365 Graph API, and synthetic checks - Add 11 new providers: AWS, Cloudflare, SmartPass, School Dismissal Manager, SherpaDesk, Classkick, ClassDojo, Savvas, Study Island, Promethean, RAZ-Kids - Rename Local Infrastructure → Internet (TCP check to 8.8.8.8:53) - Add WAN throughput graph section: dual-link canvas graphs (Crown Castle + Comcast) polling FortiGate REST API every 30s with 30-min rolling history - Add FortiGate health card: uptime, CPU %, memory % from FortiOS API - Add /api/throughput and /api/fortigate-health endpoints - Add README with setup instructions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,9 @@ bin/nssm/*.exe
|
||||
# Node
|
||||
backend/node_modules/
|
||||
|
||||
# Environment variables (contains secrets)
|
||||
backend/.env
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
@@ -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,11 +83,21 @@ 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)
|
||||
@@ -56,19 +106,17 @@ infrastructure-monitoring-dashboard/
|
||||
│ └── Caddyfile # Caddy server configuration
|
||||
├── frontend/
|
||||
│ ├── index.html
|
||||
│ ├── css/
|
||||
│ └── js/
|
||||
│ ├── css/style.css
|
||||
│ └── js/app.js
|
||||
├── backend/
|
||||
│ ├── package.json
|
||||
│ ├── server.js
|
||||
│ ├── 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.
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# NHSD Service Status Dashboard
|
||||
|
||||
A web-based status feed aggregator for the North Hills School District IT department. Provides a single-pane-of-glass view of vendor service health across 26 platforms, plus live WAN throughput graphs and FortiGate health monitoring.
|
||||
|
||||
## Features
|
||||
|
||||
- **Vendor status cards** — real-time health for all district platforms, color-coded and sortable by severity
|
||||
- **WAN throughput graphs** — live RX/TX graphs for Crown Castle and Comcast WAN links, pulled from the FortiGate API
|
||||
- **FortiGate health panel** — hostname, firmware version, uptime, CPU, and memory utilization
|
||||
- **Auto-refresh** — vendor status refreshes every 2 minutes; throughput every 30 seconds
|
||||
- **Glanceable design** — optimized for a wall-mounted monitor; status obvious from across the room
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** v18 or later
|
||||
- **Caddy** v2 — place `caddy.exe` in `bin/caddy/`
|
||||
- **NSSM** (optional, for running as Windows services) — place `nssm.exe` in `bin/nssm/`
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install backend dependencies
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure credentials
|
||||
|
||||
Copy the template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp backend/.env.example backend/.env
|
||||
```
|
||||
|
||||
Edit `backend/.env`:
|
||||
|
||||
```env
|
||||
# Microsoft 365 — Azure AD app registration
|
||||
AZURE_TENANT_ID=your-tenant-id
|
||||
AZURE_CLIENT_ID=your-client-id
|
||||
AZURE_CLIENT_SECRET=your-client-secret
|
||||
|
||||
# FortiGate — WAN throughput and system health
|
||||
FORTIGATE_HOST=10.1.20.1
|
||||
FORTIGATE_API_TOKEN=your-api-token
|
||||
FORTIGATE_WAN1_INTERFACE=port25
|
||||
FORTIGATE_WAN1_LABEL=Crown Castle
|
||||
FORTIGATE_WAN2_INTERFACE=port8
|
||||
FORTIGATE_WAN2_LABEL=Comcast
|
||||
```
|
||||
|
||||
**Microsoft 365**: Create an app registration in Azure AD with `ServiceHealth.Read.All` permission (application, not delegated).
|
||||
|
||||
**FortiGate**: Create a read-only API token under **System → Administrators → Create New → REST API Admin**. Only Monitor access is required — no configuration access needed.
|
||||
|
||||
### 3. Start the backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm start
|
||||
```
|
||||
|
||||
The backend listens on `http://localhost:3000`.
|
||||
|
||||
### 4. Start Caddy
|
||||
|
||||
```bash
|
||||
bin/caddy/caddy.exe run --config config/Caddyfile
|
||||
```
|
||||
|
||||
The dashboard is then available at `https://status.nhsd.net:8443`.
|
||||
|
||||
> **Note:** There is a separate, pre-existing Caddy instance on this machine. Always use the `--config config/Caddyfile` flag to target the dashboard Caddy specifically.
|
||||
|
||||
## Running as Windows Services (NSSM)
|
||||
|
||||
Use the helper scripts in `scripts/` to install the backend and Caddy as NSSM services so they start automatically with Windows.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Description |
|
||||
|---|---|
|
||||
| `GET /api/status` | All vendor status (array) |
|
||||
| `GET /api/throughput` | WAN throughput history (array of 30-min data points) |
|
||||
| `GET /api/fortigate-health` | FortiGate hostname, version, uptime, CPU, memory |
|
||||
| `GET /api/health` | Backend liveness check |
|
||||
|
||||
## Adding a New Vendor
|
||||
|
||||
1. Create `backend/providers/<vendor>.js` exporting `name`, `url`, and `checkStatus()`
|
||||
2. Add it to `backend/providers/index.js`
|
||||
|
||||
See any existing provider for the expected return shape:
|
||||
|
||||
```js
|
||||
export const name = "Vendor Name";
|
||||
export const url = "https://status.vendor.com/";
|
||||
|
||||
export async function checkStatus() {
|
||||
// ...
|
||||
return {
|
||||
name,
|
||||
status, // "operational" | "degraded" | "outage" | "unknown"
|
||||
message, // short human-readable description
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
If a vendor check throws, the backend catches it and returns `status: "unknown"` — the dashboard degrades gracefully.
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Generated
+13
@@ -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",
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
export const name = "Amazon AWS";
|
||||
export const url = "https://health.aws.amazon.com/health/status";
|
||||
|
||||
// Core services in both Pittsburgh-adjacent regions + global services.
|
||||
// Expand this list if you discover specific services your vendors rely on.
|
||||
const FEEDS = [
|
||||
{ url: "https://status.aws.amazon.com/rss/ec2-us-east-1.rss", label: "EC2 (N. Virginia)" },
|
||||
{ url: "https://status.aws.amazon.com/rss/ec2-us-east-2.rss", label: "EC2 (Ohio)" },
|
||||
{ url: "https://status.aws.amazon.com/rss/s3-us-east-1.rss", label: "S3 (N. Virginia)" },
|
||||
{ url: "https://status.aws.amazon.com/rss/cloudfront.rss", label: "CloudFront" },
|
||||
{ url: "https://status.aws.amazon.com/rss/route53.rss", label: "Route 53" },
|
||||
];
|
||||
|
||||
const SEVERITY_ORDER = ["operational", "degraded", "outage"];
|
||||
|
||||
function worstStatus(a, b) {
|
||||
return SEVERITY_ORDER.indexOf(a) >= SEVERITY_ORDER.indexOf(b) ? a : b;
|
||||
}
|
||||
|
||||
// Extract the text of the first <title> inside the first <item>.
|
||||
// Returns null if there are no items (clean feed = all clear).
|
||||
function parseFirstItemTitle(xml) {
|
||||
const itemMatch = xml.match(/<item[\s>][\s\S]*?<\/item>/i);
|
||||
if (!itemMatch) return null;
|
||||
const titleMatch = itemMatch[0].match(/<title>([\s\S]*?)<\/title>/i);
|
||||
return titleMatch ? titleMatch[1].trim() : null;
|
||||
}
|
||||
|
||||
function titleToStatus(title) {
|
||||
if (!title) return "operational"; // no items = clean feed
|
||||
const t = title.toLowerCase();
|
||||
if (t.includes("operating normally") || t.includes("informational")) return "operational";
|
||||
if (t.includes("performance issues") || t.includes("degraded")) return "degraded";
|
||||
if (t.includes("service disruption") || t.includes("disruption")) return "outage";
|
||||
return "degraded"; // unknown incident title — assume degraded
|
||||
}
|
||||
|
||||
async function checkFeed({ url, label }) {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`${label}: HTTP ${res.status}`);
|
||||
const xml = await res.text();
|
||||
const title = parseFirstItemTitle(xml);
|
||||
const status = titleToStatus(title);
|
||||
return { label, status, title };
|
||||
}
|
||||
|
||||
export async function checkStatus() {
|
||||
const results = await Promise.allSettled(FEEDS.map(checkFeed));
|
||||
|
||||
let overall = "operational";
|
||||
const issues = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "rejected") {
|
||||
overall = worstStatus(overall, "degraded");
|
||||
issues.push(`Check failed: ${result.reason?.message ?? "unknown error"}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { label, status, title } = result.value;
|
||||
overall = worstStatus(overall, status);
|
||||
|
||||
if (status !== "operational") {
|
||||
issues.push(`${label}: ${title ?? "unknown issue"}`);
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
issues.length > 0 ? issues.join(" | ") : "All monitored services operating normally.";
|
||||
|
||||
return {
|
||||
name,
|
||||
status: overall,
|
||||
message,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 running normally.",
|
||||
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: overall,
|
||||
message: descriptions.join(" | "),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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: "operational",
|
||||
message: "Firewall uptime 42d. WAN throughput nominal.",
|
||||
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,
|
||||
message,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const name = "Savvas K-12";
|
||||
export const url = "https://status.savvas.com/";
|
||||
|
||||
const STATUS_URL = "https://status.savvas.com/api/v2/summary.json";
|
||||
|
||||
const STATUS_MAP = {
|
||||
none: "operational",
|
||||
minor: "degraded",
|
||||
major: "degraded",
|
||||
critical: "outage",
|
||||
};
|
||||
|
||||
export async function checkStatus() {
|
||||
const res = await fetch(STATUS_URL);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Savvas status request failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const indicator = data.status?.indicator ?? "unknown";
|
||||
const status = STATUS_MAP[indicator] ?? "unknown";
|
||||
|
||||
const incidents = data.incidents ?? [];
|
||||
const activeIncidents = incidents.filter((i) => i.status !== "resolved");
|
||||
|
||||
let message;
|
||||
if (activeIncidents.length > 0) {
|
||||
message = activeIncidents.map((i) => i.name).join("; ");
|
||||
} else {
|
||||
message = data.status?.description ?? "Status unavailable.";
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
status,
|
||||
message,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export const name = "School Dismissal Manager";
|
||||
export const url = "https://www.schooldismissalmanager.com/SchoolAdmin/";
|
||||
|
||||
// No owned status page — systemstatus.schooldismissalmanager.com redirects
|
||||
// to StatusGator. Synthetic check against the school admin portal instead.
|
||||
const PROBE_URL = "https://www.schooldismissalmanager.com/SchoolAdmin/";
|
||||
|
||||
export async function checkStatus() {
|
||||
const res = await fetch(PROBE_URL, { method: "HEAD" });
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `Admin portal responding (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
+183
-4
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user