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

+46 -5
View File
@@ -806,7 +806,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
show_docker_in_sidebar INTEGER NOT NULL DEFAULT 0,
show_server_stats_in_sidebar INTEGER NOT NULL DEFAULT 0,
default_path TEXT,
stats_config TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
docker_config TEXT,
web_ui_config TEXT,
terminal_config TEXT,
@@ -928,8 +929,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
const sshHosts =
await createCurrentHostRepository().listDecryptedByUserId(userId);
const insertHost = exportDb.prepare(`
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, stats_config, docker_config, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, mac_address, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO ssh_data (id, user_id, connection_type, name, ip, port, username, folder, tags, pin, auth_type, force_keyboard_interactive, password, key, key_password, key_type, sudo_password, autostart_password, autostart_key, autostart_key_password, credential_id, override_credential_username, enable_terminal, enable_tunnel, tunnel_connections, jump_hosts, enable_file_manager, enable_docker, enable_web_ui, show_terminal_in_sidebar, show_file_manager_in_sidebar, show_tunnel_in_sidebar, show_docker_in_sidebar, show_server_stats_in_sidebar, default_path, status_check_enabled, status_check_interval, docker_config, web_ui_config, terminal_config, quick_actions, notes, use_socks5, socks5_host, socks5_port, socks5_username, socks5_password, socks5_proxy_chain, domain, mac_address, port_knock_sequence, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const decrypted of sshHosts) {
@@ -969,7 +970,8 @@ app.post("/database/export", authenticateJWT, async (req, res) => {
decrypted.showDockerInSidebar ? 1 : 0,
decrypted.showServerStatsInSidebar ? 1 : 0,
decrypted.defaultPath || null,
decrypted.statsConfig || null,
decrypted.statusCheckEnabled === false ? 0 : 1,
decrypted.statusCheckInterval ?? null,
decrypted.dockerConfig || null,
decrypted.webUiConfig || null,
decrypted.terminalConfig || null,
@@ -1378,7 +1380,7 @@ app.post(
host.show_server_stats_in_sidebar,
),
defaultPath: host.default_path,
statsConfig: host.stats_config,
...legacyStatusCheck(host),
terminalConfig: host.terminal_config,
quickActions: host.quick_actions,
notes: host.notes,
@@ -2156,3 +2158,42 @@ if (
);
}
}
/**
* Status check columns from an export row. Exports from before 2.9.0 only
* have stats_config, which carried them.
*/
function legacyStatusCheck(row: Record<string, unknown>): {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
} {
if (row.status_check_enabled !== undefined) {
return {
statusCheckEnabled: Boolean(row.status_check_enabled),
statusCheckInterval:
typeof row.status_check_interval === "number"
? row.status_check_interval
: null,
};
}
let legacy: Record<string, unknown> = {};
try {
legacy =
typeof row.stats_config === "string" && row.stats_config
? JSON.parse(row.stats_config)
: {};
} catch {
legacy = {};
}
const seconds = Number(legacy.statusCheckInterval);
return {
statusCheckEnabled:
legacy.statusCheckEnabled !== false && legacy.disableTcpPing !== true,
statusCheckInterval:
legacy.useGlobalStatusInterval === false &&
Number.isInteger(seconds) &&
seconds >= 5
? seconds
: null,
};
}
+8 -88
View File
@@ -334,7 +334,8 @@ async function initializeCompleteDatabase(): Promise<void> {
autostart_key TEXT,
autostart_key_password TEXT,
force_keyboard_interactive TEXT,
stats_config TEXT,
status_check_enabled INTEGER NOT NULL DEFAULT 1,
status_check_interval INTEGER,
docker_config TEXT,
web_ui_config TEXT,
terminal_config TEXT,
@@ -687,20 +688,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS host_metrics_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
layout TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_metrics_prefs_user_host
ON host_metrics_preferences (user_id, host_id);
CREATE TABLE IF NOT EXISTS host_sidebar_preferences (
user_id TEXT PRIMARY KEY,
data TEXT NOT NULL,
@@ -722,37 +709,6 @@ async function initializeCompleteDatabase(): Promise<void> {
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS host_health_checks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
checks TEXT NOT NULL,
interval_seconds INTEGER NOT NULL DEFAULT 300,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_host_health_checks_user_host
ON host_health_checks (user_id, host_id);
CREATE TABLE IF NOT EXISTS host_health_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
host_id INTEGER NOT NULL,
check_id TEXT NOT NULL,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
ok INTEGER NOT NULL,
latency_ms INTEGER,
detail TEXT,
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_host_health_history_lookup
ON host_health_history (user_id, host_id, check_id, ts);
CREATE TABLE IF NOT EXISTS sso_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
@@ -1111,7 +1067,12 @@ const migrateSchema = () => {
addColumnIfNotExists("ssh_data", "autostart_password", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key", "TEXT");
addColumnIfNotExists("ssh_data", "autostart_key_password", "TEXT");
addColumnIfNotExists("ssh_data", "stats_config", "TEXT");
addColumnIfNotExists(
"ssh_data",
"status_check_enabled",
"INTEGER NOT NULL DEFAULT 1",
);
addColumnIfNotExists("ssh_data", "status_check_interval", "INTEGER");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "TEXT");
addColumnIfNotExists(
@@ -2034,34 +1995,6 @@ const migrateSchema = () => {
});
}
// --- metrics-history begin ---
try {
sqlite.prepare("SELECT id FROM host_metrics_history LIMIT 1").get();
} catch {
try {
sqlite.exec(`
CREATE TABLE IF NOT EXISTS host_metrics_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
cpu_percent REAL,
mem_percent REAL,
disk_percent REAL,
net_rx_bytes INTEGER,
net_tx_bytes INTEGER
);
CREATE INDEX IF NOT EXISTS idx_host_metrics_history_host_ts
ON host_metrics_history (host_id, ts DESC);
`);
} catch (createError) {
databaseLogger.warn("Failed to create host_metrics_history table", {
operation: "schema_migration",
error: createError,
});
}
}
// --- metrics-history end ---
// --- alerts begin ---
// alert_rules, alert_rule_channels and alert_firings were only ever created
// here, never declared in schema.ts, so they never existed on Postgres or
@@ -2258,19 +2191,6 @@ const migrateSchema = () => {
}
// --- automations end ---
// Seed default metrics history retention setting
try {
ensureRawSettingDefault("metrics_history_retention_days", "7");
} catch (e) {
databaseLogger.warn(
"Could not initialize metrics_history_retention_days setting",
{
operation: "schema_migration",
error: e,
},
);
}
// --- homepage begin ---
try {
sqlite.prepare("SELECT id FROM homepage_items LIMIT 1").get();
+5 -88
View File
@@ -295,7 +295,10 @@ export const hosts = mysqlTable(
.notNull()
.default(false),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
statusCheckEnabled: boolean("status_check_enabled")
.notNull()
.default(true),
statusCheckInterval: int("status_check_interval"),
dockerConfig: text("docker_config"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
@@ -887,40 +890,12 @@ export const userPreferences = mysqlTable("user_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const hostMetricsPreferences = mysqlTable(
"host_metrics_preferences",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
// plain JSON (no field-level encryption).
layout: text("layout").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// One layout per user per host. Enforced in production since the inline DDL
// creates it, but it was never declared here, so the generated Postgres and
// MySQL schemas lacked it — and the upsert has nothing to conflict on.
(table) => [
uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId),
],
);
export const hostSidebarPreferences = mysqlTable("host_sidebar_preferences", {
userId: varchar("user_id", { length: 255 })
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
// plain JSON like hostMetricsPreferences.layout.
// plain JSON.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
@@ -955,47 +930,6 @@ export const uiPreferences = mysqlTable("ui_preferences", {
.default(sql`(CURRENT_TIMESTAMP)`),
});
export const hostHealthChecks = mysqlTable(
"host_health_checks",
{
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
checks: text("checks").notNull(),
intervalSeconds: int("interval_seconds").notNull().default(300),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
},
// Same as above: one set of checks per user per host.
(table) => [
uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId),
],
);
export const hostHealthHistory = mysqlTable("host_health_history", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
checkId: text("check_id").notNull(),
ts: text("ts").notNull().default(sql`(CURRENT_TIMESTAMP)`),
ok: boolean("ok").notNull(),
latencyMs: int("latency_ms"),
detail: text("detail"),
});
export const dashboardServiceLinks = mysqlTable("dashboard_service_links", {
id: int("id").autoincrement().primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -1086,23 +1020,6 @@ export const termixIdentityCa = mysqlTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- metrics-history begin ---
export const hostMetricsHistory = mysqlTable("host_metrics_history", {
id: int("id").autoincrement().primaryKey(),
hostId: int("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
ts: text("ts")
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
cpuPercent: double("cpu_percent"),
memPercent: double("mem_percent"),
diskPercent: double("disk_percent"),
netRxBytes: int("net_rx_bytes"),
netTxBytes: int("net_tx_bytes"),
});
// --- metrics-history end ---
// --- alerts begin ---
export const notificationChannels = mysqlTable("notification_channels", {
id: int("id").autoincrement().primaryKey(),
+5 -88
View File
@@ -296,7 +296,10 @@ export const hosts = pgTable(
.notNull()
.default(false),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
statusCheckEnabled: boolean("status_check_enabled")
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
dockerConfig: text("docker_config"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
@@ -888,40 +891,12 @@ export const userPreferences = pgTable("user_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostMetricsPreferences = pgTable(
"host_metrics_preferences",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
// plain JSON (no field-level encryption).
layout: text("layout").notNull(),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// One layout per user per host. Enforced in production since the inline DDL
// creates it, but it was never declared here, so the generated Postgres and
// MySQL schemas lacked it — and the upsert has nothing to conflict on.
(table) => [
uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId),
],
);
export const hostSidebarPreferences = pgTable("host_sidebar_preferences", {
userId: varchar("user_id", { length: 255 })
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
// plain JSON like hostMetricsPreferences.layout.
// plain JSON.
data: text("data").notNull(),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
@@ -956,47 +931,6 @@ export const uiPreferences = pgTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostHealthChecks = pgTable(
"host_health_checks",
{
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
checks: text("checks").notNull(),
intervalSeconds: integer("interval_seconds").notNull().default(300),
createdAt: varchar("created_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: varchar("updated_at", { length: 255 })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same as above: one set of checks per user per host.
(table) => [
uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId),
],
);
export const hostHealthHistory = pgTable("host_health_history", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
checkId: text("check_id").notNull(),
ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`),
ok: boolean("ok").notNull(),
latencyMs: integer("latency_ms"),
detail: text("detail"),
});
export const dashboardServiceLinks = pgTable("dashboard_service_links", {
id: serial("id").primaryKey(),
userId: varchar("user_id", { length: 255 })
@@ -1087,23 +1021,6 @@ export const termixIdentityCa = pgTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- metrics-history begin ---
export const hostMetricsHistory = pgTable("host_metrics_history", {
id: serial("id").primaryKey(),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
ts: text("ts")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
cpuPercent: doublePrecision("cpu_percent"),
memPercent: doublePrecision("mem_percent"),
diskPercent: doublePrecision("disk_percent"),
netRxBytes: integer("net_rx_bytes"),
netTxBytes: integer("net_tx_bytes"),
});
// --- metrics-history end ---
// --- alerts begin ---
export const notificationChannels = pgTable("notification_channels", {
id: serial("id").primaryKey(),
+5 -88
View File
@@ -288,7 +288,10 @@ export const hosts = sqliteTable(
.notNull()
.default(false),
defaultPath: text("default_path"),
statsConfig: text("stats_config"),
statusCheckEnabled: integer("status_check_enabled", { mode: "boolean" })
.notNull()
.default(true),
statusCheckInterval: integer("status_check_interval"),
dockerConfig: text("docker_config"),
webUiConfig: text("web_ui_config"),
terminalConfig: text("terminal_config"),
@@ -884,40 +887,12 @@ export const userPreferences = sqliteTable("user_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostMetricsPreferences = sqliteTable(
"host_metrics_preferences",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON-encoded HostMetricsLayout. Layout has no secrets, so it is stored as
// plain JSON (no field-level encryption).
layout: text("layout").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// One layout per user per host. Enforced in production since the inline DDL
// creates it, but it was never declared here, so the generated Postgres and
// MySQL schemas lacked it — and the upsert has nothing to conflict on.
(table) => [
uniqueIndex("idx_host_metrics_prefs_user_host").on(table.userId, table.hostId),
],
);
export const hostSidebarPreferences = sqliteTable("host_sidebar_preferences", {
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
// JSON-encoded HostSidebarPreferences. No secrets in this blob, stored as
// plain JSON like hostMetricsPreferences.layout.
// plain JSON.
data: text("data").notNull(),
updatedAt: text("updated_at")
.notNull()
@@ -952,47 +927,6 @@ export const uiPreferences = sqliteTable("ui_preferences", {
.default(sql`CURRENT_TIMESTAMP`),
});
export const hostHealthChecks = sqliteTable(
"host_health_checks",
{
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
// JSON array of { id, name, type: "tcp"|"http", target, port, path }
checks: text("checks").notNull(),
intervalSeconds: integer("interval_seconds").notNull().default(300),
createdAt: text("created_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
updatedAt: text("updated_at")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
},
// Same as above: one set of checks per user per host.
(table) => [
uniqueIndex("idx_host_health_checks_user_host").on(table.userId, table.hostId),
],
);
export const hostHealthHistory = sqliteTable("host_health_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
checkId: text("check_id").notNull(),
ts: text("ts").notNull().default(sql`CURRENT_TIMESTAMP`),
ok: integer("ok", { mode: "boolean" }).notNull(),
latencyMs: integer("latency_ms"),
detail: text("detail"),
});
export const dashboardServiceLinks = sqliteTable("dashboard_service_links", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: text("user_id")
@@ -1083,23 +1017,6 @@ export const termixIdentityCa = sqliteTable("termix_identity_ca", {
});
// --- termix-id end ---
// --- metrics-history begin ---
export const hostMetricsHistory = sqliteTable("host_metrics_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
hostId: integer("host_id")
.notNull()
.references(() => hosts.id, { onDelete: "cascade" }),
ts: text("ts")
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
cpuPercent: real("cpu_percent"),
memPercent: real("mem_percent"),
diskPercent: real("disk_percent"),
netRxBytes: integer("net_rx_bytes"),
netTxBytes: integer("net_tx_bytes"),
});
// --- metrics-history end ---
// --- alerts begin ---
export const notificationChannels = sqliteTable("notification_channels", {
id: integer("id").primaryKey({ autoIncrement: true }),
@@ -15,9 +15,6 @@ import { DismissedAlertRepository } from "./dismissed-alert-repository.js";
import { HomepageItemRepository } from "./homepage-item-repository.js";
import { HomepageLayoutRepository } from "./homepage-layout-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
import { HostHealthRepository } from "./host-health-repository.js";
import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js";
import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js";
import { HostRepository } from "./host-repository.js";
import { HostResolutionRepository } from "./host-resolution-repository.js";
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
@@ -230,31 +227,6 @@ export function createCurrentHostFolderRepository(): HostFolderRepository {
);
}
export function createCurrentHostHealthRepository(): HostHealthRepository {
return new HostHealthRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_health_repository_write"),
);
}
export function createCurrentHostMetricsHistoryRepository(): HostMetricsHistoryRepository {
return new HostMetricsHistoryRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryLazyWriteHook(
"host_metrics_history_repository_write",
),
);
}
export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPreferenceRepository {
return new HostMetricsPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"host_metrics_preference_repository_write",
),
);
}
export function createCurrentHostRepository(): HostRepository {
return new HostRepository(
createCurrentRepositoryContext(),
@@ -1,178 +0,0 @@
import { and, desc, eq, notInArray } from "drizzle-orm";
import { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HostHealthCheckRecord = typeof hostHealthChecks.$inferSelect;
export type HostHealthHistoryRecord = typeof hostHealthHistory.$inferSelect;
export interface HostHealthResultInput {
checkId: string;
ok: boolean;
latencyMs: number | null;
detail: string;
}
export class HostHealthRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findChecksByUserAndHost(
userId: string,
hostId: number,
): Promise<HostHealthCheckRecord | null> {
const rows = await this.context.drizzle
.select()
.from(hostHealthChecks)
.where(
and(
eq(hostHealthChecks.userId, userId),
eq(hostHealthChecks.hostId, hostId),
),
)
.limit(1);
return rows[0] ?? null;
}
async upsertChecks(
userId: string,
hostId: number,
checks: string,
intervalSeconds: number,
now = new Date().toISOString(),
): Promise<HostHealthCheckRecord> {
const existing = await this.findChecksByUserAndHost(userId, hostId);
if (existing) {
const [updated] = await updateReturning(
this.context,
hostHealthChecks,
{ checks, intervalSeconds, updatedAt: now },
eq(hostHealthChecks.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await insertReturning(this.context, hostHealthChecks, {
userId,
hostId,
checks,
intervalSeconds,
createdAt: now,
updatedAt: now,
});
await this.afterWrite();
return created;
}
async recordHistory(
userId: string,
hostId: number,
results: HostHealthResultInput[],
keep: number,
now = new Date().toISOString(),
): Promise<number> {
if (results.length === 0) {
return 0;
}
await this.context.drizzle.insert(hostHealthHistory).values(
results.map((result) => ({
userId,
hostId,
checkId: result.checkId,
ts: now,
ok: result.ok,
latencyMs: result.latencyMs,
detail: result.detail,
})),
);
await this.pruneHistory(userId, hostId, keep);
await this.afterWrite();
return results.length;
}
async listHistory(
userId: string,
hostId: number,
limit: number,
): Promise<HostHealthHistoryRecord[]> {
return this.context.drizzle
.select()
.from(hostHealthHistory)
.where(
and(
eq(hostHealthHistory.userId, userId),
eq(hostHealthHistory.hostId, hostId),
),
)
.orderBy(desc(hostHealthHistory.ts))
.limit(limit);
}
async deleteByUserId(userId: string): Promise<{
checksDeleted: number;
historyDeleted: number;
}> {
const historyResult = await this.context.drizzle
.delete(hostHealthHistory)
.where(eq(hostHealthHistory.userId, userId));
const result = await this.context.drizzle
.delete(hostHealthChecks)
.where(eq(hostHealthChecks.userId, userId));
if (rowsAffected(historyResult) > 0 || rowsAffected(result) > 0) {
await this.afterWrite();
}
return {
checksDeleted: rowsAffected(result),
historyDeleted: rowsAffected(historyResult),
};
}
/** Keeps the newest `keep` rows for the host and drops the rest. */
private async pruneHistory(
userId: string,
hostId: number,
keep: number,
): Promise<void> {
const scope = and(
eq(hostHealthHistory.userId, userId),
eq(hostHealthHistory.hostId, hostId),
);
const retained = await this.context.drizzle
.select({ id: hostHealthHistory.id })
.from(hostHealthHistory)
.where(scope)
.orderBy(desc(hostHealthHistory.ts))
.limit(keep);
// Nothing retained means nothing to keep back, so the scope alone is the
// delete condition.
await this.context.drizzle.delete(hostHealthHistory).where(
retained.length
? and(
scope,
notInArray(
hostHealthHistory.id,
retained.map((row) => row.id),
),
)
: scope,
);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,68 +0,0 @@
import { and, asc, eq, gte, lt, lte } from "drizzle-orm";
import { hostMetricsHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect;
export interface HostMetricsHistoryCreateInput {
hostId: number;
cpuPercent?: number | null;
memPercent?: number | null;
diskPercent?: number | null;
netRxBytes?: number | null;
netTxBytes?: number | null;
}
export class HostMetricsHistoryRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async create(input: HostMetricsHistoryCreateInput): Promise<void> {
await this.context.drizzle.insert(hostMetricsHistory).values({
hostId: input.hostId,
cpuPercent: input.cpuPercent,
memPercent: input.memPercent,
diskPercent: input.diskPercent,
netRxBytes: input.netRxBytes,
netTxBytes: input.netTxBytes,
});
await this.afterWrite();
}
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
await this.context.drizzle
.delete(hostMetricsHistory)
.where(
and(
eq(hostMetricsHistory.hostId, hostId),
lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)),
),
);
}
async listRange(
hostId: number,
fromTs: string,
toTs: string,
): Promise<HostMetricsHistoryRecord[]> {
return this.context.drizzle
.select()
.from(hostMetricsHistory)
.where(
and(
eq(hostMetricsHistory.hostId, hostId),
gte(hostMetricsHistory.ts, fromTs),
lte(hostMetricsHistory.ts, toTs),
),
)
.orderBy(asc(hostMetricsHistory.ts));
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -1,99 +0,0 @@
import { and, eq } from "drizzle-orm";
import { hostMetricsPreferences, hosts } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js";
import { rowsAffected } from "./mutation-result.js";
import { insertReturning, updateReturning } from "./returning.js";
export type HostMetricsPreferenceRecord =
typeof hostMetricsPreferences.$inferSelect;
export class HostMetricsPreferenceRepository {
constructor(
private readonly context: DatabaseContext,
private readonly onWrite?: () => void | Promise<void>,
) {}
async findByUserAndHost(
userId: string,
hostId: number,
): Promise<HostMetricsPreferenceRecord | null> {
const rows = await this.context.drizzle
.select()
.from(hostMetricsPreferences)
.where(
and(
eq(hostMetricsPreferences.userId, userId),
eq(hostMetricsPreferences.hostId, hostId),
),
)
.limit(1);
return rows[0] ?? null;
}
async upsertLayout(
userId: string,
hostId: number,
layout: string,
now = new Date().toISOString(),
): Promise<HostMetricsPreferenceRecord> {
const existing = await this.findByUserAndHost(userId, hostId);
if (existing) {
const [updated] = await updateReturning(
this.context,
hostMetricsPreferences,
{ layout, updatedAt: now },
eq(hostMetricsPreferences.id, existing.id),
);
await this.afterWrite();
return updated;
}
const [created] = await insertReturning(
this.context,
hostMetricsPreferences,
{
userId,
hostId,
layout,
createdAt: now,
updatedAt: now,
},
);
await this.afterWrite();
return created;
}
async updateHostStatsConfig(
userId: string,
hostId: number,
statsConfig: string,
): Promise<boolean> {
const result = await this.context.drizzle
.update(hosts)
.set({ statsConfig })
.where(and(eq(hosts.id, hostId), eq(hosts.userId, userId)));
if (rowsAffected(result) === 0) return false;
await this.afterWrite();
return true;
}
async deleteByUserId(userId: string): Promise<number> {
const result = await this.context.drizzle
.delete(hostMetricsPreferences)
.where(eq(hostMetricsPreferences.userId, userId));
if (rowsAffected(result) > 0) {
await this.afterWrite();
}
return rowsAffected(result);
}
private async afterWrite(): Promise<void> {
await this.onWrite?.();
}
}
@@ -15,7 +15,6 @@ export type NewHostRecord = typeof hosts.$inferInsert;
export type HostUpdate = Partial<Omit<NewHostRecord, "id" | "userId">>;
export interface HostBulkUpdateState {
id: number;
statsConfig: string | null;
credentialId: number | null;
}
@@ -232,7 +231,6 @@ export class HostRepository {
return this.context.drizzle
.select({
id: hosts.id,
statsConfig: hosts.statsConfig,
credentialId: hosts.credentialId,
})
.from(hosts)
@@ -57,6 +57,17 @@ export type HostListRow = HostResolutionHostRecord & {
expiresAt?: string | null;
};
export interface HostStatusTargetRow {
id: number;
userId: string;
ip: string;
port: number;
connectionType: string;
jumpHosts: string | null;
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
}
export class HostResolutionRepository {
constructor(
private readonly context: DatabaseContext,
@@ -262,6 +273,34 @@ export class HostResolutionRepository {
return rows.length > 0;
}
/**
* What the status checker needs, without decrypting anything: none of these
* columns are encrypted. Filtered by owner, by id, or both.
*/
async listStatusTargets(filter: {
userId?: string;
hostIds?: number[];
}): Promise<HostStatusTargetRow[]> {
if (filter.hostIds && filter.hostIds.length === 0) return [];
const conditions = [
filter.userId ? eq(hosts.userId, filter.userId) : undefined,
filter.hostIds ? inArray(hosts.id, filter.hostIds) : undefined,
].filter((condition) => condition !== undefined);
return this.context.drizzle
.select({
id: hosts.id,
userId: hosts.userId,
ip: hosts.ip,
port: hosts.port,
connectionType: hosts.connectionType,
jumpHosts: hosts.jumpHosts,
statusCheckEnabled: hosts.statusCheckEnabled,
statusCheckInterval: hosts.statusCheckInterval,
})
.from(hosts)
.where(conditions.length > 0 ? and(...conditions) : undefined);
}
async listAllHosts(): Promise<HostResolutionHostRecord[]> {
const rows = await this.context.drizzle.select().from(hosts);
@@ -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;