mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 07:35:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
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,
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user