feat(automations): add per-host maintenance mode and recurring schedules (#1521)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:51:51 -05:00
1 parent fd91d4925a
commit 99cffc9ba6
26 files changed
+1813 -6

No files matched your search

+34 -3
View File
@@ -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,
};
}
+9 -1
View File
@@ -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,
+17
View File
@@ -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,
+21 -1
View File
@@ -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>
);
}
+33 -1
View File
@@ -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>;
+31
View File
@@ -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: [],
});