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:
@@ -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 operating normally.",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
let overall = "operational";
|
||||
const descriptions = [];
|
||||
|
||||
for (const incident of active) {
|
||||
const mapped = STATUS_MAP[incident.status_impact] ?? "degraded";
|
||||
overall = worstStatus(overall, mapped);
|
||||
|
||||
const services = (incident.affected_products ?? [])
|
||||
.map((p) => p.title)
|
||||
.join(", ");
|
||||
const label = services || incident.service_name || "Unknown service";
|
||||
const impact = IMPACT_LABEL[incident.status_impact] ?? "incident";
|
||||
descriptions.push(`${label}: ${impact}`);
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: "All services running normally.",
|
||||
status: overall,
|
||||
message: descriptions.join(" | "),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: "outage",
|
||||
message: `No response from ${CHECK_HOST}:${CHECK_PORT} — internet may be down.`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
const status = latencyMs >= 100 ? "degraded" : "operational";
|
||||
const message = latencyMs >= 100
|
||||
? `High latency to ${CHECK_HOST}: ${latencyMs}ms.`
|
||||
: `Internet reachable — ${CHECK_HOST} responded in ${latencyMs}ms.`;
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: "Firewall uptime 42d. WAN throughput nominal.",
|
||||
status,
|
||||
message,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user