Add Gitea remote info to CLAUDE.md; implement vendor integrations and remove FortiGate modules
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,97 +0,0 @@
|
||||
import https from "https";
|
||||
|
||||
const HOST = process.env.FORTIGATE_HOST;
|
||||
const TOKEN = process.env.FORTIGATE_API_TOKEN;
|
||||
|
||||
const POLL_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
let cached = null;
|
||||
|
||||
function fetchJson(urlStr, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(urlStr);
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname + url.search,
|
||||
method: "GET",
|
||||
headers,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
} else {
|
||||
try { resolve(JSON.parse(body)); }
|
||||
catch { reject(new Error("Invalid JSON in response")); }
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function formatUptime(seconds) {
|
||||
const d = Math.floor(seconds / 86400);
|
||||
const h = Math.floor((seconds % 86400) / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
const headers = { Authorization: `Bearer ${TOKEN}` };
|
||||
const base = `https://${HOST}`;
|
||||
|
||||
const [statusData, resourceData] = await Promise.all([
|
||||
fetchJson(`${base}/api/v2/monitor/system/status`, headers),
|
||||
fetchJson(`${base}/api/v2/monitor/system/resource/usage`, headers),
|
||||
]);
|
||||
|
||||
const r = statusData.results ?? {};
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const uptimeSec = r.utc_last_reboot
|
||||
? nowSec - r.utc_last_reboot
|
||||
: null;
|
||||
|
||||
const cpuPct = resourceData.results?.cpu?.[0]?.current ?? null;
|
||||
const memPct = resourceData.results?.mem?.[0]?.current ?? null;
|
||||
|
||||
cached = {
|
||||
hostname: r.hostname ?? "FortiGate",
|
||||
model: r.model_number ?? "1800F",
|
||||
version: statusData.version ?? "—",
|
||||
uptimeSeconds: uptimeSec,
|
||||
uptimeLabel: uptimeSec !== null ? formatUptime(uptimeSec) : "—",
|
||||
cpuPct,
|
||||
memPct,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getHealth() {
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function startPolling() {
|
||||
if (!HOST || !TOKEN) {
|
||||
console.warn("[fg-health] FORTIGATE_HOST or FORTIGATE_API_TOKEN not set — health polling disabled.");
|
||||
return;
|
||||
}
|
||||
poll().catch((err) =>
|
||||
console.error("[fg-health] Initial poll failed:", err.message)
|
||||
);
|
||||
setInterval(
|
||||
() => poll().catch((err) =>
|
||||
console.error("[fg-health] Poll failed:", err.message)
|
||||
),
|
||||
POLL_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import https from "https";
|
||||
|
||||
const HOST = process.env.FORTIGATE_HOST;
|
||||
const TOKEN = process.env.FORTIGATE_API_TOKEN;
|
||||
const WAN1_IF = process.env.FORTIGATE_WAN1_INTERFACE;
|
||||
const WAN1_LABEL = process.env.FORTIGATE_WAN1_LABEL ?? WAN1_IF;
|
||||
const WAN2_IF = process.env.FORTIGATE_WAN2_INTERFACE;
|
||||
const WAN2_LABEL = process.env.FORTIGATE_WAN2_LABEL ?? WAN2_IF;
|
||||
|
||||
const POLL_INTERVAL_MS = 30_000;
|
||||
const HISTORY_POINTS = 60; // 30 minutes at 30s intervals
|
||||
|
||||
let history = [];
|
||||
let prevReading = null;
|
||||
|
||||
// Use the https module directly so we can disable rejectUnauthorized —
|
||||
// FortiGate management interface uses a self-signed cert.
|
||||
function fetchJson(urlStr, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(urlStr);
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: url.hostname,
|
||||
port: url.port || 443,
|
||||
path: url.pathname + url.search,
|
||||
method: "GET",
|
||||
headers,
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(new Error(`HTTP ${res.statusCode}`));
|
||||
} else {
|
||||
try { resolve(JSON.parse(body)); }
|
||||
catch (e) { reject(new Error("Invalid JSON in response")); }
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function getIfBytes(data, ifname) {
|
||||
const results = data?.results;
|
||||
if (!results) return null;
|
||||
// FortiOS may return results as an object keyed by interface name or as an array
|
||||
const iface = Array.isArray(results)
|
||||
? results.find((i) => i.name === ifname)
|
||||
: results[ifname];
|
||||
if (!iface) return null;
|
||||
return { rx: iface.rx_bytes ?? 0, tx: iface.tx_bytes ?? 0 };
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
const data = await fetchJson(
|
||||
`https://${HOST}/api/v2/monitor/system/interface`,
|
||||
{ Authorization: `Bearer ${TOKEN}` }
|
||||
);
|
||||
|
||||
const now = Date.now();
|
||||
const wan1 = getIfBytes(data, WAN1_IF);
|
||||
const wan2 = getIfBytes(data, WAN2_IF);
|
||||
|
||||
if (!wan1 || !wan2) {
|
||||
console.warn("[throughput] Interface not found in FortiGate response — check interface names in .env");
|
||||
return;
|
||||
}
|
||||
|
||||
if (prevReading) {
|
||||
const elapsed = (now - prevReading.timestamp) / 1000; // seconds
|
||||
const mbps = (curr, prev) =>
|
||||
Math.max(0, ((curr - prev) * 8) / elapsed / 1_000_000);
|
||||
|
||||
const point = {
|
||||
timestamp: now,
|
||||
wan1: {
|
||||
label: WAN1_LABEL,
|
||||
rxMbps: mbps(wan1.rx, prevReading.wan1.rx),
|
||||
txMbps: mbps(wan1.tx, prevReading.wan1.tx),
|
||||
},
|
||||
wan2: {
|
||||
label: WAN2_LABEL,
|
||||
rxMbps: mbps(wan2.rx, prevReading.wan2.rx),
|
||||
txMbps: mbps(wan2.tx, prevReading.wan2.tx),
|
||||
},
|
||||
};
|
||||
|
||||
history.push(point);
|
||||
if (history.length > HISTORY_POINTS) history.shift();
|
||||
}
|
||||
|
||||
prevReading = { timestamp: now, wan1, wan2 };
|
||||
}
|
||||
|
||||
export function getHistory() {
|
||||
return history;
|
||||
}
|
||||
|
||||
export function startPolling() {
|
||||
if (!HOST || !TOKEN) {
|
||||
console.warn("[throughput] FORTIGATE_HOST or FORTIGATE_API_TOKEN not set — throughput polling disabled.");
|
||||
return;
|
||||
}
|
||||
poll().catch((err) =>
|
||||
console.error("[throughput] Initial poll failed:", err.message)
|
||||
);
|
||||
setInterval(
|
||||
() => poll().catch((err) =>
|
||||
console.error("[throughput] Poll failed:", err.message)
|
||||
),
|
||||
POLL_INTERVAL_MS
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,10 @@ export async function checkStatus() {
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `App portal responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `App portal responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from app portal (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ export async function checkStatus() {
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `App portal responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `App portal responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from app portal (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
export const name = "DRC";
|
||||
export const url = "https://wbte.drcedirect.com/PA/portals/pa";
|
||||
export const url = "https://status.drcedirect.com/PA";
|
||||
|
||||
// status.drcedirect.com is a JS-rendered Angular app with no accessible API.
|
||||
// Synthetic check against the PA INSIGHT portal instead.
|
||||
const PROBE_URL = "https://wbte.drcedirect.com/PA/portals/pa";
|
||||
// Note: /PA/portals/pa returns 500 — /PA/ is the correct probe path.
|
||||
const PROBE_URL = "https://wbte.drcedirect.com/PA/";
|
||||
|
||||
export async function checkStatus() {
|
||||
const res = await fetch(PROBE_URL, { method: "HEAD" });
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `PA INSIGHT portal responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `PA INSIGHT portal responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from PA INSIGHT portal (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,8 +11,10 @@ export async function checkStatus() {
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `Portal responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `Portal responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from portal (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ export async function checkStatus() {
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `Destiny portal responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `Destiny portal responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from Destiny portal (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ export async function checkStatus() {
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `ConnectED portal responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `ConnectED portal responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from ConnectED portal (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const name = "Microsoft 365";
|
||||
export const url = "https://admin.microsoft.com/Adminportal/Home#/servicehealth";
|
||||
export const url = "https://status.cloud.microsoft/m365/referrer=serviceStatusRedirect";
|
||||
|
||||
const GRAPH_HEALTH_URL =
|
||||
"https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/healthOverviews";
|
||||
|
||||
@@ -10,8 +10,10 @@ export async function checkStatus() {
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `Site responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `Site responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from site (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,10 +26,19 @@ export async function checkStatus() {
|
||||
const status = mapStatusCode(overall.status_code);
|
||||
|
||||
const incidents = result.incidents ?? [];
|
||||
let message;
|
||||
|
||||
if (incidents.length > 0) {
|
||||
message = incidents
|
||||
// Only surface incidents still being actively investigated or identified.
|
||||
// State 300 = Monitoring (fix deployed), 400 = Resolved — exclude both.
|
||||
const activeIncidents = incidents.filter((i) => {
|
||||
const messages = i.messages ?? [];
|
||||
if (messages.length === 0) return true;
|
||||
const latestState = messages[messages.length - 1].state;
|
||||
return latestState < 300;
|
||||
});
|
||||
|
||||
let message;
|
||||
if (activeIncidents.length > 0) {
|
||||
message = activeIncidents
|
||||
.map((i) => {
|
||||
const components = (i.containers_affected ?? [])
|
||||
.map((c) => c.name)
|
||||
|
||||
@@ -15,6 +15,16 @@ const HEADERS = {
|
||||
export async function checkStatus() {
|
||||
const res = await fetch(PROBE_URL, { method: "GET", headers: HEADERS });
|
||||
|
||||
// Cloudflare managed challenge — JS required, not a real error
|
||||
if (res.headers.get("cf-mitigated") === "challenge") {
|
||||
return {
|
||||
name,
|
||||
status: "unknown",
|
||||
message: "Cloudflare challenge blocked synthetic check — status cannot be determined.",
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
|
||||
@@ -10,8 +10,10 @@ export async function checkStatus() {
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `Admin portal responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `Admin portal responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from admin portal (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
export const name = "SherpaDesk";
|
||||
export const url = "https://app.sherpadesk.com/new/login/";
|
||||
export const url = "https://nhsd.sherpadesk.com/";
|
||||
|
||||
// No usable public status API — status.sherpadesk.com is a Pingdom uptime
|
||||
// report page with an invalid cert. Synthetic check against the app portal.
|
||||
const PROBE_URL = "https://app.sherpadesk.com/new/login/";
|
||||
// No usable public status API. Synthetic check against the district portal.
|
||||
// HEAD returns 404 on this host — use GET.
|
||||
const PROBE_URL = "https://nhsd.sherpadesk.com/";
|
||||
|
||||
export async function checkStatus() {
|
||||
const res = await fetch(PROBE_URL, { method: "HEAD" });
|
||||
const res = await fetch(PROBE_URL, { method: "GET" });
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `App portal responding (HTTP ${res.status}).`,
|
||||
status: res.ok ? "operational" : "degraded",
|
||||
message: res.ok
|
||||
? `App portal responding (HTTP ${res.status}).`
|
||||
: `Unexpected response from app portal (HTTP ${res.status}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,11 +37,14 @@ function probe(host) {
|
||||
|
||||
export async function checkStatus() {
|
||||
const statusCode = await probe(HOST);
|
||||
const ok = statusCode >= 200 && statusCode < 400;
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "operational",
|
||||
message: `Mail portal responding (HTTP ${statusCode}).`,
|
||||
status: ok ? "operational" : "degraded",
|
||||
message: ok
|
||||
? `Mail portal responding (HTTP ${statusCode}).`
|
||||
: `Unexpected response from mail portal (HTTP ${statusCode}).`,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ import "dotenv/config";
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { providers } from "./providers/index.js";
|
||||
import { startPolling as startThroughputPolling, getHistory as getThroughputHistory } from "./fortigate-throughput.js";
|
||||
import { startPolling as startHealthPolling, getHealth as getFgHealth } from "./fortigate-health.js";
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
@@ -52,23 +50,11 @@ app.get("/api/status", (_req, res) => {
|
||||
res.json(cachedStatuses);
|
||||
});
|
||||
|
||||
app.get("/api/throughput", (_req, res) => {
|
||||
res.json(getThroughputHistory());
|
||||
});
|
||||
|
||||
app.get("/api/fortigate-health", (_req, res) => {
|
||||
const health = getFgHealth();
|
||||
if (!health) return res.status(503).json({ error: "Health data not yet available." });
|
||||
res.json(health);
|
||||
});
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true, providers: providers.length });
|
||||
});
|
||||
|
||||
// Poll immediately, then on interval
|
||||
startThroughputPolling();
|
||||
startHealthPolling();
|
||||
await pollAll();
|
||||
setInterval(pollAll, POLL_INTERVAL);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user