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,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 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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user