feat: finish the host-metrics plugin, move host status into core (B16)

This commit is contained in:
LukeGus committed 2026-09-24 11:21:23 -05:00
1 parent 14eb558b85
commit 0a3f05d99d
308 files changed
+53073 -8985

No files matched your search

@@ -9,9 +9,7 @@ import {
createCurrentDismissedAlertRepository,
createCurrentHomepageItemRepository,
createCurrentHomepageLayoutRepository,
createCurrentHostHealthRepository,
createCurrentHostFolderRepository,
createCurrentHostMetricsPreferenceRepository,
createCurrentHostRepository,
createCurrentHostSidebarPreferenceRepository,
createCurrentCredentialSidebarPreferenceRepository,
@@ -93,8 +91,6 @@ export async function deleteUserAndRelatedData(
await createCurrentHostFolderRepository().deleteByUserId(userId);
await createCurrentHostHealthRepository().deleteByUserId(userId);
await createCurrentHostMetricsPreferenceRepository().deleteByUserId(userId);
await createCurrentHostSidebarPreferenceRepository().deleteByUserId(userId);
await createCurrentCredentialSidebarPreferenceRepository().deleteByUserId(
userId,
+31 -20
View File
@@ -292,6 +292,8 @@ export function registerHostBulkRoutes(
simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar;
if (typeof updates.enableAiAssistant === "boolean")
simpleUpdates.enableAiAssistant = updates.enableAiAssistant;
if (typeof updates.statusCheckEnabled === "boolean")
simpleUpdates.statusCheckEnabled = updates.statusCheckEnabled;
if (Object.keys(simpleUpdates).length > 0) {
await hostRepository.updateManyForUser(
@@ -301,22 +303,6 @@ export function registerHostBulkRoutes(
);
}
if (updates.statsConfig && typeof updates.statsConfig === "object") {
for (const host of ownedHosts) {
try {
const existing = host.statsConfig
? JSON.parse(host.statsConfig as string)
: {};
const merged = { ...existing, ...updates.statsConfig };
await hostRepository.updateForUser(userId, host.id, {
statsConfig: JSON.stringify(merged),
});
} catch {
errors.push(`Failed to update statsConfig for host ${host.id}`);
}
}
}
// Proxmox enable/disable now lives in the proxmox plugin's own
// host-scope settings, not a hosts column. Disabling is a plain flag
// flip; enabling also defaults the Proxmox credential to the one
@@ -784,9 +770,7 @@ export function registerHostBulkRoutes(
quickActions: hostData.quickActions
? JSON.stringify(hostData.quickActions)
: null,
statsConfig: hostData.statsConfig
? JSON.stringify(hostData.statsConfig)
: null,
...importedStatusCheck(hostData as Record<string, unknown>),
dockerConfig: hostData.dockerConfig
? JSON.stringify(hostData.dockerConfig)
: null,
@@ -1048,7 +1032,8 @@ export function registerHostBulkRoutes(
? JSON.stringify(hostData.jumpHosts)
: null,
quickActions: null,
statsConfig: null,
statusCheckEnabled: true,
statusCheckInterval: null,
dockerConfig: null,
terminalConfig: null,
forceKeyboardInteractive: "false",
@@ -1099,3 +1084,29 @@ export function registerHostBulkRoutes(
},
);
}
/**
* Status check fields from an import row. Exports from before 2.9.0 carried
* them inside statsConfig.
*/
function importedStatusCheck(raw: Record<string, unknown>): {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
} {
const legacy =
raw.statsConfig && typeof raw.statsConfig === "object"
? (raw.statsConfig as Record<string, unknown>)
: {};
const enabled = raw.statusCheckEnabled ?? legacy.statusCheckEnabled;
const useGlobal = legacy.useGlobalStatusInterval !== false;
const interval =
raw.statusCheckInterval ?? (useGlobal ? null : legacy.statusCheckInterval);
const seconds = Number(interval);
return {
statusCheckEnabled: enabled !== false && legacy.disableTcpPing !== true,
statusCheckInterval:
interval != null && Number.isInteger(seconds) && seconds >= 5
? seconds
: null,
};
}
@@ -191,7 +191,8 @@ export type NormalizedImportedHost = Record<string, unknown> & {
tunnelConnections?: unknown;
jumpHosts?: unknown;
quickActions?: unknown;
statsConfig?: unknown;
statusCheckEnabled?: unknown;
statusCheckInterval?: unknown;
dockerConfig?: unknown;
webUiConfig?: unknown;
proxmoxConfig?: unknown;
@@ -348,6 +349,8 @@ const CONNECT_LEVEL_FIELDS = new Set([
"showTunnelInSidebar",
"showDockerInSidebar",
"showServerStatsInSidebar",
"statusCheckEnabled",
"statusCheckInterval",
"enableSsh",
"sshPort",
"rdpAuthType",
@@ -456,9 +459,12 @@ export function transformHostResponse(
quickActions: host.quickActions
? JSON.parse(host.quickActions as string)
: [],
statsConfig: host.statsConfig
? JSON.parse(host.statsConfig as string)
: undefined,
statusCheckEnabled:
host.statusCheckEnabled !== false && host.statusCheckEnabled !== 0,
statusCheckInterval:
typeof host.statusCheckInterval === "number"
? host.statusCheckInterval
: null,
terminalConfig: host.terminalConfig
? JSON.parse(host.terminalConfig as string)
: undefined,
@@ -0,0 +1,259 @@
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { Request, RequestHandler, Response, Router } from "express";
import { PermissionManager } from "../../utils/permission-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { sshLogger } from "../../utils/logger.js";
import { createCurrentSettingsRepository } from "../repositories/factory.js";
import {
DEFAULT_STATUS_INTERVAL,
GLOBAL_STATUS_INTERVAL_KEY,
hostStatusService,
type HostStatusEntry,
} from "../../hosts/status/host-status-service.js";
interface HostStatusRoutesDeps {
authenticateJWT: RequestHandler;
requireAdmin: RequestHandler;
}
/** "1,2,3" to a set of ids; undefined means the caller asked for all. */
export function parseStatusHostIds(value: unknown): Set<number> | null {
if (value === undefined) return null;
if (typeof value !== "string") return new Set();
return new Set(
value
.split(",")
.map(Number)
.filter((id) => Number.isSafeInteger(id) && id > 0),
);
}
function sessionExpired(res: Response): Response {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
export function registerHostStatusRoutes(
router: Router,
{ authenticateJWT, requireAdmin }: HostStatusRoutesDeps,
): void {
const permissionManager = PermissionManager.getInstance();
/**
* @openapi
* /host/status:
* get:
* summary: Get host statuses
* description: Returns the online, reachable or offline status of every host the user can see. Starts status checks for the user's own hosts on first call.
* tags:
* - SSH
* parameters:
* - in: query
* name: hostIds
* required: false
* schema:
* type: string
* description: Comma separated ids. Only these of the user's own hosts are checked.
* responses:
* 200:
* description: A map of host ids to status entries.
* 401:
* description: Session expired - please log in again.
*/
router.get(
"/status",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null)
return sessionExpired(res);
const requested = parseStatusHostIds(req.query.hostIds);
const statuses = await hostStatusService.statusesFor(userId, requested);
// One batched permission check: this is polled every few seconds, and a
// per-host check made it linear in host count against the database.
const entries = [...statuses.entries()];
const allowed = await permissionManager.filterAccessibleHostIds(
userId,
entries.map(([id]) => id),
);
const result: Record<number, HostStatusEntry> = {};
for (const [id, entry] of entries) {
if (allowed.has(id) && (requested === null || requested.has(id))) {
result[id] = entry;
}
}
res.json(result);
},
);
/**
* @openapi
* /host/status/settings:
* get:
* summary: Get status check settings
* description: Returns the default number of seconds between status checks.
* tags:
* - SSH
* responses:
* 200:
* description: The status check settings.
* 403:
* description: Admin access required.
*/
router.get(
"/status/settings",
authenticateJWT,
requireAdmin,
async (_req: Request, res: Response) => {
const value = Number(
await createCurrentSettingsRepository().get(GLOBAL_STATUS_INTERVAL_KEY),
);
res.json({
statusCheckInterval:
Number.isInteger(value) && value >= 5
? value
: DEFAULT_STATUS_INTERVAL,
});
},
);
/**
* @openapi
* /host/status/settings:
* put:
* summary: Update status check settings
* description: Sets the default number of seconds between status checks and re-times every running check.
* tags:
* - SSH
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* statusCheckInterval:
* type: integer
* minimum: 5
* maximum: 3600
* responses:
* 200:
* description: Settings saved.
* 400:
* description: Invalid interval.
* 403:
* description: Admin access required.
*/
router.put(
"/status/settings",
authenticateJWT,
requireAdmin,
async (req: Request, res: Response) => {
const seconds = Number(req.body?.statusCheckInterval);
if (!Number.isInteger(seconds) || seconds < 5 || seconds > 3600) {
return res
.status(400)
.json({ error: "Status check interval must be 5 to 3600 seconds" });
}
try {
await createCurrentSettingsRepository().set(
GLOBAL_STATUS_INTERVAL_KEY,
String(seconds),
);
hostStatusService.retimeAll();
res.json({ statusCheckInterval: seconds });
} catch (error) {
sshLogger.error("Failed to save status check settings", error, {
operation: "host_status_settings",
});
res.status(500).json({ error: "Failed to save settings" });
}
},
);
/**
* @openapi
* /host/status/refresh:
* post:
* summary: Restart status checks
* description: Re-reads the user's own hosts and restarts their status checks.
* tags:
* - SSH
* responses:
* 200:
* description: Status checks restarted.
* 401:
* description: Session expired - please log in again.
*/
router.post(
"/status/refresh",
authenticateJWT,
async (req: Request, res: Response) => {
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return sessionExpired(res);
}
await hostStatusService.refresh(userId);
res.json({ message: "Status checks restarted" });
},
);
/**
* @openapi
* /host/status/{id}:
* get:
* summary: Get one host's status
* description: Returns the status of one host the user can connect to.
* tags:
* - SSH
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: The host's status entry.
* 400:
* description: Invalid host id.
* 404:
* description: Status not available.
*/
router.get(
"/status/:id",
authenticateJWT,
async (req: Request, res: Response) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: "Invalid host ID" });
}
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return sessionExpired(res);
}
const access = await permissionManager.canAccessHost(
userId,
id,
"connect",
);
if (!access.hasAccess) {
return res.status(404).json({ error: "Status not available" });
}
await hostStatusService.statusesFor(userId, null);
const entry =
hostStatusService.get(id) ?? (await hostStatusService.check(id));
if (!entry) {
return res.status(404).json({ error: "Status not available" });
}
res.json(entry);
},
);
}
+25 -19
View File
@@ -55,6 +55,7 @@ import { registerHostAutostartRoutes } from "./host-autostart-routes.js";
import { registerHostInternalRoutes } from "./host-internal-routes.js";
import { registerHostNetworkRoutes } from "./host-network-routes.js";
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
import { registerHostStatusRoutes } from "./host-status-routes.js";
import {
applyHostEnrollmentDefaults,
requireHostEnrollmentAccessForPath,
@@ -109,6 +110,11 @@ const requireDataAccess = authManager.createDataAccessMiddleware();
registerHostInternalRoutes(router);
registerHostStatusRoutes(router, {
authenticateJWT,
requireAdmin: authManager.createAdminMiddleware(),
});
/**
* @openapi
* /host/db/host:
@@ -207,7 +213,8 @@ router.post(
tunnelConnections,
jumpHosts,
quickActions,
statsConfig,
statusCheckEnabled,
statusCheckInterval,
dockerConfig,
proxmoxConfig,
enableProxmoxStats,
@@ -335,11 +342,8 @@ router.post(
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig
? typeof statsConfig === "string"
? statsConfig
: JSON.stringify(statsConfig)
: null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
dockerConfig: dockerConfig
? typeof dockerConfig === "string"
? dockerConfig
@@ -774,7 +778,8 @@ router.post(
tunnelConnections: [],
jumpHosts: [],
quickActions: [],
statsConfig: {},
statusCheckEnabled: true,
statusCheckInterval: null,
notes: "",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -904,7 +909,8 @@ router.put(
tunnelConnections,
jumpHosts,
quickActions,
statsConfig,
statusCheckEnabled,
statusCheckInterval,
dockerConfig,
proxmoxConfig,
enableProxmoxStats,
@@ -1033,11 +1039,8 @@ router.put(
showDockerInSidebar: showDockerInSidebar ? 1 : 0,
showServerStatsInSidebar: showServerStatsInSidebar ? 1 : 0,
defaultPath: defaultPath || null,
statsConfig: statsConfig
? typeof statsConfig === "string"
? statsConfig
: JSON.stringify(statsConfig)
: null,
statusCheckEnabled: statusCheckEnabled === false ? 0 : 1,
statusCheckInterval: normalizeStatusInterval(statusCheckInterval),
dockerConfig: dockerConfig
? typeof dockerConfig === "string"
? dockerConfig
@@ -2119,9 +2122,6 @@ router.get(
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
webUiConfig: webEndpointSettings?.webUiConfig ?? { endpoints: [] },
dockerConfig: resolvedHost.dockerConfig
? JSON.parse(resolvedHost.dockerConfig as string)
@@ -2285,9 +2285,6 @@ router.get(
quickActions: resolvedHost.quickActions
? JSON.parse(resolvedHost.quickActions as string)
: null,
statsConfig: resolvedHost.statsConfig
? JSON.parse(resolvedHost.statsConfig as string)
: null,
webUiConfig: webEndpointSettings?.webUiConfig ?? {
endpoints: [],
},
@@ -2929,3 +2926,12 @@ function safeParseJson(raw: string): unknown {
return null;
}
}
/** Seconds between status checks, or null to follow the global setting. */
function normalizeStatusInterval(value: unknown): number | null {
const seconds = Number(value);
if (value === null || value === undefined || value === "") return null;
return Number.isInteger(seconds) && seconds >= 5 && seconds <= 86400
? seconds
: null;
}
@@ -32,7 +32,6 @@ export type HostDefaults = {
socks5Username?: string;
socks5Password?: string;
credentialId?: number | null;
metricsEnabled?: boolean;
statusCheckEnabled?: boolean;
fontSize?: number;
fontFamily?: string;