mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
Alert rules already auto-migrate into automations on boot and the old engine stands down after. Deletes the rule/firing tables, routes, UI, and AI tools; keeps notification channels since automations still use them. Also fixes automations' notify step, which still called the deleted repository.
250 lines
7.2 KiB
TypeScript
250 lines
7.2 KiB
TypeScript
import type { Express } from "express";
|
|
import type { Client } from "ssh2";
|
|
import type { AuthenticatedRequest } from "../../../../src/types/index.js";
|
|
import { execCommand } from "../../../../src/backend/hosts/metrics-shared/common-utils.js";
|
|
import { createCurrentHostHealthRepository } from "../../../../src/backend/database/repositories/factory.js";
|
|
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
|
import { shellSingleQuote } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
|
import { isValidPort } from "../../../../src/backend/hosts/metrics-shared/validation.js";
|
|
import type { ManagerRoutesDeps } from "./types.js";
|
|
import { notifyAutomationHealthCheck } from "../automation-bridge.js";
|
|
|
|
export interface HealthCheck {
|
|
id: string;
|
|
name: string;
|
|
type: "tcp" | "http";
|
|
target: string;
|
|
port?: number;
|
|
path?: string;
|
|
}
|
|
|
|
export interface HealthResult {
|
|
checkId: string;
|
|
ok: boolean;
|
|
latencyMs: number | null;
|
|
detail: string;
|
|
}
|
|
|
|
const TARGET_RE = /^[A-Za-z0-9.\-_:]+$/;
|
|
const PATH_RE = /^\/[A-Za-z0-9._~!$&'()*+,;=:@/%-]*$/;
|
|
|
|
export function isValidHealthCheck(c: unknown): c is HealthCheck {
|
|
if (!c || typeof c !== "object") return false;
|
|
const o = c as Record<string, unknown>;
|
|
if (typeof o.id !== "string" || !o.id) return false;
|
|
if (typeof o.name !== "string") return false;
|
|
if (o.type !== "tcp" && o.type !== "http") return false;
|
|
if (typeof o.target !== "string" || !TARGET_RE.test(o.target)) return false;
|
|
if (o.type === "tcp" && !isValidPort(o.port)) return false;
|
|
if (
|
|
o.path !== undefined &&
|
|
(typeof o.path !== "string" || !PATH_RE.test(o.path))
|
|
)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
/** Build a command that runs the check from the host and prints "ok latency". */
|
|
export function buildHealthCheckCommand(check: HealthCheck): string {
|
|
if (check.type === "tcp") {
|
|
const host = shellSingleQuote(check.target);
|
|
const port = check.port;
|
|
// Prefer bash /dev/tcp; time it with date in ms.
|
|
return `start=$(date +%s%3N); if timeout 3 bash -c '</dev/tcp/'${host}'/'${port} 2>/dev/null; then echo "ok $(( $(date +%s%3N) - start ))"; else echo "fail $(( $(date +%s%3N) - start ))"; fi`;
|
|
}
|
|
// http
|
|
const scheme = check.target.includes("://") ? "" : "http://";
|
|
const url = shellSingleQuote(`${scheme}${check.target}${check.path ?? ""}`);
|
|
return `curl -s -o /dev/null -m 5 -w '%{http_code} %{time_total}' ${url} || echo '000 0'`;
|
|
}
|
|
|
|
export function parseHealthResult(
|
|
check: HealthCheck,
|
|
output: string,
|
|
): HealthResult {
|
|
const line = output.trim().split("\n").pop() ?? "";
|
|
if (check.type === "tcp") {
|
|
const [status, ms] = line.split(/\s+/);
|
|
return {
|
|
checkId: check.id,
|
|
ok: status === "ok",
|
|
latencyMs: Number(ms) || null,
|
|
detail: status === "ok" ? "open" : "closed/timeout",
|
|
};
|
|
}
|
|
const m = line.match(/^(\d{3})\s+([\d.]+)/);
|
|
if (!m)
|
|
return { checkId: check.id, ok: false, latencyMs: null, detail: line };
|
|
const code = Number(m[1]);
|
|
return {
|
|
checkId: check.id,
|
|
ok: code >= 200 && code < 400,
|
|
latencyMs: Math.round(Number(m[2]) * 1000),
|
|
detail: `HTTP ${code}`,
|
|
};
|
|
}
|
|
|
|
async function runChecks(
|
|
client: Client,
|
|
checks: HealthCheck[],
|
|
): Promise<HealthResult[]> {
|
|
return Promise.all(
|
|
checks.map(async (check) => {
|
|
try {
|
|
const { stdout } = await execCommand(
|
|
client,
|
|
buildHealthCheckCommand(check),
|
|
8000,
|
|
);
|
|
return parseHealthResult(check, stdout);
|
|
} catch {
|
|
return {
|
|
checkId: check.id,
|
|
ok: false,
|
|
latencyMs: null,
|
|
detail: "error",
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
const HISTORY_KEEP = 500;
|
|
|
|
function recordHistory(
|
|
userId: string,
|
|
hostId: number,
|
|
results: HealthResult[],
|
|
): Promise<void> {
|
|
return createCurrentHostHealthRepository()
|
|
.recordHistory(userId, hostId, results, HISTORY_KEEP)
|
|
.then(() => undefined);
|
|
}
|
|
|
|
async function loadChecks(
|
|
userId: string,
|
|
hostId: number,
|
|
): Promise<HealthCheck[]> {
|
|
const row = await createCurrentHostHealthRepository().findChecksByUserAndHost(
|
|
userId,
|
|
hostId,
|
|
);
|
|
if (!row?.checks) return [];
|
|
try {
|
|
const parsed = JSON.parse(row.checks);
|
|
return Array.isArray(parsed) ? parsed.filter(isValidHealthCheck) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export function registerHealthRoutes(
|
|
app: Express,
|
|
{ validateHostId, runOnHost }: ManagerRoutesDeps,
|
|
): void {
|
|
app.get(
|
|
"/host-metrics/managers/health/:id",
|
|
validateHostId,
|
|
managerHandler(
|
|
runOnHost,
|
|
"connect",
|
|
"health_get",
|
|
async (client, host, req) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const checks = await loadChecks(userId, host.id);
|
|
const results = checks.length ? await runChecks(client, checks) : [];
|
|
if (results.length) {
|
|
await recordHistory(userId, host.id, results);
|
|
for (const r of results) {
|
|
notifyAutomationHealthCheck(
|
|
host.id,
|
|
userId,
|
|
r.checkId,
|
|
r.ok,
|
|
r.detail ?? undefined,
|
|
);
|
|
}
|
|
}
|
|
|
|
const history = (
|
|
await createCurrentHostHealthRepository().listHistory(
|
|
userId,
|
|
host.id,
|
|
200,
|
|
)
|
|
).map((row) => ({
|
|
checkId: row.checkId,
|
|
ts: row.ts,
|
|
ok: row.ok,
|
|
latencyMs: row.latencyMs,
|
|
detail: row.detail,
|
|
}));
|
|
return { checks, results, history };
|
|
},
|
|
),
|
|
);
|
|
|
|
app.post(
|
|
"/host-metrics/managers/health/:id/config",
|
|
validateHostId,
|
|
managerHandler(
|
|
runOnHost,
|
|
"connect",
|
|
"health_config",
|
|
async (_client, host, req) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const { checks, intervalSeconds } = req.body as {
|
|
checks?: unknown;
|
|
intervalSeconds?: number;
|
|
};
|
|
if (!Array.isArray(checks) || !checks.every(isValidHealthCheck)) {
|
|
throw new ManagerInputError("Invalid checks");
|
|
}
|
|
const interval =
|
|
typeof intervalSeconds === "number" &&
|
|
intervalSeconds >= 30 &&
|
|
intervalSeconds <= 86400
|
|
? Math.round(intervalSeconds)
|
|
: 300;
|
|
const now = new Date().toISOString();
|
|
await createCurrentHostHealthRepository().upsertChecks(
|
|
userId,
|
|
host.id,
|
|
JSON.stringify(checks),
|
|
interval,
|
|
now,
|
|
);
|
|
return { success: true };
|
|
},
|
|
),
|
|
);
|
|
|
|
app.post(
|
|
"/host-metrics/managers/health/:id/run",
|
|
validateHostId,
|
|
managerHandler(
|
|
runOnHost,
|
|
"connect",
|
|
"health_run",
|
|
async (client, host, req) => {
|
|
const userId = (req as AuthenticatedRequest).userId;
|
|
const checks = await loadChecks(userId, host.id);
|
|
const results = await runChecks(client, checks);
|
|
if (results.length) {
|
|
await recordHistory(userId, host.id, results);
|
|
for (const r of results) {
|
|
notifyAutomationHealthCheck(
|
|
host.id,
|
|
userId,
|
|
r.checkId,
|
|
r.ok,
|
|
r.detail ?? undefined,
|
|
);
|
|
}
|
|
}
|
|
return { results };
|
|
},
|
|
),
|
|
);
|
|
}
|