Files
Termix/plugins/host-metrics/backend/preferences-routes.ts
T

231 lines
7.3 KiB
TypeScript

import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../src/types/index.js";
import { createCurrentHostMetricsPreferenceRepository } from "../../../src/backend/database/repositories/factory.js";
import { statsLogger } from "../../../src/backend/utils/logger.js";
import {
deriveEnabledWidgets,
defaultLayoutFromWidgets,
type HostMetricsLayout,
} from "../../../src/types/host-metrics.js";
import type { HostAction } from "../../../src/backend/utils/permission-manager.js";
interface PrefStatsConfig {
enabledWidgets?: string[];
}
interface PrefHost {
id: number;
userId: string;
statsConfig?: unknown;
}
type HostMetricsPreferencesRoutesDeps = {
validateHostId: RequestHandler;
fetchHostById: (hostId: number, userId: string) => Promise<PrefHost | null>;
parseStatsConfig: (statsConfig: unknown) => PrefStatsConfig;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
function sanitizeLayout(input: unknown): HostMetricsLayout | null {
if (!input || typeof input !== "object") return null;
const obj = input as Record<string, unknown>;
if (!Array.isArray(obj.slots)) return null;
const columns =
typeof obj.columns === "number" && obj.columns >= 1 && obj.columns <= 4
? Math.round(obj.columns)
: 3;
const slots = obj.slots
.filter((s): s is Record<string, unknown> => !!s && typeof s === "object")
.map((s, i) => ({
id: String(s.id),
order: typeof s.order === "number" ? s.order : i,
colSpan:
s.colSpan === 1 || s.colSpan === 2 || s.colSpan === 3 ? s.colSpan : 1,
height:
typeof s.height === "number" && s.height > 0
? Math.round(s.height)
: null,
}))
.filter((s) => s.id && s.id !== "undefined");
return { slots, columns } as HostMetricsLayout;
}
export function registerHostMetricsPreferencesRoutes(
app: Express,
{
validateHostId,
fetchHostById,
parseStatsConfig,
canAccessHost,
}: HostMetricsPreferencesRoutesDeps,
): void {
/**
* @openapi
* /host-metrics/preferences/{id}:
* get:
* summary: Get the Host Metrics layout for a host
* description: Returns the current user's saved card layout for the host, or a default layout derived from the host's enabled widgets when none is saved.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The layout for this host.
* 403:
* description: No access to this host.
* 404:
* description: Host not found.
*/
app.get("/host-metrics/preferences/:id", validateHostId, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const row =
await createCurrentHostMetricsPreferenceRepository().findByUserAndHost(
userId,
hostId,
);
if (row?.layout) {
try {
const parsed = sanitizeLayout(JSON.parse(row.layout));
if (parsed) return res.json({ layout: parsed });
} catch {
// fall through to default
}
}
const statsConfig = parseStatsConfig(host.statsConfig);
const layout = defaultLayoutFromWidgets(statsConfig.enabledWidgets ?? []);
return res.json({ layout });
} catch (error) {
statsLogger.error("Failed to fetch host metrics preferences", {
operation: "host_metrics_prefs_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to fetch host metrics preferences" });
}
});
/**
* @openapi
* /host-metrics/preferences/{id}:
* post:
* summary: Save the Host Metrics layout for a host
* description: Persists the current user's card layout for the host and keeps statsConfig.enabledWidgets in sync (for hosts the user owns) so the mobile app keeps working.
* tags:
* - Host Metrics
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* slots:
* type: array
* columns:
* type: integer
* responses:
* 200:
* description: Layout saved.
* 400:
* description: Invalid layout.
* 403:
* description: No access to this host.
*/
app.post(
"/host-metrics/preferences/:id",
validateHostId,
async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const hostId = parseInt(String(req.params.id), 10);
try {
if (!(await canAccessHost(userId, hostId, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(hostId, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
const layout = sanitizeLayout(req.body);
if (!layout) {
return res.status(400).json({ error: "Invalid layout" });
}
const now = new Date().toISOString();
const layoutJson = JSON.stringify(layout);
await createCurrentHostMetricsPreferenceRepository().upsertLayout(
userId,
hostId,
layoutJson,
now,
);
// Keep statsConfig.enabledWidgets in sync (mobile contract) for hosts the
// user owns. stats_config is plain JSON text (not an encrypted field).
if (host.userId === userId) {
try {
const current = parseStatsConfig(host.statsConfig);
const merged = {
...current,
enabledWidgets: deriveEnabledWidgets(layout.slots),
};
await createCurrentHostMetricsPreferenceRepository().updateHostStatsConfig(
userId,
hostId,
JSON.stringify(merged),
);
} catch (syncErr) {
statsLogger.warn("Failed to sync enabledWidgets from layout", {
operation: "host_metrics_prefs_sync_widgets",
hostId,
error:
syncErr instanceof Error ? syncErr.message : String(syncErr),
});
}
}
return res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to save host metrics preferences", {
operation: "host_metrics_prefs_save_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
return res
.status(500)
.json({ error: "Failed to save host metrics preferences" });
}
},
);
}