fix: maintenance index name, shared host read only view and badge polling

This commit is contained in:
LukeGus committed 2026-10-01 13:14:07 -05:00
1 parent 5ff5ae19d9
commit 3b58415f8f
16 files changed
+166 -37

No files matched your search

+1
View File
@@ -176,6 +176,7 @@
"title": "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.", "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.", "denied": "You do not have permission to view maintenance.",
"sharedReadOnly": "This host was shared with you. Only its owner can change maintenance.",
"failed": "Maintenance could not be updated.", "failed": "Maintenance could not be updated.",
"active": "Maintenance active", "active": "Maintenance active",
"estimated": "Estimated completion", "estimated": "Estimated completion",
@@ -0,0 +1,5 @@
-- automations 0004: rename_maintenance_index
-- Renames the maintenance index to the idx_ pattern every other index uses.
CREATE UNIQUE INDEX `idx_automation_maintenance_host_owner` ON `p_automations_host_maintenance` (`user_id`, `host_id`);
DROP INDEX `maintenance_host_owner` ON `p_automations_host_maintenance`;
@@ -0,0 +1,5 @@
-- automations 0004: rename_maintenance_index
-- Renames the maintenance index to the idx_ pattern every other index uses.
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
DROP INDEX IF EXISTS "maintenance_host_owner";
+1 -1
View File
@@ -24,7 +24,7 @@
}, },
"indexes": [ "indexes": [
{ {
"name": "maintenance_host_owner", "name": "idx_automation_maintenance_host_owner",
"columns": ["userId", "hostId"], "columns": ["userId", "hostId"],
"unique": true "unique": true
} }
@@ -0,0 +1,5 @@
-- automations 0004: rename_maintenance_index
-- Renames the maintenance index to the idx_ pattern every other index uses.
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
DROP INDEX IF EXISTS "maintenance_host_owner";
@@ -13,6 +13,7 @@ export function registerMaintenanceRoutes(
* /plugin-api/automations/maintenance: * /plugin-api/automations/maintenance:
* get: * get:
* summary: List maintenance windows on the caller's hosts * summary: List maintenance windows on the caller's hosts
* description: Covers hosts the caller owns and hosts shared with them. A shared host carries its owner's maintenance with owned set to false.
* tags: [Automations] * tags: [Automations]
* responses: * responses:
* 200: { description: Maintenance state per host. } * 200: { description: Maintenance state per host. }
@@ -26,14 +27,25 @@ export function registerMaintenanceRoutes(
const user = ctx.currentActor(); const user = ctx.currentActor();
if (!user) if (!user)
return res.status(401).json({ error: "Authentication required" }); return res.status(401).json({ error: "Authentication required" });
const hosts = new Set( // Maintenance belongs to the host's owner, so a shared host shows
(await ctx.hosts.list()) // the owner's windows, read only.
.filter((host) => host.userId === user) const hostsByOwner = new Map<string, Set<number>>();
.map((host) => host.id), for (const host of await ctx.hosts.list()) {
); const ids = hostsByOwner.get(host.userId) ?? new Set<number>();
res.json( ids.add(host.id);
(await service.list(user)).filter((row) => hosts.has(row.hostId)), hostsByOwner.set(host.userId, ids);
); }
const rows = [];
for (const [owner, ids] of hostsByOwner) {
for (const row of await service.list(owner)) {
if (!ids.has(row.hostId)) continue;
rows.push({
hostId: row.hostId,
state: { ...row.state, owned: owner === user },
});
}
}
res.json(rows);
} catch (error) { } catch (error) {
ctx.log.error("Failed to list maintenance", error as Error); ctx.log.error("Failed to list maintenance", error as Error);
res.status(500).json({ error: "Failed to list maintenance" }); res.status(500).json({ error: "Failed to list maintenance" });
@@ -49,9 +61,17 @@ export function registerMaintenanceRoutes(
return res.status(401).json({ error: "Authentication required" }); return res.status(401).json({ error: "Authentication required" });
try { try {
const host = await ctx.hosts.get(hostId); const host = await ctx.hosts.get(hostId);
if (!host || host.userId !== user) if (!host) return res.status(404).json({ error: "Host not found" });
return res.status(404).json({ error: "Host not found" }); const owned = host.userId === user;
if (!mutate) return res.json(await service.read(user, hostId)); if (!mutate)
return res.json({
...(await service.read(host.userId, hostId)),
owned,
});
if (!owned)
return res
.status(403)
.json({ error: "Only the host's owner can change maintenance" });
const state = await service.edit( const state = await service.edit(
user, user,
hostId, hostId,
@@ -65,7 +85,7 @@ export function registerMaintenanceRoutes(
resourceName: host.name || host.ip, resourceName: host.name || host.ip,
success: true, success: true,
}); });
res.json(state); res.json({ ...state, owned: true });
} catch (error) { } catch (error) {
// Validation failures have no side effects; storage failures remain errors. // Validation failures have no side effects; storage failures remain errors.
if (error instanceof MaintenanceInputError) { if (error instanceof MaintenanceInputError) {
@@ -87,8 +107,8 @@ export function registerMaintenanceRoutes(
* required: true * required: true
* schema: { type: integer } * schema: { type: integer }
* responses: * responses:
* 200: { description: The active window, if any, and the planned ones. } * 200: { description: The active window, if any, and the planned ones. owned is false on a host shared with the caller. }
* 404: { description: Not one of the caller's hosts. } * 404: { description: A host the caller cannot see. }
*/ */
router.get( router.get(
"/maintenance/:hostId", "/maintenance/:hostId",
@@ -100,7 +120,7 @@ export function registerMaintenanceRoutes(
* /plugin-api/automations/maintenance/{hostId}: * /plugin-api/automations/maintenance/{hostId}:
* post: * post:
* summary: Start, schedule, end or remove maintenance on a host * summary: Start, schedule, end or remove maintenance on a host
* description: While a host is in maintenance its downtime does not raise alerts. Past the estimated end plus the grace period, it can alert again. * description: While a host is in maintenance, automations with host status, health check, metric, Docker or tunnel triggers do not fire for it. Manual, scheduled, webhook and login triggers still run. Past the estimated end plus the grace period, an optional overdue alert is sent.
* tags: [Automations] * tags: [Automations]
* parameters: * parameters:
* - in: path * - in: path
@@ -124,7 +144,8 @@ export function registerMaintenanceRoutes(
* responses: * responses:
* 200: { description: The new maintenance state. } * 200: { description: The new maintenance state. }
* 400: { description: Invalid action or plan. } * 400: { description: Invalid action or plan. }
* 404: { description: Not one of the caller's hosts. } * 403: { description: The host was shared with the caller; only its owner can change maintenance. }
* 404: { description: A host the caller cannot see. }
*/ */
router.post( router.post(
"/maintenance/:hostId", "/maintenance/:hostId",
+4 -1
View File
@@ -198,7 +198,10 @@ export const maintenanceTable = defineTable(
}, },
{ {
uniques: [ uniques: [
{ name: "maintenance_host_owner", columns: ["userId", "hostId"] }, {
name: "idx_automation_maintenance_host_owner",
columns: ["userId", "hostId"],
},
], ],
}, },
); );
@@ -41,7 +41,7 @@ function VisibleBadge({
return ( return (
<Badge <Badge
variant="outline" variant="outline"
title={`${active.reasons.join("; ")}\n${active.estimatedEnd}`} title={`${active.reasons.join("; ")}\n${new Date(active.estimatedEnd).toLocaleString()}`}
> >
{title} {title}
</Badge> </Badge>
@@ -72,9 +72,11 @@ function MaintenanceEditor({
store: MaintenanceStore; store: MaintenanceStore;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const canEdit = usePermission("edit"); const canEditPermission = usePermission("edit");
const states = useSyncExternalStore(store.subscribe, store.snapshot); const states = useSyncExternalStore(store.subscribe, store.snapshot);
const state = states[Number(host.id)]; const state = states[Number(host.id)];
// A shared host shows its owner's maintenance, read only.
const canEdit = canEditPermission && state?.owned !== false;
const [mode, setMode] = useState<"start" | "schedule">("start"); const [mode, setMode] = useState<"start" | "schedule">("start");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [start, setStart] = useState(""); const [start, setStart] = useState("");
@@ -174,6 +176,11 @@ function MaintenanceEditor({
))} ))}
</section> </section>
)} )}
{state?.owned === false && (
<p className="text-sm text-muted-foreground">
{t("maintenance.sharedReadOnly")}
</p>
)}
{canEdit && ( {canEdit && (
<form onSubmit={submit} className="space-y-4 border p-4"> <form onSubmit={submit} className="space-y-4 border p-4">
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
@@ -21,11 +21,16 @@ export function createMaintenanceStore(api: PluginApiClient) {
"/maintenance", "/maintenance",
); );
if (!disposed && version === revision) { if (!disposed && version === revision) {
error = ""; const next = Object.fromEntries(
loaded = true;
snapshot = Object.fromEntries(
data.map(({ hostId, state }) => [hostId, state]), data.map(({ hostId, state }) => [hostId, state]),
); );
// Most polls change nothing; skip the emit so badges do not re-render.
const unchanged =
loaded && !error && JSON.stringify(next) === JSON.stringify(snapshot);
error = "";
loaded = true;
if (unchanged) return;
snapshot = next;
emit(); emit();
} }
} catch { } catch {
+2
View File
@@ -23,6 +23,8 @@ export interface ActiveMaintenance {
export interface HostMaintenance { export interface HostMaintenance {
active: ActiveMaintenance | null; active: ActiveMaintenance | null;
plans: MaintenancePlan[]; plans: MaintenancePlan[];
/** Set on API responses: false when the host was shared with the caller. */
owned?: boolean;
} }
export const emptyMaintenance = (): HostMaintenance => ({ export const emptyMaintenance = (): HostMaintenance => ({
@@ -28,14 +28,29 @@ const input = {
}; };
describe("host maintenance", () => { describe("host maintenance", () => {
it("scopes reads and edits to the host owner and enforces edit permission", async () => { it("shows a shared host the owner's maintenance read only and enforces edit permission", async () => {
// The mock serves every host to every user, as if alice shared host 1.
server = await startServer({ hosts: [host(1), host(2, "bob")] }); server = await startServer({ hosts: [host(1), host(2, "bob")] });
expect( const started = await server.request("POST", "/maintenance/1", {
(await server.request("POST", "/maintenance/1", { body: input })).status, body: input,
).toBe(200); });
expect( expect(started.status).toBe(200);
(await server.request("GET", "/maintenance", { user: "bob" })).body, expect(started.body).toMatchObject({ owned: true });
).toEqual([]);
const listed = (
await server.request("GET", "/maintenance", { user: "bob" })
).body as Array<{
hostId: number;
state: { owned: boolean; active: unknown };
}>;
expect(listed).toHaveLength(1);
expect(listed[0].hostId).toBe(1);
expect(listed[0].state.owned).toBe(false);
expect(listed[0].state.active).toBeTruthy();
const read = await server.request("GET", "/maintenance/1", { user: "bob" });
expect(read.status).toBe(200);
expect(read.body).toMatchObject({ owned: false });
expect( expect(
( (
await server.request("POST", "/maintenance/1", { await server.request("POST", "/maintenance/1", {
@@ -43,9 +58,9 @@ describe("host maintenance", () => {
body: { action: "end" }, body: { action: "end" },
}) })
).status, ).status,
).toBe(404); ).toBe(403);
expect( expect(
(await server.request("GET", "/maintenance/1", { user: "bob" })).status, (await server.request("GET", "/maintenance/99", { user: "bob" })).status,
).toBe(404); ).toBe(404);
expect( expect(
(await server.request("POST", "/maintenance/1x", { body: input })).status, (await server.request("POST", "/maintenance/1x", { body: input })).status,
@@ -224,4 +224,36 @@ describe("adopting the automation tables", () => {
expect(count(db, "p_automations_automations")).toBe(0); expect(count(db, "p_automations_automations")).toBe(0);
expect(count(db, "p_automations_run_steps")).toBe(0); expect(count(db, "p_automations_run_steps")).toBe(0);
}); });
it("creates host maintenance with one row per owner and host, removed with either", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => sqlite.exec(NOTIFICATION_CHANNELS_DDL),
});
const maintenanceIndexes = indexes(db, "p_automations_host_maintenance");
expect(maintenanceIndexes).toContain(
"idx_automation_maintenance_host_owner",
);
expect(maintenanceIndexes).not.toContain("maintenance_host_owner");
db.sqlite.exec(`
INSERT INTO users (id, username) VALUES ('alice', 'alice');
INSERT INTO ssh_data (id) VALUES (1);
INSERT INTO ssh_data (id) VALUES (2);
INSERT INTO p_automations_host_maintenance (user_id, host_id, state) VALUES ('alice', 1, '{}');
INSERT INTO p_automations_host_maintenance (user_id, host_id, state) VALUES ('alice', 2, '{}');
`);
expect(() =>
db.sqlite
.prepare(
"INSERT INTO p_automations_host_maintenance (user_id, host_id, state) VALUES ('alice', 1, '{}')",
)
.run(),
).toThrow();
db.sqlite.prepare("DELETE FROM ssh_data WHERE id = 1").run();
expect(count(db, "p_automations_host_maintenance")).toBe(1);
db.sqlite.prepare("DELETE FROM users WHERE id = 'alice'").run();
expect(count(db, "p_automations_host_maintenance")).toBe(0);
});
}); });
@@ -71,6 +71,13 @@ describe("maintenance UI", () => {
expect(view.queryByRole("button", { name: "End maintenance" })).toBeNull(); expect(view.queryByRole("button", { name: "End maintenance" })).toBeNull();
expect(view.queryByLabelText("Reason")).toBeNull(); expect(view.queryByLabelText("Reason")).toBeNull();
}); });
it("shows a shared host's maintenance read only even with edit permission", async () => {
const view = await render(apiFor({ ...active, owned: false }));
await waitFor(() => expect(view.getByText("OS upgrade")).toBeTruthy());
expect(view.getByText(locales.maintenance.sharedReadOnly)).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 () => { it("submits a recurring UTC schedule with its grace period", async () => {
const api = apiFor(emptyMaintenance()); const api = apiFor(emptyMaintenance());
const view = await render(api); const view = await render(api);
+7
View File
@@ -800,6 +800,13 @@ CREATE TABLE IF NOT EXISTS `p_automations_host_maintenance` (
); );
CREATE UNIQUE INDEX `maintenance_host_owner` ON `p_automations_host_maintenance` (`user_id`, `host_id`); CREATE UNIQUE INDEX `maintenance_host_owner` ON `p_automations_host_maintenance` (`user_id`, `host_id`);
-- ==== plugin automations 0004_rename_maintenance_index.sql ====
-- automations 0004: rename_maintenance_index
-- Renames the maintenance index to the idx_ pattern every other index uses.
CREATE UNIQUE INDEX `idx_automation_maintenance_host_owner` ON `p_automations_host_maintenance` (`user_id`, `host_id`);
DROP INDEX `maintenance_host_owner` ON `p_automations_host_maintenance`;
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ==== -- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
-- homepage 0001: adopt_homepage_tables -- homepage 0001: adopt_homepage_tables
-- Generated by termix-plugin migrations. Review before committing. -- Generated by termix-plugin migrations. Review before committing.
@@ -841,6 +841,13 @@ CREATE TABLE IF NOT EXISTS "p_automations_host_maintenance" (
); );
CREATE UNIQUE INDEX IF NOT EXISTS "maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id"); CREATE UNIQUE INDEX IF NOT EXISTS "maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
-- ==== plugin automations 0004_rename_maintenance_index.sql ====
-- automations 0004: rename_maintenance_index
-- Renames the maintenance index to the idx_ pattern every other index uses.
CREATE UNIQUE INDEX IF NOT EXISTS "idx_automation_maintenance_host_owner" ON "p_automations_host_maintenance" ("user_id", "host_id");
DROP INDEX IF EXISTS "maintenance_host_owner";
-- ==== plugin homepage 0001_adopt_homepage_tables.sql ==== -- ==== plugin homepage 0001_adopt_homepage_tables.sql ====
-- homepage 0001: adopt_homepage_tables -- homepage 0001: adopt_homepage_tables
-- Generated by termix-plugin migrations. Review before committing. -- Generated by termix-plugin migrations. Review before committing.
@@ -169,14 +169,21 @@ describe("the migration checker", () => {
for (const dialect of ["sqlite", "postgres", "mysql"]) { for (const dialect of ["sqlite", "postgres", "mysql"]) {
const dir = path.join(plugins, id, "migrations", dialect); const dir = path.join(plugins, id, "migrations", dialect);
if (!fs.existsSync(dir)) continue; if (!fs.existsSync(dir)) continue;
for (const file of fs.readdirSync(dir)) { const files = fs
if (!file.endsWith(".sql")) continue; .readdirSync(dir)
const sql = fs.readFileSync(path.join(dir, file), "utf8"); .filter((file) => file.endsWith(".sql"))
.sort();
const sqls = files.map((file) =>
fs.readFileSync(path.join(dir, file), "utf8"),
);
// The runner lets a later migration drop an index an earlier one made.
const ownedIndexes = collectOwnedIndexes(id, sqls, legacy);
files.forEach((file, index) => {
expect( expect(
findUnownedTableWrites(id, sql, legacy), findUnownedTableWrites(id, sqls[index], legacy, ownedIndexes),
`${id}/${dialect}/${file}`, `${id}/${dialect}/${file}`,
).toEqual([]); ).toEqual([]);
} });
} }
} }
}); });