mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat(automations): add per-host maintenance mode and recurring schedules (#1521)
This commit is contained in:
1 parent
fd91d4925a
commit
99cffc9ba6
26 files changed
+1813
-6
No files matched your search
@@ -9,6 +9,9 @@ import { registerRoutes } from "./routes.js";
|
||||
import { createScheduler, STARTUP_DELAY_MS, TICK_MS } from "./scheduler.js";
|
||||
import { createAutomationsService } from "./service.js";
|
||||
import { createTriggers } from "./triggers.js";
|
||||
import { createMaintenanceRepository } from "./maintenance-repository.js";
|
||||
import { createMaintenanceService } from "./maintenance-service.js";
|
||||
import { registerMaintenanceRoutes } from "./maintenance-routes.js";
|
||||
|
||||
export type { AutomationsAccessV1, AutomationSummary } from "./service.js";
|
||||
|
||||
@@ -17,9 +20,30 @@ export async function activate(ctx: PluginContext) {
|
||||
const channels = await ctx.notify.channels();
|
||||
return channels.map((channel) => channel.id);
|
||||
});
|
||||
const maintenance = createMaintenanceService(
|
||||
ctx,
|
||||
await createMaintenanceRepository(ctx.db),
|
||||
async (userId, hostId) => {
|
||||
for (const state of await repository.listOpenBreaches()) {
|
||||
if (Number(state.stateKey.split(":")[0]) !== hostId) continue;
|
||||
const automation = await repository.findById(state.automationId);
|
||||
if (automation?.userId === userId)
|
||||
await repository.clearBreach(state.automationId, state.stateKey);
|
||||
}
|
||||
},
|
||||
);
|
||||
ctx.events.on("user.data_wiped", (payload) => {
|
||||
const userId = (payload as { userId?: string } | undefined)?.userId;
|
||||
if (userId) return maintenance.wipeUser(userId);
|
||||
});
|
||||
const deps = createDeps(ctx.services);
|
||||
const engine = new AutomationEngine(ctx, repository, deps);
|
||||
const triggers = createTriggers(repository, engine, ctx.log);
|
||||
const triggers = createTriggers(
|
||||
repository,
|
||||
engine,
|
||||
ctx.log,
|
||||
maintenance.isMaintaining,
|
||||
);
|
||||
triggers.subscribe(ctx.events);
|
||||
|
||||
const viewers = createHeadlessViewers(ctx, deps, () =>
|
||||
@@ -40,15 +64,22 @@ export async function activate(ctx: PluginContext) {
|
||||
repository,
|
||||
engine,
|
||||
log: ctx.log,
|
||||
reconcile: [() => viewers.reconcile(), (now) => docker.reconcile(now)],
|
||||
reconcile: [
|
||||
() => maintenance.tick(),
|
||||
() => viewers.reconcile(),
|
||||
(now) => docker.reconcile(now),
|
||||
],
|
||||
isMaintaining: maintenance.isMaintaining,
|
||||
});
|
||||
ctx.schedule.after(STARTUP_DELAY_MS, () => scheduler.tick());
|
||||
ctx.schedule.every(TICK_MS, () => scheduler.tick());
|
||||
|
||||
const router = ctx.http.router<Router>({ public: ["/webhook/:token"] });
|
||||
registerMaintenanceRoutes(router, ctx, maintenance);
|
||||
registerRoutes(
|
||||
// The webhook route authenticates on its own per-automation token rather
|
||||
// than a session, which is the point of an inbound webhook.
|
||||
ctx.http.router<Router>({ public: ["/webhook/:token"] }),
|
||||
router,
|
||||
{ ctx, repository, engine, deps },
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
import { maintenanceTable } from "./tables.js";
|
||||
import { emptyMaintenance, type HostMaintenance } from "../maintenance.js";
|
||||
|
||||
export interface MaintenanceRow {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
state: HostMaintenance;
|
||||
}
|
||||
export type MaintenanceRepository = Awaited<
|
||||
ReturnType<typeof createMaintenanceRepository>
|
||||
>;
|
||||
|
||||
export async function createMaintenanceRepository(db: PluginDatabase) {
|
||||
// The SDK constructs dialect-specific tables at runtime.
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const table: any = await db.define(maintenanceTable);
|
||||
const client = () => db.client<any>();
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
const where = (userId: string, hostId: number) =>
|
||||
and(eq(table.userId, userId), eq(table.hostId, hostId));
|
||||
return {
|
||||
async read(userId: string, hostId: number): Promise<HostMaintenance> {
|
||||
const rows = await (
|
||||
await client()
|
||||
)
|
||||
.select()
|
||||
.from(table)
|
||||
.where(where(userId, hostId));
|
||||
return rows.length ? JSON.parse(rows[0].state) : emptyMaintenance();
|
||||
},
|
||||
async write(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
state: HostMaintenance,
|
||||
): Promise<void> {
|
||||
const connection = await client();
|
||||
const rows = await connection
|
||||
.select({ id: table.id })
|
||||
.from(table)
|
||||
.where(where(userId, hostId));
|
||||
const values = { state: JSON.stringify(state) };
|
||||
if (rows.length)
|
||||
await connection.update(table).set(values).where(where(userId, hostId));
|
||||
else await connection.insert(table).values({ userId, hostId, ...values });
|
||||
await db.persist();
|
||||
},
|
||||
async list(userId?: string): Promise<MaintenanceRow[]> {
|
||||
const query = (await client()).select().from(table);
|
||||
const rows = await (userId
|
||||
? query.where(eq(table.userId, userId))
|
||||
: query);
|
||||
return rows.map(
|
||||
(row: { userId: string; hostId: number; state: string }) => ({
|
||||
...row,
|
||||
state: JSON.parse(row.state),
|
||||
}),
|
||||
);
|
||||
},
|
||||
async wipeUser(userId: string): Promise<void> {
|
||||
await (await client()).delete(table).where(eq(table.userId, userId));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { Router, Request, Response } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { MaintenanceInputError } from "./maintenance-time.js";
|
||||
import type { MaintenanceService } from "./maintenance-service.js";
|
||||
|
||||
export function registerMaintenanceRoutes(
|
||||
router: Router,
|
||||
ctx: PluginContext,
|
||||
service: MaintenanceService,
|
||||
) {
|
||||
router.get(
|
||||
"/maintenance",
|
||||
ctx.rbac.require("view") as never,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const user = ctx.currentActor();
|
||||
if (!user)
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
const hosts = new Set(
|
||||
(await ctx.hosts.list())
|
||||
.filter((host) => host.userId === user)
|
||||
.map((host) => host.id),
|
||||
);
|
||||
res.json(
|
||||
(await service.list(user)).filter((row) => hosts.has(row.hostId)),
|
||||
);
|
||||
} catch (error) {
|
||||
ctx.log.error("Failed to list maintenance", error as Error);
|
||||
res.status(500).json({ error: "Failed to list maintenance" });
|
||||
}
|
||||
},
|
||||
);
|
||||
const handle = (mutate: boolean) => async (req: Request, res: Response) => {
|
||||
const hostId = Number(req.params.hostId);
|
||||
if (!Number.isSafeInteger(hostId) || hostId < 1)
|
||||
return res.status(400).json({ error: "Invalid host id" });
|
||||
const user = ctx.currentActor();
|
||||
if (!user)
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
try {
|
||||
const host = await ctx.hosts.get(hostId);
|
||||
if (!host || host.userId !== user)
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
if (!mutate) return res.json(await service.read(user, hostId));
|
||||
const state = await service.edit(
|
||||
user,
|
||||
hostId,
|
||||
req.body?.action,
|
||||
req.body,
|
||||
);
|
||||
await ctx.audit.record({
|
||||
action: `maintenance_${req.body.action}`,
|
||||
resourceType: "host",
|
||||
resourceId: String(hostId),
|
||||
resourceName: host.name || host.ip,
|
||||
success: true,
|
||||
});
|
||||
res.json(state);
|
||||
} catch (error) {
|
||||
// Validation failures have no side effects; storage failures remain errors.
|
||||
if (error instanceof MaintenanceInputError) {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
ctx.log.error("Failed to update maintenance", error as Error);
|
||||
res.status(500).json({ error: "Failed to update maintenance" });
|
||||
}
|
||||
};
|
||||
router.get(
|
||||
"/maintenance/:hostId",
|
||||
ctx.rbac.require("view") as never,
|
||||
handle(false),
|
||||
);
|
||||
router.post(
|
||||
"/maintenance/:hostId",
|
||||
ctx.rbac.require("edit") as never,
|
||||
handle(true),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import type { HostMaintenance } from "../maintenance.js";
|
||||
import type { MaintenanceRepository } from "./maintenance-repository.js";
|
||||
import {
|
||||
MaintenanceInputError,
|
||||
advanceMaintenance,
|
||||
beginMaintenance,
|
||||
parsePlan,
|
||||
plansOverlap,
|
||||
} from "./maintenance-time.js";
|
||||
|
||||
export type MaintenanceService = ReturnType<typeof createMaintenanceService>;
|
||||
|
||||
export function createMaintenanceService(
|
||||
ctx: PluginContext,
|
||||
repository: MaintenanceRepository,
|
||||
resetBreaches: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
) => Promise<void> = async () => {},
|
||||
) {
|
||||
// Serialize reads that advance schedules with edits and the reminder tick.
|
||||
let tail: Promise<unknown> = Promise.resolve();
|
||||
const exclusive = <T>(work: () => Promise<T>): Promise<T> => {
|
||||
const result = tail.then(work);
|
||||
tail = result.catch(() => undefined);
|
||||
return result;
|
||||
};
|
||||
async function update(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
edit?: (state: HostMaintenance) => void,
|
||||
) {
|
||||
const state = await repository.read(userId, hostId);
|
||||
const previous = JSON.stringify(state);
|
||||
const wasActive = !!state.active;
|
||||
advanceMaintenance(state, Date.now());
|
||||
const activated = !wasActive && !!state.active;
|
||||
edit?.(state);
|
||||
if (activated || wasActive !== !!state.active)
|
||||
await resetBreaches(userId, hostId);
|
||||
if (JSON.stringify(state) !== previous)
|
||||
await repository.write(userId, hostId, state);
|
||||
return state;
|
||||
}
|
||||
const service = {
|
||||
read: (userId: string, hostId: number) =>
|
||||
exclusive(() => update(userId, hostId)),
|
||||
async isMaintaining(userId: string, hostId: number): Promise<boolean> {
|
||||
return !!(await service.read(userId, hostId)).active;
|
||||
},
|
||||
edit: (userId: string, hostId: number, action: string, input: unknown) =>
|
||||
exclusive(() =>
|
||||
update(userId, hostId, (state) => {
|
||||
if (action === "end") {
|
||||
state.active = null;
|
||||
return;
|
||||
}
|
||||
if (action === "remove") {
|
||||
const id = (input as { id?: unknown } | null)?.id;
|
||||
if (
|
||||
typeof id !== "string" ||
|
||||
!state.plans.some((plan) => plan.id === id)
|
||||
)
|
||||
throw new MaintenanceInputError("Schedule not found");
|
||||
state.plans = state.plans.filter((plan) => plan.id !== id);
|
||||
return;
|
||||
}
|
||||
if (action !== "start" && action !== "schedule")
|
||||
throw new MaintenanceInputError("Invalid maintenance action");
|
||||
if (action === "start" && state.active)
|
||||
throw new MaintenanceInputError("Maintenance is already active");
|
||||
const plan = parsePlan(input, Date.now(), action === "start");
|
||||
plan.id = randomUUID();
|
||||
if (action === "start") {
|
||||
beginMaintenance(state, plan, Date.now());
|
||||
return;
|
||||
}
|
||||
if (state.plans.length >= 20)
|
||||
throw new MaintenanceInputError(
|
||||
"At most 20 maintenance schedules are allowed per host",
|
||||
);
|
||||
if (
|
||||
state.plans.some(
|
||||
(other) =>
|
||||
other.nextStart &&
|
||||
plansOverlap(plan, { ...other, start: other.nextStart }),
|
||||
)
|
||||
) {
|
||||
throw new MaintenanceInputError(
|
||||
"Planned maintenance times overlap",
|
||||
);
|
||||
}
|
||||
state.plans.push(plan);
|
||||
}),
|
||||
),
|
||||
async list(userId: string) {
|
||||
const rows = await repository.list(userId);
|
||||
return Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
hostId: row.hostId,
|
||||
state: await service.read(userId, row.hostId),
|
||||
})),
|
||||
);
|
||||
},
|
||||
wipeUser: (userId: string) => exclusive(() => repository.wipeUser(userId)),
|
||||
async tick() {
|
||||
for (const row of await repository.list()) {
|
||||
try {
|
||||
await exclusive(async () => {
|
||||
const state = await update(row.userId, row.hostId);
|
||||
const active = state.active;
|
||||
if (!active?.notifyOverdue || active.notified) return;
|
||||
const deadline =
|
||||
Date.parse(active.estimatedEnd) + active.graceMinutes * 60_000;
|
||||
if (Date.now() < deadline) return;
|
||||
await ctx.asUser(row.userId, async () => {
|
||||
const host = await ctx.hosts.get(row.hostId);
|
||||
if (!host || host.userId !== row.userId) return;
|
||||
const status = await ctx.hosts.status.get(row.hostId);
|
||||
// Unknown or stale data is not evidence of an overdue outage.
|
||||
if (
|
||||
status?.status !== "offline" ||
|
||||
Date.parse(status.lastChecked) < deadline ||
|
||||
!Number.isFinite(Date.parse(status.lastChecked))
|
||||
)
|
||||
return;
|
||||
const result = await ctx.notify.send({
|
||||
title: `Maintenance overdue: ${host.name || host.ip}`,
|
||||
body: `${active.reasons.join("; ")}. Estimated completion: ${active.estimatedEnd}. The host is still unavailable after the grace period. Maintenance remains active until you end it.`,
|
||||
severity: "warning",
|
||||
category: "automations.maintenance_overdue",
|
||||
dedupeKey: `maintenance:${row.hostId}:${active.startedAt}:${active.estimatedEnd}`,
|
||||
context: { hostId: row.hostId },
|
||||
});
|
||||
if (result.recipients > 0) {
|
||||
active.notified = true;
|
||||
await repository.write(row.userId, row.hostId, state);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.log.warn(
|
||||
`Maintenance check for host ${row.hostId} failed: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
return service;
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { MaintenancePlan, HostMaintenance } from "../maintenance.js";
|
||||
|
||||
export class MaintenanceInputError extends Error {}
|
||||
|
||||
const DAY = 86_400_000;
|
||||
const WEEK = 7 * DAY;
|
||||
|
||||
/** UTC wall-clock recurrence; monthly dates missing from a month are skipped. */
|
||||
export function nextOccurrence(
|
||||
plan: MaintenancePlan,
|
||||
after: number,
|
||||
): number | null {
|
||||
const first = Date.parse(plan.start);
|
||||
if (first > after) return first;
|
||||
if (plan.recurrence === "once") return null;
|
||||
if (plan.recurrence === "weekly") {
|
||||
return first + (Math.floor((after - first) / WEEK) + 1) * WEEK;
|
||||
}
|
||||
const start = new Date(first);
|
||||
const from = new Date(after);
|
||||
let month = from.getUTCFullYear() * 12 + from.getUTCMonth();
|
||||
for (let i = 0; i < 14; i++, month++) {
|
||||
const candidate = Date.UTC(
|
||||
Math.floor(month / 12),
|
||||
month % 12,
|
||||
start.getUTCDate(),
|
||||
start.getUTCHours(),
|
||||
start.getUTCMinutes(),
|
||||
);
|
||||
if (new Date(candidate).getUTCMonth() === month % 12 && candidate > after)
|
||||
return candidate;
|
||||
}
|
||||
throw new MaintenanceInputError("Cannot calculate maintenance recurrence");
|
||||
}
|
||||
|
||||
/** Exhaust a Gregorian cycle for recurring pairs, not an arbitrary lookahead. */
|
||||
export function plansOverlap(a: MaintenancePlan, b: MaintenancePlan): boolean {
|
||||
const start = Math.max(Date.parse(a.start), Date.parse(b.start));
|
||||
const end =
|
||||
a.recurrence === "once" || b.recurrence === "once"
|
||||
? start + WEEK
|
||||
: start + 146097 * DAY + WEEK;
|
||||
let left = nextOccurrence(a, start - WEEK - 1);
|
||||
let right = nextOccurrence(b, start - WEEK - 1);
|
||||
while (left !== null && right !== null && left <= end && right <= end) {
|
||||
const leftEnd = left + a.durationMinutes * 60_000;
|
||||
const rightEnd = right + b.durationMinutes * 60_000;
|
||||
if (left < rightEnd && right < leftEnd) return true;
|
||||
if (leftEnd <= right) left = nextOccurrence(a, left);
|
||||
else right = nextOccurrence(b, right);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function beginMaintenance(
|
||||
state: HostMaintenance,
|
||||
plan: MaintenancePlan,
|
||||
start: number,
|
||||
): void {
|
||||
const end = new Date(start + plan.durationMinutes * 60_000).toISOString();
|
||||
const current = state.active;
|
||||
state.active = {
|
||||
startedAt: new Date(
|
||||
Math.min(start, current ? Date.parse(current.startedAt) : start),
|
||||
).toISOString(),
|
||||
reasons: [...new Set([...(current?.reasons ?? []), plan.reason])].slice(
|
||||
-20,
|
||||
),
|
||||
estimatedEnd:
|
||||
current && current.estimatedEnd > end ? current.estimatedEnd : end,
|
||||
graceMinutes: Math.max(current?.graceMinutes ?? 0, plan.graceMinutes),
|
||||
notifyOverdue: !!current?.notifyOverdue || plan.notifyOverdue,
|
||||
notified: current && current.estimatedEnd >= end ? current.notified : false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Catch up after downtime, preserving maintenance until explicitly ended. */
|
||||
export function advanceMaintenance(state: HostMaintenance, now: number): void {
|
||||
for (const plan of state.plans) {
|
||||
if (!plan.nextStart || Date.parse(plan.nextStart) > now) continue;
|
||||
let due = Date.parse(plan.nextStart);
|
||||
let next = nextOccurrence(plan, due);
|
||||
while (next !== null && next <= now) {
|
||||
due = next;
|
||||
next = nextOccurrence(plan, due);
|
||||
}
|
||||
beginMaintenance(state, plan, due);
|
||||
plan.nextStart = next === null ? null : new Date(next).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePlan(
|
||||
input: unknown,
|
||||
now: number,
|
||||
immediate = false,
|
||||
): MaintenancePlan {
|
||||
if (!input || typeof input !== "object")
|
||||
throw new MaintenanceInputError("Invalid maintenance plan");
|
||||
const value = input as Record<string, unknown>;
|
||||
const reason = typeof value.reason === "string" ? value.reason.trim() : "";
|
||||
const start = immediate
|
||||
? now
|
||||
: typeof value.start === "string"
|
||||
? Date.parse(value.start)
|
||||
: NaN;
|
||||
const duration = value.durationMinutes;
|
||||
const grace = value.graceMinutes;
|
||||
const recurrence = immediate ? "once" : value.recurrence;
|
||||
if (!reason || reason.length > 200)
|
||||
throw new MaintenanceInputError("Reason must contain 1 to 200 characters");
|
||||
if (
|
||||
!Number.isFinite(start) ||
|
||||
(!immediate && start <= now) ||
|
||||
start > now + 366 * 10 * DAY
|
||||
) {
|
||||
throw new MaintenanceInputError(
|
||||
"Start must be a future time within ten years",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof duration !== "number" ||
|
||||
!Number.isInteger(duration) ||
|
||||
duration < 1 ||
|
||||
duration > 10080
|
||||
) {
|
||||
throw new MaintenanceInputError(
|
||||
"Estimated duration must be 1 to 10080 minutes",
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof grace !== "number" ||
|
||||
!Number.isInteger(grace) ||
|
||||
grace < 0 ||
|
||||
grace > 1440
|
||||
) {
|
||||
throw new MaintenanceInputError("Grace period must be 0 to 1440 minutes");
|
||||
}
|
||||
if (!["once", "weekly", "monthly"].includes(String(recurrence)))
|
||||
throw new MaintenanceInputError("Invalid recurrence");
|
||||
if (typeof value.notifyOverdue !== "boolean")
|
||||
throw new MaintenanceInputError("Invalid overdue notification setting");
|
||||
const iso = new Date(start).toISOString();
|
||||
if (!immediate && start % 60_000 !== 0)
|
||||
throw new MaintenanceInputError("Start must use whole minutes");
|
||||
return {
|
||||
id: "",
|
||||
reason,
|
||||
start: iso,
|
||||
durationMinutes: duration,
|
||||
graceMinutes: grace,
|
||||
recurrence: recurrence as MaintenancePlan["recurrence"],
|
||||
notifyOverdue: value.notifyOverdue,
|
||||
nextStart: iso,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface SchedulerDeps {
|
||||
repository: AutomationRepository;
|
||||
engine: Pick<AutomationEngine, "run">;
|
||||
log: PluginLogger;
|
||||
isMaintaining?: (userId: string, hostId: number) => Promise<boolean>;
|
||||
/** Watchers brought in line with the enabled automations every tick. */
|
||||
reconcile: Array<(now: number) => Promise<unknown>>;
|
||||
}
|
||||
@@ -78,6 +79,14 @@ export function createScheduler(deps: SchedulerDeps) {
|
||||
for (const state of open) {
|
||||
const automation = await repository.findById(state.automationId);
|
||||
if (!automation || !automation.enabled) continue;
|
||||
const hostId = Number(state.stateKey.split(":")[0]);
|
||||
if (
|
||||
Number.isFinite(hostId) &&
|
||||
(await deps.isMaintaining?.(automation.userId, hostId))
|
||||
) {
|
||||
await repository.clearBreach(automation.id, state.stateKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
let definition: AutomationDefinition;
|
||||
try {
|
||||
@@ -96,7 +105,6 @@ export function createScheduler(deps: SchedulerDeps) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hostId = Number(state.stateKey.split(":")[0]);
|
||||
await repository.upsertTriggerState({
|
||||
automationId: automation.id,
|
||||
stateKey: state.stateKey,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
integer,
|
||||
real,
|
||||
refUser,
|
||||
refHost,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
@@ -187,7 +188,23 @@ export const channels = adoptLegacyTable(
|
||||
),
|
||||
);
|
||||
|
||||
export const maintenanceTable = defineTable(
|
||||
"host_maintenance",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
state: text().notNull(),
|
||||
},
|
||||
{
|
||||
uniques: [
|
||||
{ name: "maintenance_host_owner", columns: ["userId", "hostId"] },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
export const tables = [
|
||||
maintenanceTable,
|
||||
automations,
|
||||
triggerState,
|
||||
schedules,
|
||||
|
||||
@@ -69,6 +69,10 @@ export function createTriggers(
|
||||
repository: AutomationRepository,
|
||||
engine: Pick<AutomationEngine, "run">,
|
||||
log: PluginLogger,
|
||||
isMaintaining: (
|
||||
userId: string,
|
||||
hostId: number,
|
||||
) => Promise<boolean> = async () => false,
|
||||
) {
|
||||
async function loadEnabledFor(userId: string): Promise<LoadedAutomation[]> {
|
||||
try {
|
||||
@@ -120,6 +124,12 @@ export function createTriggers(
|
||||
triggerContext: Record<string, unknown>,
|
||||
hostId?: number,
|
||||
): Promise<void> {
|
||||
if (
|
||||
hostId !== undefined &&
|
||||
triggerType !== "internal_event" &&
|
||||
(await isMaintaining(automation.userId, hostId))
|
||||
)
|
||||
return;
|
||||
await repository.upsertTriggerState({
|
||||
automationId: automation.id,
|
||||
stateKey,
|
||||
@@ -149,6 +159,7 @@ export function createTriggers(
|
||||
async function onMetrics(event: MetricEvent): Promise<void> {
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const now = Date.now();
|
||||
const maintaining = await isMaintaining(event.ownerUserId, event.hostId);
|
||||
|
||||
for (const { row, definition } of automations) {
|
||||
const trigger = definition.trigger;
|
||||
@@ -162,7 +173,7 @@ export function createTriggers(
|
||||
const state = await repository.getTriggerState(row.id, stateKey);
|
||||
const breaching = compare(value, trigger.operator, trigger.value);
|
||||
|
||||
if (!breaching) {
|
||||
if (maintaining || !breaching) {
|
||||
if (state?.breachStartedAt) {
|
||||
await repository.clearBreach(row.id, stateKey);
|
||||
}
|
||||
@@ -228,6 +239,7 @@ export function createTriggers(
|
||||
|
||||
// The first observation establishes a baseline rather than firing, so a
|
||||
// restart does not announce every host as though it just changed.
|
||||
if (await isMaintaining(row.userId, event.hostId)) continue;
|
||||
if (!state?.lastObservedState) continue;
|
||||
if (trigger.to !== observed) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
@@ -265,6 +277,7 @@ export function createTriggers(
|
||||
lastObservedState: observed,
|
||||
});
|
||||
|
||||
if (await isMaintaining(row.userId, event.hostId)) continue;
|
||||
if (!state?.lastObservedState) continue;
|
||||
if (trigger.to !== observed) continue;
|
||||
if (isCoolingDown(state?.lastFiredAt, trigger.cooldownMinutes, now))
|
||||
@@ -286,6 +299,7 @@ export function createTriggers(
|
||||
}
|
||||
|
||||
async function onDockerEvent(event: DockerEvent): Promise<void> {
|
||||
if (await isMaintaining(event.ownerUserId, event.hostId)) return;
|
||||
const automations = await loadEnabledFor(event.ownerUserId);
|
||||
const now = Date.now();
|
||||
|
||||
@@ -316,6 +330,12 @@ export function createTriggers(
|
||||
}
|
||||
|
||||
async function onInternalEvent(event: InternalEvent): Promise<void> {
|
||||
if (
|
||||
event.event === "tunnel_disconnected" &&
|
||||
event.hostId !== undefined &&
|
||||
(await isMaintaining(event.userId, event.hostId))
|
||||
)
|
||||
return;
|
||||
const automations = await loadEnabledFor(event.userId);
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import {
|
||||
usePermission,
|
||||
useTranslation,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Badge, Button, Input, Label } from "@termix/plugin-sdk/ui";
|
||||
import type { MaintenanceStore } from "./maintenance-store";
|
||||
import type { Recurrence } from "../maintenance";
|
||||
|
||||
export function MaintenanceBadge({
|
||||
host,
|
||||
store,
|
||||
}: {
|
||||
host: PluginHostRecord;
|
||||
store: MaintenanceStore;
|
||||
}) {
|
||||
const allowed = usePermission("view");
|
||||
const { t } = useTranslation();
|
||||
return allowed ? (
|
||||
<VisibleBadge host={host} store={store} title={t("maintenance.title")} />
|
||||
) : null;
|
||||
}
|
||||
function VisibleBadge({
|
||||
host,
|
||||
store,
|
||||
title,
|
||||
}: {
|
||||
host: PluginHostRecord;
|
||||
store: MaintenanceStore;
|
||||
title: string;
|
||||
}) {
|
||||
const states = useSyncExternalStore(store.subscribe, store.snapshot);
|
||||
const active = states[Number(host.id)]?.active;
|
||||
if (!active) return null;
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
title={`${active.reasons.join("; ")} · ${active.estimatedEnd}`}
|
||||
>
|
||||
{title}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostMaintenance({
|
||||
host,
|
||||
store,
|
||||
visible,
|
||||
}: {
|
||||
host: PluginHostRecord;
|
||||
store: MaintenanceStore;
|
||||
visible: boolean;
|
||||
}) {
|
||||
const canView = usePermission("view");
|
||||
const { t } = useTranslation();
|
||||
if (!canView) return <p className="p-4">{t("maintenance.denied")}</p>;
|
||||
if (!visible) return null;
|
||||
return <MaintenanceEditor key={host.id} host={host} store={store} />;
|
||||
}
|
||||
|
||||
function MaintenanceEditor({
|
||||
host,
|
||||
store,
|
||||
}: {
|
||||
host: PluginHostRecord;
|
||||
store: MaintenanceStore;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const canEdit = usePermission("edit");
|
||||
const states = useSyncExternalStore(store.subscribe, store.snapshot);
|
||||
const state = states[Number(host.id)];
|
||||
const [mode, setMode] = useState<"start" | "schedule">("start");
|
||||
const [reason, setReason] = useState("");
|
||||
const [start, setStart] = useState("");
|
||||
const [duration, setDuration] = useState(60);
|
||||
const [grace, setGrace] = useState(20);
|
||||
const [recurrence, setRecurrence] = useState<Recurrence>("once");
|
||||
const [notify, setNotify] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [ending, setEnding] = useState(false);
|
||||
useEffect(() => {
|
||||
void store.refresh();
|
||||
}, [store]);
|
||||
async function mutate(input: Record<string, unknown>) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await store.edit(Number(host.id), input);
|
||||
setEnding(false);
|
||||
} catch (error) {
|
||||
const body = (error as { response?: { data?: { error?: string } } })
|
||||
.response?.data?.error;
|
||||
setError(
|
||||
body ||
|
||||
(error instanceof Error ? error.message : t("maintenance.failed")),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
void mutate({
|
||||
action: mode,
|
||||
reason,
|
||||
start: start ? `${start}:00.000Z` : undefined,
|
||||
durationMinutes: duration,
|
||||
graceMinutes: grace,
|
||||
recurrence,
|
||||
notifyOverdue: notify,
|
||||
});
|
||||
}
|
||||
const fieldClass = "h-9 border bg-background px-3 text-sm";
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 overflow-auto p-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("maintenance.title")} · {host.name || host.ip}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("maintenance.description")}
|
||||
</p>
|
||||
</div>
|
||||
{store.error() && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{t(store.error())}
|
||||
</p>
|
||||
)}
|
||||
{!store.loaded() && <p>{t("maintenance.loading")}</p>}
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{state?.active && (
|
||||
<section className="space-y-3 border p-4">
|
||||
<Badge>{t("maintenance.active")}</Badge>
|
||||
<p className="break-words">{state.active.reasons.join("; ")}</p>
|
||||
<p>
|
||||
{t("maintenance.estimated")}:{" "}
|
||||
{new Date(state.active.estimatedEnd).toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("maintenance.noAutoEnd")}
|
||||
</p>
|
||||
{canEdit &&
|
||||
(ending ? (
|
||||
<div className="space-y-2">
|
||||
<p>{t("maintenance.endConfirm")}</p>
|
||||
<Button
|
||||
disabled={busy || !store.loaded() || !!store.error()}
|
||||
onClick={() => void mutate({ action: "end" })}
|
||||
>
|
||||
{t("maintenance.confirmEnd")}
|
||||
</Button>{" "}
|
||||
<Button variant="outline" onClick={() => setEnding(false)}>
|
||||
{t("maintenance.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || !store.loaded() || !!store.error()}
|
||||
onClick={() => setEnding(true)}
|
||||
>
|
||||
{t("maintenance.end")}
|
||||
</Button>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
{canEdit && (
|
||||
<form onSubmit={submit} className="space-y-4 border p-4">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Label htmlFor="maintenance-mode">{t("maintenance.action")}</Label>
|
||||
<select
|
||||
id="maintenance-mode"
|
||||
className={fieldClass}
|
||||
value={mode}
|
||||
onChange={(event) => setMode(event.target.value as typeof mode)}
|
||||
>
|
||||
<option value="start">{t("maintenance.startNow")}</option>
|
||||
<option value="schedule">{t("maintenance.schedule")}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-reason">
|
||||
{t("maintenance.reason")}
|
||||
</Label>
|
||||
<Input
|
||||
id="maintenance-reason"
|
||||
required
|
||||
maxLength={200}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{mode === "schedule" && (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-start">
|
||||
{t("maintenance.startUtc")}
|
||||
</Label>
|
||||
<Input
|
||||
id="maintenance-start"
|
||||
type="datetime-local"
|
||||
required
|
||||
value={start}
|
||||
onChange={(event) => setStart(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-repeat">
|
||||
{t("maintenance.repeat")}
|
||||
</Label>
|
||||
<select
|
||||
id="maintenance-repeat"
|
||||
className={`${fieldClass} w-full`}
|
||||
value={recurrence}
|
||||
onChange={(event) =>
|
||||
setRecurrence(event.target.value as Recurrence)
|
||||
}
|
||||
>
|
||||
{(["once", "weekly", "monthly"] as const).map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{t(`maintenance.${value}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground sm:col-span-2">
|
||||
{t("maintenance.utcHelp")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-duration">
|
||||
{t("maintenance.duration")}
|
||||
</Label>
|
||||
<Input
|
||||
id="maintenance-duration"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10080}
|
||||
required
|
||||
value={duration}
|
||||
onChange={(event) => setDuration(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maintenance-grace">
|
||||
{t("maintenance.grace")}
|
||||
</Label>
|
||||
<Input
|
||||
id="maintenance-grace"
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
required
|
||||
value={grace}
|
||||
onChange={(event) => setGrace(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notify}
|
||||
onChange={(event) => setNotify(event.target.checked)}
|
||||
/>
|
||||
{t("maintenance.notify")}
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
busy ||
|
||||
!store.loaded() ||
|
||||
!!store.error() ||
|
||||
(mode === "start" && !!state?.active)
|
||||
}
|
||||
>
|
||||
{t(
|
||||
mode === "start"
|
||||
? "maintenance.startNow"
|
||||
: "maintenance.addSchedule",
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-medium">{t("maintenance.schedules")}</h3>
|
||||
{!state?.plans.length && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("maintenance.noSchedules")}
|
||||
</p>
|
||||
)}
|
||||
{state?.plans.map((plan) => (
|
||||
<div
|
||||
key={plan.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 border p-3"
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="break-words">{plan.reason}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(`maintenance.${plan.recurrence}`)} ·{" "}
|
||||
{plan.nextStart
|
||||
? `${plan.nextStart.replace("T", " ").slice(0, 16)} UTC`
|
||||
: t("maintenance.completed")}{" "}
|
||||
· {t("maintenance.minutes", { count: plan.durationMinutes })}
|
||||
</p>
|
||||
</div>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || !store.loaded() || !!store.error()}
|
||||
onClick={() => void mutate({ action: "remove", id: plan.id })}
|
||||
>
|
||||
{t("maintenance.remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Workflow } from "lucide-react";
|
||||
import { Workflow, Wrench } from "lucide-react";
|
||||
import type {
|
||||
PanelProps,
|
||||
TabProps,
|
||||
@@ -7,6 +7,9 @@ import type {
|
||||
import { AutomationsPanel } from "./AutomationsPanel";
|
||||
import { createAutomationsApi } from "./automations-api";
|
||||
|
||||
import { createMaintenanceStore } from "./maintenance-store";
|
||||
import { HostMaintenance, MaintenanceBadge } from "./HostMaintenance";
|
||||
|
||||
const VIEW_ID = "automations";
|
||||
|
||||
function Panel({ active, setEditing }: PanelProps) {
|
||||
@@ -22,6 +25,35 @@ function AutomationsTab({ isVisible }: TabProps) {
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
const maintenance = createMaintenanceStore(app.api);
|
||||
app.onDispose(() => maintenance.dispose());
|
||||
app.registerTab(
|
||||
"host_maintenance",
|
||||
({ host, isVisible }) =>
|
||||
host ? (
|
||||
<HostMaintenance host={host} store={maintenance} visible={isVisible} />
|
||||
) : null,
|
||||
{
|
||||
icon: Wrench,
|
||||
titleKey: "maintenance.title",
|
||||
},
|
||||
);
|
||||
app.registerHostAction({
|
||||
id: "maintenance",
|
||||
titleKey: "maintenance.title",
|
||||
icon: Wrench,
|
||||
kind: "open",
|
||||
tabType: "host_maintenance",
|
||||
tray: false,
|
||||
when: (host) => Number(host.id) > 0,
|
||||
});
|
||||
app.registerHostBadge({
|
||||
id: "maintenance",
|
||||
when: (host) => Number(host.id) > 0,
|
||||
component: ({ host }) => (
|
||||
<MaintenanceBadge host={host} store={maintenance} />
|
||||
),
|
||||
});
|
||||
app.registerRailItem({
|
||||
id: VIEW_ID,
|
||||
icon: Workflow,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import type { HostMaintenance } from "../maintenance";
|
||||
|
||||
export function createMaintenanceStore(api: PluginApiClient) {
|
||||
let snapshot: Record<number, HostMaintenance> = {};
|
||||
const listeners = new Set<() => void>();
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
let loading = false;
|
||||
let disposed = false;
|
||||
let revision = 0;
|
||||
let error = "";
|
||||
let loaded = false;
|
||||
const emit = () => listeners.forEach((listener) => listener());
|
||||
async function refresh() {
|
||||
if (loading || disposed || document.hidden) return;
|
||||
loading = true;
|
||||
const version = revision;
|
||||
try {
|
||||
const { data } =
|
||||
await api.get<Array<{ hostId: number; state: HostMaintenance }>>(
|
||||
"/maintenance",
|
||||
);
|
||||
if (!disposed && version === revision) {
|
||||
error = "";
|
||||
loaded = true;
|
||||
snapshot = Object.fromEntries(
|
||||
data.map(({ hostId, state }) => [hostId, state]),
|
||||
);
|
||||
emit();
|
||||
}
|
||||
} catch {
|
||||
if (!disposed && version === revision) {
|
||||
error = "maintenance.loadFailed";
|
||||
snapshot = { ...snapshot };
|
||||
emit();
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
return {
|
||||
snapshot: () => snapshot,
|
||||
error: () => error,
|
||||
loaded: () => loaded,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
if (!timer) {
|
||||
void refresh();
|
||||
timer = setInterval(() => void refresh(), 15_000);
|
||||
document.addEventListener("visibilitychange", refresh);
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (!listeners.size) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
}
|
||||
};
|
||||
},
|
||||
async edit(hostId: number, input: Record<string, unknown>) {
|
||||
const { data } = await api.post<HostMaintenance>(
|
||||
`/maintenance/${hostId}`,
|
||||
input,
|
||||
);
|
||||
revision++;
|
||||
snapshot = { ...snapshot, [hostId]: data };
|
||||
emit();
|
||||
return data;
|
||||
},
|
||||
refresh,
|
||||
dispose() {
|
||||
disposed = true;
|
||||
clearInterval(timer);
|
||||
document.removeEventListener("visibilitychange", refresh);
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
export type MaintenanceStore = ReturnType<typeof createMaintenanceStore>;
|
||||
@@ -0,0 +1,31 @@
|
||||
export type Recurrence = "once" | "weekly" | "monthly";
|
||||
|
||||
export interface MaintenancePlan {
|
||||
id: string;
|
||||
reason: string;
|
||||
start: string;
|
||||
durationMinutes: number;
|
||||
recurrence: Recurrence;
|
||||
graceMinutes: number;
|
||||
notifyOverdue: boolean;
|
||||
nextStart: string | null;
|
||||
}
|
||||
|
||||
export interface ActiveMaintenance {
|
||||
startedAt: string;
|
||||
reasons: string[];
|
||||
estimatedEnd: string;
|
||||
graceMinutes: number;
|
||||
notifyOverdue: boolean;
|
||||
notified: boolean;
|
||||
}
|
||||
|
||||
export interface HostMaintenance {
|
||||
active: ActiveMaintenance | null;
|
||||
plans: MaintenancePlan[];
|
||||
}
|
||||
|
||||
export const emptyMaintenance = (): HostMaintenance => ({
|
||||
active: null,
|
||||
plans: [],
|
||||
});
|
||||
Reference in new issue
Block a user