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:
Klein
2026-06-07 08:37:10 -04:00
parent 51eb3bf7c8
commit 09404db559
19 changed files with 95 additions and 521 deletions
-97
View File
@@ -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
);
}
-118
View File
@@ -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
);
}
+4 -2
View File
@@ -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(),
};
}
+4 -2
View File
@@ -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(),
};
}
+7 -4
View File
@@ -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(),
};
}
+4 -2
View File
@@ -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(),
};
}
+4 -2
View File
@@ -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(),
};
}
+4 -2
View File
@@ -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 -1
View File
@@ -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";
+4 -2
View File
@@ -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(),
};
}
+12 -3
View File
@@ -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)
+10
View File
@@ -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(),
};
}
+9 -7
View File
@@ -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(),
};
}
+5 -2
View File
@@ -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(),
};
}
-14
View File
@@ -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);