mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05: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
@@ -35,3 +35,17 @@ npm run build # build into dist/
|
||||
npm run test # run this plugin's tests
|
||||
npm run typecheck # type-check this plugin
|
||||
```
|
||||
|
||||
## Host maintenance
|
||||
|
||||
Open **Maintenance** from a saved host's actions. Its owner can start maintenance now, record a reason and estimated duration, or add a one-time, weekly or monthly schedule. Viewing requires `automations.view`; changes require `automations.edit`. Active maintenance appears as a host badge. The Automations plugin must remain enabled for maintenance scheduling and suppression to operate.
|
||||
|
||||
Schedules use UTC, including through daylight-saving changes. A monthly schedule for a day missing from a month skips that month. Up to 20 plans per host are stored; remove completed one-time plans when no longer needed. Planned windows cannot overlap (touching endpoints are allowed), and recurring conflicts are checked over the Gregorian calendar cycle. Removing a plan cancels future occurrences, not maintenance already in progress.
|
||||
|
||||
The estimated completion time is informational. Maintenance **only ends when its owner explicitly ends it**. If work overruns into another scheduled window, the host stays in maintenance and the estimate extends to the later planned end. After server downtime, due plans are caught up without briefly exposing an outage between maintenance windows. Each plan's next occurrence and the active state survive restarts.
|
||||
|
||||
While active, maintenance suppresses new host-status, health-check, metric-threshold, container-event and tunnel-disconnection automation runs for that host. This pauses the entire triggered automation, including any remediation steps. Monitoring continues to report the real status; observed status/health baselines still advance and metric dwell windows are reset. Manual runs, schedules, webhooks, security login events and runs already in progress continue normally. Maintenance does not globally silence alerts from unrelated plugins.
|
||||
|
||||
An optional overdue reminder uses the estimate plus a configurable grace period (20 minutes by default). It only sends after core has observed the host offline at or after that deadline; an unknown, online or older cached status does not qualify. Enable host status checks to supply those observations. Reminders use the Alerts hub and category `automations.maintenance_overdue`, which can be routed through the user's notification rules. A successful reminder is remembered across restarts; an extended estimate permits another reminder for the new deadline. No reminder automatically ends maintenance.
|
||||
|
||||
Maintenance rows reference their owner and host with cascading deletion. SQLite, PostgreSQL and MySQL migrations add only the new maintenance table.
|
||||
@@ -171,5 +171,40 @@
|
||||
"onboarding": {
|
||||
"feature_automations": "Automations",
|
||||
"feature_automations_desc": "Run steps automatically when something happens, and get notified."
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Maintenance",
|
||||
"description": "Pause host status, health, metric, container and tunnel-disconnection automations while work is in progress. Monitoring, manual runs and scheduled automations continue.",
|
||||
"denied": "You do not have permission to view maintenance.",
|
||||
"failed": "Maintenance could not be updated.",
|
||||
"active": "Maintenance active",
|
||||
"estimated": "Estimated completion",
|
||||
"noAutoEnd": "The estimate does not end maintenance. End it explicitly when the work is complete.",
|
||||
"endConfirm": "End maintenance and resume event-triggered automations for this host?",
|
||||
"confirmEnd": "Confirm end",
|
||||
"cancel": "Cancel",
|
||||
"end": "End maintenance",
|
||||
"action": "Action",
|
||||
"startNow": "Start now",
|
||||
"schedule": "Schedule for later",
|
||||
"reason": "Reason",
|
||||
"startUtc": "First start (UTC)",
|
||||
"repeat": "Repeat",
|
||||
"once": "Once",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"utcHelp": "Schedules use UTC throughout the year. Monthly schedules skip months without the selected day. Planned windows cannot overlap; overrunning maintenance continues across later windows.",
|
||||
"duration": "Estimated duration (minutes)",
|
||||
"grace": "Grace period (minutes)",
|
||||
"notify": "Notify once if still offline after the estimate plus grace period",
|
||||
"addSchedule": "Add schedule",
|
||||
"schedules": "Schedules",
|
||||
"noSchedules": "No maintenance schedules.",
|
||||
"completed": "Already started",
|
||||
"minutes_one": "{{count}} minute",
|
||||
"minutes_other": "{{count}} minutes",
|
||||
"remove": "Remove schedule",
|
||||
"loadFailed": "Could not refresh maintenance status. Changes are disabled until it can be loaded.",
|
||||
"loading": "Loading maintenance status…"
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,12 @@
|
||||
"titleKey": "nav.automations",
|
||||
"icon": "Workflow",
|
||||
"openFrom": ["rail"]
|
||||
},
|
||||
{
|
||||
"id": "host_maintenance",
|
||||
"titleKey": "maintenance.title",
|
||||
"icon": "Wrench",
|
||||
"openFrom": ["host-context-menu"]
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- automations 0003: host_maintenance
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `p_automations_host_maintenance` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`state` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX `maintenance_host_owner` ON `p_automations_host_maintenance` (`user_id`, `host_id`);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- automations 0003: host_maintenance
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_automations_host_maintenance" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"state" text NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
|
||||
@@ -1,6 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"host_maintenance": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "refHost",
|
||||
"notNull": true
|
||||
},
|
||||
"state": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "maintenance_host_owner",
|
||||
"columns": ["userId", "hostId"],
|
||||
"unique": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"automations": {
|
||||
"columns": {
|
||||
"id": {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
-- automations 0003: host_maintenance
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "p_automations_host_maintenance" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"state" text NOT NULL,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
|
||||
@@ -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: [],
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createScheduler } from "../../src/backend/scheduler.js";
|
||||
|
||||
it("discards sustained breaches during maintenance but still runs scheduled work", async () => {
|
||||
const run = vi.fn(async () => ({ runId: 1, status: "success" as const }));
|
||||
const clearBreach = vi.fn(async () => {});
|
||||
const repository = {
|
||||
listDueSchedules: async () => [{ automationId: 2, intervalSeconds: 60 }],
|
||||
markScheduleTicked: vi.fn(),
|
||||
listOpenBreaches: async () => [
|
||||
{
|
||||
automationId: 1,
|
||||
stateKey: "7:cpu.percent",
|
||||
breachStartedAt: "2000-01-01T00:00:00Z",
|
||||
},
|
||||
],
|
||||
findById: async () => ({
|
||||
id: 1,
|
||||
userId: "alice",
|
||||
enabled: true,
|
||||
definition: JSON.stringify({
|
||||
trigger: { kind: "metric_threshold", forSeconds: 60 },
|
||||
}),
|
||||
}),
|
||||
clearBreach,
|
||||
failStaleRunningRuns: vi.fn(),
|
||||
pruneRunsOlderThan: async () => 0,
|
||||
};
|
||||
const maintaining = vi.fn(async () => true);
|
||||
const scheduler = createScheduler({
|
||||
repository: repository as never,
|
||||
engine: { run },
|
||||
log: { debug() {}, info() {}, warn() {}, error() {} },
|
||||
reconcile: [],
|
||||
isMaintaining: maintaining,
|
||||
});
|
||||
await scheduler.tick();
|
||||
expect(maintaining).toHaveBeenCalledWith("alice", 7);
|
||||
expect(clearBreach).toHaveBeenCalledWith(1, "7:cpu.percent");
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
automationId: 2,
|
||||
triggerType: "schedule",
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
advanceMaintenance,
|
||||
beginMaintenance,
|
||||
nextOccurrence,
|
||||
parsePlan,
|
||||
plansOverlap,
|
||||
} from "../../src/backend/maintenance-time.js";
|
||||
import {
|
||||
emptyMaintenance,
|
||||
type MaintenancePlan,
|
||||
} from "../../src/maintenance.js";
|
||||
|
||||
const at = (value: string) => Date.parse(value);
|
||||
const plan = (overrides: Partial<MaintenancePlan> = {}): MaintenancePlan => ({
|
||||
id: "one",
|
||||
reason: "Upgrade",
|
||||
start: "2026-01-31T21:00:00.000Z",
|
||||
nextStart: "2026-01-31T21:00:00.000Z",
|
||||
durationMinutes: 60,
|
||||
recurrence: "once",
|
||||
graceMinutes: 20,
|
||||
notifyOverdue: true,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("maintenance calendar", () => {
|
||||
it("keeps weekly UTC time across DST and skips missing monthly days", () => {
|
||||
expect(
|
||||
new Date(
|
||||
nextOccurrence(
|
||||
plan({ recurrence: "weekly" }),
|
||||
at("2026-03-28T21:00Z"),
|
||||
)!,
|
||||
).toISOString(),
|
||||
).toBe("2026-04-04T21:00:00.000Z");
|
||||
expect(
|
||||
new Date(
|
||||
nextOccurrence(
|
||||
plan({ recurrence: "monthly" }),
|
||||
at("2026-01-31T21:00Z"),
|
||||
)!,
|
||||
).toISOString(),
|
||||
).toBe("2026-03-31T21:00:00.000Z");
|
||||
expect(nextOccurrence(plan(), at("2026-01-31T21:00Z"))).toBeNull();
|
||||
});
|
||||
it("rejects actual overlaps and accepts touching windows", () => {
|
||||
expect(plansOverlap(plan(), plan({ start: "2026-01-31T21:59:00Z" }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(plansOverlap(plan(), plan({ start: "2026-01-31T22:00:00Z" }))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
plansOverlap(
|
||||
plan({ recurrence: "monthly" }),
|
||||
plan({ start: "2030-03-31T21:30:00Z" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
plansOverlap(
|
||||
plan({ recurrence: "weekly" }),
|
||||
plan({ recurrence: "monthly", start: "2026-03-31T21:30:00Z" }),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
plansOverlap(
|
||||
plan({ recurrence: "weekly" }),
|
||||
plan({ recurrence: "monthly", start: "2026-03-31T23:00:00Z" }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it("does not end at ETM and merges a later nonoverlapping schedule", () => {
|
||||
const state = emptyMaintenance();
|
||||
state.plans = [
|
||||
plan(),
|
||||
plan({
|
||||
id: "two",
|
||||
reason: "Firmware",
|
||||
start: "2026-01-31T22:30:00Z",
|
||||
nextStart: "2026-01-31T22:30:00Z",
|
||||
}),
|
||||
];
|
||||
advanceMaintenance(state, at("2026-01-31T22:15Z"));
|
||||
expect(state.active?.estimatedEnd).toBe("2026-01-31T22:00:00.000Z");
|
||||
advanceMaintenance(state, at("2026-01-31T22:45Z"));
|
||||
expect(state.active?.startedAt).toBe("2026-01-31T21:00:00.000Z");
|
||||
expect(state.active?.estimatedEnd).toBe("2026-01-31T23:30:00.000Z");
|
||||
expect(state.active?.reasons).toEqual(["Upgrade", "Firmware"]);
|
||||
advanceMaintenance(state, at("2026-02-02T00:00Z"));
|
||||
expect(state.active).not.toBeNull();
|
||||
});
|
||||
it("catches up after restart and never re-enters a consumed one-time plan", () => {
|
||||
const state = emptyMaintenance();
|
||||
state.plans = [plan({ recurrence: "monthly" })];
|
||||
advanceMaintenance(state, at("2026-05-01T00:00Z"));
|
||||
expect(state.active?.startedAt).toBe("2026-03-31T21:00:00.000Z");
|
||||
expect(state.plans[0].nextStart).toBe("2026-05-31T21:00:00.000Z");
|
||||
state.active = null;
|
||||
advanceMaintenance(state, at("2026-05-01T00:00Z"));
|
||||
expect(state.active).toBeNull();
|
||||
});
|
||||
it("preserves a delivered reminder unless a later estimate extends maintenance", () => {
|
||||
const state = emptyMaintenance();
|
||||
beginMaintenance(state, plan(), at("2026-01-31T21:00Z"));
|
||||
state.active!.notified = true;
|
||||
beginMaintenance(
|
||||
state,
|
||||
plan({ durationMinutes: 10 }),
|
||||
at("2026-01-31T21:05Z"),
|
||||
);
|
||||
expect(state.active!.notified).toBe(true);
|
||||
beginMaintenance(state, plan(), at("2026-01-31T22:30Z"));
|
||||
expect(state.active!.notified).toBe(false);
|
||||
});
|
||||
it.each([
|
||||
{ reason: " " },
|
||||
{ durationMinutes: 0 },
|
||||
{ durationMinutes: 10081 },
|
||||
{ graceMinutes: -1 },
|
||||
{ recurrence: "daily" },
|
||||
{ notifyOverdue: "true" },
|
||||
{ start: "2020-01-01T00:00Z" },
|
||||
{ start: "2026-01-31T21:00:01Z" },
|
||||
])("rejects invalid input %j", (override) => {
|
||||
expect(() =>
|
||||
parsePlan(plan(override as never), at("2026-01-01T00:00Z")),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { startServer as createServer, host, type TestServer } from "./helpers";
|
||||
import { createMaintenanceRepository } from "../../src/backend/maintenance-repository.js";
|
||||
import { createMaintenanceService } from "../../src/backend/maintenance-service.js";
|
||||
|
||||
function startServer(options: Parameters<typeof createServer>[0] = {}) {
|
||||
return createServer({
|
||||
...options,
|
||||
before: (db) => {
|
||||
db.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
|
||||
options.before?.(db);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let server: TestServer | undefined;
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await server?.close();
|
||||
server = undefined;
|
||||
});
|
||||
const input = {
|
||||
action: "start",
|
||||
reason: "OS upgrade",
|
||||
durationMinutes: 60,
|
||||
graceMinutes: 20,
|
||||
notifyOverdue: true,
|
||||
};
|
||||
|
||||
describe("host maintenance", () => {
|
||||
it("scopes reads and edits to the host owner and enforces edit permission", async () => {
|
||||
server = await startServer({ hosts: [host(1), host(2, "bob")] });
|
||||
expect(
|
||||
(await server.request("POST", "/maintenance/1", { body: input })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await server.request("GET", "/maintenance", { user: "bob" })).body,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/maintenance/1", {
|
||||
user: "bob",
|
||||
body: { action: "end" },
|
||||
})
|
||||
).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("GET", "/maintenance/1", { user: "bob" })).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("POST", "/maintenance/1x", { body: input })).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await server.request("GET", "/maintenance/1", { user: null })).status,
|
||||
).toBe(401);
|
||||
await server.close();
|
||||
server = await startServer({ permissions: ["automations.view"] });
|
||||
expect(
|
||||
(await server.request("POST", "/maintenance/1", { body: input })).status,
|
||||
).toBe(403);
|
||||
});
|
||||
it("survives service restart, remains active beyond ETM, and reminds only once with fresh offline evidence", async () => {
|
||||
server = await startServer();
|
||||
let now = Date.parse("2026-01-01T12:00Z");
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
await server.request("POST", "/maintenance/1", { body: input });
|
||||
const repository = await createMaintenanceRepository(server.db.database);
|
||||
let service = createMaintenanceService(server.mock.ctx, repository);
|
||||
now = Date.parse("2026-01-01T13:21Z");
|
||||
server.mock.hostStatuses.set(1, {
|
||||
status: "offline",
|
||||
lastChecked: "2026-01-01T12:00Z",
|
||||
});
|
||||
await service.tick();
|
||||
expect(server.mock.notifications).toHaveLength(0);
|
||||
server.mock.hostStatuses.set(1, {
|
||||
status: "reachable",
|
||||
lastChecked: "2026-01-01T13:21Z",
|
||||
});
|
||||
await service.tick();
|
||||
expect(server.mock.notifications).toHaveLength(0);
|
||||
server.mock.hostStatuses.set(1, {
|
||||
status: "offline",
|
||||
lastChecked: "2026-01-01T13:21Z",
|
||||
});
|
||||
await service.tick();
|
||||
expect(server.mock.notifications).toHaveLength(1);
|
||||
expect(server.mock.notifications[0].actor).toBe("alice");
|
||||
service = createMaintenanceService(server.mock.ctx, repository);
|
||||
await service.tick();
|
||||
expect(server.mock.notifications).toHaveLength(1);
|
||||
expect((await service.read("alice", 1)).active).not.toBeNull();
|
||||
await service.edit("alice", 1, "end", {});
|
||||
expect(await service.isMaintaining("alice", 1)).toBe(false);
|
||||
});
|
||||
it("serializes concurrent starts and conflicting schedules without losing state", async () => {
|
||||
server = await startServer();
|
||||
const service = createMaintenanceService(
|
||||
server.mock.ctx,
|
||||
await createMaintenanceRepository(server.db.database),
|
||||
);
|
||||
const starts = await Promise.allSettled([
|
||||
service.edit("alice", 1, "start", input),
|
||||
service.edit("alice", 1, "start", input),
|
||||
]);
|
||||
expect(starts.map((r) => r.status).sort()).toEqual([
|
||||
"fulfilled",
|
||||
"rejected",
|
||||
]);
|
||||
const start = new Date(
|
||||
Math.ceil((Date.now() + 86400000) / 60000) * 60000,
|
||||
).toISOString();
|
||||
const schedules = await Promise.allSettled([
|
||||
service.edit("alice", 1, "schedule", {
|
||||
...input,
|
||||
start,
|
||||
recurrence: "weekly",
|
||||
}),
|
||||
service.edit("alice", 1, "schedule", {
|
||||
...input,
|
||||
start,
|
||||
recurrence: "monthly",
|
||||
}),
|
||||
]);
|
||||
expect(schedules.map((r) => r.status).sort()).toEqual([
|
||||
"fulfilled",
|
||||
"rejected",
|
||||
]);
|
||||
expect((await service.read("alice", 1)).plans).toHaveLength(1);
|
||||
});
|
||||
it("starts a due schedule before accepting status events and handles deletion", async () => {
|
||||
server = await startServer();
|
||||
let now = Date.parse("2026-01-01T12:00Z");
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const service = createMaintenanceService(
|
||||
server.mock.ctx,
|
||||
await createMaintenanceRepository(server.db.database),
|
||||
);
|
||||
await service.edit("alice", 1, "schedule", {
|
||||
...input,
|
||||
start: "2026-01-01T13:00:00Z",
|
||||
recurrence: "once",
|
||||
});
|
||||
expect(await service.isMaintaining("alice", 1)).toBe(false);
|
||||
now = Date.parse("2026-01-01T13:00Z");
|
||||
expect(await service.isMaintaining("alice", 1)).toBe(true);
|
||||
const state = await service.read("alice", 1);
|
||||
await service.edit("alice", 1, "remove", { id: state.plans[0].id });
|
||||
expect(await service.isMaintaining("alice", 1)).toBe(true);
|
||||
await service.wipeUser("alice");
|
||||
expect(await service.list("alice")).toEqual([]);
|
||||
});
|
||||
it("clears old dwell windows on start and end and cascades host deletion", async () => {
|
||||
server = await startServer();
|
||||
const created = await server.request("POST", "/", {
|
||||
body: {
|
||||
name: "CPU",
|
||||
definition: {
|
||||
version: 1,
|
||||
trigger: {
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "host", hostId: 1 },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
forSeconds: 60,
|
||||
cooldownMinutes: 0,
|
||||
},
|
||||
steps: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
const { createAutomationRepository } =
|
||||
await import("../../src/backend/repository.js");
|
||||
const repository = await createAutomationRepository(
|
||||
server.db.database,
|
||||
async () => [],
|
||||
);
|
||||
const seed = () =>
|
||||
repository.upsertTriggerState({
|
||||
automationId: created.body.id,
|
||||
stateKey: "1",
|
||||
breachStartedAt: "2000-01-01T00:00:00Z",
|
||||
});
|
||||
await seed();
|
||||
expect(
|
||||
(await server.request("POST", "/maintenance/1", { body: input })).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await repository.getTriggerState(created.body.id, "1"))?.breachStartedAt,
|
||||
).toBeNull();
|
||||
await seed();
|
||||
expect(
|
||||
(
|
||||
await server.request("POST", "/maintenance/1", {
|
||||
body: { action: "end" },
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(await repository.getTriggerState(created.body.id, "1"))?.breachStartedAt,
|
||||
).toBeNull();
|
||||
server.db.sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
|
||||
expect(
|
||||
await (await createMaintenanceRepository(server.db.database)).list(),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -438,3 +438,132 @@ describe("watchedHosts", () => {
|
||||
expect((await triggers.watchedHosts("metric_threshold")).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("maintenance suppression", () => {
|
||||
it("updates the status baseline without firing or replaying maintenance edges", async () => {
|
||||
let maintaining = false;
|
||||
const guarded = createTriggers(
|
||||
repository as never,
|
||||
{ run },
|
||||
log,
|
||||
async () => maintaining,
|
||||
);
|
||||
addAutomation({
|
||||
kind: "host_status",
|
||||
hostSelector: { kind: "all" },
|
||||
to: "offline",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: true });
|
||||
maintaining = true;
|
||||
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: false });
|
||||
maintaining = false;
|
||||
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: false });
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: true });
|
||||
await guarded.onStatus({ hostId: 7, ownerUserId: "user-1", online: false });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("maintains health baselines and suppresses container events", async () => {
|
||||
const guarded = createTriggers(
|
||||
repository as never,
|
||||
{ run },
|
||||
log,
|
||||
async () => true,
|
||||
);
|
||||
addAutomation({
|
||||
kind: "health_check",
|
||||
hostSelector: { kind: "all" },
|
||||
to: "failing",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
addAutomation({
|
||||
kind: "docker_event",
|
||||
hostSelector: { kind: "all" },
|
||||
event: "exited",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
await guarded.onHealthCheck({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
checkId: "http",
|
||||
ok: true,
|
||||
});
|
||||
await guarded.onHealthCheck({
|
||||
hostId: 7,
|
||||
userId: "user-1",
|
||||
checkId: "http",
|
||||
ok: false,
|
||||
});
|
||||
await guarded.onDockerEvent({
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
container: "app",
|
||||
event: "exited",
|
||||
});
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(triggerState.get("1:7:http")?.lastObservedState).toBe("failing");
|
||||
});
|
||||
it("clears a metric dwell window so maintenance time cannot count toward a later alert", async () => {
|
||||
let maintaining = true;
|
||||
const guarded = createTriggers(
|
||||
repository as never,
|
||||
{ run },
|
||||
log,
|
||||
async () => maintaining,
|
||||
);
|
||||
addAutomation({
|
||||
kind: "metric_threshold",
|
||||
hostSelector: { kind: "all" },
|
||||
metric: { path: "cpu.percent" },
|
||||
operator: ">",
|
||||
value: 90,
|
||||
forSeconds: 60,
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
triggerState.set("1:7", {
|
||||
breachStartedAt: "2000-01-01T00:00:00Z",
|
||||
});
|
||||
const event = {
|
||||
hostId: 7,
|
||||
ownerUserId: "user-1",
|
||||
metrics: { cpu: { percent: 99 } },
|
||||
};
|
||||
await guarded.onMetrics(event);
|
||||
expect(triggerState.get("1:7")?.breachStartedAt).toBeNull();
|
||||
maintaining = false;
|
||||
await guarded.onMetrics(event);
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(triggerState.get("1:7")?.breachStartedAt).not.toBeNull();
|
||||
});
|
||||
it("suppresses tunnel disconnects but preserves security login events", async () => {
|
||||
const guarded = createTriggers(
|
||||
repository as never,
|
||||
{ run },
|
||||
log,
|
||||
async () => true,
|
||||
);
|
||||
addAutomation({
|
||||
kind: "internal_event",
|
||||
event: "tunnel_disconnected",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
addAutomation({
|
||||
kind: "internal_event",
|
||||
event: "user_login",
|
||||
cooldownMinutes: 0,
|
||||
});
|
||||
await guarded.onInternalEvent({
|
||||
event: "tunnel_disconnected",
|
||||
userId: "user-1",
|
||||
hostId: 7,
|
||||
});
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
await guarded.onInternalEvent({
|
||||
event: "user_login",
|
||||
userId: "user-1",
|
||||
hostId: 7,
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifest from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
import { emptyMaintenance, type HostMaintenance } from "../../src/maintenance";
|
||||
import { createMaintenanceStore } from "../../src/frontend/maintenance-store";
|
||||
|
||||
let rendered: RenderedPluginApp | undefined;
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
const active: HostMaintenance = {
|
||||
plans: [],
|
||||
active: {
|
||||
startedAt: "2026-01-01T12:00Z",
|
||||
estimatedEnd: "2026-01-01T13:00Z",
|
||||
reasons: ["OS upgrade"],
|
||||
graceMinutes: 20,
|
||||
notifyOverdue: true,
|
||||
notified: false,
|
||||
},
|
||||
};
|
||||
const host = { id: 7, name: "Database", ip: "10.0.0.7" };
|
||||
function apiFor(state: HostMaintenance) {
|
||||
return {
|
||||
get: vi.fn(async () => ({ data: [{ hostId: 7, state }] })),
|
||||
post: vi.fn(async () => ({ data: emptyMaintenance() })),
|
||||
};
|
||||
}
|
||||
async function render(
|
||||
api: ReturnType<typeof apiFor>,
|
||||
permissions = ["automations.view", "automations.edit"],
|
||||
) {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest: manifest as unknown as PluginManifest,
|
||||
locales,
|
||||
api: api as unknown as PluginApiClient,
|
||||
permissions,
|
||||
});
|
||||
return within(
|
||||
rendered.renderTab("host_maintenance", { host, isVisible: true }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("maintenance UI", () => {
|
||||
it("shows the reason and estimate and confirms before ending the correct host", async () => {
|
||||
const api = apiFor(active);
|
||||
const view = await render(api);
|
||||
await waitFor(() => expect(view.getByText("OS upgrade")).toBeTruthy());
|
||||
fireEvent.click(view.getByRole("button", { name: "End maintenance" }));
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
fireEvent.click(view.getByRole("button", { name: "Confirm end" }));
|
||||
await waitFor(() =>
|
||||
expect(api.post).toHaveBeenCalledWith("/maintenance/7", {
|
||||
action: "end",
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("offers a read-only view without edit permission", async () => {
|
||||
const view = await render(apiFor(active), ["automations.view"]);
|
||||
await waitFor(() => expect(view.getByText("OS upgrade")).toBeTruthy());
|
||||
expect(view.queryByRole("button", { name: "End maintenance" })).toBeNull();
|
||||
expect(view.queryByLabelText("Reason")).toBeNull();
|
||||
});
|
||||
it("submits a recurring UTC schedule with its grace period", async () => {
|
||||
const api = apiFor(emptyMaintenance());
|
||||
const view = await render(api);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
view
|
||||
.getByRole("button", { name: "Start now" })
|
||||
.hasAttribute("disabled"),
|
||||
).toBe(false),
|
||||
);
|
||||
fireEvent.change(view.getByLabelText("Action"), {
|
||||
target: { value: "schedule" },
|
||||
});
|
||||
fireEvent.change(view.getByLabelText("Reason"), {
|
||||
target: { value: "Upgrade" },
|
||||
});
|
||||
fireEvent.change(view.getByLabelText("First start (UTC)"), {
|
||||
target: { value: "2027-01-01T12:00" },
|
||||
});
|
||||
fireEvent.change(view.getByLabelText("Repeat"), {
|
||||
target: { value: "monthly" },
|
||||
});
|
||||
fireEvent.click(view.getByRole("button", { name: "Add schedule" }));
|
||||
await waitFor(() =>
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/maintenance/7",
|
||||
expect.objectContaining({
|
||||
action: "schedule",
|
||||
start: "2027-01-01T12:00:00.000Z",
|
||||
recurrence: "monthly",
|
||||
graceMinutes: 20,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
it("disables edits when status cannot be loaded", async () => {
|
||||
const api = apiFor(active);
|
||||
api.get.mockRejectedValue(new Error("offline"));
|
||||
const view = await render(api);
|
||||
await waitFor(() =>
|
||||
expect(view.getByRole("alert").textContent).toContain(
|
||||
"Could not refresh",
|
||||
),
|
||||
);
|
||||
expect(
|
||||
view.getByRole("button", { name: "Start now" }).hasAttribute("disabled"),
|
||||
).toBe(true);
|
||||
});
|
||||
it("does not let a stale refresh overwrite an acknowledged edit", async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
const api = {
|
||||
get: vi.fn(
|
||||
() =>
|
||||
new Promise((done) => {
|
||||
resolve = done;
|
||||
}),
|
||||
),
|
||||
post: vi.fn(async () => ({ data: active })),
|
||||
};
|
||||
const store = createMaintenanceStore(api as unknown as PluginApiClient);
|
||||
const request = store.refresh();
|
||||
await store.edit(7, { action: "start" });
|
||||
resolve({ data: [] });
|
||||
await request;
|
||||
expect(store.snapshot()[7]).toEqual(active);
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user