mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authFailureTracker } from "./metrics-shared/state.js";
|
||||
import { pluginEvents, TOPICS } from "../plugins/events.js";
|
||||
import type { WebSocket } from "ws";
|
||||
import { createCurrentHostResolutionRepository } from "../database/repositories/factory.js";
|
||||
import { sshLogger } from "../utils/logger.js";
|
||||
@@ -328,7 +328,7 @@ export class SSHHostKeyVerifier {
|
||||
algorithm,
|
||||
currentChangeCount,
|
||||
);
|
||||
authFailureTracker.resetHostKeyFailure(hostId);
|
||||
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId });
|
||||
}
|
||||
|
||||
private static async promptUserForNewKey(
|
||||
|
||||
@@ -117,13 +117,6 @@ export async function resolveHostById(
|
||||
host.tunnelConnections = [];
|
||||
}
|
||||
}
|
||||
if (typeof host.statsConfig === "string" && host.statsConfig) {
|
||||
try {
|
||||
host.statsConfig = JSON.parse(host.statsConfig as string);
|
||||
} catch {
|
||||
host.statsConfig = undefined;
|
||||
}
|
||||
}
|
||||
if (typeof host.terminalConfig === "string" && host.terminalConfig) {
|
||||
try {
|
||||
host.terminalConfig = JSON.parse(host.terminalConfig as string);
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
|
||||
export function execCommand(
|
||||
client: Client,
|
||||
command: string,
|
||||
timeoutMs = 30000,
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let stream: ClientChannel | null = null;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
if (stream) {
|
||||
try {
|
||||
stream.removeAllListeners();
|
||||
if (stream.stderr) {
|
||||
stream.stderr.removeAllListeners();
|
||||
}
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// expected - cleanup errors ignored
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.exec(command, { pty: false }, (err, _stream) => {
|
||||
if (err) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
stream = _stream;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
|
||||
stream
|
||||
.on("close", (code: number | undefined) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
cleanup();
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
}
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
})
|
||||
.on("error", (streamErr: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(streamErr);
|
||||
}
|
||||
});
|
||||
|
||||
if (stream.stderr) {
|
||||
stream.stderr
|
||||
.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
})
|
||||
.on("error", (stderrErr: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(stderrErr);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type HostPlatform = "darwin" | "linux" | "windows" | "other";
|
||||
|
||||
// Windows OpenSSH's default shell is usually cmd.exe (occasionally
|
||||
// PowerShell), so scripts are sent base64-encoded via -EncodedCommand -
|
||||
// that avoids every cmd.exe quoting/escaping pitfall entirely, since the
|
||||
// argument ends up being plain alphanumeric text with no shell metacharacters.
|
||||
export function encodePowerShellCommand(script: string): string {
|
||||
return Buffer.from(script, "utf16le").toString("base64");
|
||||
}
|
||||
|
||||
export function execPowerShell(
|
||||
client: Client,
|
||||
script: string,
|
||||
timeoutMs = 30000,
|
||||
): Promise<{ stdout: string; stderr: string; code: number | null }> {
|
||||
const encoded = encodePowerShellCommand(script);
|
||||
return execCommand(
|
||||
client,
|
||||
`powershell -NoProfile -NonInteractive -EncodedCommand ${encoded}`,
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
export async function detectPlatform(client: Client): Promise<HostPlatform> {
|
||||
try {
|
||||
const { stdout, code } = await execCommand(client, "uname -s", 10000);
|
||||
const kernel = stdout.trim().toLowerCase();
|
||||
if (kernel === "darwin") return "darwin";
|
||||
if (kernel === "linux") return "linux";
|
||||
if (code === 0 && kernel) return "other";
|
||||
} catch {
|
||||
// expected on hosts with no POSIX shell (e.g. Windows via cmd.exe)
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await execPowerShell(client, "'WIN_OK'", 10000);
|
||||
if (stdout.includes("WIN_OK")) return "windows";
|
||||
} catch {
|
||||
// expected on hosts with no PowerShell available
|
||||
}
|
||||
|
||||
return "other";
|
||||
}
|
||||
|
||||
export function toFixedNum(
|
||||
n: number | null | undefined,
|
||||
digits = 2,
|
||||
): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
|
||||
export function kibToGiB(kib: number): number {
|
||||
return kib / (1024 * 1024);
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
|
||||
export type ElevationErrorCode = "SUDO_REQUIRED" | "SUDO_FAILED" | "NOT_SUDOER";
|
||||
|
||||
export class ElevationError extends Error {
|
||||
code: ElevationErrorCode;
|
||||
constructor(code: ElevationErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "ElevationError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ElevatedResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
usedSudo: boolean;
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED = [
|
||||
"permission denied",
|
||||
"operation not permitted",
|
||||
"must be run as root",
|
||||
"must be superuser",
|
||||
"you need to be root",
|
||||
"are you root",
|
||||
"access denied",
|
||||
];
|
||||
|
||||
/**
|
||||
* Phrases sudo itself prints to STDERR when authentication or authorization
|
||||
* fails. We only match these against sudo's stderr (never command output), so a
|
||||
* command that happens to print "permission denied" on stdout is not mistaken
|
||||
* for a sudo failure.
|
||||
*/
|
||||
const SUDO_AUTH_FAILED = [
|
||||
"incorrect password",
|
||||
"a password is required",
|
||||
"a terminal is required",
|
||||
"no tty present",
|
||||
"sorry, try again",
|
||||
"no password was provided",
|
||||
"1 incorrect password attempt",
|
||||
];
|
||||
|
||||
const SUDO_NOT_SUDOER = [
|
||||
"is not in the sudoers file",
|
||||
"not allowed to run sudo",
|
||||
"not allowed to execute",
|
||||
];
|
||||
|
||||
/**
|
||||
* Marker printed only after sudo has successfully authenticated and started the
|
||||
* inner shell. Its presence on stdout is the authoritative "elevation worked"
|
||||
* signal; its absence (together with sudo stderr) means auth failed.
|
||||
*/
|
||||
const SUDO_OK_MARKER = "__TX_SUDO_OK__";
|
||||
const SUDO_OK_LINE_RE = new RegExp(`^${SUDO_OK_MARKER}\\r?\\n?`);
|
||||
|
||||
/** Escape a value for single-quoted shell context. */
|
||||
export function shellSingleQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the elevated command string:
|
||||
* echo '<pw>' | sudo -S -p '' sh -c 'echo __TX_SUDO_OK__; <command>'
|
||||
*
|
||||
* `-p ''` suppresses the prompt. stderr is NOT merged into stdout, so the
|
||||
* command's own output stays clean and sudo's auth errors stay on stderr. The
|
||||
* marker is echoed by the inner shell once sudo authenticates, letting us tell a
|
||||
* genuine auth failure from command output that merely contains scary words.
|
||||
*/
|
||||
export function buildSudoCommand(
|
||||
command: string,
|
||||
sudoPassword: string,
|
||||
): string {
|
||||
const pw = shellSingleQuote(sudoPassword);
|
||||
const inner = shellSingleQuote(`echo ${SUDO_OK_MARKER}; ${command}`);
|
||||
return `echo ${pw} | sudo -S -p '' sh -c ${inner}`;
|
||||
}
|
||||
|
||||
function includesAny(text: string, needles: string[]): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return needles.some((n) => lower.includes(n));
|
||||
}
|
||||
|
||||
function looksLikePermissionDenied(text: string): boolean {
|
||||
return includesAny(text, PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
/** Strip the success marker line from the front of stdout. */
|
||||
function stripMarker(stdout: string): string {
|
||||
return stdout.replace(SUDO_OK_LINE_RE, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command on a pooled client, elevating with the host's stored sudo
|
||||
* password only when needed (or when forced). Throws a typed `ElevationError`
|
||||
* when elevation is required but unavailable/incorrect.
|
||||
*/
|
||||
export async function execElevated(
|
||||
client: Client,
|
||||
command: string,
|
||||
sudoPassword: string | undefined,
|
||||
opts: { forceSudo?: boolean; timeoutMs?: number } = {},
|
||||
): Promise<ElevatedResult> {
|
||||
const timeoutMs = opts.timeoutMs ?? 30000;
|
||||
|
||||
if (!opts.forceSudo) {
|
||||
const direct = await execCommand(client, command, timeoutMs);
|
||||
if (direct.code === 0) {
|
||||
return { ...direct, usedSudo: false };
|
||||
}
|
||||
// Only escalate when the failure looks like a privilege problem. A
|
||||
// permission-denied phrase on stderr (not arbitrary stdout) is the signal.
|
||||
if (!looksLikePermissionDenied(direct.stderr)) {
|
||||
return { ...direct, usedSudo: false };
|
||||
}
|
||||
if (!sudoPassword) {
|
||||
throw new ElevationError(
|
||||
"SUDO_REQUIRED",
|
||||
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||
);
|
||||
}
|
||||
} else if (!sudoPassword) {
|
||||
throw new ElevationError(
|
||||
"SUDO_REQUIRED",
|
||||
"This action requires elevated privileges. Set a sudo password for this host to continue.",
|
||||
);
|
||||
}
|
||||
|
||||
const sudoCmd = buildSudoCommand(command, sudoPassword as string);
|
||||
const result = await execCommand(client, sudoCmd, timeoutMs);
|
||||
const authenticated = result.stdout.includes(SUDO_OK_MARKER);
|
||||
|
||||
if (!authenticated) {
|
||||
// Elevation never started: diagnose from sudo's stderr only.
|
||||
const notSudoer = includesAny(result.stderr, SUDO_NOT_SUDOER);
|
||||
const authFailed = includesAny(result.stderr, SUDO_AUTH_FAILED);
|
||||
if (notSudoer) {
|
||||
throw new ElevationError(
|
||||
"NOT_SUDOER",
|
||||
"The connected user is not permitted to use sudo on this host.",
|
||||
);
|
||||
}
|
||||
if (authFailed) {
|
||||
throw new ElevationError(
|
||||
"SUDO_FAILED",
|
||||
"Elevation failed. Check the host's sudo password.",
|
||||
);
|
||||
}
|
||||
// No marker and no recognizable sudo error: treat as a generic failure but
|
||||
// keep the original output so the caller can surface it.
|
||||
throw new ElevationError(
|
||||
"SUDO_FAILED",
|
||||
"Elevation failed. Check the host's sudo password.",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stdout: stripMarker(result.stdout),
|
||||
stderr: result.stderr,
|
||||
code: result.code,
|
||||
usedSudo: true,
|
||||
};
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import type { PackageManager } from "./platform.js";
|
||||
|
||||
export interface UpgradablePackage {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
}
|
||||
|
||||
export function buildListUpgradableCommand(pkg: PackageManager): string | null {
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
return "apt list --upgradable 2>/dev/null | tail -n +2";
|
||||
case "dnf":
|
||||
return "dnf -q check-update 2>/dev/null || true";
|
||||
case "yum":
|
||||
return "yum -q check-update 2>/dev/null || true";
|
||||
case "pacman":
|
||||
return "pacman -Qu 2>/dev/null || true";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseUpgradable(
|
||||
pkg: PackageManager,
|
||||
output: string,
|
||||
): UpgradablePackage[] {
|
||||
const out: UpgradablePackage[] = [];
|
||||
const lines = output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean);
|
||||
if (pkg === "apt") {
|
||||
for (const line of lines) {
|
||||
// name/suite newver arch [upgradable from: oldver]
|
||||
const m = line.match(
|
||||
/^([^/\s]+)\/\S+\s+(\S+)\s+\S+(?:\s+\[upgradable from:\s+(\S+)\])?/,
|
||||
);
|
||||
if (m) out.push({ name: m[1], newVersion: m[2], currentVersion: m[3] });
|
||||
}
|
||||
} else if (pkg === "dnf" || pkg === "yum") {
|
||||
for (const line of lines) {
|
||||
if (/^(Last metadata|Obsoleting|Security:)/i.test(line)) continue;
|
||||
const m = line.match(/^(\S+)\s+(\S+)\s+\S+$/);
|
||||
if (m && m[1].includes(".")) out.push({ name: m[1], newVersion: m[2] });
|
||||
}
|
||||
} else if (pkg === "pacman") {
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^(\S+)\s+(\S+)\s+->\s+(\S+)$/);
|
||||
if (m) out.push({ name: m[1], currentVersion: m[2], newVersion: m[3] });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export type PackageAction = "upgrade-all" | "install" | "upgrade";
|
||||
|
||||
export function buildPackageActionCommand(
|
||||
pkg: PackageManager,
|
||||
action: PackageAction,
|
||||
name?: string,
|
||||
): string | null {
|
||||
const target = name ? ` ${name}` : "";
|
||||
switch (pkg) {
|
||||
case "apt":
|
||||
if (action === "upgrade-all")
|
||||
return "DEBIAN_FRONTEND=noninteractive apt-get -y upgrade";
|
||||
return `DEBIAN_FRONTEND=noninteractive apt-get -y install${target}`;
|
||||
case "dnf":
|
||||
return action === "upgrade-all"
|
||||
? "dnf -y upgrade"
|
||||
: `dnf -y install${target}`;
|
||||
case "yum":
|
||||
return action === "upgrade-all"
|
||||
? "yum -y update"
|
||||
: `yum -y install${target}`;
|
||||
case "pacman":
|
||||
return action === "upgrade-all"
|
||||
? "pacman -Syu --noconfirm"
|
||||
: `pacman -S --noconfirm${target}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
|
||||
export type PackageManager = "apt" | "dnf" | "yum" | "pacman" | null;
|
||||
|
||||
export interface PlatformInfo {
|
||||
hasSystemd: boolean;
|
||||
pkg: PackageManager;
|
||||
hasCertbot: boolean;
|
||||
hasAcmeSh: boolean;
|
||||
hasDocker: boolean;
|
||||
osPrettyName: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||
* Extend PATH with their common install locations before probing.
|
||||
*/
|
||||
export const EXTRA_PATH_DIRS =
|
||||
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
||||
|
||||
const DOCKER_PROBE =
|
||||
`command -v docker >/dev/null 2>&1` +
|
||||
` || [ -S /var/run/docker.sock ]` +
|
||||
` || [ -S "$HOME/.orbstack/run/docker.sock" ]`;
|
||||
|
||||
/**
|
||||
* Single probe that reports which tooling is available. Each line is
|
||||
* "key=value" so the parser is trivial and order-independent.
|
||||
*/
|
||||
export const PLATFORM_PROBE_COMMAND = [
|
||||
`PATH="${EXTRA_PATH_DIRS}:$PATH"`,
|
||||
"echo systemd=$(command -v systemctl >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo apt=$(command -v apt-get >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo dnf=$(command -v dnf >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo yum=$(command -v yum >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo pacman=$(command -v pacman >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
"echo certbot=$(command -v certbot >/dev/null 2>&1 && echo 1 || echo 0)",
|
||||
'echo acmesh=$( { command -v acme.sh >/dev/null 2>&1 || [ -x "$HOME/.acme.sh/acme.sh" ]; } && echo 1 || echo 0)',
|
||||
`echo docker=$(${DOCKER_PROBE} && echo 1 || echo 0)`,
|
||||
'echo os=$(. /etc/os-release 2>/dev/null && echo "$PRETTY_NAME")',
|
||||
].join("; ");
|
||||
|
||||
export function parsePlatformProbe(output: string): PlatformInfo {
|
||||
const map = new Map<string, string>();
|
||||
for (const line of output.split("\n")) {
|
||||
const idx = line.indexOf("=");
|
||||
if (idx === -1) continue;
|
||||
map.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim());
|
||||
}
|
||||
const on = (k: string) => map.get(k) === "1";
|
||||
|
||||
// Prefer dnf over yum when both exist (dnf is the modern front-end).
|
||||
let pkg: PackageManager = null;
|
||||
if (on("apt")) pkg = "apt";
|
||||
else if (on("dnf")) pkg = "dnf";
|
||||
else if (on("yum")) pkg = "yum";
|
||||
else if (on("pacman")) pkg = "pacman";
|
||||
|
||||
const os = map.get("os");
|
||||
return {
|
||||
hasSystemd: on("systemd"),
|
||||
pkg,
|
||||
hasCertbot: on("certbot"),
|
||||
hasAcmeSh: on("acmesh"),
|
||||
hasDocker: on("docker"),
|
||||
osPrettyName: os && os.length > 0 ? os : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function detectPlatform(client: Client): Promise<PlatformInfo> {
|
||||
const { stdout } = await execCommand(client, PLATFORM_PROBE_COMMAND, 15000);
|
||||
return parsePlatformProbe(stdout);
|
||||
}
|
||||
@@ -1,434 +0,0 @@
|
||||
class RequestQueue {
|
||||
private queues = new Map<number, Array<() => Promise<unknown>>>();
|
||||
private processing = new Set<number>();
|
||||
private requestTimeout = 60000;
|
||||
|
||||
async queueRequest<T>(hostId: number, request: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const wrappedRequest = async () => {
|
||||
try {
|
||||
const result = await Promise.race<T>([
|
||||
request(),
|
||||
new Promise<never>((_, rej) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
rej(
|
||||
new Error(
|
||||
`Request timeout after ${this.requestTimeout}ms for host ${hostId}`,
|
||||
),
|
||||
),
|
||||
this.requestTimeout,
|
||||
),
|
||||
),
|
||||
]);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
queue.push(wrappedRequest);
|
||||
this.queues.set(hostId, queue);
|
||||
this.processQueue(hostId);
|
||||
});
|
||||
}
|
||||
|
||||
private async processQueue(hostId: number): Promise<void> {
|
||||
if (this.processing.has(hostId)) return;
|
||||
|
||||
this.processing.add(hostId);
|
||||
const queue = this.queues.get(hostId) || [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const request = queue.shift();
|
||||
if (request) {
|
||||
try {
|
||||
await request();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.processing.delete(hostId);
|
||||
const currentQueue = this.queues.get(hostId);
|
||||
if (currentQueue && currentQueue.length > 0) {
|
||||
this.processQueue(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CachedMetrics {
|
||||
data: unknown;
|
||||
timestamp: number;
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
class MetricsCache {
|
||||
private cache = new Map<number, CachedMetrics>();
|
||||
private ttl = 30000;
|
||||
|
||||
get(hostId: number): unknown | null {
|
||||
const cached = this.cache.get(hostId);
|
||||
if (cached && Date.now() - cached.timestamp < this.ttl) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
set(hostId: number, data: unknown): void {
|
||||
this.cache.set(hostId, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
hostId,
|
||||
});
|
||||
}
|
||||
|
||||
clear(hostId?: number): void {
|
||||
if (hostId) {
|
||||
this.cache.delete(hostId);
|
||||
} else {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthFailureRecord {
|
||||
count: number;
|
||||
lastFailure: number;
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY";
|
||||
permanent: boolean;
|
||||
}
|
||||
|
||||
class AuthFailureTracker {
|
||||
private failures = new Map<number, AuthFailureRecord>();
|
||||
private maxRetries = 3;
|
||||
private backoffBase = 5000;
|
||||
|
||||
recordFailure(
|
||||
hostId: number,
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT" | "HOST_KEY",
|
||||
permanent = false,
|
||||
): void {
|
||||
const existing = this.failures.get(hostId);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
existing.lastFailure = Date.now();
|
||||
existing.reason = reason;
|
||||
if (permanent) existing.permanent = true;
|
||||
} else {
|
||||
this.failures.set(hostId, {
|
||||
count: 1,
|
||||
lastFailure: Date.now(),
|
||||
reason,
|
||||
permanent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
shouldSkip(hostId: number): boolean {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return false;
|
||||
|
||||
if (record.reason === "TOTP" || record.permanent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (record.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
|
||||
return timeSinceFailure < backoffTime;
|
||||
}
|
||||
|
||||
getSkipReason(hostId: number): string | null {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return null;
|
||||
|
||||
if (record.reason === "TOTP") {
|
||||
return "TOTP authentication required (metrics unavailable)";
|
||||
}
|
||||
|
||||
if (record.reason === "HOST_KEY") {
|
||||
return "SSH host key changed (verification required)";
|
||||
}
|
||||
|
||||
if (record.permanent) {
|
||||
return "Authentication permanently failed";
|
||||
}
|
||||
|
||||
if (record.count >= this.maxRetries) {
|
||||
return `Too many authentication failures (${record.count} attempts)`;
|
||||
}
|
||||
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
const remainingTime = Math.ceil((backoffTime - timeSinceFailure) / 1000);
|
||||
|
||||
if (timeSinceFailure < backoffTime) {
|
||||
return `Retry in ${remainingTime}s (attempt ${record.count}/${this.maxRetries})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
resetHostKeyFailure(hostId: number): void {
|
||||
if (this.failures.get(hostId)?.reason === "HOST_KEY") this.reset(hostId);
|
||||
}
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hostId, record] of this.failures.entries()) {
|
||||
if (!record.permanent && now - record.lastFailure > maxAge) {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PollingBackoff {
|
||||
private failures = new Map<number, { count: number; nextRetry: number }>();
|
||||
private baseDelay = 30000;
|
||||
private maxDelay = 600000;
|
||||
private maxRetries = 5;
|
||||
|
||||
recordFailure(hostId: number): void {
|
||||
const existing = this.failures.get(hostId) || { count: 0, nextRetry: 0 };
|
||||
const delay = Math.min(
|
||||
this.baseDelay * Math.pow(2, existing.count),
|
||||
this.maxDelay,
|
||||
);
|
||||
this.failures.set(hostId, {
|
||||
count: existing.count + 1,
|
||||
nextRetry: Date.now() + delay,
|
||||
});
|
||||
}
|
||||
|
||||
shouldSkip(hostId: number): boolean {
|
||||
const backoff = this.failures.get(hostId);
|
||||
if (!backoff) return false;
|
||||
|
||||
if (backoff.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Date.now() < backoff.nextRetry;
|
||||
}
|
||||
|
||||
getBackoffInfo(hostId: number): string | null {
|
||||
const backoff = this.failures.get(hostId);
|
||||
if (!backoff) return null;
|
||||
|
||||
if (backoff.count >= this.maxRetries) {
|
||||
return `Max retries exceeded (${backoff.count} failures) - polling suspended`;
|
||||
}
|
||||
|
||||
const remainingMs = backoff.nextRetry - Date.now();
|
||||
if (remainingMs > 0) {
|
||||
const remainingSec = Math.ceil(remainingMs / 1000);
|
||||
return `Retry in ${remainingSec}s (attempt ${backoff.count}/${this.maxRetries})`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
for (const [hostId, backoff] of this.failures.entries()) {
|
||||
if (backoff.count < this.maxRetries && now - backoff.nextRetry > maxAge) {
|
||||
this.failures.delete(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
|
||||
* Used to stop host-metrics status/metrics polls from stampeding under load.
|
||||
*/
|
||||
export class ConcurrentLimiter {
|
||||
private active = 0;
|
||||
private readonly waiters: Array<() => void> = [];
|
||||
|
||||
constructor(private maxConcurrent: number) {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.waiters.length;
|
||||
}
|
||||
|
||||
get limit(): number {
|
||||
return this.maxConcurrent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the ceiling at runtime.
|
||||
*
|
||||
* Raising it wakes the waiters the new headroom allows, so a queue that built
|
||||
* up under the old limit drains at once instead of one job at a time as
|
||||
* running work finishes. Lowering it never interrupts work already running;
|
||||
* the new limit simply applies from the next release onward.
|
||||
*/
|
||||
setLimit(maxConcurrent: number): void {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.releaseWaiters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes as many queued callers as there is now room for.
|
||||
*
|
||||
* `woken` counts callers that have been resumed but have not yet reached the
|
||||
* `active += 1` on the far side of their await. Without it the occupancy
|
||||
* looks lower than it really is for a microtask, and the loop would release
|
||||
* past the ceiling.
|
||||
*/
|
||||
private releaseWaiters(): void {
|
||||
while (
|
||||
this.active + this.woken < this.maxConcurrent &&
|
||||
this.waiters.length > 0
|
||||
) {
|
||||
this.woken += 1;
|
||||
this.waiters.shift()!();
|
||||
}
|
||||
}
|
||||
|
||||
private woken = 0;
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Queue behind anything already woken, so a caller arriving mid-handoff
|
||||
// cannot jump the queue and push occupancy past the ceiling.
|
||||
if (this.active + this.woken >= this.maxConcurrent) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
// Resumed by a slot handoff; that reservation is now consumed.
|
||||
this.woken = Math.max(0, this.woken - 1);
|
||||
}
|
||||
|
||||
this.active += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.active -= 1;
|
||||
this.releaseWaiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many metrics polls may run at once for a given number of polled hosts.
|
||||
*
|
||||
* A metrics poll is an SSH exec, so this cannot simply be unbounded — but the
|
||||
* old fixed ceiling of 5 meant a sweep of 500 hosts took ~40s against a 30s
|
||||
* interval, so polling fell permanently behind and a host could wait over a
|
||||
* minute for a "30 second" metric. Scaling with the fleet keeps a sweep inside
|
||||
* its interval; the cap keeps file descriptors and CPU bounded.
|
||||
*/
|
||||
export const METRICS_CONCURRENCY_ENV = "METRICS_POLL_CONCURRENCY";
|
||||
const MIN_METRICS_CONCURRENCY = 5;
|
||||
const MAX_METRICS_CONCURRENCY = 50;
|
||||
/** Aim to spend about a twentieth of the interval per sweep wave. */
|
||||
const HOSTS_PER_WORKER = 20;
|
||||
|
||||
export function metricsConcurrencyFor(
|
||||
hostCount: number,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): number {
|
||||
const override = Number(env[METRICS_CONCURRENCY_ENV]);
|
||||
if (Number.isFinite(override) && override >= 1) {
|
||||
return Math.min(Math.floor(override), MAX_METRICS_CONCURRENCY);
|
||||
}
|
||||
|
||||
const scaled = Math.ceil(Math.max(0, hostCount) / HOSTS_PER_WORKER);
|
||||
return Math.min(
|
||||
Math.max(scaled, MIN_METRICS_CONCURRENCY),
|
||||
MAX_METRICS_CONCURRENCY,
|
||||
);
|
||||
}
|
||||
|
||||
/** Short-lived host snapshots for polling — avoids decrypting host rows every tick. */
|
||||
export class HostPollCache<THost extends { id: number } = { id: number }> {
|
||||
private cache = new Map<
|
||||
number,
|
||||
{ host: THost; userId: string; expiresAt: number }
|
||||
>();
|
||||
|
||||
constructor(private readonly ttlMs = 30_000) {}
|
||||
|
||||
get(hostId: number, userId: string): THost | null {
|
||||
const entry = this.cache.get(hostId);
|
||||
if (!entry) return null;
|
||||
if (entry.userId !== userId || Date.now() >= entry.expiresAt) {
|
||||
this.cache.delete(hostId);
|
||||
return null;
|
||||
}
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
set(hostId: number, userId: string, host: THost): void {
|
||||
this.cache.set(hostId, {
|
||||
host,
|
||||
userId,
|
||||
expiresAt: Date.now() + this.ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
invalidate(hostId?: number): void {
|
||||
if (hostId === undefined) {
|
||||
this.cache.clear();
|
||||
return;
|
||||
}
|
||||
this.cache.delete(hostId);
|
||||
}
|
||||
}
|
||||
|
||||
/** TCP status checks are cheap relative to SSH metrics collection. */
|
||||
export const statusPollLimiter = new ConcurrentLimiter(20);
|
||||
/** SSH metrics execs are expensive; keep concurrency tight. */
|
||||
export const metricsPollLimiter = new ConcurrentLimiter(5);
|
||||
/** Viewer registration is bursty; admit only two first samples at a time. */
|
||||
export const initialMetricsPollLimiter = new ConcurrentLimiter(2);
|
||||
|
||||
export function canStartInitialMetrics(
|
||||
status: HostStatus | undefined,
|
||||
hasViewers: boolean,
|
||||
statusCheckEnabled = true,
|
||||
): boolean {
|
||||
return (
|
||||
hasViewers &&
|
||||
(!statusCheckEnabled || status === "reachable" || status === "online")
|
||||
);
|
||||
}
|
||||
export const hostPollCache = new HostPollCache(30_000);
|
||||
|
||||
export const requestQueue = new RequestQueue();
|
||||
export const metricsCache = new MetricsCache();
|
||||
export const authFailureTracker = new AuthFailureTracker();
|
||||
export const pollingBackoff = new PollingBackoff();
|
||||
import type { HostStatus } from "./host-status.js";
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Strict allowlist validators for every dynamic value that reaches a shell
|
||||
* command. Managers MUST validate inputs through these before interpolation;
|
||||
* never pass raw user text to the shell.
|
||||
*/
|
||||
|
||||
const SYSTEMD_UNIT_RE =
|
||||
/^[A-Za-z0-9@._:\\-]+\.(service|socket|timer|target|path|mount)$/;
|
||||
const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/;
|
||||
const USERNAME_RE = /^[a-z_][a-z0-9_-]*\$?$/;
|
||||
const GROUP_RE = /^[a-z_][a-z0-9_-]*$/;
|
||||
const DOMAIN_RE =
|
||||
/^(\*\.)?(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/;
|
||||
const PROVIDER_RE = /^[a-z0-9_]+$/;
|
||||
|
||||
export function isValidSystemdUnit(unit: unknown): unit is string {
|
||||
return (
|
||||
typeof unit === "string" && unit.length <= 256 && SYSTEMD_UNIT_RE.test(unit)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidPid(pid: unknown): pid is number {
|
||||
const n = typeof pid === "string" ? Number(pid) : pid;
|
||||
return typeof n === "number" && Number.isInteger(n) && n > 0 && n < 2 ** 31;
|
||||
}
|
||||
|
||||
export function isValidPort(port: unknown): port is number {
|
||||
const n = typeof port === "string" ? Number(port) : port;
|
||||
return typeof n === "number" && Number.isInteger(n) && n >= 1 && n <= 65535;
|
||||
}
|
||||
|
||||
export function isValidPackageName(pkg: unknown): pkg is string {
|
||||
return typeof pkg === "string" && pkg.length <= 128 && PACKAGE_RE.test(pkg);
|
||||
}
|
||||
|
||||
export function isValidUsername(name: unknown): name is string {
|
||||
return (
|
||||
typeof name === "string" && name.length <= 32 && USERNAME_RE.test(name)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidGroupName(name: unknown): name is string {
|
||||
return typeof name === "string" && name.length <= 32 && GROUP_RE.test(name);
|
||||
}
|
||||
|
||||
export function isValidDomain(domain: unknown): domain is string {
|
||||
return (
|
||||
typeof domain === "string" && domain.length <= 253 && DOMAIN_RE.test(domain)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidDnsProvider(provider: unknown): provider is string {
|
||||
return (
|
||||
typeof provider === "string" &&
|
||||
provider.length <= 64 &&
|
||||
PROVIDER_RE.test(provider)
|
||||
);
|
||||
}
|
||||
|
||||
export type Signal = "TERM" | "KILL" | "HUP" | "INT";
|
||||
const SIGNALS: Signal[] = ["TERM", "KILL", "HUP", "INT"];
|
||||
export function isValidSignal(sig: unknown): sig is Signal {
|
||||
return typeof sig === "string" && (SIGNALS as string[]).includes(sig);
|
||||
}
|
||||
|
||||
export type ServiceAction =
|
||||
"start" | "stop" | "restart" | "reload" | "enable" | "disable";
|
||||
const SERVICE_ACTIONS: ServiceAction[] = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"reload",
|
||||
"enable",
|
||||
"disable",
|
||||
];
|
||||
export function isValidServiceAction(a: unknown): a is ServiceAction {
|
||||
return typeof a === "string" && (SERVICE_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
export type IpProtocol = "tcp" | "udp";
|
||||
export function isValidIpProtocol(p: unknown): p is IpProtocol {
|
||||
return p === "tcp" || p === "udp";
|
||||
}
|
||||
|
||||
export type FirewallTarget = "ACCEPT" | "DROP" | "REJECT";
|
||||
const FW_TARGETS: FirewallTarget[] = ["ACCEPT", "DROP", "REJECT"];
|
||||
export function isValidFirewallTarget(t: unknown): t is FirewallTarget {
|
||||
return typeof t === "string" && (FW_TARGETS as string[]).includes(t);
|
||||
}
|
||||
|
||||
const WG_IFACE_RE = /^[A-Za-z0-9_-]{1,15}$/;
|
||||
export function isValidWireGuardInterface(name: unknown): name is string {
|
||||
return typeof name === "string" && WG_IFACE_RE.test(name);
|
||||
}
|
||||
|
||||
export type WireGuardAction = "up" | "down";
|
||||
const WG_ACTIONS: WireGuardAction[] = ["up", "down"];
|
||||
export function isValidWireGuardAction(a: unknown): a is WireGuardAction {
|
||||
return typeof a === "string" && (WG_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
export type TailscaleAction = "up" | "down";
|
||||
const TAILSCALE_ACTIONS: TailscaleAction[] = ["up", "down"];
|
||||
export function isValidTailscaleAction(a: unknown): a is TailscaleAction {
|
||||
return typeof a === "string" && (TAILSCALE_ACTIONS as string[]).includes(a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an absolute file path against an allowlist of permitted prefixes and
|
||||
* reject traversal. Used by the log viewer (e.g. only under /var/log).
|
||||
*/
|
||||
export function isAllowedPath(
|
||||
path: unknown,
|
||||
allowedPrefixes: string[],
|
||||
): path is string {
|
||||
if (typeof path !== "string" || path.length === 0 || path.length > 4096) {
|
||||
return false;
|
||||
}
|
||||
if (!path.startsWith("/")) return false;
|
||||
if (path.includes("\0")) return false;
|
||||
if (path.split("/").some((seg) => seg === "..")) return false;
|
||||
return allowedPrefixes.some(
|
||||
(prefix) =>
|
||||
path === prefix ||
|
||||
path.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* The host list's status dot.
|
||||
*
|
||||
* Every host with status checks on gets a TCP connection to its port on a
|
||||
* timer. That alone says "reachable". A host becomes "online" when a login
|
||||
* worked: an open terminal session, or a plugin reporting one through
|
||||
* ctx.hosts.status.reportLogin. Probing never logs in: on RADIUS or Duo backed
|
||||
* devices a login fires a real 2FA push, every interval.
|
||||
*
|
||||
* Polling is demand driven. A user asking for statuses starts polling their
|
||||
* own hosts; nothing is probed for a user who never opened the app.
|
||||
*/
|
||||
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
import { hostSessionStatus } from "../host-session-status.js";
|
||||
import { sshLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
createCurrentHostResolutionRepository,
|
||||
getCurrentSettingValue,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import type { HostStatusTargetRow } from "../../database/repositories/host-resolution-repository.js";
|
||||
import {
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
type HostStatus,
|
||||
} from "./host-status.js";
|
||||
import { ConcurrentLimiter } from "./limiter.js";
|
||||
import { tcpPing, tcpPingThroughJumpHost } from "./tcp-ping.js";
|
||||
|
||||
export const GLOBAL_STATUS_INTERVAL_KEY = "global_status_check_interval";
|
||||
export const DEFAULT_STATUS_INTERVAL = 60;
|
||||
/** A status younger than this is fresh enough for check(). */
|
||||
const FRESH_MS = 30_000;
|
||||
|
||||
export interface HostStatusEntry {
|
||||
status: HostStatus;
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
export interface HostStatusPayload {
|
||||
hostId: number;
|
||||
ownerUserId: string;
|
||||
status: HostStatus;
|
||||
previous: HostStatus | null;
|
||||
/** Anything but offline, the shape automations' trigger already reads. */
|
||||
online: boolean;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
export interface StatusTarget {
|
||||
id: number;
|
||||
userId: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
connectionType: string;
|
||||
jumpHosts: Array<{ hostId: number }>;
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number | null;
|
||||
}
|
||||
|
||||
type PortResolver = (
|
||||
hostId: number,
|
||||
) => number | undefined | Promise<number | undefined>;
|
||||
|
||||
export interface HostStatusDeps {
|
||||
loadTargets: (filter: {
|
||||
userId?: string;
|
||||
hostIds?: number[];
|
||||
}) => Promise<StatusTarget[]>;
|
||||
ping: (host: string, port: number) => Promise<boolean>;
|
||||
pingThroughJumpHosts: (
|
||||
target: StatusTarget,
|
||||
port: number,
|
||||
) => Promise<boolean>;
|
||||
globalInterval: () => number;
|
||||
emit: (payload: HostStatusPayload) => void;
|
||||
}
|
||||
|
||||
function parseJumpHosts(raw: string | null): Array<{ hostId: number }> {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter(
|
||||
(hop): hop is { hostId: number } =>
|
||||
!!hop && Number.isInteger((hop as { hostId?: unknown }).hostId),
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function toStatusTarget(row: HostStatusTargetRow): StatusTarget {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
ip: row.ip,
|
||||
port: row.port,
|
||||
connectionType: row.connectionType || "ssh",
|
||||
jumpHosts: parseJumpHosts(row.jumpHosts),
|
||||
statusCheckEnabled: row.statusCheckEnabled !== false,
|
||||
statusCheckInterval: row.statusCheckInterval ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultDeps: HostStatusDeps = {
|
||||
loadTargets: async (filter) =>
|
||||
(
|
||||
await createCurrentHostResolutionRepository().listStatusTargets(filter)
|
||||
).map(toStatusTarget),
|
||||
ping: (host, port) => tcpPing(host, port, 5000),
|
||||
pingThroughJumpHosts: async (target, port) => {
|
||||
const { createJumpHostChain } = await import("../jump-host-chain.js");
|
||||
const client = await createJumpHostChain(
|
||||
target.jumpHosts,
|
||||
target.userId,
|
||||
).catch(() => null);
|
||||
return client
|
||||
? tcpPingThroughJumpHost(client, target.ip, port, 5000)
|
||||
: false;
|
||||
},
|
||||
globalInterval: () => {
|
||||
const value = Number(getCurrentSettingValue(GLOBAL_STATUS_INTERVAL_KEY));
|
||||
return Number.isInteger(value) && value >= 5
|
||||
? value
|
||||
: DEFAULT_STATUS_INTERVAL;
|
||||
},
|
||||
emit: (payload) => pluginEvents.emit(TOPICS.hostStatus, payload),
|
||||
};
|
||||
|
||||
interface Polled {
|
||||
target: StatusTarget;
|
||||
timer?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
export class HostStatusService {
|
||||
private readonly polled = new Map<number, Polled>();
|
||||
private readonly store = new Map<number, HostStatusEntry>();
|
||||
private readonly owners = new Map<number, string>();
|
||||
private readonly inFlight = new Map<number, Promise<void>>();
|
||||
private readonly sessionOnline = new Set<number>();
|
||||
private readonly loggedIn = new Set<number>();
|
||||
private readonly startedUsers = new Set<string>();
|
||||
private readonly portResolvers = new Map<string, Set<PortResolver>>();
|
||||
private readonly limiter = new ConcurrentLimiter(20);
|
||||
private unsubscribers: Array<() => void> = [];
|
||||
|
||||
constructor(private readonly deps: HostStatusDeps = defaultDeps) {}
|
||||
|
||||
/** Subscribes to the core events the status depends on. */
|
||||
start(): void {
|
||||
if (this.unsubscribers.length > 0) return;
|
||||
this.unsubscribers = [
|
||||
hostSessionStatus.subscribe((hostId, online) =>
|
||||
this.setSessionOnline(hostId, online),
|
||||
),
|
||||
pluginEvents.on(TOPICS.hostUpdated, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) void this.reload(hostId);
|
||||
}),
|
||||
pluginEvents.on(TOPICS.hostDeleted, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) this.forget(hostId);
|
||||
}),
|
||||
pluginEvents.on(TOPICS.hostKeyUpdated, (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (hostId) this.clearHostKeyReason(hostId);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
for (const unsubscribe of this.unsubscribers) unsubscribe();
|
||||
this.unsubscribers = [];
|
||||
for (const hostId of [...this.polled.keys()]) this.stopPolling(hostId);
|
||||
this.startedUsers.clear();
|
||||
}
|
||||
|
||||
get(hostId: number): HostStatusEntry | null {
|
||||
return this.store.get(hostId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statuses for a user's request. With hostIds, polling is brought in line
|
||||
* with exactly those of their own hosts (the desktop app sends only the
|
||||
* hosts that live on this backend). Without, their own hosts are started
|
||||
* once.
|
||||
*/
|
||||
async statusesFor(
|
||||
userId: string,
|
||||
requestedHostIds: Set<number> | null,
|
||||
): Promise<Map<number, HostStatusEntry>> {
|
||||
if (requestedHostIds !== null) {
|
||||
await this.reconcile(userId, requestedHostIds);
|
||||
} else if (!this.startedUsers.has(userId)) {
|
||||
await this.startUser(userId);
|
||||
}
|
||||
return this.store;
|
||||
}
|
||||
|
||||
/** Drops every timer for a user's hosts and starts them again. */
|
||||
async refresh(userId: string): Promise<void> {
|
||||
const targets = await this.deps.loadTargets({ userId });
|
||||
for (const target of targets) this.stopPolling(target.id);
|
||||
for (const target of targets) this.startPolling(target);
|
||||
this.startedUsers.add(userId);
|
||||
}
|
||||
|
||||
/** Re-times every polled host, after the global interval changed. */
|
||||
retimeAll(): void {
|
||||
for (const { target } of [...this.polled.values()]) {
|
||||
this.startPolling(target, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Checks now unless the last result is fresh. */
|
||||
async check(hostId: number): Promise<HostStatusEntry | null> {
|
||||
const current = this.store.get(hostId);
|
||||
if (current && Date.now() - Date.parse(current.lastChecked) < FRESH_MS) {
|
||||
return current;
|
||||
}
|
||||
const target =
|
||||
this.polled.get(hostId)?.target ??
|
||||
(await this.deps.loadTargets({ hostIds: [hostId] }))[0];
|
||||
if (!target || !target.statusCheckEnabled) return current ?? null;
|
||||
await this.probe(target);
|
||||
return this.store.get(hostId) ?? null;
|
||||
}
|
||||
|
||||
reportLogin(
|
||||
hostId: number,
|
||||
outcome: { ok: boolean; hostKeyChanged?: boolean },
|
||||
): void {
|
||||
if (outcome.ok) {
|
||||
this.loggedIn.add(hostId);
|
||||
this.set(hostId, { status: statusAfterAuthentication(true) });
|
||||
return;
|
||||
}
|
||||
this.loggedIn.delete(hostId);
|
||||
this.set(hostId, {
|
||||
status: this.sessionOnline.has(hostId)
|
||||
? "online"
|
||||
: statusAfterAuthentication(false, this.store.get(hostId)?.status),
|
||||
...(outcome.hostKeyChanged ? { reason: "host_key_changed" } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
registerPort(connectionType: string, resolve: PortResolver): () => void {
|
||||
let set = this.portResolvers.get(connectionType);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.portResolvers.set(connectionType, set);
|
||||
}
|
||||
set.add(resolve);
|
||||
return () => {
|
||||
set!.delete(resolve);
|
||||
if (set!.size === 0) this.portResolvers.delete(connectionType);
|
||||
};
|
||||
}
|
||||
|
||||
private async startUser(userId: string): Promise<void> {
|
||||
this.startedUsers.add(userId);
|
||||
const targets = await this.deps.loadTargets({ userId });
|
||||
for (const target of targets) {
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(userId: string, allowed: Set<number>): Promise<void> {
|
||||
const targets = await this.deps.loadTargets({ userId });
|
||||
for (const target of targets) {
|
||||
if (!allowed.has(target.id)) {
|
||||
this.stopPolling(target.id);
|
||||
this.store.delete(target.id);
|
||||
continue;
|
||||
}
|
||||
if (!this.polled.has(target.id)) this.startPolling(target);
|
||||
}
|
||||
}
|
||||
|
||||
private async reload(hostId: number): Promise<void> {
|
||||
try {
|
||||
const [target] = await this.deps.loadTargets({ hostIds: [hostId] });
|
||||
if (!target) {
|
||||
this.forget(hostId);
|
||||
return;
|
||||
}
|
||||
if (this.polled.has(hostId) || this.startedUsers.has(target.userId)) {
|
||||
this.startPolling(target);
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.warn("Could not reload host for status checks", {
|
||||
operation: "host_status_reload",
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private forget(hostId: number): void {
|
||||
this.stopPolling(hostId);
|
||||
this.store.delete(hostId);
|
||||
this.owners.delete(hostId);
|
||||
this.loggedIn.delete(hostId);
|
||||
}
|
||||
|
||||
private clearHostKeyReason(hostId: number): void {
|
||||
const current = this.store.get(hostId);
|
||||
if (current?.reason !== "host_key_changed") return;
|
||||
this.store.set(hostId, {
|
||||
status: current.status,
|
||||
lastChecked: current.lastChecked,
|
||||
});
|
||||
}
|
||||
|
||||
private setSessionOnline(hostId: number, online: boolean): void {
|
||||
if (online) {
|
||||
this.sessionOnline.add(hostId);
|
||||
if (this.polled.has(hostId)) this.set(hostId, { status: "online" });
|
||||
return;
|
||||
}
|
||||
this.sessionOnline.delete(hostId);
|
||||
if (
|
||||
!this.loggedIn.has(hostId) &&
|
||||
this.store.get(hostId)?.status === "online"
|
||||
) {
|
||||
this.set(hostId, { status: "reachable" });
|
||||
}
|
||||
}
|
||||
|
||||
private intervalMs(target: StatusTarget): number {
|
||||
const seconds = target.statusCheckInterval ?? this.deps.globalInterval();
|
||||
const intervalMs = Math.max(5, seconds) * 1000;
|
||||
// Spread hosts so a fleet does not fire on the same second.
|
||||
const spread = Math.min(intervalMs * 0.2, 15_000);
|
||||
return (
|
||||
intervalMs + ((target.id * 1103515245) % Math.max(1, Math.floor(spread)))
|
||||
);
|
||||
}
|
||||
|
||||
private startPolling(target: StatusTarget, probeNow = true): void {
|
||||
this.stopPolling(target.id);
|
||||
this.owners.set(target.id, target.userId);
|
||||
if (!target.statusCheckEnabled) {
|
||||
this.store.delete(target.id);
|
||||
return;
|
||||
}
|
||||
const polled: Polled = { target };
|
||||
this.polled.set(target.id, polled);
|
||||
if (probeNow) void this.probe(target);
|
||||
polled.timer = setInterval(() => {
|
||||
const latest = this.polled.get(target.id);
|
||||
if (latest) void this.probe(latest.target);
|
||||
}, this.intervalMs(target));
|
||||
polled.timer.unref?.();
|
||||
}
|
||||
|
||||
private stopPolling(hostId: number): void {
|
||||
const polled = this.polled.get(hostId);
|
||||
if (polled?.timer) clearInterval(polled.timer);
|
||||
this.polled.delete(hostId);
|
||||
}
|
||||
|
||||
private probe(target: StatusTarget): Promise<void> {
|
||||
const running = this.inFlight.get(target.id);
|
||||
if (running) return running;
|
||||
const job = this.limiter
|
||||
.run(() => this.probeNow(target))
|
||||
.catch((error) => {
|
||||
sshLogger.error("Status check failed", error, {
|
||||
operation: "host_status_probe",
|
||||
hostId: target.id,
|
||||
});
|
||||
})
|
||||
.finally(() => this.inFlight.delete(target.id));
|
||||
this.inFlight.set(target.id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
private async portFor(target: StatusTarget): Promise<number> {
|
||||
if (target.connectionType !== "ssh") {
|
||||
for (const resolve of this.portResolvers.get(target.connectionType) ??
|
||||
[]) {
|
||||
try {
|
||||
const port = await resolve(target.id);
|
||||
if (Number.isInteger(port) && port! > 0) return port!;
|
||||
} catch {
|
||||
// fall through to the next resolver
|
||||
}
|
||||
}
|
||||
}
|
||||
return target.port;
|
||||
}
|
||||
|
||||
private async probeNow(target: StatusTarget): Promise<void> {
|
||||
this.owners.set(target.id, target.userId);
|
||||
let reachable = false;
|
||||
try {
|
||||
const port = await this.portFor(target);
|
||||
reachable =
|
||||
target.jumpHosts.length > 0
|
||||
? await this.deps.pingThroughJumpHosts(target, port)
|
||||
: await this.deps.ping(target.ip, port);
|
||||
} catch {
|
||||
reachable = false;
|
||||
}
|
||||
|
||||
const current = this.store.get(target.id);
|
||||
this.set(target.id, {
|
||||
status:
|
||||
reachable && this.sessionOnline.has(target.id)
|
||||
? "online"
|
||||
: statusAfterReachabilityCheck(reachable, current?.status),
|
||||
...(reachable && current?.reason === "host_key_changed"
|
||||
? { reason: "host_key_changed" as const }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
private set(hostId: number, entry: Omit<HostStatusEntry, "lastChecked">) {
|
||||
const previous = this.store.get(hostId)?.status ?? null;
|
||||
const next: HostStatusEntry = {
|
||||
...entry,
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
this.store.set(hostId, next);
|
||||
if (previous === next.status) return;
|
||||
void this.emitChange(hostId, next, previous);
|
||||
}
|
||||
|
||||
private async emitChange(
|
||||
hostId: number,
|
||||
entry: HostStatusEntry,
|
||||
previous: HostStatus | null,
|
||||
): Promise<void> {
|
||||
let ownerUserId = this.owners.get(hostId);
|
||||
if (!ownerUserId) {
|
||||
const [target] = await this.deps
|
||||
.loadTargets({ hostIds: [hostId] })
|
||||
.catch(() => []);
|
||||
if (!target) return;
|
||||
ownerUserId = target.userId;
|
||||
this.owners.set(hostId, ownerUserId);
|
||||
}
|
||||
this.deps.emit({
|
||||
hostId,
|
||||
ownerUserId,
|
||||
status: entry.status,
|
||||
previous,
|
||||
online: entry.status !== "offline",
|
||||
...(entry.reason ? { reason: entry.reason } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const hostStatusService = new HostStatusService();
|
||||
File renamed without changes.
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Limits how many async jobs run at once. Extra callers wait in FIFO order.
|
||||
*/
|
||||
export class ConcurrentLimiter {
|
||||
private active = 0;
|
||||
private readonly waiters: Array<() => void> = [];
|
||||
|
||||
constructor(private maxConcurrent: number) {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
}
|
||||
|
||||
get activeCount(): number {
|
||||
return this.active;
|
||||
}
|
||||
|
||||
get pendingCount(): number {
|
||||
return this.waiters.length;
|
||||
}
|
||||
|
||||
get limit(): number {
|
||||
return this.maxConcurrent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the ceiling at runtime.
|
||||
*
|
||||
* Raising it wakes the waiters the new headroom allows, so a queue that built
|
||||
* up under the old limit drains at once instead of one job at a time as
|
||||
* running work finishes. Lowering it never interrupts work already running;
|
||||
* the new limit simply applies from the next release onward.
|
||||
*/
|
||||
setLimit(maxConcurrent: number): void {
|
||||
if (maxConcurrent < 1) {
|
||||
throw new Error("maxConcurrent must be >= 1");
|
||||
}
|
||||
this.maxConcurrent = maxConcurrent;
|
||||
this.releaseWaiters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wakes as many queued callers as there is now room for.
|
||||
*
|
||||
* `woken` counts callers that have been resumed but have not yet reached the
|
||||
* `active += 1` on the far side of their await. Without it the occupancy
|
||||
* looks lower than it really is for a microtask, and the loop would release
|
||||
* past the ceiling.
|
||||
*/
|
||||
private releaseWaiters(): void {
|
||||
while (
|
||||
this.active + this.woken < this.maxConcurrent &&
|
||||
this.waiters.length > 0
|
||||
) {
|
||||
this.woken += 1;
|
||||
this.waiters.shift()!();
|
||||
}
|
||||
}
|
||||
|
||||
private woken = 0;
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
// Queue behind anything already woken, so a caller arriving mid-handoff
|
||||
// cannot jump the queue and push occupancy past the ceiling.
|
||||
if (this.active + this.woken >= this.maxConcurrent) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.waiters.push(resolve);
|
||||
});
|
||||
// Resumed by a slot handoff; that reservation is now consumed.
|
||||
this.woken = Math.max(0, this.woken - 1);
|
||||
}
|
||||
|
||||
this.active += 1;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.active -= 1;
|
||||
this.releaseWaiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import net from "net";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
/**
|
||||
* Opens a TCP connection and closes it again. An SSH server gets a polite
|
||||
* banner back so it does not log a failed handshake.
|
||||
*/
|
||||
export function tcpPing(
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
};
|
||||
|
||||
socket.setTimeout(timeoutMs);
|
||||
|
||||
socket.once("connect", () => {
|
||||
const dataTimeout = setTimeout(() => {
|
||||
cleanup();
|
||||
finish(true);
|
||||
}, 2000);
|
||||
|
||||
socket.once("data", (data) => {
|
||||
clearTimeout(dataTimeout);
|
||||
if (data.toString("utf8").startsWith("SSH-")) {
|
||||
try {
|
||||
socket.end("SSH-2.0-TermixHealthCheck\r\n");
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
setTimeout(cleanup, 200);
|
||||
} else {
|
||||
cleanup();
|
||||
}
|
||||
finish(true);
|
||||
});
|
||||
});
|
||||
|
||||
socket.once("timeout", () => {
|
||||
cleanup();
|
||||
finish(false);
|
||||
});
|
||||
socket.once("error", () => {
|
||||
cleanup();
|
||||
finish(false);
|
||||
});
|
||||
socket.connect(port, host);
|
||||
});
|
||||
}
|
||||
|
||||
/** The same check from the far end of a jump host chain. Ends the chain. */
|
||||
export function tcpPingThroughJumpHost(
|
||||
jumpClient: Pick<Client, "forwardOut" | "end">,
|
||||
host: string,
|
||||
port: number,
|
||||
timeoutMs = 5000,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
jumpClient.end();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
|
||||
jumpClient.forwardOut("127.0.0.1", 0, host, port, (error, stream) => {
|
||||
stream?.destroy();
|
||||
finish(!error && !!stream);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { assertCapability } from "./permissions.js";
|
||||
import { hostSessionStatus } from "../hosts/host-session-status.js";
|
||||
import { getActor } from "./actor.js";
|
||||
import { hostStatusService } from "../hosts/status/host-status-service.js";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
|
||||
type AuditFn = (
|
||||
action: string,
|
||||
@@ -34,6 +36,8 @@ type AuditFn = (
|
||||
|
||||
interface Deps {
|
||||
manifest: PluginManifest;
|
||||
/** Where registerPort entries go, so deactivate removes them. */
|
||||
bag?: DisposableBag;
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
@@ -98,11 +102,16 @@ function actingUser(): string {
|
||||
return actor;
|
||||
}
|
||||
|
||||
export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
|
||||
export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
|
||||
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
|
||||
const requireReadSync = () => {
|
||||
if (!declared.includes("hosts:read")) {
|
||||
throw new PluginCapabilityError(pluginId, "hosts:read");
|
||||
}
|
||||
};
|
||||
const requireWrite = () =>
|
||||
assertCapability(pluginId, "hosts:write", declared);
|
||||
|
||||
@@ -418,5 +427,34 @@ export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
|
||||
await import("../services/recent-activity.js");
|
||||
await recordRecentActivity(userId, { type, hostId, hostName });
|
||||
},
|
||||
|
||||
// Synchronous and not audited, like trackSession: pollers call these on
|
||||
// every sample.
|
||||
status: {
|
||||
get: (hostId) => {
|
||||
requireReadSync();
|
||||
return hostStatusService.get(hostId);
|
||||
},
|
||||
check: async (hostId) => {
|
||||
await requireRead();
|
||||
return hostStatusService.check(hostId);
|
||||
},
|
||||
reportLogin: (hostId, outcome) => {
|
||||
requireReadSync();
|
||||
hostStatusService.reportLogin(hostId, outcome);
|
||||
},
|
||||
registerPort: (connectionType, resolve) => {
|
||||
requireReadSync();
|
||||
const unregister = hostStatusService.registerPort(
|
||||
connectionType,
|
||||
resolve,
|
||||
);
|
||||
const drop = bag?.add(unregister, `status port for ${connectionType}`);
|
||||
return () => {
|
||||
drop?.();
|
||||
unregister();
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -90,7 +90,9 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
const open = new Set<() => void>();
|
||||
const poolKeys = new Set<string>();
|
||||
// Pool key -> host id, so dropPooled can find a host's keys after its
|
||||
// address changed.
|
||||
const poolKeys = new Map<string, number>();
|
||||
|
||||
bag.add(async () => {
|
||||
for (const dispose of [...open]) dispose();
|
||||
@@ -98,7 +100,9 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
if (poolKeys.size > 0) {
|
||||
const { connectionPool } =
|
||||
await import("../hosts/ssh-connection-pool.js");
|
||||
for (const key of poolKeys) connectionPool.clearKeyConnections(key);
|
||||
for (const key of poolKeys.keys()) {
|
||||
connectionPool.clearKeyConnections(key);
|
||||
}
|
||||
poolKeys.clear();
|
||||
}
|
||||
}, "SSH connections");
|
||||
@@ -183,7 +187,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
actingUser(host),
|
||||
);
|
||||
const key = poolKey(options.pool, resolved as PluginSshHost);
|
||||
poolKeys.add(key);
|
||||
poolKeys.set(key, resolved.id);
|
||||
const { withConnection } =
|
||||
await import("../hosts/ssh-connection-pool.js");
|
||||
return withConnection(
|
||||
@@ -228,6 +232,19 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
|
||||
poolKey,
|
||||
|
||||
dropPooled: (pool, hostId) => {
|
||||
const keys = [...poolKeys.entries()]
|
||||
.filter(([key, id]) => id === hostId && key.startsWith(`${pool}:`))
|
||||
.map(([key]) => key);
|
||||
if (keys.length === 0) return;
|
||||
for (const key of keys) poolKeys.delete(key);
|
||||
void import("../hosts/ssh-connection-pool.js").then(
|
||||
({ connectionPool }) => {
|
||||
for (const key of keys) connectionPool.clearKeyConnections(key);
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
resolveHost: async (hostId, options) => {
|
||||
await checkSsh(true);
|
||||
const userId = actingUser(hostId);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
} from "../database/db/dialect.js";
|
||||
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
|
||||
import { createPluginHosts } from "./ctx-hosts.js";
|
||||
import { createPluginSchedule } from "./schedule.js";
|
||||
import { createPluginCredentials } from "./ctx-credentials.js";
|
||||
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
|
||||
|
||||
@@ -425,11 +426,15 @@ export function createPluginContext(
|
||||
refs: () => dbRefs() as never,
|
||||
// Gated but not audited: it follows every write, and the write itself
|
||||
// went through a client() call that already left an audit line.
|
||||
persist: async () => {
|
||||
persist: async (options) => {
|
||||
await assertCapability(pluginId, "db:own", manifest.capabilities);
|
||||
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
|
||||
const { DatabaseSaveTrigger } =
|
||||
await import("../utils/database-save-trigger.js");
|
||||
if (options?.lazy) {
|
||||
DatabaseSaveTrigger.triggerSave(`plugin_${pluginId}_write`);
|
||||
return;
|
||||
}
|
||||
await DatabaseSaveTrigger.forceSave(`plugin_${pluginId}_write`);
|
||||
},
|
||||
get dialect() {
|
||||
@@ -715,7 +720,15 @@ export function createPluginContext(
|
||||
},
|
||||
},
|
||||
|
||||
hosts: createPluginHosts({ manifest, audit: auditCall }),
|
||||
hosts: createPluginHosts({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
schedule: createPluginSchedule(handle.bag, (message, error) =>
|
||||
pluginLogger.error(
|
||||
message,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
logContext,
|
||||
),
|
||||
),
|
||||
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/**
|
||||
* The process-wide event bus behind ctx.events.
|
||||
*
|
||||
* This formalises three ad-hoc publish paths that already existed:
|
||||
* This formalises the ad-hoc publish paths that already existed:
|
||||
* - hosts/automation-events.ts (generic named internal events)
|
||||
* - hosts/metrics/automation-bridge.ts (metrics-shaped siblings)
|
||||
* - hosts/host-session-status.ts (the "host.session.status" topic)
|
||||
*
|
||||
* Each of those lazily imported the automations engine and swallowed errors,
|
||||
@@ -28,12 +27,10 @@ export const TOPICS = {
|
||||
hostSessionStatus: "host.session.status",
|
||||
/** A generic named internal event (host_deleted, user_login, ...). */
|
||||
internalEvent: "internal.event",
|
||||
/** A metrics snapshot for one host. */
|
||||
hostMetrics: "host.metrics",
|
||||
/** A host went online or offline as seen by the metrics poller. */
|
||||
/** A host's status dot changed (hosts/status/host-status-service.ts). */
|
||||
hostStatus: "host.status",
|
||||
/** A health check result. */
|
||||
hostHealthCheck: "host.health_check",
|
||||
/** A host's key was accepted again after it changed. */
|
||||
hostKeyUpdated: "host.key.updated",
|
||||
/** A host's connection details changed and pollers should re-read them. */
|
||||
hostUpdated: "host.updated",
|
||||
/** A host was deleted and any poller holding it should drop it. */
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* ctx.schedule: timers a plugin owns. They live in the plugin's disposable
|
||||
* bag, so deactivate clears every one of them.
|
||||
*/
|
||||
|
||||
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
|
||||
import type { DisposableBag } from "./disposables.js";
|
||||
|
||||
type Log = (message: string, error: unknown) => void;
|
||||
|
||||
export function createPluginSchedule(
|
||||
bag: DisposableBag,
|
||||
logError: Log,
|
||||
): PluginSchedule {
|
||||
const run = async (fn: () => void | Promise<void>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
logError("Scheduled job failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
every: (intervalMs, fn, options) => {
|
||||
let running = false;
|
||||
let stopped = false;
|
||||
let interval: NodeJS.Timeout | undefined;
|
||||
|
||||
const tick = () => {
|
||||
if (running || stopped) return;
|
||||
running = true;
|
||||
void run(fn).finally(() => {
|
||||
running = false;
|
||||
});
|
||||
};
|
||||
|
||||
const jitter = Math.max(0, Math.floor(options?.jitterMs ?? 0));
|
||||
const start = setTimeout(
|
||||
() => {
|
||||
if (stopped) return;
|
||||
interval = setInterval(tick, Math.max(1, intervalMs));
|
||||
if (jitter > 0) tick();
|
||||
},
|
||||
jitter > 0 ? Math.floor(Math.random() * jitter) : 0,
|
||||
);
|
||||
if (options?.runNow) tick();
|
||||
|
||||
let drop = () => {};
|
||||
const stop = () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearTimeout(start);
|
||||
if (interval) clearInterval(interval);
|
||||
drop();
|
||||
};
|
||||
drop = bag.add(stop, "scheduled job");
|
||||
return stop;
|
||||
},
|
||||
|
||||
after: (delayMs, fn) => {
|
||||
let done = false;
|
||||
let drop = () => {};
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
done = true;
|
||||
drop();
|
||||
void run(fn);
|
||||
},
|
||||
Math.max(0, delayMs),
|
||||
);
|
||||
const cancel = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
drop();
|
||||
};
|
||||
drop = bag.add(cancel, "scheduled job");
|
||||
return cancel;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,11 +38,7 @@ export type { PluginSettingsScope };
|
||||
* to it means deciding that every plugin holding settings:read-core may see
|
||||
* that value, so it is a review decision rather than a convenience.
|
||||
*/
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
|
||||
"app_name",
|
||||
"global_metrics_interval",
|
||||
"global_status_check_interval",
|
||||
];
|
||||
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = ["app_name"];
|
||||
|
||||
type ChangeListener = (value: unknown) => void;
|
||||
|
||||
|
||||
@@ -269,6 +269,14 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
await import("./utils/crypto-migration/external-identity-migration.js");
|
||||
await runExternalIdentityMigration();
|
||||
|
||||
const { runHostStatusConfigMigration } =
|
||||
await import("./utils/crypto-migration/host-status-config-migration.js");
|
||||
await runHostStatusConfigMigration();
|
||||
|
||||
const { hostStatusService } =
|
||||
await import("./hosts/status/host-status-service.js");
|
||||
hostStatusService.start();
|
||||
|
||||
if (process.env.ELECTRON_EMBEDDED === "true") {
|
||||
await provisionLocalDesktopUserIfNeeded();
|
||||
}
|
||||
@@ -381,6 +389,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
const { runRemoteDesktopSettingsMigration } =
|
||||
await import("./utils/crypto-migration/remote-desktop-settings-migration.js");
|
||||
await runRemoteDesktopSettingsMigration();
|
||||
|
||||
const { runHostMetricsSettingsMigration } =
|
||||
await import("./utils/crypto-migration/host-metrics-settings-migration.js");
|
||||
await runHostMetricsSettingsMigration();
|
||||
} catch (error) {
|
||||
systemLogger.warn("Plugin runtime failed to initialize", {
|
||||
operation: "plugin_init",
|
||||
|
||||
@@ -483,7 +483,6 @@ describe("HostRepository and CredentialRepository", () => {
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
statsConfig: JSON.stringify({ cpu: true }),
|
||||
});
|
||||
const second = await repo.hosts.create({
|
||||
userId: "user-1",
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { HostHealthRepository } from "../../../database/repositories/host-health-repository.js";
|
||||
|
||||
describe("HostHealthRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<HostHealthRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash)
|
||||
VALUES ('user-1', 'alice', 'hash'), ('user-2', 'bob', 'hash');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
|
||||
VALUES (1, 'user-1', 'one', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'two', '10.0.0.1', 22, 'root', 'password');
|
||||
INSERT INTO host_health_checks (
|
||||
user_id, host_id, checks, interval_seconds, created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
'user-1',
|
||||
1,
|
||||
'[{"id":"tcp","name":"TCP","type":"tcp","target":"localhost","port":22}]',
|
||||
300,
|
||||
'2026-01-01T00:00:00.000Z',
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
);
|
||||
`);
|
||||
|
||||
return new HostHealthRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("finds and upserts check configuration", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const existing = await repo.findChecksByUserAndHost("user-1", 1);
|
||||
expect(existing?.intervalSeconds).toBe(300);
|
||||
|
||||
const updated = await repo.upsertChecks(
|
||||
"user-1",
|
||||
1,
|
||||
'[{"id":"http"}]',
|
||||
60,
|
||||
"2026-02-01T00:00:00.000Z",
|
||||
);
|
||||
expect(updated).toMatchObject({
|
||||
id: existing?.id,
|
||||
checks: '[{"id":"http"}]',
|
||||
intervalSeconds: 60,
|
||||
updatedAt: "2026-02-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const created = await repo.upsertChecks(
|
||||
"user-2",
|
||||
2,
|
||||
'[{"id":"tcp"}]',
|
||||
120,
|
||||
"2026-03-01T00:00:00.000Z",
|
||||
);
|
||||
expect(created).toMatchObject({
|
||||
userId: "user-2",
|
||||
hostId: 2,
|
||||
checks: '[{"id":"tcp"}]',
|
||||
intervalSeconds: 120,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("records and prunes history", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
expect(
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
|
||||
1,
|
||||
"2026-01-01T00:00:00.000Z",
|
||||
),
|
||||
).toBe(1);
|
||||
expect(
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
|
||||
1,
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
),
|
||||
).toBe(1);
|
||||
|
||||
const history = await repo.listHistory("user-1", 1, 10);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]).toMatchObject({
|
||||
userId: "user-1",
|
||||
hostId: 1,
|
||||
checkId: "two",
|
||||
ok: false,
|
||||
latencyMs: null,
|
||||
detail: "closed",
|
||||
});
|
||||
expect(writeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("deletes all health checks and history for a user", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
await repo.upsertChecks("user-2", 2, '[{"id":"tcp"}]', 120);
|
||||
await repo.recordHistory(
|
||||
"user-1",
|
||||
1,
|
||||
[{ checkId: "one", ok: true, latencyMs: 12, detail: "open" }],
|
||||
10,
|
||||
);
|
||||
await repo.recordHistory(
|
||||
"user-2",
|
||||
2,
|
||||
[{ checkId: "two", ok: false, latencyMs: null, detail: "closed" }],
|
||||
10,
|
||||
);
|
||||
|
||||
await expect(repo.deleteByUserId("user-1")).resolves.toEqual({
|
||||
checksDeleted: 1,
|
||||
historyDeleted: 1,
|
||||
});
|
||||
await expect(repo.deleteByUserId("missing")).resolves.toEqual({
|
||||
checksDeleted: 0,
|
||||
historyDeleted: 0,
|
||||
});
|
||||
|
||||
expect(await repo.findChecksByUserAndHost("user-1", 1)).toBeNull();
|
||||
expect(await repo.listHistory("user-1", 1, 10)).toEqual([]);
|
||||
expect((await repo.findChecksByUserAndHost("user-2", 2))?.hostId).toBe(2);
|
||||
expect(await repo.listHistory("user-2", 2, 10)).toHaveLength(1);
|
||||
expect(writeCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -148,7 +148,6 @@ describe("HostRepository Proxmox sync inserts", () => {
|
||||
tunnelConnections: "[]",
|
||||
jumpHosts: null,
|
||||
quickActions: null,
|
||||
statsConfig: null,
|
||||
dockerConfig: null,
|
||||
terminalConfig: null,
|
||||
forceKeyboardInteractive: "false",
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import express, { type RequestHandler } from "express";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: {} as Record<string, string>,
|
||||
locked: false,
|
||||
accessible: new Set<number>([1, 2]),
|
||||
statuses: new Map<number, { status: string; lastChecked: string }>(),
|
||||
}));
|
||||
|
||||
const service = vi.hoisted(() => ({
|
||||
statusesFor: vi.fn(),
|
||||
get: vi.fn(),
|
||||
check: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
retimeAll: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock("../../../utils/data-crypto.js", () => ({
|
||||
DataCrypto: { getUserDataKey: () => (state.locked ? null : "key") },
|
||||
}));
|
||||
vi.mock("../../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({
|
||||
filterAccessibleHostIds: async (_user: string, ids: number[]) =>
|
||||
new Set(ids.filter((id) => state.accessible.has(id))),
|
||||
canAccessHost: async (_user: string, id: number) => ({
|
||||
hasAccess: state.accessible.has(id),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings[key] ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings[key] = value;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../hosts/status/host-status-service.js", () => ({
|
||||
DEFAULT_STATUS_INTERVAL: 60,
|
||||
GLOBAL_STATUS_INTERVAL_KEY: "global_status_check_interval",
|
||||
hostStatusService: service,
|
||||
}));
|
||||
|
||||
const { registerHostStatusRoutes, parseStatusHostIds } =
|
||||
await import("../../../database/routes/host-status-routes.js");
|
||||
|
||||
let server: http.Server | null = null;
|
||||
let base = "";
|
||||
|
||||
async function start(isAdmin = true) {
|
||||
const router = express.Router();
|
||||
const authenticateJWT: RequestHandler = (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId = "user-1";
|
||||
next();
|
||||
};
|
||||
const requireAdmin: RequestHandler = (_req, res, next) =>
|
||||
isAdmin ? next() : void res.status(403).json({ error: "Admin only" });
|
||||
registerHostStatusRoutes(router, { authenticateJWT, requireAdmin });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/host", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server!.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
}
|
||||
|
||||
async function call(method: string, path: string, body?: unknown) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: body ? { "content-type": "application/json" } : {},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings = {};
|
||||
state.locked = false;
|
||||
state.accessible = new Set([1, 2]);
|
||||
state.statuses = new Map([
|
||||
[1, { status: "online", lastChecked: "t" }],
|
||||
[2, { status: "offline", lastChecked: "t" }],
|
||||
[3, { status: "reachable", lastChecked: "t" }],
|
||||
]);
|
||||
service.statusesFor.mockImplementation(async () => state.statuses);
|
||||
service.get.mockImplementation(
|
||||
(id: number) => state.statuses.get(id) ?? null,
|
||||
);
|
||||
service.check.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (server)
|
||||
await new Promise<void>((resolve) => server!.close(() => resolve()));
|
||||
server = null;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /host/status", () => {
|
||||
it("only returns hosts the user can reach", async () => {
|
||||
await start();
|
||||
const { status, body } = await call("GET", "/host/status");
|
||||
expect(status).toBe(200);
|
||||
expect(Object.keys(body)).toEqual(["1", "2"]);
|
||||
expect(service.statusesFor).toHaveBeenCalledWith("user-1", null);
|
||||
});
|
||||
|
||||
it("narrows to the requested hosts", async () => {
|
||||
await start();
|
||||
const { body } = await call("GET", "/host/status?hostIds=2");
|
||||
expect(Object.keys(body)).toEqual(["2"]);
|
||||
expect(service.statusesFor).toHaveBeenCalledWith("user-1", new Set([2]));
|
||||
});
|
||||
|
||||
it("answers 401 while the user's data is locked", async () => {
|
||||
await start();
|
||||
state.locked = true;
|
||||
expect((await call("GET", "/host/status")).status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /host/status/:id", () => {
|
||||
it("returns one host's status", async () => {
|
||||
await start();
|
||||
const { status, body } = await call("GET", "/host/status/1");
|
||||
expect(status).toBe(200);
|
||||
expect(body.status).toBe("online");
|
||||
});
|
||||
|
||||
it("hides a host the user cannot reach", async () => {
|
||||
await start();
|
||||
expect((await call("GET", "/host/status/3")).status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("status settings", () => {
|
||||
it("reads the default interval", async () => {
|
||||
await start();
|
||||
expect((await call("GET", "/host/status/settings")).body).toEqual({
|
||||
statusCheckInterval: 60,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves a new interval and re-times the checks", async () => {
|
||||
await start();
|
||||
const { status } = await call("PUT", "/host/status/settings", {
|
||||
statusCheckInterval: 90,
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
expect(state.settings.global_status_check_interval).toBe("90");
|
||||
expect(service.retimeAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects an interval out of range", async () => {
|
||||
await start();
|
||||
expect(
|
||||
(await call("PUT", "/host/status/settings", { statusCheckInterval: 1 }))
|
||||
.status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("is admin only", async () => {
|
||||
await start(false);
|
||||
expect((await call("GET", "/host/status/settings")).status).toBe(403);
|
||||
expect(
|
||||
(await call("PUT", "/host/status/settings", { statusCheckInterval: 90 }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /host/status/refresh", () => {
|
||||
it("restarts the user's checks", async () => {
|
||||
await start();
|
||||
expect((await call("POST", "/host/status/refresh")).status).toBe(200);
|
||||
expect(service.refresh).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseStatusHostIds", () => {
|
||||
it("tells an unrestricted request from an empty host set", () => {
|
||||
expect(parseStatusHostIds(undefined)).toBeNull();
|
||||
expect(parseStatusHostIds("")).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("keeps only valid positive host ids", () => {
|
||||
expect(parseStatusHostIds("7,2,7,-1,nope,1.5")).toEqual(new Set([7, 2]));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { WebSocket } from "ws";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SSHHostKeyVerifier } from "../../hosts/host-key-verifier.js";
|
||||
import { authFailureTracker } from "../../hosts/metrics-shared/state.js";
|
||||
import { pluginEvents, TOPICS } from "../../plugins/events.js";
|
||||
|
||||
const { updateHostKey } = vi.hoisted(() => ({ updateHostKey: vi.fn() }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
@@ -12,10 +12,15 @@ vi.mock("../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
const hostId = 991267;
|
||||
const accepted: unknown[] = [];
|
||||
const unsubscribe = pluginEvents.on(TOPICS.hostKeyUpdated, (payload) =>
|
||||
accepted.push(payload),
|
||||
);
|
||||
afterEach(() => {
|
||||
authFailureTracker.reset(hostId);
|
||||
accepted.length = 0;
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
afterAll(() => unsubscribe());
|
||||
|
||||
async function verifyChangedKey(action: "accept" | "reject") {
|
||||
const socket = new EventEmitter() as EventEmitter & {
|
||||
@@ -52,35 +57,22 @@ async function verifyChangedKey(action: "accept" | "reject") {
|
||||
);
|
||||
}
|
||||
|
||||
describe("metrics recovery after host key verification", () => {
|
||||
it("allows polling after accepting and saving the replacement key", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
describe("host key recovery", () => {
|
||||
it("announces an accepted and saved replacement key", async () => {
|
||||
expect(await verifyChangedKey("accept")).toBe(true);
|
||||
expect(updateHostKey).toHaveBeenCalledOnce();
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(false);
|
||||
expect(accepted).toEqual([{ hostId }]);
|
||||
});
|
||||
|
||||
it("keeps polling blocked when the new key is rejected", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
it("announces nothing when the new key is rejected", async () => {
|
||||
expect(await verifyChangedKey("reject")).toBe(false);
|
||||
expect(updateHostKey).not.toHaveBeenCalled();
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(accepted).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps polling blocked if persisting the accepted key fails", async () => {
|
||||
authFailureTracker.recordFailure(hostId, "HOST_KEY", true);
|
||||
it("announces nothing if persisting the accepted key fails", async () => {
|
||||
updateHostKey.mockRejectedValueOnce(new Error("database unavailable"));
|
||||
expect(await verifyChangedKey("accept")).toBe(false);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
expect(accepted).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["AUTH", "TOTP"] as const)(
|
||||
"preserves unrelated %s failures",
|
||||
async (reason) => {
|
||||
authFailureTracker.recordFailure(hostId, reason, true);
|
||||
expect(await verifyChangedKey("accept")).toBe(true);
|
||||
expect(authFailureTracker.shouldSkip(hostId)).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -122,7 +122,6 @@ function baseHost(overrides: Record<string, unknown> = {}) {
|
||||
autostartKeyPassword: null,
|
||||
jumpHosts: null,
|
||||
tunnelConnections: null,
|
||||
statsConfig: null,
|
||||
terminalConfig: null,
|
||||
socks5ProxyChain: null,
|
||||
quickActions: null,
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentHostResolutionRepository: vi.fn(),
|
||||
getCurrentSettingValue: vi.fn(() => null),
|
||||
}));
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
sshLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
pluginLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const { HostStatusService, toStatusTarget } =
|
||||
await import("../../../hosts/status/host-status-service.js");
|
||||
const { hostSessionStatus } =
|
||||
await import("../../../hosts/host-session-status.js");
|
||||
const { pluginEvents, TOPICS } = await import("../../../plugins/events.js");
|
||||
|
||||
type Target = ReturnType<typeof toStatusTarget>;
|
||||
|
||||
function target(id: number, extra: Partial<Target> = {}): Target {
|
||||
return {
|
||||
id,
|
||||
userId: "owner",
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
connectionType: "ssh",
|
||||
jumpHosts: [],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(targets: Target[], reachable = true) {
|
||||
const emitted: unknown[] = [];
|
||||
const ping = vi.fn(async () => reachable);
|
||||
const pingThroughJumpHosts = vi.fn(async () => reachable);
|
||||
const loadTargets = vi.fn(
|
||||
async (filter: { userId?: string; hostIds?: number[] }) =>
|
||||
targets.filter(
|
||||
(t) =>
|
||||
(!filter.userId || t.userId === filter.userId) &&
|
||||
(!filter.hostIds || filter.hostIds.includes(t.id)),
|
||||
),
|
||||
);
|
||||
const service = new HostStatusService({
|
||||
loadTargets,
|
||||
ping,
|
||||
pingThroughJumpHosts,
|
||||
globalInterval: () => 60,
|
||||
emit: (payload) => emitted.push(payload),
|
||||
});
|
||||
return { service, emitted, ping, pingThroughJumpHosts, loadTargets };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
let active: InstanceType<typeof HostStatusService> | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["setInterval", "clearInterval"] });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
active?.stop();
|
||||
active = null;
|
||||
pluginEvents.clear();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("HostStatusService", () => {
|
||||
it("starts a user's own hosts and reports them reachable", async () => {
|
||||
const { service, emitted, ping } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 22);
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
expect(emitted).toContainEqual({
|
||||
hostId: 1,
|
||||
ownerUserId: "owner",
|
||||
status: "reachable",
|
||||
previous: null,
|
||||
online: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports offline for a host that does not answer", async () => {
|
||||
const { service } = setup([target(1)], false);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("offline");
|
||||
});
|
||||
|
||||
it("never checks a host with status checks off", async () => {
|
||||
const { service, ping } = setup([target(1, { statusCheckEnabled: false })]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).not.toHaveBeenCalled();
|
||||
expect(service.get(1)).toBeNull();
|
||||
});
|
||||
|
||||
it("goes through the jump hosts when a host has them", async () => {
|
||||
const { service, ping, pingThroughJumpHosts } = setup([
|
||||
target(1, { jumpHosts: [{ hostId: 9 }] }),
|
||||
]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).not.toHaveBeenCalled();
|
||||
expect(pingThroughJumpHosts).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("asks a registered plugin for a protocol's port", async () => {
|
||||
const { service, ping } = setup([target(1, { connectionType: "rdp" })]);
|
||||
active = service;
|
||||
service.registerPort("rdp", async () => 3390);
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3390);
|
||||
});
|
||||
|
||||
it("falls back to the host's port when no plugin knows the protocol", async () => {
|
||||
const { service, ping } = setup([
|
||||
target(1, { connectionType: "rdp", port: 3389 }),
|
||||
]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.1", 3389);
|
||||
});
|
||||
|
||||
it("turns reachable into online when a plugin reports a login", async () => {
|
||||
const { service, emitted } = setup([target(1)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
service.reportLogin(1, { ok: true });
|
||||
await flush();
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
expect(emitted.at(-1)).toMatchObject({
|
||||
status: "online",
|
||||
previous: "reachable",
|
||||
});
|
||||
|
||||
service.reportLogin(1, { ok: false, hostKeyChanged: true });
|
||||
expect(service.get(1)).toMatchObject({
|
||||
status: "reachable",
|
||||
reason: "host_key_changed",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the host key reason once the new key is accepted", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
service.reportLogin(1, { ok: false, hostKeyChanged: true });
|
||||
|
||||
pluginEvents.emit(TOPICS.hostKeyUpdated, { hostId: 1 });
|
||||
expect(service.get(1)?.reason).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows a host with an open terminal session as online", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
const release = hostSessionStatus.register(1);
|
||||
expect(service.get(1)?.status).toBe("online");
|
||||
release();
|
||||
expect(service.get(1)?.status).toBe("reachable");
|
||||
});
|
||||
|
||||
it("only checks the requested hosts on the desktop app", async () => {
|
||||
const { service, ping } = setup([target(1), target(2)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", new Set([2]));
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
expect(ping).toHaveBeenCalledWith("10.0.0.2", 22);
|
||||
});
|
||||
|
||||
it("checks again on its interval", async () => {
|
||||
const { service, ping } = setup([target(1, { statusCheckInterval: 10 })]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
|
||||
vi.advanceTimersByTime(15_000);
|
||||
await flush();
|
||||
expect(ping).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("checks a host on demand unless the last result is fresh", async () => {
|
||||
const { service, ping } = setup([target(1)]);
|
||||
active = service;
|
||||
expect(await service.check(1)).toMatchObject({ status: "reachable" });
|
||||
expect(await service.check(1)).toMatchObject({ status: "reachable" });
|
||||
expect(ping).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forgets a deleted host", async () => {
|
||||
const { service } = setup([target(1)]);
|
||||
active = service;
|
||||
service.start();
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
|
||||
pluginEvents.emit(TOPICS.hostDeleted, { hostId: 1 });
|
||||
expect(service.get(1)).toBeNull();
|
||||
});
|
||||
|
||||
it("never logs in to check a host", async () => {
|
||||
// The only transports are the two ping functions: nothing here can
|
||||
// authenticate, so a RADIUS or Duo backed host gets no 2FA push.
|
||||
const { service, ping, pingThroughJumpHosts } = setup([target(1)]);
|
||||
active = service;
|
||||
await service.statusesFor("owner", null);
|
||||
await flush();
|
||||
expect(ping.mock.calls[0]).toEqual(["10.0.0.1", 22]);
|
||||
expect(pingThroughJumpHosts).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toStatusTarget", () => {
|
||||
it("parses the jump hosts and defaults the connection type", () => {
|
||||
expect(
|
||||
toStatusTarget({
|
||||
id: 1,
|
||||
userId: "u",
|
||||
ip: "h",
|
||||
port: 22,
|
||||
connectionType: "",
|
||||
jumpHosts: '[{"hostId":3},{"bad":1}]',
|
||||
statusCheckEnabled: false,
|
||||
statusCheckInterval: 30,
|
||||
}),
|
||||
).toEqual({
|
||||
id: 1,
|
||||
userId: "u",
|
||||
ip: "h",
|
||||
port: 22,
|
||||
connectionType: "ssh",
|
||||
jumpHosts: [{ hostId: 3 }],
|
||||
statusCheckEnabled: false,
|
||||
statusCheckInterval: 30,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostKeyVerificationError,
|
||||
statusAfterAuthentication,
|
||||
statusAfterReachabilityCheck,
|
||||
} from "../../../hosts/status/host-status.js";
|
||||
|
||||
describe("isHostKeyVerificationError", () => {
|
||||
it.each([
|
||||
"Host denied (verification failed)",
|
||||
"Host key changed - please connect via Terminal to verify the new key",
|
||||
])("classifies %s", (message) => {
|
||||
expect(isHostKeyVerificationError(new Error(message))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify ordinary authentication failures", () => {
|
||||
expect(isHostKeyVerificationError(new Error("Permission denied"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("host availability status", () => {
|
||||
it("does not call a TCP-reachable host online before authentication", () => {
|
||||
expect(statusAfterReachabilityCheck(true)).toBe("reachable");
|
||||
});
|
||||
|
||||
it("keeps a verified host online across later reachability checks", () => {
|
||||
expect(statusAfterReachabilityCheck(true, "online")).toBe("online");
|
||||
});
|
||||
|
||||
it("marks successful authentication online", () => {
|
||||
expect(statusAfterAuthentication(true, "reachable")).toBe("online");
|
||||
});
|
||||
|
||||
it("downgrades failed authentication without hiding reachability", () => {
|
||||
expect(statusAfterAuthentication(false, "online")).toBe("reachable");
|
||||
expect(statusAfterAuthentication(false, "offline")).toBe("offline");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ConcurrentLimiter } from "../../../hosts/status/limiter.js";
|
||||
|
||||
describe("ConcurrentLimiter", () => {
|
||||
it("never exceeds max concurrent runners", async () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
let peak = 0;
|
||||
let current = 0;
|
||||
|
||||
const job = async () => {
|
||||
current += 1;
|
||||
peak = Math.max(peak, current);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
current -= 1;
|
||||
};
|
||||
|
||||
await Promise.all([
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
limiter.run(job),
|
||||
]);
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(2);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("runs waiters in FIFO order after a slot frees", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
const order: number[] = [];
|
||||
|
||||
const first = limiter.run(async () => {
|
||||
order.push(1);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
});
|
||||
const second = limiter.run(async () => {
|
||||
order.push(2);
|
||||
});
|
||||
const third = limiter.run(async () => {
|
||||
order.push(3);
|
||||
});
|
||||
|
||||
await Promise.all([first, second, third]);
|
||||
expect(order).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("rejects invalid maxConcurrent", () => {
|
||||
expect(() => new ConcurrentLimiter(0)).toThrow(/maxConcurrent/);
|
||||
});
|
||||
|
||||
describe("setLimit", () => {
|
||||
it("releases queued waiters as soon as the ceiling is raised", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(1);
|
||||
expect(limiter.pendingCount).toBe(3);
|
||||
|
||||
// Widening must drain the backlog without waiting for the running job.
|
||||
limiter.setLimit(4);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
|
||||
release.forEach((fn) => fn());
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
});
|
||||
|
||||
it("does not over-release beyond the new ceiling", async () => {
|
||||
const limiter = new ConcurrentLimiter(1);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
// Later waves of woken jobs enqueue their own resolvers, so draining has
|
||||
// to keep going until nothing is left rather than flushing a snapshot.
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
limiter.setLimit(3);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(3);
|
||||
expect(limiter.pendingCount).toBe(2);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
});
|
||||
|
||||
it("lets running work finish when the ceiling shrinks", async () => {
|
||||
const limiter = new ConcurrentLimiter(4);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
const release: Array<() => void> = [];
|
||||
const drain = async () => {
|
||||
while (release.length > 0) {
|
||||
release.splice(0).forEach((fn) => fn());
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
};
|
||||
|
||||
const job = () =>
|
||||
limiter.run(async () => {
|
||||
running += 1;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise<void>((r) => release.push(r));
|
||||
running -= 1;
|
||||
});
|
||||
|
||||
const jobs = [job(), job(), job(), job(), job(), job()];
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(peak).toBe(4);
|
||||
|
||||
// Shrinking never kills in-flight work; it applies to later releases.
|
||||
limiter.setLimit(2);
|
||||
expect(limiter.activeCount).toBe(4);
|
||||
|
||||
await drain();
|
||||
await Promise.all(jobs);
|
||||
expect(limiter.activeCount).toBe(0);
|
||||
expect(limiter.pendingCount).toBe(0);
|
||||
// The two queued jobs ran only after the shrink, so they never pushed
|
||||
// occupancy back up to the old width.
|
||||
expect(peak).toBe(4);
|
||||
});
|
||||
|
||||
it("rejects an invalid new limit", () => {
|
||||
const limiter = new ConcurrentLimiter(2);
|
||||
expect(() => limiter.setLimit(0)).toThrow(/maxConcurrent/);
|
||||
expect(limiter.limit).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { tcpPingThroughJumpHost } from "../../../hosts/status/tcp-ping.js";
|
||||
|
||||
describe("tcpPingThroughJumpHost", () => {
|
||||
it("reports the final destination online when forwarding succeeds", async () => {
|
||||
const stream = { destroy: vi.fn() };
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, host, port, callback) => {
|
||||
expect(host).toBe("private.example");
|
||||
expect(port).toBe(22);
|
||||
callback(undefined, stream);
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(true);
|
||||
expect(stream.destroy).toHaveBeenCalledOnce();
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding fails", async () => {
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn((_src, _srcPort, _host, _port, callback) => {
|
||||
callback(new Error("Connection refused"));
|
||||
}),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
await expect(
|
||||
tcpPingThroughJumpHost(jumpClient, "private.example", 22),
|
||||
).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports the final destination offline when forwarding times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
const jumpClient = {
|
||||
end: vi.fn(),
|
||||
forwardOut: vi.fn(),
|
||||
} as unknown as Pick<Client, "forwardOut" | "end">;
|
||||
|
||||
const result = tcpPingThroughJumpHost(
|
||||
jumpClient,
|
||||
"private.example",
|
||||
22,
|
||||
5000,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
await expect(result).resolves.toBe(false);
|
||||
expect(jumpClient.end).toHaveBeenCalledOnce();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,9 @@ const h = vi.hoisted(() => ({
|
||||
sessions: [] as number[],
|
||||
released: [] as number[],
|
||||
importRows: [] as Array<{ hostId: number; row: Record<string, unknown> }>,
|
||||
statusReports: [] as Array<Record<string, unknown>>,
|
||||
statusPorts: [] as string[],
|
||||
statusPortsRemoved: [] as string[],
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -111,6 +114,22 @@ vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
get: (hostId: number) =>
|
||||
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
|
||||
check: async (hostId: number) => ({
|
||||
status: "offline",
|
||||
lastChecked: String(hostId),
|
||||
}),
|
||||
reportLogin: (hostId: number, outcome: Record<string, unknown>) =>
|
||||
h.statusReports.push({ hostId, ...outcome }),
|
||||
registerPort: (connectionType: string) => {
|
||||
h.statusPorts.push(connectionType);
|
||||
return () => h.statusPortsRemoved.push(connectionType);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: async (
|
||||
hostId: number,
|
||||
@@ -419,3 +438,69 @@ describe("ctx.hosts session tracking and activity", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.status", () => {
|
||||
it("reads and checks core's status with hosts:read", async () => {
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(hosts.status.get(4)).toEqual({
|
||||
status: "reachable",
|
||||
lastChecked: "t",
|
||||
});
|
||||
expect(hosts.status.get(5)).toBeNull();
|
||||
await expect(hosts.status.check(5)).resolves.toEqual({
|
||||
status: "offline",
|
||||
lastChecked: "5",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses every status member without hosts:read", async () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest([]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() => hosts.status.get(4)).toThrow(PluginCapabilityError);
|
||||
expect(() => hosts.status.reportLogin(4, { ok: true })).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(() => hosts.status.registerPort("rdp", () => 3389)).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
await expect(hosts.status.check(4)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes login reports on to core", () => {
|
||||
h.statusReports = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
|
||||
expect(h.statusReports).toEqual([
|
||||
{ hostId: 4, ok: false, hostKeyChanged: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes a registered port when the plugin is disposed", async () => {
|
||||
h.statusPorts = [];
|
||||
h.statusPortsRemoved = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const bag = new DisposableBag("fixture");
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.registerPort("rdp", () => 3389);
|
||||
expect(h.statusPorts).toEqual(["rdp"]);
|
||||
await bag.disposeAll();
|
||||
expect(h.statusPortsRemoved).toEqual(["rdp"]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,9 @@ const h = vi.hoisted(() => ({
|
||||
connects: [] as Array<{ target: unknown; options: Record<string, unknown> }>,
|
||||
factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>,
|
||||
actor: undefined as string | undefined,
|
||||
cleared: [] as string[],
|
||||
clearedAll: 0,
|
||||
pooled: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
@@ -27,7 +30,33 @@ vi.mock("../../plugins/permissions.js", async () => {
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
key: string,
|
||||
factory: () => Promise<unknown>,
|
||||
fn: (client: unknown) => Promise<unknown>,
|
||||
) => {
|
||||
h.pooled.push(key);
|
||||
return fn(await factory());
|
||||
},
|
||||
connectionPool: {
|
||||
clearKeyConnections: (key: string) => h.cleared.push(key),
|
||||
clearAllConnections: () => {
|
||||
h.clearedAll += 1;
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
resolveConnectHost: async (target: unknown) =>
|
||||
typeof target === "number"
|
||||
? {
|
||||
id: target,
|
||||
userId: "user-1",
|
||||
ip: `10.0.0.${target}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
}
|
||||
: target,
|
||||
connectHost: async (target: unknown, options: Record<string, unknown>) => {
|
||||
h.connects.push({ target, options });
|
||||
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
|
||||
@@ -71,6 +100,9 @@ beforeEach(() => {
|
||||
h.connects = [];
|
||||
h.factors = [];
|
||||
h.actor = "user-1";
|
||||
h.cleared = [];
|
||||
h.clearedAll = 0;
|
||||
h.pooled = [];
|
||||
});
|
||||
|
||||
describe("ctx.ssh", () => {
|
||||
@@ -170,6 +202,62 @@ describe("ctx.ssh", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh pooled connections", () => {
|
||||
const both = ["ssh:connect", "credentials:use"];
|
||||
|
||||
function plugin(id: string) {
|
||||
const bag = new DisposableBag(id);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: { ...(manifest(both) as object), id } as never,
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
return { bag, ssh };
|
||||
}
|
||||
|
||||
it("disabling one plugin leaves ctx.ssh and the pool working for others", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
const other = plugin("fleets");
|
||||
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => "a");
|
||||
await other.ssh.withConnection(8, { pool: "fleet" }, async () => "b");
|
||||
await metrics.bag.disposeAll();
|
||||
|
||||
expect(h.clearedAll).toBe(0);
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
|
||||
await expect(
|
||||
other.ssh.withConnection(8, { pool: "fleet" }, async () => "still"),
|
||||
).resolves.toBe("still");
|
||||
});
|
||||
|
||||
it("dropPooled clears only this plugin's keys for that host", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
|
||||
await metrics.ssh.withConnection(8, { pool: "stats" }, async () => 1);
|
||||
|
||||
metrics.ssh.dropPooled("stats", 7);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
|
||||
|
||||
// Dropped keys are forgotten, so deactivate only clears what is left.
|
||||
h.cleared = [];
|
||||
await metrics.bag.disposeAll();
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.8:22:root"]);
|
||||
});
|
||||
|
||||
it("dropPooled ignores another pool and an unknown host", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
|
||||
metrics.ssh.dropPooled("other", 7);
|
||||
metrics.ssh.dropPooled("stats", 99);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth", () => {
|
||||
it("needs auth:provide and a contributes.auth entry to register anything", () => {
|
||||
const auth = createPluginAuth({
|
||||
|
||||
@@ -96,9 +96,8 @@ describe("plugin event bus", () => {
|
||||
it("exposes the internal topic names the server publishes", () => {
|
||||
expect(TOPICS.hostSessionStatus).toBe("host.session.status");
|
||||
expect(TOPICS.internalEvent).toBe("internal.event");
|
||||
expect(TOPICS.hostMetrics).toBe("host.metrics");
|
||||
expect(TOPICS.hostStatus).toBe("host.status");
|
||||
expect(TOPICS.hostHealthCheck).toBe("host.health_check");
|
||||
expect(TOPICS.hostKeyUpdated).toBe("host.key.updated");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,30 +134,6 @@ describe("hosts publishers route through the bus", () => {
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
|
||||
const { notifyAutomationStatus, notifyAutomationMetrics } =
|
||||
await import("../../../../plugins/host-metrics/src/backend/automation-bridge.js");
|
||||
|
||||
const status = vi.fn();
|
||||
const metrics = vi.fn();
|
||||
pluginEvents.on(TOPICS.hostStatus, status);
|
||||
pluginEvents.on(TOPICS.hostMetrics, metrics);
|
||||
|
||||
notifyAutomationStatus(42, "user-1", true);
|
||||
notifyAutomationMetrics(42, "user-1", { cpu: 10 } as never);
|
||||
|
||||
expect(status).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
ownerUserId: "user-1",
|
||||
online: true,
|
||||
});
|
||||
expect(metrics).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
ownerUserId: "user-1",
|
||||
metrics: { cpu: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hostSessionStatus publishes host.session.status on the refcount edges", async () => {
|
||||
const { HostSessionStatus } =
|
||||
await import("../../hosts/host-session-status.js");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log };
|
||||
});
|
||||
|
||||
const { createPluginSchedule } = await import("../../plugins/schedule.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ctx.schedule", () => {
|
||||
it("repeats a job and stops it", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const job = vi.fn();
|
||||
|
||||
const stop = schedule.every(1000, job);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(job).toHaveBeenCalledTimes(3);
|
||||
|
||||
stop();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(job).toHaveBeenCalledTimes(3);
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
|
||||
it("runs straight away when asked", async () => {
|
||||
const schedule = createPluginSchedule(
|
||||
new DisposableBag("fixture"),
|
||||
vi.fn(),
|
||||
);
|
||||
const job = vi.fn();
|
||||
schedule.every(1000, job, { runNow: true });
|
||||
expect(job).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never overlaps a slow run with the next tick", async () => {
|
||||
const schedule = createPluginSchedule(
|
||||
new DisposableBag("fixture"),
|
||||
vi.fn(),
|
||||
);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
schedule.every(100, async () => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
running--;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
|
||||
it("logs a failing job instead of throwing", async () => {
|
||||
const log = vi.fn();
|
||||
const schedule = createPluginSchedule(new DisposableBag("fixture"), log);
|
||||
schedule.every(100, () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(log).toHaveBeenCalledWith("Scheduled job failed", expect.any(Error));
|
||||
});
|
||||
|
||||
it("clears every timer when the plugin is disposed", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const repeating = vi.fn();
|
||||
const once = vi.fn();
|
||||
schedule.every(100, repeating, { jitterMs: 50 });
|
||||
schedule.after(500, once);
|
||||
|
||||
await bag.disposeAll();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(repeating).not.toHaveBeenCalled();
|
||||
expect(once).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs a one-off job once and can cancel it", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const once = vi.fn();
|
||||
schedule.after(100, once);
|
||||
const cancelled = vi.fn();
|
||||
const cancel = schedule.after(100, cancelled);
|
||||
cancel();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(once).toHaveBeenCalledOnce();
|
||||
expect(cancelled).not.toHaveBeenCalled();
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Host Metrics' settings leaving core: the two admin keys, the new-host
|
||||
* default inside host_defaults, and the metrics half of every host's
|
||||
* stats_config. Nothing may be lost, and a second run must change nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
coreSettings: new Map<string, string>(),
|
||||
pluginRows: [] as Array<{
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
pluginInstalled: true,
|
||||
db: null as unknown,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "host-metrics" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
state.pluginRows.filter(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else state.pluginRows.push({ scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
|
||||
await import("../../utils/crypto-migration/host-metrics-settings-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = find(scope, scopeId, key) as Row | undefined;
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.coreSettings.clear();
|
||||
state.pluginRows = [];
|
||||
state.pluginInstalled = true;
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, stats_config TEXT)",
|
||||
);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
describe("hostSettingsFromStatsConfig", () => {
|
||||
it("keeps only what differs from the defaults", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(
|
||||
JSON.stringify({
|
||||
statusCheckEnabled: false,
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
useGlobalMetricsInterval: false,
|
||||
enabledWidgets: ["cpu", "disk"],
|
||||
excludedMounts: ["/snap"],
|
||||
monitoredMounts: [{ path: "/data", label: "Data" }, { bad: 1 }],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
enabledWidgets: ["cpu", "disk"],
|
||||
excludedMounts: ["/snap"],
|
||||
monitoredMounts: [{ path: "/data", label: "Data" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the host's interval while it followed the global one", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(JSON.stringify({ metricsInterval: 45 })),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("reads a value that was stringified twice", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(
|
||||
JSON.stringify(JSON.stringify({ metricsEnabled: false })),
|
||||
),
|
||||
).toEqual({ metricsEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHostMetricsSettingsMigration", () => {
|
||||
it("moves the admin keys, the new-host default and every host", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
state.coreSettings.set("metrics_history_retention_days", "30");
|
||||
state.coreSettings.set(
|
||||
"host_defaults",
|
||||
JSON.stringify({ metricsEnabled: false, statusCheckEnabled: true }),
|
||||
);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
|
||||
);
|
||||
insert.run(1, JSON.stringify({ metricsEnabled: false }));
|
||||
insert.run(2, JSON.stringify({ enabledWidgets: ["cpu"] }));
|
||||
insert.run(3, JSON.stringify({ statusCheckEnabled: false }));
|
||||
insert.run(4, null);
|
||||
|
||||
const result = await runHostMetricsSettingsMigration();
|
||||
|
||||
expect(result.moved.sort()).toEqual([
|
||||
"global_metrics_interval",
|
||||
"host_defaults.metricsEnabled",
|
||||
"metrics_history_retention_days",
|
||||
]);
|
||||
expect(value("admin", null, "metricsInterval")).toBe(20);
|
||||
expect(value("admin", null, "historyRetentionDays")).toBe(30);
|
||||
expect(value("admin", null, "enabledForNewHosts")).toBe(false);
|
||||
expect(value("host", "1", "metricsEnabled")).toBe(false);
|
||||
expect(value("host", "2", "enabledWidgets")).toEqual(["cpu"]);
|
||||
// Only the status half, which is core's: nothing for the plugin.
|
||||
expect(state.pluginRows.filter((row) => row.scopeId === "3")).toEqual([]);
|
||||
expect(result.hostsMoved).toBe(2);
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
|
||||
.run(1, JSON.stringify({ metricsEnabled: false }));
|
||||
await runHostMetricsSettingsMigration();
|
||||
|
||||
// Changed through the plugin after the upgrade.
|
||||
const interval = find("admin", null, "metricsInterval")!;
|
||||
interval.value = "90";
|
||||
const enabled = find("host", "1", "metricsEnabled")!;
|
||||
enabled.value = "true";
|
||||
|
||||
const second = await runHostMetricsSettingsMigration();
|
||||
expect(second).toEqual({ moved: [], hostsMoved: 0, hostsSkipped: 1 });
|
||||
expect(value("admin", null, "metricsInterval")).toBe(90);
|
||||
expect(value("host", "1", "metricsEnabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("waits until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
expect(await runHostMetricsSettingsMigration()).toEqual({
|
||||
moved: [],
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
});
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips values out of range", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "1");
|
||||
state.coreSettings.set("metrics_history_retention_days", "900");
|
||||
await runHostMetricsSettingsMigration();
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The status half of ssh_data.stats_config moving into the core
|
||||
* status_check_enabled and status_check_interval columns. Run against a real
|
||||
* SQLite database through the same raw SQL helpers the boot path uses.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: new Map<string, string>(),
|
||||
db: null as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const {
|
||||
runHostStatusConfigMigration,
|
||||
statusConfigFromStatsConfig,
|
||||
HOST_STATUS_CONFIG_MIGRATED,
|
||||
} =
|
||||
await import("../../utils/crypto-migration/host-status-config-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
function createHosts(withStatsConfig = true) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
${withStatsConfig ? "stats_config TEXT," : ""}
|
||||
status_check_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status_check_interval INTEGER
|
||||
);
|
||||
`);
|
||||
state.db = drizzle(sqlite);
|
||||
}
|
||||
|
||||
const hosts = () =>
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT id, status_check_enabled AS enabled, status_check_interval AS interval FROM ssh_data ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings.clear();
|
||||
createHosts();
|
||||
});
|
||||
|
||||
describe("statusConfigFromStatsConfig", () => {
|
||||
it.each([
|
||||
[null, true, null],
|
||||
["{}", true, null],
|
||||
['{"statusCheckEnabled":false}', false, null],
|
||||
['{"disableTcpPing":true}', false, null],
|
||||
['{"useGlobalStatusInterval":false,"statusCheckInterval":45}', true, 45],
|
||||
['{"useGlobalStatusInterval":true,"statusCheckInterval":45}', true, null],
|
||||
['{"useGlobalStatusInterval":false,"statusCheckInterval":2}', true, null],
|
||||
[
|
||||
JSON.stringify(JSON.stringify({ statusCheckEnabled: false })),
|
||||
false,
|
||||
null,
|
||||
],
|
||||
["not json", true, null],
|
||||
])("%s", (raw, enabled, interval) => {
|
||||
expect(statusConfigFromStatsConfig(raw)).toEqual({
|
||||
statusCheckEnabled: enabled,
|
||||
statusCheckInterval: interval,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHostStatusConfigMigration", () => {
|
||||
it("copies each host's status options into the new columns", async () => {
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
|
||||
);
|
||||
insert.run(1, JSON.stringify({ statusCheckEnabled: false }));
|
||||
insert.run(
|
||||
2,
|
||||
JSON.stringify({
|
||||
useGlobalStatusInterval: false,
|
||||
statusCheckInterval: 45,
|
||||
}),
|
||||
);
|
||||
insert.run(
|
||||
3,
|
||||
JSON.stringify({ disableTcpPing: true, metricsEnabled: false }),
|
||||
);
|
||||
insert.run(4, JSON.stringify({ metricsInterval: 10 }));
|
||||
insert.run(5, null);
|
||||
|
||||
const result = await runHostStatusConfigMigration();
|
||||
|
||||
expect(result).toEqual({ skipped: false, hostsMoved: 3 });
|
||||
expect(hosts()).toEqual([
|
||||
{ id: 1, enabled: 0, interval: null },
|
||||
{ id: 2, enabled: 1, interval: 45 },
|
||||
{ id: 3, enabled: 0, interval: null },
|
||||
{ id: 4, enabled: 1, interval: null },
|
||||
{ id: 5, enabled: 1, interval: null },
|
||||
]);
|
||||
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
|
||||
.run(1, JSON.stringify({ statusCheckEnabled: false }));
|
||||
await runHostStatusConfigMigration();
|
||||
|
||||
// The user turns checks back on after the upgrade.
|
||||
sqlite.exec("UPDATE ssh_data SET status_check_enabled = 1 WHERE id = 1");
|
||||
expect(await runHostStatusConfigMigration()).toEqual({
|
||||
skipped: true,
|
||||
hostsMoved: 0,
|
||||
});
|
||||
expect(hosts()).toEqual([{ id: 1, enabled: 1, interval: null }]);
|
||||
});
|
||||
|
||||
it("finishes on a fresh install that never had stats_config", async () => {
|
||||
createHosts(false);
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
|
||||
expect(await runHostStatusConfigMigration()).toEqual({
|
||||
skipped: false,
|
||||
hostsMoved: 0,
|
||||
});
|
||||
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Moves Host Metrics' settings out of core and into the host-metrics
|
||||
* plugin's own settings.
|
||||
*
|
||||
* Admin keys from `settings`: global_metrics_interval,
|
||||
* metrics_history_retention_days and metricsEnabled inside host_defaults (the
|
||||
* default for new hosts). Per host, the metrics half of
|
||||
* ssh_data.stats_config: metricsEnabled, the host's own interval, the enabled
|
||||
* widgets and the mount lists. The status half is core and moved by
|
||||
* host-status-config-migration.ts.
|
||||
*
|
||||
* The old settings rows and the column stay in place, unused.
|
||||
*
|
||||
* Idempotent: an admin key already set, or a host that already has any
|
||||
* host-metrics row, is skipped, so this is safe on every boot.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import {
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentSettingsRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { selectRows } from "./raw-rows.js";
|
||||
|
||||
const PLUGIN_ID = "host-metrics";
|
||||
|
||||
/** A JSON object stored as text, or {} when it is anything else. */
|
||||
function parseStatsConfig(raw: string | null): Record<string, unknown> {
|
||||
try {
|
||||
let parsed: unknown = raw ? JSON.parse(raw) : null;
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function seconds(value: unknown, min: number, max: number): number | null {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= min && parsed <= max
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The plugin's host settings for one stats_config value, defaults left out. */
|
||||
export function hostSettingsFromStatsConfig(
|
||||
raw: string | null,
|
||||
): Record<string, unknown> {
|
||||
const config = parseStatsConfig(raw);
|
||||
const values: Record<string, unknown> = {};
|
||||
if (config.metricsEnabled === false) values.metricsEnabled = false;
|
||||
if (config.useGlobalMetricsInterval === false) {
|
||||
const interval = seconds(config.metricsInterval, 5, 3600);
|
||||
if (interval !== null) values.metricsInterval = interval;
|
||||
}
|
||||
if (Array.isArray(config.enabledWidgets)) {
|
||||
values.enabledWidgets = config.enabledWidgets.filter(
|
||||
(widget): widget is string => typeof widget === "string",
|
||||
);
|
||||
}
|
||||
if (Array.isArray(config.excludedMounts) && config.excludedMounts.length) {
|
||||
values.excludedMounts = config.excludedMounts.filter(
|
||||
(mount): mount is string => typeof mount === "string",
|
||||
);
|
||||
}
|
||||
if (Array.isArray(config.monitoredMounts) && config.monitoredMounts.length) {
|
||||
values.monitoredMounts = config.monitoredMounts.filter(
|
||||
(mount) =>
|
||||
!!mount &&
|
||||
typeof mount === "object" &&
|
||||
typeof (mount as { path?: unknown }).path === "string",
|
||||
);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export interface HostMetricsSettingsMigrationResult {
|
||||
moved: string[];
|
||||
hostsMoved: number;
|
||||
hostsSkipped: number;
|
||||
}
|
||||
|
||||
export async function runHostMetricsSettingsMigration(): Promise<HostMetricsSettingsMigrationResult> {
|
||||
const result: HostMetricsSettingsMigrationResult = {
|
||||
moved: [],
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
};
|
||||
|
||||
// plugin_settings has a foreign key to plugins: nothing to migrate into
|
||||
// until the plugin row exists.
|
||||
try {
|
||||
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
|
||||
if (!plugin) return result;
|
||||
} catch {
|
||||
return result;
|
||||
}
|
||||
|
||||
const pluginSettings = createCurrentPluginSettingsRepository();
|
||||
const write = (
|
||||
scope: "admin" | "host",
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) =>
|
||||
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
|
||||
|
||||
try {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const moves: [string, string, number, number][] = [
|
||||
["global_metrics_interval", "metricsInterval", 5, 3600],
|
||||
["metrics_history_retention_days", "historyRetentionDays", 1, 90],
|
||||
];
|
||||
for (const [legacyKey, field, min, max] of moves) {
|
||||
const value = seconds(await settings.get(legacyKey), min, max);
|
||||
if (value === null) continue;
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
field,
|
||||
);
|
||||
if (existing && existing.value !== null) continue;
|
||||
await write("admin", null, field, value);
|
||||
result.moved.push(legacyKey);
|
||||
}
|
||||
|
||||
const defaults = parseStatsConfig(await settings.get("host_defaults"));
|
||||
if (defaults.metricsEnabled === false) {
|
||||
const existing = await pluginSettings.get(
|
||||
PLUGIN_ID,
|
||||
"admin",
|
||||
null,
|
||||
"enabledForNewHosts",
|
||||
);
|
||||
if (!existing || existing.value === null) {
|
||||
await write("admin", null, "enabledForNewHosts", false);
|
||||
result.moved.push("host_defaults.metricsEnabled");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Host Metrics admin settings migration failed", {
|
||||
operation: "host_metrics_settings_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares stats_config.
|
||||
const rows = await selectRows<{ id: number; stats_config: string | null }>(
|
||||
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
const values = hostSettingsFromStatsConfig(row.stats_config);
|
||||
if (Object.keys(values).length === 0) continue;
|
||||
const scopeId = String(row.id);
|
||||
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
|
||||
if (existing.length > 0) {
|
||||
result.hostsSkipped++;
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
await write("host", scopeId, key, value);
|
||||
}
|
||||
result.hostsMoved++;
|
||||
}
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
}
|
||||
|
||||
if (result.moved.length > 0 || result.hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved ${result.moved.length} Host Metrics setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
|
||||
{ operation: "host_metrics_settings_migration" },
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copies the status check options out of ssh_data.stats_config into the
|
||||
* status_check_enabled and status_check_interval columns. Status checks are
|
||||
* core; the rest of stats_config belongs to the host-metrics plugin and is
|
||||
* moved by host-metrics-settings-migration.ts.
|
||||
*
|
||||
* The stats_config column stays in the database, unused: core's drizzle
|
||||
* migrations run before this does, so dropping it there would lose the data.
|
||||
*
|
||||
* Runs once, marked by a settings row, because the new columns have defaults
|
||||
* and there is no other way to tell a copied row from an untouched one.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
|
||||
import { runStatement, selectRows } from "./raw-rows.js";
|
||||
|
||||
export const HOST_STATUS_CONFIG_MIGRATED = "host_status_config_migrated";
|
||||
|
||||
export interface LegacyStatusConfig {
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number | null;
|
||||
}
|
||||
|
||||
/** The status part of an old stats_config value. */
|
||||
export function statusConfigFromStatsConfig(
|
||||
raw: string | null,
|
||||
): LegacyStatusConfig {
|
||||
let parsed: unknown = null;
|
||||
try {
|
||||
parsed = raw ? JSON.parse(raw) : null;
|
||||
// Some rows were stringified twice.
|
||||
if (typeof parsed === "string") parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
const config =
|
||||
parsed && typeof parsed === "object"
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const seconds = Number(config.statusCheckInterval);
|
||||
return {
|
||||
statusCheckEnabled:
|
||||
config.statusCheckEnabled !== false && config.disableTcpPing !== true,
|
||||
statusCheckInterval:
|
||||
config.useGlobalStatusInterval === false &&
|
||||
Number.isInteger(seconds) &&
|
||||
seconds >= 5
|
||||
? seconds
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface HostStatusConfigMigrationResult {
|
||||
skipped: boolean;
|
||||
hostsMoved: number;
|
||||
}
|
||||
|
||||
export async function runHostStatusConfigMigration(): Promise<HostStatusConfigMigrationResult> {
|
||||
const settings = createCurrentSettingsRepository();
|
||||
try {
|
||||
if ((await settings.get(HOST_STATUS_CONFIG_MIGRATED)) === "true") {
|
||||
return { skipped: true, hostsMoved: 0 };
|
||||
}
|
||||
} catch {
|
||||
return { skipped: true, hostsMoved: 0 };
|
||||
}
|
||||
|
||||
let hostsMoved = 0;
|
||||
try {
|
||||
// Raw SQL: schema.ts no longer declares stats_config.
|
||||
const rows = await selectRows<{ id: number; stats_config: string | null }>(
|
||||
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
const config = statusConfigFromStatsConfig(row.stats_config);
|
||||
if (config.statusCheckEnabled && config.statusCheckInterval === null) {
|
||||
continue;
|
||||
}
|
||||
// TRUE and FALSE as literals: SQLite cannot bind a JS boolean.
|
||||
await runStatement(sql`
|
||||
UPDATE ssh_data
|
||||
SET status_check_enabled = ${sql.raw(config.statusCheckEnabled ? "TRUE" : "FALSE")},
|
||||
status_check_interval = ${config.statusCheckInterval}
|
||||
WHERE id = ${row.id}
|
||||
`);
|
||||
hostsMoved++;
|
||||
}
|
||||
} catch {
|
||||
// A fresh install never had the column.
|
||||
}
|
||||
|
||||
try {
|
||||
await settings.set(HOST_STATUS_CONFIG_MIGRATED, "true");
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not mark the host status migration done", {
|
||||
operation: "host_status_config_migration",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (hostsMoved > 0) {
|
||||
databaseLogger.info(
|
||||
`Moved status check options for ${hostsMoved} host(s) out of stats_config`,
|
||||
{ operation: "host_status_config_migration" },
|
||||
);
|
||||
}
|
||||
return { skipped: false, hostsMoved };
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { WidgetType } from "./stats-widgets.js";
|
||||
|
||||
/**
|
||||
* Host Metrics layout model. Shared by the frontend tab and the backend
|
||||
* preferences endpoint. Metric card ids reuse the existing `WidgetType` values
|
||||
* verbatim (so `enabledWidgets` stays valid for the mobile app); manager card
|
||||
* ids are new and never enter `statsConfig.enabledWidgets`.
|
||||
*/
|
||||
|
||||
export type HostMetricCardId = WidgetType;
|
||||
|
||||
export type HostMetricManagerId =
|
||||
| "service_manager"
|
||||
| "process_inspector"
|
||||
| "log_viewer"
|
||||
| "cron_manager"
|
||||
| "package_manager"
|
||||
| "ssl_manager"
|
||||
| "firewall_manager"
|
||||
| "user_manager"
|
||||
| "health_check"
|
||||
| "disk_breakdown"
|
||||
| "systemd_timers"
|
||||
| "top_memory"
|
||||
| "wireguard_manager"
|
||||
| "tailscale_manager";
|
||||
|
||||
export type HostMetricsCardId = HostMetricCardId | HostMetricManagerId;
|
||||
|
||||
export type HostMetricsColSpan = 1 | 2 | 3;
|
||||
|
||||
export interface HostMetricsSlot {
|
||||
id: HostMetricsCardId;
|
||||
order: number;
|
||||
colSpan: HostMetricsColSpan;
|
||||
height: number | null;
|
||||
}
|
||||
|
||||
export interface HostMetricsLayout {
|
||||
slots: HostMetricsSlot[];
|
||||
columns: number;
|
||||
}
|
||||
|
||||
/** Canonical order of metric cards (also the canonical `enabledWidgets` order). */
|
||||
export const METRIC_CARD_IDS: HostMetricCardId[] = [
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"network",
|
||||
"uptime",
|
||||
"system",
|
||||
"login_stats",
|
||||
"processes",
|
||||
"ports",
|
||||
"firewall",
|
||||
"temperature",
|
||||
"gpu",
|
||||
];
|
||||
|
||||
export const MANAGER_CARD_IDS: HostMetricManagerId[] = [
|
||||
"service_manager",
|
||||
"process_inspector",
|
||||
"log_viewer",
|
||||
"cron_manager",
|
||||
"package_manager",
|
||||
"ssl_manager",
|
||||
"firewall_manager",
|
||||
"user_manager",
|
||||
"health_check",
|
||||
"disk_breakdown",
|
||||
"systemd_timers",
|
||||
"top_memory",
|
||||
"wireguard_manager",
|
||||
"tailscale_manager",
|
||||
];
|
||||
|
||||
export function isMetricCardId(id: string): id is HostMetricCardId {
|
||||
return (METRIC_CARD_IDS as string[]).includes(id);
|
||||
}
|
||||
|
||||
export function isManagerCardId(id: string): id is HostMetricManagerId {
|
||||
return (MANAGER_CARD_IDS as string[]).includes(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the flat `enabledWidgets` array (canonical order, deduped, metric ids
|
||||
* only) from a set of layout slots. This is the single source of truth keeping
|
||||
* `statsConfig.enabledWidgets` valid for the mobile app.
|
||||
*/
|
||||
export function deriveEnabledWidgets(
|
||||
slots: Array<{ id: string }>,
|
||||
): WidgetType[] {
|
||||
const present = new Set(slots.map((s) => s.id));
|
||||
return METRIC_CARD_IDS.filter((id) => present.has(id));
|
||||
}
|
||||
|
||||
const DEFAULT_COLSPAN: Partial<Record<HostMetricsCardId, HostMetricsColSpan>> =
|
||||
{
|
||||
cpu: 1,
|
||||
memory: 1,
|
||||
disk: 1,
|
||||
uptime: 1,
|
||||
system: 1,
|
||||
network: 1,
|
||||
temperature: 1,
|
||||
processes: 2,
|
||||
ports: 2,
|
||||
firewall: 2,
|
||||
login_stats: 2,
|
||||
gpu: 2,
|
||||
};
|
||||
|
||||
// All cards default to content/auto height (null); the masonry packs them
|
||||
// tetris-style and scrollable cards cap their own body height. Users can still
|
||||
// pin an explicit height by dragging a card's resize handle.
|
||||
const DEFAULT_HEIGHT: Partial<Record<HostMetricsCardId, number | null>> = {};
|
||||
|
||||
/**
|
||||
* Build a default layout from a list of enabled metric-card ids (e.g. the host's
|
||||
* current `statsConfig.enabledWidgets`), preserving canonical order. Manager
|
||||
* cards are not added by default.
|
||||
*/
|
||||
export function defaultLayoutFromWidgets(
|
||||
enabledWidgets: string[],
|
||||
columns = 3,
|
||||
): HostMetricsLayout {
|
||||
const enabled = new Set(enabledWidgets);
|
||||
const ids = METRIC_CARD_IDS.filter((id) => enabled.has(id));
|
||||
const slots: HostMetricsSlot[] = ids.map((id, i) => ({
|
||||
id,
|
||||
order: i,
|
||||
colSpan: DEFAULT_COLSPAN[id] ?? 1,
|
||||
height: DEFAULT_HEIGHT[id] ?? null,
|
||||
}));
|
||||
return { slots, columns };
|
||||
}
|
||||
|
||||
export function defaultColSpanFor(id: HostMetricsCardId): HostMetricsColSpan {
|
||||
return DEFAULT_COLSPAN[id] ?? (isManagerCardId(id) ? 2 : 1);
|
||||
}
|
||||
|
||||
export function defaultHeightFor(id: HostMetricsCardId): number | null {
|
||||
if (id in DEFAULT_HEIGHT) return DEFAULT_HEIGHT[id] ?? null;
|
||||
return null;
|
||||
}
|
||||
+6
-3
@@ -1,4 +1,3 @@
|
||||
import type { StatsConfig } from "./stats-widgets.js";
|
||||
import type { Request } from "express";
|
||||
import type { RefObject } from "react";
|
||||
import type { HostAuthOverrides } from "./auth-protocols.js";
|
||||
@@ -230,7 +229,9 @@ export type Host = {
|
||||
tunnelConnections: TunnelConnection[];
|
||||
jumpHosts?: JumpHost[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
statusCheckEnabled?: boolean;
|
||||
/** Seconds between status checks; null follows the global setting. */
|
||||
statusCheckInterval?: number | null;
|
||||
terminalConfig?: Partial<TerminalConfig>;
|
||||
notes?: string;
|
||||
|
||||
@@ -385,7 +386,9 @@ export interface HostData {
|
||||
tunnelConnections?: TunnelConnection[];
|
||||
jumpHosts?: JumpHostData[];
|
||||
quickActions?: QuickActionData[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
statusCheckEnabled?: boolean;
|
||||
/** Seconds between status checks; null follows the global setting. */
|
||||
statusCheckInterval?: number | null;
|
||||
terminalConfig?: Partial<TerminalConfig>;
|
||||
notes?: string;
|
||||
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
export type WidgetType =
|
||||
| "cpu"
|
||||
| "memory"
|
||||
| "disk"
|
||||
| "network"
|
||||
| "uptime"
|
||||
| "processes"
|
||||
| "system"
|
||||
| "login_stats"
|
||||
| "ports"
|
||||
| "firewall"
|
||||
| "temperature"
|
||||
| "gpu";
|
||||
|
||||
export interface ListeningPort {
|
||||
protocol: "tcp" | "udp";
|
||||
localAddress: string;
|
||||
localPort: number;
|
||||
state?: string;
|
||||
pid?: number;
|
||||
process?: string;
|
||||
}
|
||||
|
||||
export interface PortsMetrics {
|
||||
source: "ss" | "netstat" | "none";
|
||||
ports: ListeningPort[];
|
||||
}
|
||||
|
||||
export interface FirewallRule {
|
||||
chain: string;
|
||||
target: string;
|
||||
protocol: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
dport?: string;
|
||||
sport?: string;
|
||||
state?: string;
|
||||
interface?: string;
|
||||
extra?: string;
|
||||
}
|
||||
|
||||
export interface FirewallChain {
|
||||
name: string;
|
||||
policy: string;
|
||||
rules: FirewallRule[];
|
||||
}
|
||||
|
||||
export interface FirewallMetrics {
|
||||
type: "iptables" | "nftables" | "none";
|
||||
status: "active" | "inactive" | "unknown";
|
||||
chains: FirewallChain[];
|
||||
}
|
||||
|
||||
export interface TemperatureSensor {
|
||||
label: string;
|
||||
celsius: number;
|
||||
}
|
||||
|
||||
export interface TemperatureMetrics {
|
||||
source: "sysfs" | "sensors" | "none";
|
||||
highestCelsius: number | null;
|
||||
sensors: TemperatureSensor[];
|
||||
}
|
||||
|
||||
export interface GpuDevice {
|
||||
index: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
driverVersion: string | null;
|
||||
utilizationPercent: number | null;
|
||||
memoryUsedMiB: number | null;
|
||||
memoryTotalMiB: number | null;
|
||||
memoryPercent: number | null;
|
||||
temperatureCelsius: number | null;
|
||||
powerDrawWatts: number | null;
|
||||
powerLimitWatts: number | null;
|
||||
fanPercent: number | null;
|
||||
}
|
||||
|
||||
export interface GpuProcess {
|
||||
gpuIndex: number | null;
|
||||
pid: number;
|
||||
name: string;
|
||||
memoryUsedMiB: number | null;
|
||||
}
|
||||
|
||||
export interface GpuMetrics {
|
||||
source: "nvidia-smi" | "none";
|
||||
gpus: GpuDevice[];
|
||||
processes: GpuProcess[];
|
||||
}
|
||||
|
||||
export interface StatsConfig {
|
||||
enabledWidgets: WidgetType[];
|
||||
statusCheckEnabled: boolean;
|
||||
statusCheckInterval: number;
|
||||
useGlobalStatusInterval?: boolean;
|
||||
metricsEnabled: boolean;
|
||||
metricsInterval: number;
|
||||
useGlobalMetricsInterval?: boolean;
|
||||
disableTcpPing?: boolean;
|
||||
/** Filesystem mount points to leave out of the disk widget. */
|
||||
excludedMounts?: string[];
|
||||
/** Extra paths to monitor, including paths inside bind-mounted containers. */
|
||||
monitoredMounts?: Array<{ path: string; label?: string }>;
|
||||
}
|
||||
|
||||
export const DEFAULT_STATS_CONFIG: StatsConfig = {
|
||||
enabledWidgets: [
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"network",
|
||||
"uptime",
|
||||
"system",
|
||||
"login_stats",
|
||||
"processes",
|
||||
"ports",
|
||||
"firewall",
|
||||
"temperature",
|
||||
],
|
||||
statusCheckEnabled: true,
|
||||
statusCheckInterval: 30,
|
||||
useGlobalStatusInterval: true,
|
||||
metricsEnabled: true,
|
||||
metricsInterval: 30,
|
||||
useGlobalMetricsInterval: true,
|
||||
disableTcpPing: false,
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { TerminalConfig } from "./index.js";
|
||||
import type { StatsConfig } from "./stats-widgets.js";
|
||||
import type { HostAuthOverrides } from "./auth-protocols.js";
|
||||
|
||||
export type Host = {
|
||||
@@ -147,7 +146,9 @@ export type Host = {
|
||||
enabledCards?: string[];
|
||||
} | null;
|
||||
|
||||
statsConfig?: StatsConfig;
|
||||
statusCheckEnabled?: boolean;
|
||||
/** Seconds between status checks; null follows the global setting. */
|
||||
statusCheckInterval?: number | null;
|
||||
quickActions: { name: string; snippetId: string }[];
|
||||
|
||||
enableSsh: boolean;
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import {
|
||||
handleApiError,
|
||||
statsApi,
|
||||
getRemoteStatsApi,
|
||||
isElectron,
|
||||
sshHostApi,
|
||||
} from "@/main-axios";
|
||||
import type { ServerMetrics, ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { createKeyedRequestCache } from "@/lib/keyed-request-cache";
|
||||
|
||||
// Metrics collection/viewer registration below (startMetricsPolling,
|
||||
// registerMetricsViewer, etc.) is NOT origin-routed: the backend that
|
||||
// receives the call must own the target host by numeric database id, and a
|
||||
// synced host has a different numeric id in each database (only its
|
||||
// syncId matches across them). Only the aggregate status read is merged
|
||||
// across local + remote, same as tunnel status.
|
||||
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return !!config?.serverUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
type ApiConnectionLog = {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type ConnectErrorResponse = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
// SERVER STATISTICS
|
||||
// ============================================================================
|
||||
|
||||
const metricsCache = createKeyedRequestCache<ServerMetrics | null>(1_500, 100);
|
||||
|
||||
/**
|
||||
* Progressive retry schedule for the background /status poll.
|
||||
*
|
||||
* Each entry describes one attempt's per-request timeout and the pause to
|
||||
* observe before the next attempt. The pause on the last entry is `null`:
|
||||
* after that final failure we surface the network error, which flows
|
||||
* through the response interceptor + dbHealthMonitor (which decides
|
||||
* between the degraded toast and the full-outage overlay based on whether
|
||||
* any WebSocket is still alive).
|
||||
*
|
||||
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
|
||||
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
|
||||
* poll cadence, so the next tick acts as the next retry without overlap.
|
||||
*/
|
||||
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
|
||||
timeoutMs: number;
|
||||
pauseAfterMs: number | null;
|
||||
}> = [
|
||||
{ timeoutMs: 2000, pauseAfterMs: 3000 },
|
||||
{ timeoutMs: 5000, pauseAfterMs: 5000 },
|
||||
{ timeoutMs: 8000, pauseAfterMs: null },
|
||||
];
|
||||
|
||||
function isTransientStatusError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) return false;
|
||||
if (error.response) {
|
||||
// Definitive server response (even 5xx) is not something more retries
|
||||
// will fix in a useful timeframe; bail out and report it normally.
|
||||
return false;
|
||||
}
|
||||
const code = error.code;
|
||||
if (!code) {
|
||||
// No code + no response means classic network error (offline / DNS / TCP)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
code === "ECONNABORTED" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ERR_NETWORK" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "ECONNRESET"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
let localHostIds: number[] | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
localHostIds = (response.data || [])
|
||||
.filter(
|
||||
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
|
||||
)
|
||||
.map((host) => host.id);
|
||||
} catch {
|
||||
// A host-list failure must not start local probes for hosts whose
|
||||
// configured origin may be remote.
|
||||
localHostIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
|
||||
|
||||
try {
|
||||
const response = await statsApi.get("/status", {
|
||||
timeout: timeoutMs,
|
||||
...(localHostIds === null
|
||||
? {}
|
||||
: { params: { hostIds: localHostIds.join(",") } }),
|
||||
// Silence per-attempt interceptor logging & health-monitor side
|
||||
// effects on all attempts except the final one, so background
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
localStatuses = response.data || {};
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
break;
|
||||
}
|
||||
if (pauseAfterMs === null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (await isRemoteSyncConnected()) {
|
||||
try {
|
||||
const remoteResult = await getRemoteStatsApi().get("/status", {
|
||||
timeout: 8000,
|
||||
__silentRetry: true,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||
} catch {
|
||||
// remote unreachable this tick -- fall back to local-only statuses
|
||||
}
|
||||
}
|
||||
|
||||
return localStatuses;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
try {
|
||||
const response = await statsApi.get(`/status/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch server status");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getServerMetricsById(
|
||||
id: number,
|
||||
): Promise<ServerMetrics | null> {
|
||||
return metricsCache.get(String(id), async () => {
|
||||
try {
|
||||
const response = await statsApi.get(`/metrics/${id}`, {
|
||||
// Treat 404 as an expected "no metrics yet / disabled" signal rather
|
||||
// than an error so we don't spam warn logs on the client.
|
||||
validateStatus: (status) => status === 200 || status === 404,
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
// If a 404 still slips through (e.g. intercepted before reaching here),
|
||||
// swallow it quietly; everything else still flows through handleApiError.
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
handleApiError(error, "fetch server metrics");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function startMetricsPolling(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
requires_totp?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
viewerSessionId?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post(`/metrics/start/${hostId}`);
|
||||
metricsCache.invalidate(String(hostId));
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
throw errorWithLogs;
|
||||
}
|
||||
handleApiError(error, "start metrics polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopMetricsPolling(
|
||||
hostId: number,
|
||||
viewerSessionId?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post(`/metrics/stop/${hostId}`, { viewerSessionId });
|
||||
} catch (error) {
|
||||
handleApiError(error, "stop metrics polling");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMetricsHeartbeat(
|
||||
viewerSessionId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await statsApi.post(
|
||||
"/metrics/heartbeat",
|
||||
{ viewerSessionId },
|
||||
{ validateStatus: (status) => status === 200 || status === 404 },
|
||||
);
|
||||
return response.status !== 404;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
return false;
|
||||
}
|
||||
handleApiError(error, "send metrics heartbeat");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerMetricsViewer(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post("/metrics/register-viewer", {
|
||||
hostId,
|
||||
});
|
||||
metricsCache.invalidate(String(hostId));
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "register metrics viewer");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function unregisterMetricsViewer(
|
||||
hostId: number,
|
||||
viewerSessionId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/metrics/unregister-viewer", {
|
||||
hostId,
|
||||
viewerSessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
handleApiError(error, "unregister metrics viewer");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitMetricsTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.post("/metrics/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
metricsCache.invalidate();
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "submit metrics TOTP");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshServerPolling(): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/refresh");
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh server polling:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function notifyHostCreatedOrUpdated(
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/host-updated", { hostId });
|
||||
metricsCache.invalidate(String(hostId));
|
||||
} catch (error) {
|
||||
console.warn("Failed to notify stats server of host update:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -0,0 +1,190 @@
|
||||
import axios, { type AxiosRequestConfig } from "axios";
|
||||
import {
|
||||
handleApiError,
|
||||
getRemoteCoreApi,
|
||||
isElectron,
|
||||
sshHostApi,
|
||||
} from "@/main-axios";
|
||||
import type { ServerStatus } from "@/main-axios";
|
||||
import { getCachedServerStatuses } from "@/lib/hosts-request-cache";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
|
||||
// Core's host status checks (/host/status). On the desktop app the aggregate
|
||||
// read is merged across the local backend and the connected remote server.
|
||||
async function isRemoteSyncConnected(): Promise<boolean> {
|
||||
if (!isElectron()) return false;
|
||||
try {
|
||||
const config = (await window.electronAPI?.invoke?.(
|
||||
"get-remote-sync-config",
|
||||
)) as { serverUrl?: string } | null;
|
||||
return !!config?.serverUrl;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// HOST STATUS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Progressive retry schedule for the background /status poll.
|
||||
*
|
||||
* Each entry describes one attempt's per-request timeout and the pause to
|
||||
* observe before the next attempt. The pause on the last entry is `null`:
|
||||
* after that final failure we surface the network error, which flows
|
||||
* through the response interceptor + dbHealthMonitor (which decides
|
||||
* between the degraded toast and the full-outage overlay based on whether
|
||||
* any WebSocket is still alive).
|
||||
*
|
||||
* Sequence: try(2s) -> wait 3s -> try(5s) -> wait 5s -> try(8s) -> fail.
|
||||
* Worst-case wall-clock = 23s, which fits inside the 30s ServerStatusContext
|
||||
* poll cadence, so the next tick acts as the next retry without overlap.
|
||||
*/
|
||||
const STATUS_RETRY_SCHEDULE: ReadonlyArray<{
|
||||
timeoutMs: number;
|
||||
pauseAfterMs: number | null;
|
||||
}> = [
|
||||
{ timeoutMs: 2000, pauseAfterMs: 3000 },
|
||||
{ timeoutMs: 5000, pauseAfterMs: 5000 },
|
||||
{ timeoutMs: 8000, pauseAfterMs: null },
|
||||
];
|
||||
|
||||
function isTransientStatusError(error: unknown): boolean {
|
||||
if (!axios.isAxiosError(error)) return false;
|
||||
if (error.response) {
|
||||
// Definitive server response (even 5xx) is not something more retries
|
||||
// will fix in a useful timeframe; bail out and report it normally.
|
||||
return false;
|
||||
}
|
||||
const code = error.code;
|
||||
if (!code) {
|
||||
// No code + no response means classic network error (offline / DNS / TCP)
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
code === "ECONNABORTED" ||
|
||||
code === "ETIMEDOUT" ||
|
||||
code === "ERR_NETWORK" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
code === "ECONNRESET"
|
||||
);
|
||||
}
|
||||
|
||||
export async function getAllServerStatuses(): Promise<
|
||||
Record<number, ServerStatus>
|
||||
> {
|
||||
return getCachedServerStatuses(async () => {
|
||||
let lastError: unknown = null;
|
||||
let localStatuses: Record<number, ServerStatus> = {};
|
||||
let localHostIds: number[] | null = null;
|
||||
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const response = await sshHostApi.get<SSHHost[]>("/db/host");
|
||||
const defaultOrigin = await resolveConnectionOrigin({
|
||||
connectionOrigin: null,
|
||||
});
|
||||
localHostIds = (response.data || [])
|
||||
.filter(
|
||||
(host) => (host.connectionOrigin ?? defaultOrigin) === "local",
|
||||
)
|
||||
.map((host) => host.id);
|
||||
} catch {
|
||||
// A host-list failure must not start local probes for hosts whose
|
||||
// configured origin may be remote.
|
||||
localHostIds = [];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < STATUS_RETRY_SCHEDULE.length; i++) {
|
||||
const { timeoutMs, pauseAfterMs } = STATUS_RETRY_SCHEDULE[i];
|
||||
const isFinalAttempt = i === STATUS_RETRY_SCHEDULE.length - 1;
|
||||
|
||||
try {
|
||||
const response = await sshHostApi.get("/status", {
|
||||
timeout: timeoutMs,
|
||||
...(localHostIds === null
|
||||
? {}
|
||||
: { params: { hostIds: localHostIds.join(",") } }),
|
||||
// Silence per-attempt interceptor logging & health-monitor side
|
||||
// effects on all attempts except the final one, so background
|
||||
// blips don't look like real outages.
|
||||
__silentRetry: !isFinalAttempt,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
localStatuses = response.data || {};
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!isTransientStatusError(error)) {
|
||||
break;
|
||||
}
|
||||
if (pauseAfterMs === null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pauseAfterMs));
|
||||
}
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
handleApiError(lastError, "fetch server statuses");
|
||||
return {};
|
||||
}
|
||||
|
||||
if (await isRemoteSyncConnected()) {
|
||||
try {
|
||||
const remoteResult = await getRemoteCoreApi().get("/host/status", {
|
||||
timeout: 8000,
|
||||
__silentRetry: true,
|
||||
} as AxiosRequestConfig & { __silentRetry?: boolean });
|
||||
return { ...localStatuses, ...(remoteResult.data || {}) };
|
||||
} catch {
|
||||
// remote unreachable this tick -- fall back to local-only statuses
|
||||
}
|
||||
}
|
||||
|
||||
return localStatuses;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getServerStatusById(id: number): Promise<ServerStatus> {
|
||||
try {
|
||||
const response = await sshHostApi.get(`/status/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch server status");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshServerPolling(): Promise<void> {
|
||||
try {
|
||||
await sshHostApi.post("/status/refresh");
|
||||
} catch (error) {
|
||||
console.warn("Failed to refresh status checks:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStatusCheckSettings(): Promise<{
|
||||
statusCheckInterval: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await sshHostApi.get("/status/settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch status check settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateStatusCheckSettings(settings: {
|
||||
statusCheckInterval: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await sshHostApi.put("/status/settings", settings);
|
||||
} catch (error) {
|
||||
handleApiError(error, "update status check settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1,32 +1,5 @@
|
||||
import { authApi, handleApiError, statsApi } from "@/main-axios";
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
|
||||
// GLOBAL MONITORING SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
export async function getGlobalMonitoringSettings(): Promise<{
|
||||
statusCheckInterval: number;
|
||||
metricsInterval: number;
|
||||
}> {
|
||||
try {
|
||||
const response = await statsApi.get("/global-settings");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch global monitoring settings");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateGlobalMonitoringSettings(settings: {
|
||||
statusCheckInterval?: number;
|
||||
metricsInterval?: number;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
await statsApi.post("/global-settings", settings);
|
||||
} catch (error) {
|
||||
handleApiError(error, "update global monitoring settings");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LOG LEVEL SETTINGS
|
||||
// ============================================================================
|
||||
|
||||
@@ -184,7 +157,6 @@ export type HostDefaults = {
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
credentialId?: number | null;
|
||||
metricsEnabled?: boolean;
|
||||
statusCheckEnabled?: boolean;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { enabledHostProtocols, protocolPort } from "@/sidebar/host-protocols";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { Button } from "@/components/button";
|
||||
import { Card } from "@/components/card";
|
||||
@@ -38,10 +38,6 @@ import {
|
||||
getRecentActivity,
|
||||
getCredentials,
|
||||
resetRecentActivity,
|
||||
getAllServerStatuses,
|
||||
getServerMetricsById,
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
getUserInfo,
|
||||
getServiceLinks,
|
||||
createServiceLink,
|
||||
@@ -446,45 +442,12 @@ function QuickActionsCard({
|
||||
);
|
||||
}
|
||||
|
||||
function MetricBar({ label, value }: { label: string; value: number }) {
|
||||
const color =
|
||||
value >= 90
|
||||
? "bg-red-500"
|
||||
: value >= 70
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand";
|
||||
const textColor =
|
||||
value >= 90
|
||||
? "text-red-400"
|
||||
: value >= 70
|
||||
? "text-yellow-400"
|
||||
: "text-accent-brand";
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">{label}</span>
|
||||
<span className={`text-[10px] font-bold ${textColor}`}>
|
||||
{value.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div className={`h-full ${color}`} style={{ width: `${value}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HostStatusCard({
|
||||
hosts,
|
||||
hostMetrics,
|
||||
onOpenTab,
|
||||
statusLoading,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<
|
||||
string,
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
statusLoading?: boolean;
|
||||
}) {
|
||||
@@ -518,11 +481,6 @@ export function HostStatusCard({
|
||||
: host.online
|
||||
? "online"
|
||||
: "offline";
|
||||
const metrics = hostMetrics.get(host.id);
|
||||
const cpu = metrics?.cpu ?? null;
|
||||
const ram = metrics?.ram ?? null;
|
||||
const disk = metrics?.disk ?? null;
|
||||
const hasMetrics = cpu !== null || ram !== null || disk !== null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
@@ -559,31 +517,14 @@ export function HostStatusCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-3">
|
||||
{availability === "online" && hasMetrics ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{cpu !== null && (
|
||||
<MetricBar label={t("dashboard.cpu")} value={cpu} />
|
||||
)}
|
||||
{ram !== null && (
|
||||
<MetricBar label={t("dashboard.ram")} value={ram} />
|
||||
)}
|
||||
{disk !== null && (
|
||||
<MetricBar label={t("dashboardTab.disk")} value={disk} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Plugins such as host metrics show live usage here. */}
|
||||
<ComponentSlot
|
||||
slotId="dashboard.hostMetrics"
|
||||
props={{
|
||||
hostId: Number(host.id),
|
||||
online: availability === "online",
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${getStatusClasses(availability, statusScheme, "badge", statusLoading)}`}
|
||||
>
|
||||
@@ -609,7 +550,7 @@ export function HostStatusCard({
|
||||
}
|
||||
|
||||
function isStatusCheckEnabled(host: Host): boolean {
|
||||
return host.statsConfig?.statusCheckEnabled !== false;
|
||||
return host.statusCheckEnabled !== false;
|
||||
}
|
||||
|
||||
function RecentActivityCard({
|
||||
@@ -843,7 +784,6 @@ function CardItem({
|
||||
onOpenSingletonTab,
|
||||
onOpenTab,
|
||||
hosts,
|
||||
hostMetrics,
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
@@ -871,10 +811,6 @@ function CardItem({
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<
|
||||
string,
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>;
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta" | "unknown";
|
||||
@@ -970,7 +906,6 @@ function CardItem({
|
||||
{slot.id === "host_status" && (
|
||||
<HostStatusCard
|
||||
hosts={hosts}
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
@@ -1103,10 +1038,6 @@ type PanelColumnProps = {
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<
|
||||
string,
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>;
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta" | "unknown";
|
||||
@@ -1139,7 +1070,6 @@ function PanelColumn({
|
||||
onOpenSingletonTab,
|
||||
onOpenTab,
|
||||
hosts,
|
||||
hostMetrics,
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
@@ -1198,7 +1128,6 @@ function PanelColumn({
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
hosts={hosts}
|
||||
hostMetrics={hostMetrics}
|
||||
uptimeFormatted={uptimeFormatted}
|
||||
versionText={versionText}
|
||||
versionStatus={versionStatus}
|
||||
@@ -1378,83 +1307,13 @@ export function DashboardTab({
|
||||
const [credentialCount, setCredentialCount] = useState(0);
|
||||
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
||||
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
||||
const [hostMetrics, setHostMetrics] = useState<
|
||||
Map<string, { cpu: number | null; ram: number | null; disk: number | null }>
|
||||
>(new Map());
|
||||
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
||||
const sshAuthProviders = useSshAuthProviders();
|
||||
const sshAuthProvidersRef = useRef(sshAuthProviders);
|
||||
sshAuthProvidersRef.current = sshAuthProviders;
|
||||
const statusCheckHosts = hosts.filter(isStatusCheckEnabled);
|
||||
|
||||
const fetchMetrics = useCallback(async (hostList: Host[]) => {
|
||||
let statuses: Record<number, { status?: string }> = {};
|
||||
try {
|
||||
statuses = (await getAllServerStatuses()) as Record<
|
||||
number,
|
||||
{ status?: string }
|
||||
>;
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
const newSessions = new Map<number, string>(viewerSessionsRef.current);
|
||||
const results = await Promise.all(
|
||||
hostList.map(async (host) => {
|
||||
const hostId = Number(host.id);
|
||||
const knownStatus = statuses?.[hostId]?.status;
|
||||
if (knownStatus === "offline") return null;
|
||||
// Types that need a person or a browser sign-in cannot be polled.
|
||||
const authOption = sshAuthProvidersRef.current.find(host.authType);
|
||||
if (authOption && !authOption.supportsBackground) return null;
|
||||
|
||||
try {
|
||||
const existing = newSessions.get(hostId);
|
||||
if (existing && !(await sendMetricsHeartbeat(existing))) {
|
||||
newSessions.delete(hostId);
|
||||
}
|
||||
if (!newSessions.has(hostId)) {
|
||||
const reg = await registerMetricsViewer(hostId);
|
||||
if (reg.skipped) return null;
|
||||
if (reg.success && reg.viewerSessionId) {
|
||||
newSessions.set(hostId, reg.viewerSessionId);
|
||||
}
|
||||
}
|
||||
const metrics = await getServerMetricsById(hostId);
|
||||
if (!metrics) return null;
|
||||
return {
|
||||
id: host.id,
|
||||
cpu: metrics.cpu?.percent ?? null,
|
||||
ram: metrics.memory?.percent ?? null,
|
||||
disk: metrics.disk?.percent ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
viewerSessionsRef.current = newSessions;
|
||||
const map = new Map<
|
||||
string,
|
||||
{ cpu: number | null; ram: number | null; disk: number | null }
|
||||
>();
|
||||
for (const r of results) {
|
||||
if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram, disk: r.disk });
|
||||
}
|
||||
setHostMetrics(map);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const load = async () => {
|
||||
const raw = await getSSHHosts().catch(() => []);
|
||||
const mapped = raw.map(sshHostToHost);
|
||||
const statusHosts = mapped.filter(isStatusCheckEnabled);
|
||||
if (mounted) setHosts(mapped);
|
||||
if (isVisible) {
|
||||
fetchMetrics(statusHosts).catch(() => {});
|
||||
}
|
||||
if (mounted) setHosts(raw.map(sshHostToHost));
|
||||
};
|
||||
load();
|
||||
|
||||
@@ -1518,37 +1377,17 @@ export function DashboardTab({
|
||||
};
|
||||
}
|
||||
|
||||
const metricsInterval = setInterval(async () => {
|
||||
const hostsInterval = setInterval(async () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
const raw = await getSSHHosts().catch(() => []);
|
||||
const mapped = raw.map(sshHostToHost);
|
||||
const statusHosts = mapped.filter(isStatusCheckEnabled);
|
||||
if (mounted) setHosts(mapped);
|
||||
fetchMetrics(statusHosts).catch(() => {});
|
||||
if (mounted) setHosts(raw.map(sshHostToHost));
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(metricsInterval);
|
||||
clearInterval(hostsInterval);
|
||||
};
|
||||
}, [fetchMetrics, isVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || viewerSessionsRef.current.size === 0) return;
|
||||
const heartbeat = setInterval(async () => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
for (const [hostId, sessionId] of viewerSessionsRef.current) {
|
||||
sendMetricsHeartbeat(sessionId)
|
||||
.then((alive) => {
|
||||
if (!alive && viewerSessionsRef.current.get(hostId) === sessionId) {
|
||||
viewerSessionsRef.current.delete(hostId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, 30000);
|
||||
return () => clearInterval(heartbeat);
|
||||
}, [hostMetrics, isVisible]);
|
||||
}, [isVisible]);
|
||||
|
||||
const handleClearActivity = async () => {
|
||||
try {
|
||||
@@ -1705,7 +1544,6 @@ export function DashboardTab({
|
||||
|
||||
const columnProps = {
|
||||
hosts,
|
||||
hostMetrics,
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
@@ -1847,7 +1685,6 @@ export function DashboardTab({
|
||||
{slot.id === "host_status" && (
|
||||
<HostStatusCard
|
||||
hosts={statusCheckHosts}
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
statusLoading={statusLoading}
|
||||
/>
|
||||
|
||||
@@ -24,21 +24,6 @@ const METRIC_OPTIONS: { key: HostMetricKey; labelKey: string }[] = [
|
||||
{ key: "processes", labelKey: "homepage.metricProcesses" },
|
||||
];
|
||||
|
||||
function parseMetricsEnabled(statsConfig: unknown): boolean {
|
||||
if (!statsConfig) return false;
|
||||
try {
|
||||
const cfg =
|
||||
typeof statsConfig === "string" ? JSON.parse(statsConfig) : statsConfig;
|
||||
if (typeof cfg === "object" && cfg !== null) {
|
||||
const c = cfg as Record<string, unknown>;
|
||||
return c.metricsEnabled !== false;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function HostStatusEditForm({
|
||||
config,
|
||||
onChange,
|
||||
@@ -57,7 +42,7 @@ export function HostStatusEditForm({
|
||||
.then((all) =>
|
||||
setHosts(
|
||||
all
|
||||
.filter((x) => parseMetricsEnabled(x.statsConfig))
|
||||
.filter((x) => x.statusCheckEnabled !== false)
|
||||
.map((x) => ({
|
||||
id: String(x.id),
|
||||
name: x.name,
|
||||
|
||||
@@ -8,12 +8,9 @@ import type {
|
||||
WidgetComponentProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
} from "@/api/host-metrics-status-api";
|
||||
import { getServerStatusById } from "@/api/host-status-api";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { ServerMetrics } from "@/main-axios";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { WidgetTitle } from "./WidgetTitle";
|
||||
|
||||
function getAccentColor(): string {
|
||||
@@ -34,56 +31,6 @@ function migrateConfig(config: HostStatusConfig): HostMetricKey[] {
|
||||
return metrics;
|
||||
}
|
||||
|
||||
interface MetricBarProps {
|
||||
label: string;
|
||||
value: number | null;
|
||||
sublabel?: string;
|
||||
}
|
||||
|
||||
function MetricBar({ label, value, sublabel }: MetricBarProps) {
|
||||
const pct = value ?? 0;
|
||||
const color = pct > 90 ? "#ef4444" : pct > 70 ? "#f97316" : getAccentColor();
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="text-right">
|
||||
{value != null ? `${Math.round(pct)}%` : "N/A"}
|
||||
{sublabel && <span className="ml-1 opacity-60">{sublabel}</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface InfoRowProps {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: InfoRowProps) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="flex justify-between text-[10px] gap-2">
|
||||
<span className="text-muted-foreground shrink-0">{label}</span>
|
||||
<span className="text-foreground truncate text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n >= 1073741824) return `${(n / 1073741824).toFixed(1)} GB`;
|
||||
if (n >= 1048576) return `${(n / 1048576).toFixed(1)} MB`;
|
||||
if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
function HostStatusWidget({
|
||||
widget,
|
||||
config,
|
||||
@@ -94,7 +41,6 @@ function HostStatusWidget({
|
||||
const [hostName, setHostName] = useState<string | null>(null);
|
||||
const [online, setOnline] = useState<boolean | null>(null);
|
||||
const [reachable, setReachable] = useState(false);
|
||||
const [metrics, setMetrics] = useState<ServerMetrics | null>(null);
|
||||
|
||||
const needsMetrics = shownMetrics.length > 0;
|
||||
|
||||
@@ -125,15 +71,6 @@ function HostStatusWidget({
|
||||
setReachable(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (needsMetrics) {
|
||||
try {
|
||||
const m = await getServerMetricsById(hostId);
|
||||
if (!cancelled) setMetrics(m);
|
||||
} catch {
|
||||
if (!cancelled) setMetrics(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
@@ -141,7 +78,6 @@ function HostStatusWidget({
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
const start = () => {
|
||||
if (intervalId !== null) return;
|
||||
// Align with global status cadence; metrics do not need sub-10s when hidden.
|
||||
intervalId = setInterval(() => {
|
||||
if (document.visibilityState === "hidden") return;
|
||||
void poll();
|
||||
@@ -169,7 +105,7 @@ function HostStatusWidget({
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [hostId, needsMetrics]);
|
||||
}, [hostId]);
|
||||
|
||||
if (!hostId) {
|
||||
return (
|
||||
@@ -196,16 +132,6 @@ function HostStatusWidget({
|
||||
? t("common.reachable", { defaultValue: "Reachable" })
|
||||
: t("common.offline");
|
||||
|
||||
const networkIfaces = metrics?.network?.interfaces ?? [];
|
||||
const totalRx = networkIfaces.reduce((sum, iface) => {
|
||||
const raw = iface.rxBytes ?? iface.rx ?? null;
|
||||
return raw ? sum + parseFloat(raw) : sum;
|
||||
}, 0);
|
||||
const totalTx = networkIfaces.reduce((sum, iface) => {
|
||||
const raw = iface.txBytes ?? iface.tx ?? null;
|
||||
return raw ? sum + parseFloat(raw) : sum;
|
||||
}, 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Server size={11} />} />
|
||||
@@ -231,132 +157,12 @@ function HostStatusWidget({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Plugins such as host metrics fill in the live numbers. */}
|
||||
{needsMetrics && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{shownMetrics.includes("cpu") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricCpu")}
|
||||
value={metrics?.cpu?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.cpu?.cores ? `${metrics.cpu.cores}c` : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("memory") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricMemory")}
|
||||
value={metrics?.memory?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.memory?.usedGiB != null &&
|
||||
metrics?.memory?.totalGiB != null
|
||||
? `${metrics.memory.usedGiB.toFixed(1)}/${metrics.memory.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("disk") && (
|
||||
<MetricBar
|
||||
label={t("homepage.metricDisk")}
|
||||
value={metrics?.disk?.percent ?? null}
|
||||
sublabel={
|
||||
metrics?.disk?.usedHuman && metrics?.disk?.totalHuman
|
||||
? `${metrics.disk.usedHuman}/${metrics.disk.totalHuman}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("uptime") && (
|
||||
<InfoRow
|
||||
label={t("homepage.metricUptime")}
|
||||
value={metrics?.uptime?.formatted ?? null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("system") && metrics?.system && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<InfoRow
|
||||
label={t("homepage.metricOs")}
|
||||
value={metrics.system.os ?? null}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("homepage.metricKernel")}
|
||||
value={metrics.system.kernel ?? null}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("homepage.metricHostname")}
|
||||
value={metrics.system.hostname ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("network") && networkIfaces.length > 0 && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<div className="text-[10px] font-medium text-muted-foreground mb-0.5">
|
||||
{t("homepage.metricNetwork")}
|
||||
</div>
|
||||
{networkIfaces.slice(0, 3).map((iface) => (
|
||||
<div
|
||||
key={iface.name}
|
||||
className="flex justify-between text-[10px] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 truncate max-w-[60px]">
|
||||
{iface.name}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{iface.ip || iface.state}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(totalRx > 0 || totalTx > 0) && (
|
||||
<div className="flex justify-between text-[10px] gap-2 mt-0.5">
|
||||
<span className="text-muted-foreground">RX/TX</span>
|
||||
<span className="text-foreground">
|
||||
{formatBytes(totalRx)} / {formatBytes(totalTx)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shownMetrics.includes("processes") && metrics?.processes && (
|
||||
<div className="flex flex-col gap-0.5 border-t border-border/40 pt-1.5 mt-0.5">
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-muted-foreground">
|
||||
{t("homepage.metricProcesses")}
|
||||
</span>
|
||||
<span className="text-foreground">
|
||||
{metrics.processes.total ?? "?"}{" "}
|
||||
{t("homepage.metricProcessesTotal")}
|
||||
{metrics.processes.running != null
|
||||
? `, ${metrics.processes.running} ${t("homepage.metricProcessesRunning")}`
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
{metrics.processes.top?.slice(0, 3).map((proc) => (
|
||||
<div
|
||||
key={proc.pid}
|
||||
className="flex justify-between text-[10px] gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground truncate max-w-[80px]">
|
||||
{proc.command}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0">
|
||||
{proc.cpu}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{needsMetrics && !metrics && online && (
|
||||
<span className="text-[10px] text-muted-foreground/60 italic">
|
||||
{t("homepage.metricsNotAvailable")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ComponentSlot
|
||||
slotId="homepage.hostMetrics"
|
||||
props={{ hostId, shownMetrics, online: !!online }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,6 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { getAllServerStatuses, getSSHHosts } from "@/main-axios";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import {
|
||||
ServerStatusStore,
|
||||
type ServerStatusEntry,
|
||||
@@ -73,21 +72,7 @@ export function ServerStatusProvider({
|
||||
const enabled = new Set<number>();
|
||||
|
||||
hosts.forEach((host) => {
|
||||
const statsConfig = (() => {
|
||||
try {
|
||||
if (!host.statsConfig) return DEFAULT_STATS_CONFIG;
|
||||
if (typeof host.statsConfig === "string") {
|
||||
return JSON.parse(host.statsConfig);
|
||||
}
|
||||
return host.statsConfig;
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
|
||||
if (statsConfig.statusCheckEnabled !== false) {
|
||||
enabled.add(host.id);
|
||||
}
|
||||
if (host.statusCheckEnabled !== false) enabled.add(host.id);
|
||||
});
|
||||
|
||||
store.setEnabledHostIds(enabled);
|
||||
@@ -322,3 +307,30 @@ export function useServerStatusMeta(): {
|
||||
isLoading: store.getIsLoading(),
|
||||
};
|
||||
}
|
||||
|
||||
const noStatus = () => "";
|
||||
|
||||
/**
|
||||
* One host's status for plugin views, which can render outside the app shell
|
||||
* (a standalone window). Null there, and while the host has not been checked.
|
||||
*/
|
||||
export function useOptionalHostStatusEntry(hostId: number | undefined): {
|
||||
status: StatusValue;
|
||||
reason?: "host_key_changed";
|
||||
} | null {
|
||||
const store = useContext(StatusStoreContext);
|
||||
const snapshot = useSyncExternalStore(
|
||||
(onChange) =>
|
||||
store && hostId !== undefined
|
||||
? store.subscribeHost(hostId, onChange)
|
||||
: () => {},
|
||||
() => (store && hostId !== undefined ? store.getHostSnapshot(hostId) : ""),
|
||||
noStatus,
|
||||
);
|
||||
if (!snapshot) return null;
|
||||
const [status] = snapshot.split(":", 1);
|
||||
if (!status) return null;
|
||||
return snapshot.endsWith(":host_key_changed")
|
||||
? { status: status as StatusValue, reason: "host_key_changed" }
|
||||
: { status: status as StatusValue };
|
||||
}
|
||||
+1
-17
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Show Disk Usage",
|
||||
"addToFolder": "Add widget to folder",
|
||||
"noHostSelected": "No host selected",
|
||||
"metricsNotAvailable": "Metrics not available",
|
||||
"selectHost": "Select a host...",
|
||||
"displayedMetrics": "Displayed Metrics",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Uptime",
|
||||
"metricSystem": "System",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Hostname",
|
||||
"metricNetwork": "Network",
|
||||
"metricProcesses": "Processes",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "running",
|
||||
"categoryMonitoring": "Monitoring",
|
||||
"loading": "Loading...",
|
||||
"allClear": "All clear",
|
||||
@@ -771,10 +765,6 @@
|
||||
"discardChanges": "Discard changes",
|
||||
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
|
||||
"editHostTooltip": "Edit host",
|
||||
"statusChecks": "Status Checks",
|
||||
"metricsCollection": "Metrics Collection",
|
||||
"metricsInterval": "Metrics Collection Interval",
|
||||
"metricsIntervalDesc": "How often to collect server statistics (5s - 1h)",
|
||||
"behavior": "Behavior",
|
||||
"themePreview": "Theme Preview",
|
||||
"theme": "Theme",
|
||||
@@ -1907,8 +1897,6 @@
|
||||
"serverStats": "Server Stats",
|
||||
"loadingServerStats": "Loading server stats...",
|
||||
"noServerData": "No server data available",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Customize Dashboard",
|
||||
"dashboardSettings": "Dashboard Settings",
|
||||
"enableDisableCards": "Enable/Disable Cards",
|
||||
@@ -1961,7 +1949,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Enter a valid web address",
|
||||
"serviceLinksAddFailed": "Failed to add service link",
|
||||
"disk": "Disk",
|
||||
"viewServerDetails": "View server details"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2102,7 +2089,6 @@
|
||||
"sessionTimeoutRange": "Min 1h · Max 720h",
|
||||
"monitoringDefaults": "Monitoring Defaults",
|
||||
"statusCheck": "Status Check",
|
||||
"metrics": "Metrics",
|
||||
"sec": "sec",
|
||||
"logLevel": "Log Level",
|
||||
"oidcDescription": "Configure OpenID Connect for SSO. Fields marked * are required.",
|
||||
@@ -2352,11 +2338,9 @@
|
||||
"hostDefaultsSocks5Host": "Proxy Host / Port",
|
||||
"hostDefaultsSocks5Username": "Proxy Username",
|
||||
"hostDefaultsSocks5Password": "Proxy Password",
|
||||
"hostDefaultsMetrics": "Host Metrics",
|
||||
"hostDefaultsMetricsEnabled": "Enable Metrics",
|
||||
"hostDefaultsMetricsEnabledDesc": "Collect CPU, memory, and other stats on new hosts by default",
|
||||
"hostDefaultsStatusCheckEnabled": "Enable Status Check",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Poll online/offline status on new hosts by default",
|
||||
"hostDefaultsStatus": "Status Checks",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
"hostDefaultsAutoTmux": "Auto-Tmux",
|
||||
"hostDefaultsAutoTmuxDesc": "Run new hosts' terminals inside tmux so sessions survive disconnects (needs tmux on the host)",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Wys skyfgebruik",
|
||||
"addToFolder": "Voeg widget by vouer",
|
||||
"noHostSelected": "Geen gasheer gekies nie",
|
||||
"metricsNotAvailable": "Metrieke nie beskikbaar nie",
|
||||
"selectHost": "Kies 'n gasheer...",
|
||||
"displayedMetrics": "Vertoonde Metrieke",
|
||||
"metricCpu": "SVE",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Skyf",
|
||||
"metricUptime": "Optyd",
|
||||
"metricSystem": "Stelsel",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Kern",
|
||||
"metricHostname": "Gasheernaam",
|
||||
"metricNetwork": "Netwerk",
|
||||
"metricProcesses": "Prosesse",
|
||||
"metricProcessesTotal": "totaal",
|
||||
"metricProcessesRunning": "hardloop",
|
||||
"categoryMonitoring": "Monitering",
|
||||
"loading": "Laai tans...",
|
||||
"allClear": "Alles duidelik",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Gooi veranderinge weg",
|
||||
"movedToFolder": "Het {{count}} gasheer(s) na \"{{folder}} \" geskuif",
|
||||
"editHostTooltip": "Wysig gasheer",
|
||||
"statusChecks": "Statuskontroles",
|
||||
"metricsCollection": "Metrieke-versameling",
|
||||
"metricsInterval": "Metrieke Versamelingsinterval",
|
||||
"metricsIntervalDesc": "Hoe gereeld om bedienerstatistieke in te samel (5s - 1u)",
|
||||
"behavior": "Gedrag",
|
||||
"themePreview": "Temavoorskou",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Bedienerstatistieke",
|
||||
"loadingServerStats": "Laai bedienerstatistieke...",
|
||||
"noServerData": "Geen bedienerdata beskikbaar nie",
|
||||
"cpu": "SVE",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Pasmaak Dashboard",
|
||||
"dashboardSettings": "Dashboard-instellings",
|
||||
"enableDisableCards": "Aktiveer/Deaktiveer Kaarte",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Voer 'n geldige webadres in",
|
||||
"serviceLinksAddFailed": "Kon nie diensskakel byvoeg nie",
|
||||
"disk": "Skyf",
|
||||
"viewServerDetails": "Bekyk bedienerbesonderhede"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min 1 uur · Maks 720 uur",
|
||||
"monitoringDefaults": "Monitering van Standaardwaardes",
|
||||
"statusCheck": "Statuskontrole",
|
||||
"metrics": "Metrieke",
|
||||
"sec": "sek",
|
||||
"logLevel": "Logvlak",
|
||||
"enableGuacamole": "Aktiveer Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxy-gasheer / -poort",
|
||||
"hostDefaultsSocks5Username": "Proxy-gebruikersnaam",
|
||||
"hostDefaultsSocks5Password": "Proxy-wagwoord",
|
||||
"hostDefaultsMetrics": "Gasheermetrieke",
|
||||
"hostDefaultsMetricsEnabled": "Aktiveer Metrieke",
|
||||
"hostDefaultsMetricsEnabledDesc": "Versamel standaard SVE-, geheue- en ander statistieke op nuwe gashere",
|
||||
"hostDefaultsStatusCheckEnabled": "Aktiveer Statuskontrole",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Stem aanlyn/aflyn status op nuwe gashere by verstek",
|
||||
"hostDefaultsTerminal": "Terminaal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "إظهار استخدام القرص",
|
||||
"addToFolder": "إضافة أداة إلى المجلد",
|
||||
"noHostSelected": "لم يتم تحديد أي مضيف",
|
||||
"metricsNotAvailable": "المقاييس غير متوفرة",
|
||||
"selectHost": "اختيار مضيف...",
|
||||
"displayedMetrics": "المقاييس المعروضة",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "القرص",
|
||||
"metricUptime": "وقت التشغيل",
|
||||
"metricSystem": "النظام",
|
||||
"metricOs": "نظام التشغيل",
|
||||
"metricKernel": "النواة",
|
||||
"metricHostname": "اسم المضيف",
|
||||
"metricNetwork": "الشبكة",
|
||||
"metricProcesses": "العمليات",
|
||||
"metricProcessesTotal": "إجمالي",
|
||||
"metricProcessesRunning": "قيد التشغيل",
|
||||
"categoryMonitoring": "المراقبة",
|
||||
"loading": "جار التحميل...",
|
||||
"allClear": "كل شيء طبيعي",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "تجاهل التغييرات",
|
||||
"movedToFolder": "تم نقل {{count}} مضيف إلى \"{{folder}}\"",
|
||||
"editHostTooltip": "تعديل المضيف",
|
||||
"statusChecks": "فحوصات الحالة",
|
||||
"metricsCollection": "جمع المقاييس",
|
||||
"metricsInterval": "فترة جمع المقاييس",
|
||||
"metricsIntervalDesc": "مدة جمع إحصائيات الخادم (5s - 1h)",
|
||||
"behavior": "السلوك",
|
||||
"themePreview": "معاينة السمة",
|
||||
"theme": "السمة",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "إحصائيات الخادم",
|
||||
"loadingServerStats": "جارٍ تحميل إحصائيات الخادم...",
|
||||
"noServerData": "لا تتوفر بيانات الخادم",
|
||||
"cpu": "المعالج",
|
||||
"ram": "الذاكرة",
|
||||
"customizeLayout": "تخصيص لوحة التحكم",
|
||||
"dashboardSettings": "إعدادات لوحة التحكم",
|
||||
"enableDisableCards": "تمكين/تعطيل البطاقات",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "أدخل عنوان ويب صالح",
|
||||
"serviceLinksAddFailed": "فشل في إضافة رابط الخدمة",
|
||||
"disk": "القرص",
|
||||
"viewServerDetails": "عرض تفاصيل الخادم"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "الحد الأدنى 1 ساعة · الحد الأقصى 720 ساعة",
|
||||
"monitoringDefaults": "إعدادات المراقبة الافتراضية",
|
||||
"statusCheck": "فحص الحالة",
|
||||
"metrics": "مقاييس",
|
||||
"sec": "ثانية",
|
||||
"logLevel": "مستوى السجل",
|
||||
"enableGuacamole": "تمكين Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "مضيف الوكيل / المنفذ",
|
||||
"hostDefaultsSocks5Username": "اسم مستخدم الوكيل",
|
||||
"hostDefaultsSocks5Password": "كلمة مرور الوكيل",
|
||||
"hostDefaultsMetrics": "مقاييس المضيف",
|
||||
"hostDefaultsMetricsEnabled": "تمكين المقاييس",
|
||||
"hostDefaultsMetricsEnabledDesc": "جمع إحصائيات المعالج والذاكرة وغيرها على المضيفين الجدد افتراضيًا",
|
||||
"hostDefaultsStatusCheckEnabled": "تمكين فحص الحالة",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "استطلاع حالة الاتصال أو الانفصال على المضيفين الجدد افتراضيًا",
|
||||
"hostDefaultsTerminal": "الطرفية",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Показване на използването на диска",
|
||||
"addToFolder": "Добавяне на джаджа към папката",
|
||||
"noHostSelected": "Няма избран хост",
|
||||
"metricsNotAvailable": "Показателите не са налични",
|
||||
"selectHost": "Изберете хост...",
|
||||
"displayedMetrics": "Показани показатели",
|
||||
"metricCpu": "Процесор",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Диск",
|
||||
"metricUptime": "Време на работа",
|
||||
"metricSystem": "Система",
|
||||
"metricOs": "ОС",
|
||||
"metricKernel": "Ядро",
|
||||
"metricHostname": "Име на хост",
|
||||
"metricNetwork": "Мрежа",
|
||||
"metricProcesses": "Процеси",
|
||||
"metricProcessesTotal": "общо",
|
||||
"metricProcessesRunning": "бягане",
|
||||
"categoryMonitoring": "Мониторинг",
|
||||
"loading": "Зареждане...",
|
||||
"allClear": "Всичко е чисто",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Отхвърляне на промените",
|
||||
"movedToFolder": "Преместих {{count}} хост(ове) в „{{folder}}“",
|
||||
"editHostTooltip": "Редактиране на хоста",
|
||||
"statusChecks": "Проверки на състоянието",
|
||||
"metricsCollection": "Колекция от показатели",
|
||||
"metricsInterval": "Интервал на събиране на показатели",
|
||||
"metricsIntervalDesc": "Колко често да се събира статистика за сървъра (5 секунди - 1 час)",
|
||||
"behavior": "Поведение",
|
||||
"themePreview": "Преглед на темата",
|
||||
"theme": "Тема",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Статистика на сървъра",
|
||||
"loadingServerStats": "Зареждане на статистиката на сървъра...",
|
||||
"noServerData": "Няма налични данни от сървъра",
|
||||
"cpu": "Процесор",
|
||||
"ram": "RAM памет",
|
||||
"customizeLayout": "Персонализиране на таблото за управление",
|
||||
"dashboardSettings": "Настройки на таблото за управление",
|
||||
"enableDisableCards": "Активиране/деактивиране на карти",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Въведете валиден уеб адрес",
|
||||
"serviceLinksAddFailed": "Добавянето на линк към услугата не бе успешно",
|
||||
"disk": "Диск",
|
||||
"viewServerDetails": "Преглед на подробности за сървъра"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Мин. 1 ч. · Макс. 720 ч.",
|
||||
"monitoringDefaults": "Мониторинг на настройки по подразбиране",
|
||||
"statusCheck": "Проверка на състоянието",
|
||||
"metrics": "Метрики",
|
||||
"sec": "сек",
|
||||
"logLevel": "Ниво на лога",
|
||||
"enableGuacamole": "Активиране на гуакамоле",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Прокси хост / порт",
|
||||
"hostDefaultsSocks5Username": "Потребителско име на прокси",
|
||||
"hostDefaultsSocks5Password": "Парола за прокси сървър",
|
||||
"hostDefaultsMetrics": "Метрики на хоста",
|
||||
"hostDefaultsMetricsEnabled": "Активиране на показатели",
|
||||
"hostDefaultsMetricsEnabledDesc": "Събиране на CPU, памет и други статистически данни за нови хостове по подразбиране",
|
||||
"hostDefaultsStatusCheckEnabled": "Активиране на проверка на състоянието",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "По подразбиране се задава онлайн/офлайн статус на анкетата за нови хостове",
|
||||
"hostDefaultsTerminal": "Терминал",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "ডিস্ক ব্যবহার দেখান",
|
||||
"addToFolder": "ফোল্ডারে উইজেট যোগ করুন",
|
||||
"noHostSelected": "কোন হোস্ট নির্বাচন করা হয়নি",
|
||||
"metricsNotAvailable": "মেট্রিক্স উপলব্ধ নেই",
|
||||
"selectHost": "একজন হোস্ট নির্বাচন করুন...",
|
||||
"displayedMetrics": "প্রদর্শিত মেট্রিক্স",
|
||||
"metricCpu": "সিপিইউ",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "ডিস্ক",
|
||||
"metricUptime": "আপটাইম",
|
||||
"metricSystem": "সিস্টেম",
|
||||
"metricOs": "ওএস",
|
||||
"metricKernel": "কার্নেল",
|
||||
"metricHostname": "হোস্টনাম",
|
||||
"metricNetwork": "নেটওয়ার্ক",
|
||||
"metricProcesses": "প্রক্রিয়া",
|
||||
"metricProcessesTotal": "মোট",
|
||||
"metricProcessesRunning": "দৌড়ানো",
|
||||
"categoryMonitoring": "পর্যবেক্ষণ",
|
||||
"loading": "লোড হচ্ছে...",
|
||||
"allClear": "সব পরিষ্কার",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "পরিবর্তনগুলি বাতিল করুন",
|
||||
"movedToFolder": "{{count}} হোস্ট(গুলি) \"{{folder}} \" এ সরানো হয়েছে",
|
||||
"editHostTooltip": "হোস্ট সম্পাদনা করুন",
|
||||
"statusChecks": "অবস্থা যাচাই",
|
||||
"metricsCollection": "মেট্রিক্স সংগ্রহ",
|
||||
"metricsInterval": "মেট্রিক্স সংগ্রহের ব্যবধান",
|
||||
"metricsIntervalDesc": "কত ঘন ঘন সার্ভার পরিসংখ্যান সংগ্রহ করতে হবে (৫ সেকেন্ড - ১ ঘণ্টা)",
|
||||
"behavior": "আচরণ",
|
||||
"themePreview": "থিম প্রিভিউ",
|
||||
"theme": "থিম",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "সার্ভার পরিসংখ্যান",
|
||||
"loadingServerStats": "সার্ভারের পরিসংখ্যান লোড হচ্ছে...",
|
||||
"noServerData": "কোন সার্ভার ডেটা উপলব্ধ নেই",
|
||||
"cpu": "সিপিইউ",
|
||||
"ram": "রাম",
|
||||
"customizeLayout": "ড্যাশবোর্ড কাস্টমাইজ করুন",
|
||||
"dashboardSettings": "ড্যাশবোর্ড সেটিংস",
|
||||
"enableDisableCards": "কার্ডগুলি সক্রিয়/নিষ্ক্রিয় করুন",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "একটি বৈধ ওয়েব ঠিকানা লিখুন",
|
||||
"serviceLinksAddFailed": "পরিষেবা লিঙ্ক যোগ করতে ব্যর্থ হয়েছে",
|
||||
"disk": "ডিস্ক",
|
||||
"viewServerDetails": "সার্ভারের বিবরণ দেখুন"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "সর্বনিম্ন ১ ঘণ্টা · সর্বোচ্চ ৭২০ ঘণ্টা",
|
||||
"monitoringDefaults": "ডিফল্ট নিরীক্ষণ",
|
||||
"statusCheck": "অবস্থা যাচাই",
|
||||
"metrics": "মেট্রিক্স",
|
||||
"sec": "সেকেন্ড",
|
||||
"logLevel": "লগ স্তর",
|
||||
"enableGuacamole": "গুয়াকামোল সক্রিয় করুন",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "প্রক্সি হোস্ট / পোর্ট",
|
||||
"hostDefaultsSocks5Username": "প্রক্সি ব্যবহারকারীর নাম",
|
||||
"hostDefaultsSocks5Password": "প্রক্সি পাসওয়ার্ড",
|
||||
"hostDefaultsMetrics": "হোস্ট মেট্রিক্স",
|
||||
"hostDefaultsMetricsEnabled": "মেট্রিক্স সক্ষম করুন",
|
||||
"hostDefaultsMetricsEnabledDesc": "ডিফল্টরূপে নতুন হোস্টগুলিতে সিপিইউ, মেমরি এবং অন্যান্য পরিসংখ্যান সংগ্রহ করুন।",
|
||||
"hostDefaultsStatusCheckEnabled": "স্ট্যাটাস চেক সক্ষম করুন",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "নতুন হোস্টগুলিতে ডিফল্টরূপে অনলাইন/অফলাইন স্ট্যাটাস পোল করুন।",
|
||||
"hostDefaultsTerminal": "টার্মিনাল",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Mostra l'ús del disc",
|
||||
"addToFolder": "Afegeix el widget a la carpeta",
|
||||
"noHostSelected": "No s'ha seleccionat cap amfitrió",
|
||||
"metricsNotAvailable": "Mètriques no disponibles",
|
||||
"selectHost": "Seleccioneu un amfitrió...",
|
||||
"displayedMetrics": "Mètriques mostrades",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disc",
|
||||
"metricUptime": "Temps de funcionament",
|
||||
"metricSystem": "Sistema",
|
||||
"metricOs": "Sistema operatiu",
|
||||
"metricKernel": "Nucli",
|
||||
"metricHostname": "Nom d'amfitrió",
|
||||
"metricNetwork": "Xarxa",
|
||||
"metricProcesses": "Processos",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "corrent",
|
||||
"categoryMonitoring": "Monitorització",
|
||||
"loading": "S'està carregant...",
|
||||
"allClear": "Tot clar",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Descarta els canvis",
|
||||
"movedToFolder": "S'han mogut {{count}} amfitrions a \"{{folder}}\"",
|
||||
"editHostTooltip": "Edita l'amfitrió",
|
||||
"statusChecks": "Comprovacions d'estat",
|
||||
"metricsCollection": "Col·lecció de mètriques",
|
||||
"metricsInterval": "Interval de recopilació de mètriques",
|
||||
"metricsIntervalDesc": "Amb quina freqüència s'han de recopilar estadístiques del servidor (5 segons - 1 hora)",
|
||||
"behavior": "Comportament",
|
||||
"themePreview": "Vista prèvia del tema",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Estadístiques del servidor",
|
||||
"loadingServerStats": "S'estan carregant les estadístiques del servidor...",
|
||||
"noServerData": "No hi ha dades del servidor disponibles",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Personalitza el tauler de control",
|
||||
"dashboardSettings": "Configuració del tauler de control",
|
||||
"enableDisableCards": "Activa/Desactiva les targetes",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Introduïu una adreça web vàlida",
|
||||
"serviceLinksAddFailed": "No s'ha pogut afegir l'enllaç del servei",
|
||||
"disk": "Disc",
|
||||
"viewServerDetails": "Veure els detalls del servidor"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Mín 1 h · Màx 720 h",
|
||||
"monitoringDefaults": "Monitorització dels valors predeterminats",
|
||||
"statusCheck": "Comprovació d'estat",
|
||||
"metrics": "Mètriques",
|
||||
"sec": "segons",
|
||||
"logLevel": "Nivell de registre",
|
||||
"enableGuacamole": "Activa el guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Amfitrió/port del proxy",
|
||||
"hostDefaultsSocks5Username": "Nom d'usuari del proxy",
|
||||
"hostDefaultsSocks5Password": "Contrasenya del servidor intermediari",
|
||||
"hostDefaultsMetrics": "Mètriques de l'amfitrió",
|
||||
"hostDefaultsMetricsEnabled": "Activa les mètriques",
|
||||
"hostDefaultsMetricsEnabledDesc": "Recopila la CPU, la memòria i altres estadístiques dels nous amfitrions per defecte",
|
||||
"hostDefaultsStatusCheckEnabled": "Activa la comprovació d'estat",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Sondeja l'estat en línia/fora de línia dels nous amfitrions per defecte",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Zobrazit využití disku",
|
||||
"addToFolder": "Přidat widget do složky",
|
||||
"noHostSelected": "Nebyl vybrán žádný hostitel",
|
||||
"metricsNotAvailable": "Metriky nejsou k dispozici",
|
||||
"selectHost": "Vyberte hostitele...",
|
||||
"displayedMetrics": "Zobrazené metriky",
|
||||
"metricCpu": "Procesor",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Doba provozuschopnosti",
|
||||
"metricSystem": "Systém",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Jádro",
|
||||
"metricHostname": "Název hostitele",
|
||||
"metricNetwork": "Síť",
|
||||
"metricProcesses": "Procesy",
|
||||
"metricProcessesTotal": "celkový",
|
||||
"metricProcessesRunning": "běh",
|
||||
"categoryMonitoring": "Monitorování",
|
||||
"loading": "Načítání...",
|
||||
"allClear": "Všechno čisté",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Zahodit změny",
|
||||
"movedToFolder": "Přesunuto {{count}} hostitelů do „{{folder}}“",
|
||||
"editHostTooltip": "Upravit hostitele",
|
||||
"statusChecks": "Kontroly stavu",
|
||||
"metricsCollection": "Sbírka metrik",
|
||||
"metricsInterval": "Interval sběru metrik",
|
||||
"metricsIntervalDesc": "Jak často shromažďovat statistiky serveru (5 s - 1 h)",
|
||||
"behavior": "Chování",
|
||||
"themePreview": "Náhled motivu",
|
||||
"theme": "Téma",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Statistiky serveru",
|
||||
"loadingServerStats": "Načítání statistik serveru...",
|
||||
"noServerData": "Nejsou k dispozici žádná data serveru",
|
||||
"cpu": "Procesor",
|
||||
"ram": "BERAN",
|
||||
"customizeLayout": "Přizpůsobit řídicí panel",
|
||||
"dashboardSettings": "Nastavení řídicího panelu",
|
||||
"enableDisableCards": "Povolit/Zakázat karty",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Zadejte platnou webovou adresu",
|
||||
"serviceLinksAddFailed": "Nepodařilo se přidat odkaz na službu",
|
||||
"disk": "Disk",
|
||||
"viewServerDetails": "Zobrazit podrobnosti o serveru"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min. 1h · Max. 720h",
|
||||
"monitoringDefaults": "Výchozí nastavení monitorování",
|
||||
"statusCheck": "Kontrola stavu",
|
||||
"metrics": "Metriky",
|
||||
"sec": "sekunda",
|
||||
"logLevel": "Úroveň protokolu",
|
||||
"enableGuacamole": "Povolit guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxy hostitel / port",
|
||||
"hostDefaultsSocks5Username": "Uživatelské jméno proxy serveru",
|
||||
"hostDefaultsSocks5Password": "Heslo proxy serveru",
|
||||
"hostDefaultsMetrics": "Metriky hostitele",
|
||||
"hostDefaultsMetricsEnabled": "Povolit metriky",
|
||||
"hostDefaultsMetricsEnabledDesc": "Ve výchozím nastavení shromažďovat statistiky CPU, paměti a další statistiky na nových hostitelích",
|
||||
"hostDefaultsStatusCheckEnabled": "Povolit kontrolu stavu",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Ve výchozím nastavení zobrazovat stav online/offline na nových hostitelích",
|
||||
"hostDefaultsTerminal": "Terminál",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Vis diskforbrug",
|
||||
"addToFolder": "Tilføj widget til mappe",
|
||||
"noHostSelected": "Ingen vært valgt",
|
||||
"metricsNotAvailable": "Målinger er ikke tilgængelige",
|
||||
"selectHost": "Vælg en vært...",
|
||||
"displayedMetrics": "Viste målinger",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Oppetid",
|
||||
"metricSystem": "System",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Kernen",
|
||||
"metricHostname": "Værtsnavn",
|
||||
"metricNetwork": "Netværk",
|
||||
"metricProcesses": "Processer",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "løb",
|
||||
"categoryMonitoring": "Overvågning",
|
||||
"loading": "Indlæser...",
|
||||
"allClear": "Alt klart",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Kassér ændringer",
|
||||
"movedToFolder": "Flyttede {{count}} vært(er) til \"{{folder}}\"",
|
||||
"editHostTooltip": "Rediger vært",
|
||||
"statusChecks": "Statuskontroller",
|
||||
"metricsCollection": "Metriksamling",
|
||||
"metricsInterval": "Interval for indsamling af metrikker",
|
||||
"metricsIntervalDesc": "Hvor ofte skal serverstatistik indsamles (5 sekunder - 1 time)",
|
||||
"behavior": "Opførsel",
|
||||
"themePreview": "Temaforhåndsvisning",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Serverstatistik",
|
||||
"loadingServerStats": "Indlæser serverstatistik...",
|
||||
"noServerData": "Ingen serverdata tilgængelige",
|
||||
"cpu": "CPU",
|
||||
"ram": "VÆDDER",
|
||||
"customizeLayout": "Tilpas dashboard",
|
||||
"dashboardSettings": "Dashboardindstillinger",
|
||||
"enableDisableCards": "Aktivér/deaktiver kort",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Indtast en gyldig webadresse",
|
||||
"serviceLinksAddFailed": "Kunne ikke tilføje tjenestelink",
|
||||
"disk": "Disk",
|
||||
"viewServerDetails": "Se serveroplysninger"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min. 1 time · Maks. 720 timer",
|
||||
"monitoringDefaults": "Overvågning af standardindstillinger",
|
||||
"statusCheck": "Statuskontrol",
|
||||
"metrics": "Målinger",
|
||||
"sec": "sek.",
|
||||
"logLevel": "Logniveau",
|
||||
"enableGuacamole": "Aktivér guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxy-vært / -port",
|
||||
"hostDefaultsSocks5Username": "Proxy-brugernavn",
|
||||
"hostDefaultsSocks5Password": "Proxy-adgangskode",
|
||||
"hostDefaultsMetrics": "Værtsmålinger",
|
||||
"hostDefaultsMetricsEnabled": "Aktivér metrikker",
|
||||
"hostDefaultsMetricsEnabledDesc": "Indsaml som standard CPU-, hukommelses- og andre statistikker på nye værter",
|
||||
"hostDefaultsStatusCheckEnabled": "Aktivér statuskontrol",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Afstem online/offline status på nye værter som standard",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Festplattennutzung anzeigen",
|
||||
"addToFolder": "Widget zum Ordner hinzufügen",
|
||||
"noHostSelected": "Kein Host ausgewählt",
|
||||
"metricsNotAvailable": "Metriken nicht verfügbar",
|
||||
"selectHost": "Host auswählen...",
|
||||
"displayedMetrics": "Angezeigte Metriken",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Festplatte",
|
||||
"metricUptime": "Betriebszeit",
|
||||
"metricSystem": "System",
|
||||
"metricOs": "Betriebssystem",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Hostname",
|
||||
"metricNetwork": "Netzwerk",
|
||||
"metricProcesses": "Prozesse",
|
||||
"metricProcessesTotal": "gesamt",
|
||||
"metricProcessesRunning": "laufend",
|
||||
"categoryMonitoring": "Überwachung",
|
||||
"loading": "Wird geladen...",
|
||||
"allClear": "Alles in Ordnung",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Änderungen verwerfen",
|
||||
"movedToFolder": "{{count}} Host(s) nach „{{folder}}“ verschoben",
|
||||
"editHostTooltip": "Host bearbeiten",
|
||||
"statusChecks": "Statusprüfungen",
|
||||
"metricsCollection": "Metrikerfassung",
|
||||
"metricsInterval": "Metrikerfassungsintervall",
|
||||
"metricsIntervalDesc": "Wie oft Serverstatistiken erfasst werden sollen (5s - 1h)",
|
||||
"behavior": "Verhalten",
|
||||
"themePreview": "Designvorschau",
|
||||
"theme": "Design",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Server-Statistiken",
|
||||
"loadingServerStats": "Server-Statistiken werden geladen...",
|
||||
"noServerData": "Keine Serverdaten verfügbar",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Dashboard anpassen",
|
||||
"dashboardSettings": "Dashboard-Einstellungen",
|
||||
"enableDisableCards": "Karten aktivieren/deaktivieren",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Geben Sie eine gültige Webadresse ein",
|
||||
"serviceLinksAddFailed": "Hinzufügen des Service-Links fehlgeschlagen",
|
||||
"disk": "Datenträger",
|
||||
"viewServerDetails": "Serverdetails anzeigen"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min. 1 Std. · Max. 720 Std.",
|
||||
"monitoringDefaults": "Überwachungsvoreinstellungen",
|
||||
"statusCheck": "Statusprüfung",
|
||||
"metrics": "Metriken",
|
||||
"sec": "Sek.",
|
||||
"logLevel": "Log-Level",
|
||||
"enableGuacamole": "Guacamole aktivieren",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxy-Host / Port",
|
||||
"hostDefaultsSocks5Username": "Proxy-Benutzername",
|
||||
"hostDefaultsSocks5Password": "Proxy-Passwort",
|
||||
"hostDefaultsMetrics": "Host-Metriken",
|
||||
"hostDefaultsMetricsEnabled": "Metriken aktivieren",
|
||||
"hostDefaultsMetricsEnabledDesc": "CPU, Arbeitsspeicher und andere Statistiken standardmäßig auf neuen Hosts erfassen",
|
||||
"hostDefaultsStatusCheckEnabled": "Statusüberprüfung aktivieren",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Online-/Offline-Status standardmäßig auf neuen Hosts abfragen",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Εμφάνιση χρήσης δίσκου",
|
||||
"addToFolder": "Προσθήκη γραφικού στοιχείου σε φάκελο",
|
||||
"noHostSelected": "Δεν έχει επιλεγεί κεντρικός υπολογιστής",
|
||||
"metricsNotAvailable": "Οι μετρήσεις δεν είναι διαθέσιμες",
|
||||
"selectHost": "Επιλέξτε έναν οικοδεσπότη...",
|
||||
"displayedMetrics": "Εμφανιζόμενες μετρήσεις",
|
||||
"metricCpu": "ΚΜΕ",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Δίσκος",
|
||||
"metricUptime": "Χρόνος λειτουργίας",
|
||||
"metricSystem": "Σύστημα",
|
||||
"metricOs": "Λειτουργικό σύστημα",
|
||||
"metricKernel": "Πυρήνας",
|
||||
"metricHostname": "Όνομα κεντρικού υπολογιστή",
|
||||
"metricNetwork": "Δίκτυο",
|
||||
"metricProcesses": "Διαδικασίες",
|
||||
"metricProcessesTotal": "σύνολο",
|
||||
"metricProcessesRunning": "τρέξιμο",
|
||||
"categoryMonitoring": "Παρακολούθηση",
|
||||
"loading": "Φόρτωση...",
|
||||
"allClear": "Ορίζοντας είναι σαφής",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Απόρριψη αλλαγών",
|
||||
"movedToFolder": "Μετακινήθηκαν οι {{count}} κεντρικοί υπολογιστές σε \"{{folder}}\"",
|
||||
"editHostTooltip": "Επεξεργασία κεντρικού υπολογιστή",
|
||||
"statusChecks": "Έλεγχοι κατάστασης",
|
||||
"metricsCollection": "Συλλογή μετρήσεων",
|
||||
"metricsInterval": "Διάστημα συλλογής μετρήσεων",
|
||||
"metricsIntervalDesc": "Πόσο συχνά να συλλέγονται στατιστικά στοιχεία διακομιστή (5 δευτ. - 1 ώρα)",
|
||||
"behavior": "Συμπεριφορά",
|
||||
"themePreview": "Προεπισκόπηση θέματος",
|
||||
"theme": "Θέμα",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Στατιστικά διακομιστή",
|
||||
"loadingServerStats": "Φόρτωση στατιστικών διακομιστή...",
|
||||
"noServerData": "Δεν υπάρχουν διαθέσιμα δεδομένα διακομιστή",
|
||||
"cpu": "ΚΜΕ",
|
||||
"ram": "ΕΜΒΟΛΟ",
|
||||
"customizeLayout": "Προσαρμογή Πίνακα Ελέγχου",
|
||||
"dashboardSettings": "Ρυθμίσεις πίνακα ελέγχου",
|
||||
"enableDisableCards": "Ενεργοποίηση/Απενεργοποίηση καρτών",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Εισαγάγετε μια έγκυρη διεύθυνση ιστού",
|
||||
"serviceLinksAddFailed": "Αποτυχία προσθήκης συνδέσμου υπηρεσίας",
|
||||
"disk": "Δίσκος",
|
||||
"viewServerDetails": "Προβολή λεπτομερειών διακομιστή"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Ελάχ. 1 ώρα · Μέγ. 720 ώρες",
|
||||
"monitoringDefaults": "Παρακολούθηση προεπιλογών",
|
||||
"statusCheck": "Έλεγχος κατάστασης",
|
||||
"metrics": "Μετρήσεις",
|
||||
"sec": "δευτ.",
|
||||
"logLevel": "Επίπεδο καταγραφής",
|
||||
"enableGuacamole": "Ενεργοποίηση Γουακαμόλε",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Κεντρικός υπολογιστής / θύρα μεσολάβησης",
|
||||
"hostDefaultsSocks5Username": "Όνομα χρήστη διακομιστή μεσολάβησης",
|
||||
"hostDefaultsSocks5Password": "Κωδικός πρόσβασης διακομιστή μεσολάβησης",
|
||||
"hostDefaultsMetrics": "Μετρήσεις κεντρικού υπολογιστή",
|
||||
"hostDefaultsMetricsEnabled": "Ενεργοποίηση μετρήσεων",
|
||||
"hostDefaultsMetricsEnabledDesc": "Συλλογή CPU, μνήμης και άλλων στατιστικών σε νέους κεντρικούς υπολογιστές από προεπιλογή",
|
||||
"hostDefaultsStatusCheckEnabled": "Ενεργοποίηση ελέγχου κατάστασης",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Δημοσκόπηση κατάστασης online/offline σε νέους κεντρικούς υπολογιστές από προεπιλογή",
|
||||
"hostDefaultsTerminal": "Τερματικό",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Mostrar uso de disco",
|
||||
"addToFolder": "Añadir widget a la carpeta",
|
||||
"noHostSelected": "Ningún host seleccionado",
|
||||
"metricsNotAvailable": "Métricas no disponibles",
|
||||
"selectHost": "Seleccionar un host...",
|
||||
"displayedMetrics": "Métricas mostradas",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disco",
|
||||
"metricUptime": "Tiempo de actividad",
|
||||
"metricSystem": "Sistema",
|
||||
"metricOs": "SO",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Nombre de host",
|
||||
"metricNetwork": "Red",
|
||||
"metricProcesses": "Procesos",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "en ejecución",
|
||||
"categoryMonitoring": "Monitorización",
|
||||
"loading": "Cargando...",
|
||||
"allClear": "Todo correcto",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Descartar cambios",
|
||||
"movedToFolder": "Movido(s) {{count}} host(s) a \"{{folder}}\"",
|
||||
"editHostTooltip": "Editar host",
|
||||
"statusChecks": "Comprobaciones de estado",
|
||||
"metricsCollection": "Recopilación de métricas",
|
||||
"metricsInterval": "Intervalo de recopilación de métricas",
|
||||
"metricsIntervalDesc": "Frecuencia de recopilación de estadísticas del servidor (5s - 1h)",
|
||||
"behavior": "Comportamiento",
|
||||
"themePreview": "Vista previa del tema",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Estadísticas del servidor",
|
||||
"loadingServerStats": "Cargando estadísticas del servidor...",
|
||||
"noServerData": "No hay datos del servidor disponibles",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Personalizar panel",
|
||||
"dashboardSettings": "Configuración del panel",
|
||||
"enableDisableCards": "Activar/Desactivar tarjetas",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Introduce una dirección web válida",
|
||||
"serviceLinksAddFailed": "Error al añadir el enlace de servicio",
|
||||
"disk": "Disco",
|
||||
"viewServerDetails": "Ver detalles del servidor"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Mín 1h · Máx 720h",
|
||||
"monitoringDefaults": "Configuración predeterminada de monitorización",
|
||||
"statusCheck": "Comprobación de estado",
|
||||
"metrics": "Métricas",
|
||||
"sec": "seg",
|
||||
"logLevel": "Nivel de registro",
|
||||
"enableGuacamole": "Activar Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Host / Puerto del proxy",
|
||||
"hostDefaultsSocks5Username": "Nombre de usuario del proxy",
|
||||
"hostDefaultsSocks5Password": "Contraseña del proxy",
|
||||
"hostDefaultsMetrics": "Métricas del host",
|
||||
"hostDefaultsMetricsEnabled": "Habilitar métricas",
|
||||
"hostDefaultsMetricsEnabledDesc": "Recopilar CPU, memoria y otras estadísticas en hosts nuevos de forma predeterminada",
|
||||
"hostDefaultsStatusCheckEnabled": "Habilitar verificación de estado",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Consultar estado en línea/fuera de línea en hosts nuevos de forma predeterminada",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Näytä levyn käyttö",
|
||||
"addToFolder": "Lisää widget kansioon",
|
||||
"noHostSelected": "Ei valittua isäntää",
|
||||
"metricsNotAvailable": "Mittareita ei ole saatavilla",
|
||||
"selectHost": "Valitse isäntä...",
|
||||
"displayedMetrics": "Näytetyt mittarit",
|
||||
"metricCpu": "Suoritin",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Levy",
|
||||
"metricUptime": "Käyttöaika",
|
||||
"metricSystem": "Järjestelmä",
|
||||
"metricOs": "Käyttöjärjestelmä",
|
||||
"metricKernel": "Ydin",
|
||||
"metricHostname": "Isäntänimi",
|
||||
"metricNetwork": "Verkko",
|
||||
"metricProcesses": "Prosessit",
|
||||
"metricProcessesTotal": "kokonais-",
|
||||
"metricProcessesRunning": "juokseminen",
|
||||
"categoryMonitoring": "Seuranta",
|
||||
"loading": "Ladataan...",
|
||||
"allClear": "Kaikki selvää",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Hylkää muutokset",
|
||||
"movedToFolder": "Siirretty {{count}} isäntä(ä) kohteeseen \"{{folder}}\"",
|
||||
"editHostTooltip": "Muokkaa isäntää",
|
||||
"statusChecks": "Tilatarkastukset",
|
||||
"metricsCollection": "Mittarikokoelma",
|
||||
"metricsInterval": "Mittaritietojen keräysväli",
|
||||
"metricsIntervalDesc": "Palvelintilastojen keräämisen tiheys (5 s - 1 h)",
|
||||
"behavior": "Käyttäytyminen",
|
||||
"themePreview": "Teeman esikatselu",
|
||||
"theme": "Teema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Palvelimen tilastot",
|
||||
"loadingServerStats": "Ladataan palvelimen tilastoja...",
|
||||
"noServerData": "Palvelintietoja ei saatavilla",
|
||||
"cpu": "Suoritin",
|
||||
"ram": "RAM-muistia",
|
||||
"customizeLayout": "Mukauta kojelautaa",
|
||||
"dashboardSettings": "Kojelaudan asetukset",
|
||||
"enableDisableCards": "Korttien käyttöönotto/pois käytöstä",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Anna kelvollinen verkko-osoite",
|
||||
"serviceLinksAddFailed": "Palvelulinkin lisääminen epäonnistui",
|
||||
"disk": "Levy",
|
||||
"viewServerDetails": "Näytä palvelimen tiedot"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Vähintään 1 h · Enintään 720 h",
|
||||
"monitoringDefaults": "Valvonnan oletusarvot",
|
||||
"statusCheck": "Tilatarkistus",
|
||||
"metrics": "Mittarit",
|
||||
"sec": "sek",
|
||||
"logLevel": "Lokitaso",
|
||||
"enableGuacamole": "Ota guacamole käyttöön",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Välityspalvelin/portti",
|
||||
"hostDefaultsSocks5Username": "Välityspalvelimen käyttäjätunnus",
|
||||
"hostDefaultsSocks5Password": "Välityspalvelimen salasana",
|
||||
"hostDefaultsMetrics": "Isännän mittarit",
|
||||
"hostDefaultsMetricsEnabled": "Ota mittarit käyttöön",
|
||||
"hostDefaultsMetricsEnabledDesc": "Kerää oletuksena uusien isäntien suorittimen, muistin ja muita tilastoja",
|
||||
"hostDefaultsStatusCheckEnabled": "Ota tilan tarkistus käyttöön",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Kysely online/offline-tila uusilla isännöillä oletuksena",
|
||||
"hostDefaultsTerminal": "Terminaali",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Afficher l'utilisation du disque",
|
||||
"addToFolder": "Ajouter le widget au dossier",
|
||||
"noHostSelected": "Aucun hôte sélectionné",
|
||||
"metricsNotAvailable": "Métriques non disponibles",
|
||||
"selectHost": "Sélectionner un hôte…",
|
||||
"displayedMetrics": "Métriques affichées",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disque",
|
||||
"metricUptime": "Uptime",
|
||||
"metricSystem": "Système",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Noyau",
|
||||
"metricHostname": "Nom d’hôte",
|
||||
"metricNetwork": "Réseau",
|
||||
"metricProcesses": "Processus",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "en cours",
|
||||
"categoryMonitoring": "Surveillance",
|
||||
"loading": "Chargement…",
|
||||
"allClear": "Aucune alerte",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Rejeter les modifications",
|
||||
"movedToFolder": "{{count}} hôte(s) déplacé(s) vers \"{{folder}}\"",
|
||||
"editHostTooltip": "Modifier l'hôte",
|
||||
"statusChecks": "Vérifications d'état",
|
||||
"metricsCollection": "Collecte de métriques",
|
||||
"metricsInterval": "Intervalle de collecte des métriques",
|
||||
"metricsIntervalDesc": "Fréquence de collecte des statistiques du serveur (5 s - 1 h)",
|
||||
"behavior": "Comportement",
|
||||
"themePreview": "Aperçu du thème",
|
||||
"theme": "Thème",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Statistiques serveur",
|
||||
"loadingServerStats": "Chargement des statistiques serveur...",
|
||||
"noServerData": "Aucune donnée serveur disponible",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Personnaliser le tableau de bord",
|
||||
"dashboardSettings": "Paramètres du tableau de bord",
|
||||
"enableDisableCards": "Activer/Désactiver les cartes",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Saisissez une adresse Web valide",
|
||||
"serviceLinksAddFailed": "Échec de l’ajout du lien de service",
|
||||
"disk": "Disque",
|
||||
"viewServerDetails": "Afficher les détails du serveur"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min 1 h · Max 720 h",
|
||||
"monitoringDefaults": "Paramètres de surveillance par défaut",
|
||||
"statusCheck": "Vérification de l'état",
|
||||
"metrics": "Métriques",
|
||||
"sec": "s",
|
||||
"logLevel": "Niveau de journalisation",
|
||||
"enableGuacamole": "Activer Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Hôte / Port du proxy",
|
||||
"hostDefaultsSocks5Username": "Nom d’utilisateur du proxy",
|
||||
"hostDefaultsSocks5Password": "Mot de passe du proxy",
|
||||
"hostDefaultsMetrics": "Métriques de l’hôte",
|
||||
"hostDefaultsMetricsEnabled": "Activer les métriques",
|
||||
"hostDefaultsMetricsEnabledDesc": "Collecter par défaut les statistiques CPU, mémoire, etc. des nouveaux hôtes",
|
||||
"hostDefaultsStatusCheckEnabled": "Activer la vérification de l’état",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Sonder par défaut l’état en ligne/hors ligne des nouveaux hôtes",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "הצג שימוש בדיסק",
|
||||
"addToFolder": "הוסף ווידג'ט לתיקייה",
|
||||
"noHostSelected": "לא נבחר מארח",
|
||||
"metricsNotAvailable": "מדדים אינם זמינים",
|
||||
"selectHost": "בחר מארח...",
|
||||
"displayedMetrics": "מדדים מוצגים",
|
||||
"metricCpu": "מעבד",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "דִיסק",
|
||||
"metricUptime": "זמן פעולה",
|
||||
"metricSystem": "מַעֲרֶכֶת",
|
||||
"metricOs": "מערכת הפעלה",
|
||||
"metricKernel": "גַרעִין",
|
||||
"metricHostname": "שם מארח",
|
||||
"metricNetwork": "רֶשֶׁת",
|
||||
"metricProcesses": "תהליכים",
|
||||
"metricProcessesTotal": "סַך הַכֹּל",
|
||||
"metricProcessesRunning": "רִיצָה",
|
||||
"categoryMonitoring": "ניטור",
|
||||
"loading": "טְעִינָה...",
|
||||
"allClear": "הכל ברור",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "בטל שינויים",
|
||||
"movedToFolder": "הועבר/י מארח/ים {{count}} ל-\"{{folder}}\"",
|
||||
"editHostTooltip": "עריכת מארח",
|
||||
"statusChecks": "בדיקות סטטוס",
|
||||
"metricsCollection": "אוסף מדדים",
|
||||
"metricsInterval": "מרווח איסוף מדדים",
|
||||
"metricsIntervalDesc": "באיזו תדירות לאסוף סטטיסטיקות שרת (5 שניות - שעה)",
|
||||
"behavior": "הִתְנַהֲגוּת",
|
||||
"themePreview": "תצוגה מקדימה של ערכת נושא",
|
||||
"theme": "נוֹשֵׂא",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "סטטיסטיקות שרת",
|
||||
"loadingServerStats": "טוען סטטיסטיקות שרת...",
|
||||
"noServerData": "אין נתוני שרת זמינים",
|
||||
"cpu": "מעבד",
|
||||
"ram": "אַיִל",
|
||||
"customizeLayout": "התאמה אישית של לוח המחוונים",
|
||||
"dashboardSettings": "הגדרות לוח המחוונים",
|
||||
"enableDisableCards": "הפעלה/השבתה של כרטיסים",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "הזן כתובת אינטרנט חוקית",
|
||||
"serviceLinksAddFailed": "נכשל הוספת קישור לשירות",
|
||||
"disk": "דִיסק",
|
||||
"viewServerDetails": "הצג פרטי שרת"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "מינימום שעה · מקסימום 720 שעות",
|
||||
"monitoringDefaults": "ניטור ברירות מחדל",
|
||||
"statusCheck": "בדיקת סטטוס",
|
||||
"metrics": "מדדים",
|
||||
"sec": "שניות",
|
||||
"logLevel": "רמת יומן",
|
||||
"enableGuacamole": "הפעל גוואקמולי",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "מארח פרוקסי / פורט",
|
||||
"hostDefaultsSocks5Username": "שם משתמש פרוקסי",
|
||||
"hostDefaultsSocks5Password": "סיסמת פרוקסי",
|
||||
"hostDefaultsMetrics": "מדדי מארח",
|
||||
"hostDefaultsMetricsEnabled": "הפעל מדדים",
|
||||
"hostDefaultsMetricsEnabledDesc": "איסוף נתונים סטטיסטיים של המעבד, הזיכרון ונתונים סטטיסטיים אחרים במחשבים חדשים כברירת מחדל",
|
||||
"hostDefaultsStatusCheckEnabled": "הפעל בדיקת סטטוס",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "סקר סטטוס מקוון/לא מקוון על מארחים חדשים כברירת מחדל",
|
||||
"hostDefaultsTerminal": "מָסוֹף",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "डिस्क उपयोग दिखाएं",
|
||||
"addToFolder": "फ़ोल्डर में विजेट जोड़ें",
|
||||
"noHostSelected": "कोई होस्ट चयनित नहीं",
|
||||
"metricsNotAvailable": "मेट्रिक्स उपलब्ध नहीं",
|
||||
"selectHost": "होस्ट चुनें...",
|
||||
"displayedMetrics": "प्रदर्शित मेट्रिक्स",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "डिस्क",
|
||||
"metricUptime": "अपटाइम",
|
||||
"metricSystem": "सिस्टम",
|
||||
"metricOs": "ओएस",
|
||||
"metricKernel": "कर्नेल",
|
||||
"metricHostname": "होस्टनेम",
|
||||
"metricNetwork": "नेटवर्क",
|
||||
"metricProcesses": "प्रोसेस",
|
||||
"metricProcessesTotal": "कुल",
|
||||
"metricProcessesRunning": "चालू",
|
||||
"categoryMonitoring": "निगरानी",
|
||||
"loading": "लोड हो रहा है...",
|
||||
"allClear": "सब ठीक है",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "परिवर्तनों को निरस्त करें",
|
||||
"movedToFolder": "{{count}} होस्ट(s) को \"{{folder}}\" में स्थानांतरित किया गया",
|
||||
"editHostTooltip": "होस्ट संपादित करें",
|
||||
"statusChecks": "स्थिति जांच",
|
||||
"metricsCollection": "मेट्रिक्स संग्रहण",
|
||||
"metricsInterval": "मेट्रिक्स संग्रहण अंतराल",
|
||||
"metricsIntervalDesc": "सर्वर आँकड़े कितनी बार एकत्र करें (5s - 1h)",
|
||||
"behavior": "व्यवहार",
|
||||
"themePreview": "थीम पूर्वावलोकन",
|
||||
"theme": "थीम",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "सर्वर आँकड़े",
|
||||
"loadingServerStats": "सर्वर आँकड़े लोड हो रहे हैं...",
|
||||
"noServerData": "कोई सर्वर डेटा उपलब्ध नहीं है",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "डैशबोर्ड अनुकूलित करें",
|
||||
"dashboardSettings": "डैशबोर्ड सेटिंग्स",
|
||||
"enableDisableCards": "कार्ड सक्षम/अक्षम करें",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "एक मान्य वेब पता दर्ज करें",
|
||||
"serviceLinksAddFailed": "सर्विस लिंक जोड़ने में विफल",
|
||||
"disk": "डिस्क",
|
||||
"viewServerDetails": "सर्वर विवरण देखें"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "न्यूनतम 1 घंटा · अधिकतम 720 घंटे",
|
||||
"monitoringDefaults": "निगरानी डिफ़ॉल्ट",
|
||||
"statusCheck": "स्थिति जाँच",
|
||||
"metrics": "मेट्रिक्स",
|
||||
"sec": "सेकंड",
|
||||
"logLevel": "लॉग स्तर",
|
||||
"enableGuacamole": "Guacamole सक्षम करें",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "प्रॉक्सी होस्ट / पोर्ट",
|
||||
"hostDefaultsSocks5Username": "प्रॉक्सी उपयोगकर्ता नाम",
|
||||
"hostDefaultsSocks5Password": "प्रॉक्सी पासवर्ड",
|
||||
"hostDefaultsMetrics": "होस्ट मेट्रिक्स",
|
||||
"hostDefaultsMetricsEnabled": "मेट्रिक्स सक्षम करें",
|
||||
"hostDefaultsMetricsEnabledDesc": "डिफ़ॉल्ट रूप से नए होस्ट पर CPU, मेमोरी और अन्य आँकड़े एकत्र करें",
|
||||
"hostDefaultsStatusCheckEnabled": "स्टेटस चेक सक्षम करें",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "डिफ़ॉल्ट रूप से नए होस्ट पर ऑनलाइन/ऑफ़लाइन स्थिति की जाँच करें",
|
||||
"hostDefaultsTerminal": "टर्मिनल",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Lemezhasználat megjelenítése",
|
||||
"addToFolder": "Widget hozzáadása mappához",
|
||||
"noHostSelected": "Nincs kiválasztva gazdagép",
|
||||
"metricsNotAvailable": "Mutatók nem állnak rendelkezésre",
|
||||
"selectHost": "Válasszon egy hosztot...",
|
||||
"displayedMetrics": "Megjelenített mutatók",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Korong",
|
||||
"metricUptime": "Üzemidő",
|
||||
"metricSystem": "Rendszer",
|
||||
"metricOs": "operációs rendszer",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Gazdagépnév",
|
||||
"metricNetwork": "Hálózat",
|
||||
"metricProcesses": "Folyamatok",
|
||||
"metricProcessesTotal": "teljes",
|
||||
"metricProcessesRunning": "futás",
|
||||
"categoryMonitoring": "Monitoring",
|
||||
"loading": "Terhelés...",
|
||||
"allClear": "Minden tiszta",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Változtatások elvetése",
|
||||
"movedToFolder": "{{count}} hoszt(oka)t áthelyeztem ide: \"{{folder}}\"",
|
||||
"editHostTooltip": "Gazdagép szerkesztése",
|
||||
"statusChecks": "Állapotellenőrzések",
|
||||
"metricsCollection": "Metrikagyűjtemény",
|
||||
"metricsInterval": "Metrikagyűjtési intervallum",
|
||||
"metricsIntervalDesc": "Milyen gyakran gyűjtsünk szerverstatisztikákat (5 másodperc - 1 óra)",
|
||||
"behavior": "Viselkedés",
|
||||
"themePreview": "Téma előnézete",
|
||||
"theme": "Téma",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Szerver statisztikák",
|
||||
"loadingServerStats": "Szerverstatisztikák betöltése...",
|
||||
"noServerData": "Nincsenek elérhető szerveradatok",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Irányítópult testreszabása",
|
||||
"dashboardSettings": "Irányítópult beállításai",
|
||||
"enableDisableCards": "Kártyák engedélyezése/letiltása",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Adjon meg egy érvényes webcímet",
|
||||
"serviceLinksAddFailed": "Nem sikerült hozzáadni a szolgáltatás linkjét",
|
||||
"disk": "Korong",
|
||||
"viewServerDetails": "Szerver részleteinek megtekintése"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Minimum 1 óra · Max. 720 óra",
|
||||
"monitoringDefaults": "Monitorozási alapértelmezések",
|
||||
"statusCheck": "Állapotellenőrzés",
|
||||
"metrics": "Metrikák",
|
||||
"sec": "másodperc",
|
||||
"logLevel": "Naplózási szint",
|
||||
"enableGuacamole": "Guacamole engedélyezése",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxy host / port",
|
||||
"hostDefaultsSocks5Username": "Proxy felhasználónév",
|
||||
"hostDefaultsSocks5Password": "Proxy jelszó",
|
||||
"hostDefaultsMetrics": "Gazdagép-metrikák",
|
||||
"hostDefaultsMetricsEnabled": "Metrikák engedélyezése",
|
||||
"hostDefaultsMetricsEnabledDesc": "Alapértelmezés szerint gyűjtsön CPU-, memória- és egyéb statisztikákat az új hosztokon",
|
||||
"hostDefaultsStatusCheckEnabled": "Állapotellenőrzés engedélyezése",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Online/offline állapot lekérdezése új gazdagépeken alapértelmezés szerint",
|
||||
"hostDefaultsTerminal": "Terminál",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Tampilkan Penggunaan Disk",
|
||||
"addToFolder": "Tambahkan widget ke folder",
|
||||
"noHostSelected": "Tidak ada pembawa acara yang dipilih.",
|
||||
"metricsNotAvailable": "Metrik tidak tersedia",
|
||||
"selectHost": "Pilih tuan rumah...",
|
||||
"displayedMetrics": "Metrik yang Ditampilkan",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Waktu aktif",
|
||||
"metricSystem": "Sistem",
|
||||
"metricOs": "Sistem Operasi",
|
||||
"metricKernel": "Inti",
|
||||
"metricHostname": "Nama host",
|
||||
"metricNetwork": "Jaringan",
|
||||
"metricProcesses": "Proses",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "berlari",
|
||||
"categoryMonitoring": "Pemantauan",
|
||||
"loading": "Memuat...",
|
||||
"allClear": "Semuanya aman.",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Abaikan perubahan",
|
||||
"movedToFolder": "Host {{count}} dipindahkan ke \"{{folder}}\"",
|
||||
"editHostTooltip": "Edit host",
|
||||
"statusChecks": "Pemeriksaan Status",
|
||||
"metricsCollection": "Pengumpulan Metrik",
|
||||
"metricsInterval": "Interval Pengumpulan Metrik",
|
||||
"metricsIntervalDesc": "Seberapa sering mengumpulkan statistik server (5 detik - 1 jam)",
|
||||
"behavior": "Perilaku",
|
||||
"themePreview": "Pratinjau Tema",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Statistik Server",
|
||||
"loadingServerStats": "Memuat statistik server...",
|
||||
"noServerData": "Tidak ada data server yang tersedia.",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Sesuaikan Dasbor",
|
||||
"dashboardSettings": "Pengaturan Dasbor",
|
||||
"enableDisableCards": "Aktifkan/Nonaktifkan Kartu",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Masukkan alamat web yang valid",
|
||||
"serviceLinksAddFailed": "Gagal menambahkan tautan layanan",
|
||||
"disk": "Disk",
|
||||
"viewServerDetails": "Lihat detail server"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Minimal 1 jam · Maksimal 720 jam",
|
||||
"monitoringDefaults": "Pemantauan Default",
|
||||
"statusCheck": "Pemeriksaan Status",
|
||||
"metrics": "Metrik",
|
||||
"sec": "detik",
|
||||
"logLevel": "Tingkat Log",
|
||||
"enableGuacamole": "Aktifkan Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Host/Port Proksi",
|
||||
"hostDefaultsSocks5Username": "Nama Pengguna Proksi",
|
||||
"hostDefaultsSocks5Password": "Kata Sandi Proksi",
|
||||
"hostDefaultsMetrics": "Metrik Host",
|
||||
"hostDefaultsMetricsEnabled": "Aktifkan Metrik",
|
||||
"hostDefaultsMetricsEnabledDesc": "Secara default, kumpulkan statistik CPU, memori, dan statistik lainnya pada host baru.",
|
||||
"hostDefaultsStatusCheckEnabled": "Aktifkan Pemeriksaan Status",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Secara default, lakukan polling status online/offline pada host baru.",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Mostra utilizzo disco",
|
||||
"addToFolder": "Aggiungi widget alla cartella",
|
||||
"noHostSelected": "Nessun host selezionato",
|
||||
"metricsNotAvailable": "Metriche non disponibili",
|
||||
"selectHost": "Seleziona un host...",
|
||||
"displayedMetrics": "Metriche visualizzate",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disco",
|
||||
"metricUptime": "Uptime",
|
||||
"metricSystem": "Sistema",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Nome host",
|
||||
"metricNetwork": "Rete",
|
||||
"metricProcesses": "Processi",
|
||||
"metricProcessesTotal": "totale",
|
||||
"metricProcessesRunning": "in esecuzione",
|
||||
"categoryMonitoring": "Monitoraggio",
|
||||
"loading": "Caricamento...",
|
||||
"allClear": "Tutto a posto",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Scarta le modifiche",
|
||||
"movedToFolder": "Spostato/i {{count}} host nella cartella \"{{folder}}\"",
|
||||
"editHostTooltip": "Modifica host",
|
||||
"statusChecks": "Controlli di stato",
|
||||
"metricsCollection": "Raccolta metriche",
|
||||
"metricsInterval": "Intervallo raccolta metriche",
|
||||
"metricsIntervalDesc": "Intervallo di raccolta delle metriche del server (5s - 1h)",
|
||||
"behavior": "Comportamento",
|
||||
"themePreview": "Anteprima tema",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Statistiche server",
|
||||
"loadingServerStats": "Caricamento statistiche server...",
|
||||
"noServerData": "Nessun dato del server disponibile",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Personalizza Dashboard",
|
||||
"dashboardSettings": "Impostazioni Dashboard",
|
||||
"enableDisableCards": "Abilita/Disabilita schede",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Inserisci un indirizzo web valido",
|
||||
"serviceLinksAddFailed": "Impossibile aggiungere il collegamento al servizio",
|
||||
"disk": "Disco",
|
||||
"viewServerDetails": "Visualizza dettagli server"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min 1h · Max 720h",
|
||||
"monitoringDefaults": "Impostazioni predefinite monitoraggio",
|
||||
"statusCheck": "Verifica stato",
|
||||
"metrics": "Metriche",
|
||||
"sec": "sec",
|
||||
"logLevel": "Livello di log",
|
||||
"enableGuacamole": "Abilita Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Host / Porta proxy",
|
||||
"hostDefaultsSocks5Username": "Nome utente proxy",
|
||||
"hostDefaultsSocks5Password": "Password proxy",
|
||||
"hostDefaultsMetrics": "Metriche host",
|
||||
"hostDefaultsMetricsEnabled": "Abilita metriche",
|
||||
"hostDefaultsMetricsEnabledDesc": "Raccogli metriche CPU, memoria e altre sui nuovi host per impostazione predefinita",
|
||||
"hostDefaultsStatusCheckEnabled": "Abilita controllo stato",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Verifica lo stato online/offline sui nuovi host per impostazione predefinita",
|
||||
"hostDefaultsTerminal": "Terminale",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "ディスク使用量を表示",
|
||||
"addToFolder": "ウィジェットをフォルダに追加",
|
||||
"noHostSelected": "ホストが選択されていません",
|
||||
"metricsNotAvailable": "メトリクスは利用できません",
|
||||
"selectHost": "ホストを選択...",
|
||||
"displayedMetrics": "表示メトリクス",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "ディスク",
|
||||
"metricUptime": "稼働時間",
|
||||
"metricSystem": "システム",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "カーネル",
|
||||
"metricHostname": "ホスト名",
|
||||
"metricNetwork": "ネットワーク",
|
||||
"metricProcesses": "プロセス",
|
||||
"metricProcessesTotal": "合計",
|
||||
"metricProcessesRunning": "実行中",
|
||||
"categoryMonitoring": "モニタリング",
|
||||
"loading": "読み込み中...",
|
||||
"allClear": "すべて正常",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "変更を破棄する",
|
||||
"movedToFolder": "{{count}} 個のホストを \"{{folder}}\" に移動しました",
|
||||
"editHostTooltip": "ホストを編集",
|
||||
"statusChecks": "ステータスチェック",
|
||||
"metricsCollection": "メトリクス収集",
|
||||
"metricsInterval": "メトリクス収集間隔",
|
||||
"metricsIntervalDesc": "サーバー統計を収集する頻度 (5秒〜1時間)",
|
||||
"behavior": "動作",
|
||||
"themePreview": "テーマプレビュー",
|
||||
"theme": "テーマ",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "サーバー統計",
|
||||
"loadingServerStats": "サーバー統計を読み込み中...",
|
||||
"noServerData": "サーバーデータがありません",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "ダッシュボードのカスタマイズ",
|
||||
"dashboardSettings": "ダッシュボード設定",
|
||||
"enableDisableCards": "カードの表示/非表示",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "有効なWebアドレスを入力してください",
|
||||
"serviceLinksAddFailed": "サービスリンクの追加に失敗しました",
|
||||
"disk": "ディスク",
|
||||
"viewServerDetails": "サーバーの詳細を表示"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "最小1時間・最大720時間",
|
||||
"monitoringDefaults": "監視のデフォルト",
|
||||
"statusCheck": "ステータスチェック",
|
||||
"metrics": "メトリクス",
|
||||
"sec": "秒",
|
||||
"logLevel": "ログレベル",
|
||||
"enableGuacamole": "Guacamoleを有効にする",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "プロキシホスト / ポート",
|
||||
"hostDefaultsSocks5Username": "プロキシユーザー名",
|
||||
"hostDefaultsSocks5Password": "プロキシパスワード",
|
||||
"hostDefaultsMetrics": "ホストメトリクス",
|
||||
"hostDefaultsMetricsEnabled": "メトリクスを有効にする",
|
||||
"hostDefaultsMetricsEnabledDesc": "デフォルトで新規ホストのCPU、メモリなどの統計情報を収集する",
|
||||
"hostDefaultsStatusCheckEnabled": "ステータスチェックを有効にする",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "デフォルトで新規ホストのオンライン/オフライン状態をポーリングする",
|
||||
"hostDefaultsTerminal": "ターミナル",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "디스크 사용량 표시",
|
||||
"addToFolder": "폴더에 위젯 추가",
|
||||
"noHostSelected": "선택된 호스트 없음",
|
||||
"metricsNotAvailable": "메트릭을 사용할 수 없습니다",
|
||||
"selectHost": "호스트 선택...",
|
||||
"displayedMetrics": "표시된 메트릭",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "디스크",
|
||||
"metricUptime": "업타임",
|
||||
"metricSystem": "시스템",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "커널",
|
||||
"metricHostname": "호스트명",
|
||||
"metricNetwork": "네트워크",
|
||||
"metricProcesses": "프로세스",
|
||||
"metricProcessesTotal": "전체",
|
||||
"metricProcessesRunning": "실행 중",
|
||||
"categoryMonitoring": "모니터링",
|
||||
"loading": "로딩 중...",
|
||||
"allClear": "모두 정상",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "잔돈을 버리세요",
|
||||
"movedToFolder": "호스트 {{count}}개를 \"{{folder}}\" 폴더로 이동했습니다",
|
||||
"editHostTooltip": "호스트 편집",
|
||||
"statusChecks": "상태 확인",
|
||||
"metricsCollection": "메트릭 수집",
|
||||
"metricsInterval": "메트릭 수집 간격",
|
||||
"metricsIntervalDesc": "서버 통계 수집 빈도 (5초 - 1시간)",
|
||||
"behavior": "동작",
|
||||
"themePreview": "테마 미리보기",
|
||||
"theme": "테마",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "서버 통계",
|
||||
"loadingServerStats": "서버 통계 로딩 중...",
|
||||
"noServerData": "서버 데이터 없음",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "대시보드 사용자 지정",
|
||||
"dashboardSettings": "대시보드 설정",
|
||||
"enableDisableCards": "카드 활성화/비활성화",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "올바른 웹 주소를 입력하세요",
|
||||
"serviceLinksAddFailed": "서비스 링크 추가 실패",
|
||||
"disk": "디스크",
|
||||
"viewServerDetails": "서버 세부정보 보기"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "최소 1시간 · 최대 720시간",
|
||||
"monitoringDefaults": "모니터링 기본값",
|
||||
"statusCheck": "상태 확인",
|
||||
"metrics": "메트릭",
|
||||
"sec": "초",
|
||||
"logLevel": "로그 레벨",
|
||||
"enableGuacamole": "Guacamole 활성화",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "프록시 호스트 / 포트",
|
||||
"hostDefaultsSocks5Username": "프록시 사용자 이름",
|
||||
"hostDefaultsSocks5Password": "프록시 비밀번호",
|
||||
"hostDefaultsMetrics": "호스트 메트릭",
|
||||
"hostDefaultsMetricsEnabled": "메트릭 활성화",
|
||||
"hostDefaultsMetricsEnabledDesc": "기본적으로 새 호스트에서 CPU, 메모리 등 통계 수집",
|
||||
"hostDefaultsStatusCheckEnabled": "상태 확인 활성화",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "기본적으로 새 호스트의 온라인/오프라인 상태 확인",
|
||||
"hostDefaultsTerminal": "터미널",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Schijfgebruik weergeven",
|
||||
"addToFolder": "Widget toevoegen aan map",
|
||||
"noHostSelected": "Geen host geselecteerd",
|
||||
"metricsNotAvailable": "Gegevens niet beschikbaar",
|
||||
"selectHost": "Selecteer een host...",
|
||||
"displayedMetrics": "Weergegeven statistieken",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Schijf",
|
||||
"metricUptime": "Beschikbaarheid",
|
||||
"metricSystem": "Systeem",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Hostnaam",
|
||||
"metricNetwork": "Netwerk",
|
||||
"metricProcesses": "Processen",
|
||||
"metricProcessesTotal": "totaal",
|
||||
"metricProcessesRunning": "rennen",
|
||||
"categoryMonitoring": "Monitoring",
|
||||
"loading": "Laden...",
|
||||
"allClear": "Alles in orde",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Wijzigingen negeren",
|
||||
"movedToFolder": "Verplaatst {{count}} host(s) naar \"{{folder}}\"",
|
||||
"editHostTooltip": "Bewerk de host",
|
||||
"statusChecks": "Statuscontroles",
|
||||
"metricsCollection": "Gegevensverzameling",
|
||||
"metricsInterval": "Interval voor het verzamelen van meetgegevens",
|
||||
"metricsIntervalDesc": "Hoe vaak moeten serverstatistieken worden verzameld (5s - 1u)?",
|
||||
"behavior": "Gedrag",
|
||||
"themePreview": "Themavoorbeeld",
|
||||
"theme": "Thema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Serverstatistieken",
|
||||
"loadingServerStats": "Serverstatistieken worden geladen...",
|
||||
"noServerData": "Geen servergegevens beschikbaar",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Dashboard aanpassen",
|
||||
"dashboardSettings": "Dashboardinstellingen",
|
||||
"enableDisableCards": "Kaarten in-/uitschakelen",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Voer een geldig webadres in.",
|
||||
"serviceLinksAddFailed": "Het toevoegen van de servicelink is mislukt.",
|
||||
"disk": "Schijf",
|
||||
"viewServerDetails": "Servergegevens bekijken"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Minimaal 1 uur · Maximaal 720 uur",
|
||||
"monitoringDefaults": "Standaardinstellingen bewaken",
|
||||
"statusCheck": "Statuscontrole",
|
||||
"metrics": "Statistieken",
|
||||
"sec": "sec",
|
||||
"logLevel": "Logniveau",
|
||||
"enableGuacamole": "Schakel Guacamole in",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxy-host / poort",
|
||||
"hostDefaultsSocks5Username": "Proxy-gebruikersnaam",
|
||||
"hostDefaultsSocks5Password": "Proxy-wachtwoord",
|
||||
"hostDefaultsMetrics": "Hoststatistieken",
|
||||
"hostDefaultsMetricsEnabled": "Statistieken inschakelen",
|
||||
"hostDefaultsMetricsEnabledDesc": "Verzamel standaard CPU-, geheugen- en andere statistieken van nieuwe hosts.",
|
||||
"hostDefaultsStatusCheckEnabled": "Statuscontrole inschakelen",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Controleer standaard de online/offline-status van nieuwe hosts.",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Vis diskbruk",
|
||||
"addToFolder": "Legg til widget i mappe",
|
||||
"noHostSelected": "Ingen vert valgt",
|
||||
"metricsNotAvailable": "Målinger er ikke tilgjengelige",
|
||||
"selectHost": "Velg en vert...",
|
||||
"displayedMetrics": "Viste målinger",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Oppetid",
|
||||
"metricSystem": "System",
|
||||
"metricOs": "OS",
|
||||
"metricKernel": "Kjerne",
|
||||
"metricHostname": "Vertsnavn",
|
||||
"metricNetwork": "Nettverk",
|
||||
"metricProcesses": "Prosesser",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "løping",
|
||||
"categoryMonitoring": "Overvåking",
|
||||
"loading": "Laster inn...",
|
||||
"allClear": "Alt klart",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Forkast endringer",
|
||||
"movedToFolder": "Flyttet {{count}} vert(er) til \"{{folder}}\"",
|
||||
"editHostTooltip": "Rediger vert",
|
||||
"statusChecks": "Statuskontroller",
|
||||
"metricsCollection": "Metriksamling",
|
||||
"metricsInterval": "Intervall for innsamling av metrikk",
|
||||
"metricsIntervalDesc": "Hvor ofte skal serverstatistikk samles inn (5 sekunder - 1 time)",
|
||||
"behavior": "Oppførsel",
|
||||
"themePreview": "Forhåndsvisning av tema",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Serverstatistikk",
|
||||
"loadingServerStats": "Laster inn serverstatistikk ...",
|
||||
"noServerData": "Ingen serverdata tilgjengelig",
|
||||
"cpu": "CPU",
|
||||
"ram": "VÆR",
|
||||
"customizeLayout": "Tilpass dashbordet",
|
||||
"dashboardSettings": "Innstillinger for dashbord",
|
||||
"enableDisableCards": "Aktiver/deaktiver kort",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Skriv inn en gyldig nettadresse",
|
||||
"serviceLinksAddFailed": "Kunne ikke legge til tjenestelenke",
|
||||
"disk": "Disk",
|
||||
"viewServerDetails": "Vis serverdetaljer"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min. 1 t · Maks. 720 t",
|
||||
"monitoringDefaults": "Overvåking av standardinnstillinger",
|
||||
"statusCheck": "Statussjekk",
|
||||
"metrics": "Målinger",
|
||||
"sec": "sek",
|
||||
"logLevel": "Loggnivå",
|
||||
"enableGuacamole": "Aktiver guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxy-vert / -port",
|
||||
"hostDefaultsSocks5Username": "Proxy-brukernavn",
|
||||
"hostDefaultsSocks5Password": "Proxy-passord",
|
||||
"hostDefaultsMetrics": "Vertsmålinger",
|
||||
"hostDefaultsMetricsEnabled": "Aktiver målinger",
|
||||
"hostDefaultsMetricsEnabledDesc": "Samle inn CPU-, minne- og annen statistikk på nye verter som standard",
|
||||
"hostDefaultsStatusCheckEnabled": "Aktiver statussjekk",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Avstem online/offline-status på nye verter som standard",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Pokaż użycie dysku",
|
||||
"addToFolder": "Dodaj widżet do folderu",
|
||||
"noHostSelected": "Nie wybrano hosta",
|
||||
"metricsNotAvailable": "Metryki niedostępne",
|
||||
"selectHost": "Wybierz hosta...",
|
||||
"displayedMetrics": "Wyświetlane metryki",
|
||||
"metricCpu": "Procesor",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Dysk",
|
||||
"metricUptime": "Czas działania",
|
||||
"metricSystem": "System",
|
||||
"metricOs": "System operacyjny",
|
||||
"metricKernel": "Jądro",
|
||||
"metricHostname": "Nazwa hosta",
|
||||
"metricNetwork": "Sieć",
|
||||
"metricProcesses": "Procesy",
|
||||
"metricProcessesTotal": "łącznie",
|
||||
"metricProcessesRunning": "działa",
|
||||
"categoryMonitoring": "Monitorowanie",
|
||||
"loading": "Ładowanie...",
|
||||
"allClear": "Bez alertów",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Odrzuć zmiany",
|
||||
"movedToFolder": "Przeniesiono {{count}} hostów do „{{folder}}”",
|
||||
"editHostTooltip": "Edytuj hosta",
|
||||
"statusChecks": "Sprawdzanie statusu",
|
||||
"metricsCollection": "Zbieranie metryk",
|
||||
"metricsInterval": "Interwał zbierania metryk",
|
||||
"metricsIntervalDesc": "Jak często zbierać statystyki serwera (5 sek. - 1 godz.)",
|
||||
"behavior": "Zachowanie",
|
||||
"themePreview": "Podgląd motywu",
|
||||
"theme": "Motyw",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Statystyki serwera",
|
||||
"loadingServerStats": "Ładowanie statystyk serwera...",
|
||||
"noServerData": "Brak dostępnych danych serwera",
|
||||
"cpu": "Procesor",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Dostosuj pulpit",
|
||||
"dashboardSettings": "Ustawienia pulpitu",
|
||||
"enableDisableCards": "Włącz/wyłącz karty",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Wprowadź prawidłowy adres internetowy",
|
||||
"serviceLinksAddFailed": "Nie udało się dodać linku do usługi",
|
||||
"disk": "Dysk",
|
||||
"viewServerDetails": "Wyświetl szczegóły serwera"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min. 1 godz. · Maks. 720 godz.",
|
||||
"monitoringDefaults": "Domyślne ustawienia monitorowania",
|
||||
"statusCheck": "Sprawdzanie statusu",
|
||||
"metrics": "Metryki",
|
||||
"sec": "sek",
|
||||
"logLevel": "Poziom logowania",
|
||||
"enableGuacamole": "Włącz Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Host/Port proxy",
|
||||
"hostDefaultsSocks5Username": "Nazwa użytkownika proxy",
|
||||
"hostDefaultsSocks5Password": "Hasło proxy",
|
||||
"hostDefaultsMetrics": "Metryki hosta",
|
||||
"hostDefaultsMetricsEnabled": "Włącz metryki",
|
||||
"hostDefaultsMetricsEnabledDesc": "Domyślnie zbieraj na nowych hostach dane o procesorze, pamięci i inne statystyki",
|
||||
"hostDefaultsStatusCheckEnabled": "Włącz sprawdzanie statusu",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Domyślnie sprawdzaj status online/offline na nowych hostach",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Mostrar uso do disco",
|
||||
"addToFolder": "Adicionar widget à pasta",
|
||||
"noHostSelected": "Nenhum host selecionado",
|
||||
"metricsNotAvailable": "Métricas não disponíveis",
|
||||
"selectHost": "Selecione um host...",
|
||||
"displayedMetrics": "Métricas exibidas",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disco",
|
||||
"metricUptime": "Tempo de atividade",
|
||||
"metricSystem": "Sistema",
|
||||
"metricOs": "SO",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Nome do host",
|
||||
"metricNetwork": "Rede",
|
||||
"metricProcesses": "Processos",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "correndo",
|
||||
"categoryMonitoring": "Monitoramento",
|
||||
"loading": "Carregando...",
|
||||
"allClear": "Tudo certo",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Descartar alterações",
|
||||
"movedToFolder": "Movidos os {{count}} hosts para \"{{folder}}\"",
|
||||
"editHostTooltip": "Editar anfitrião",
|
||||
"statusChecks": "Verificações de status",
|
||||
"metricsCollection": "Coleta de métricas",
|
||||
"metricsInterval": "Intervalo de coleta de métricas",
|
||||
"metricsIntervalDesc": "Com que frequência coletar estatísticas do servidor (5s - 1h)",
|
||||
"behavior": "Comportamento",
|
||||
"themePreview": "Pré-visualização do tema",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Estatísticas do servidor",
|
||||
"loadingServerStats": "Carregando estatísticas do servidor...",
|
||||
"noServerData": "Não há dados disponíveis no servidor.",
|
||||
"cpu": "CPU",
|
||||
"ram": "BATER",
|
||||
"customizeLayout": "Personalize o painel de controle",
|
||||
"dashboardSettings": "Configurações do painel",
|
||||
"enableDisableCards": "Ativar/Desativar Cartões",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Insira um endereço web válido.",
|
||||
"serviceLinksAddFailed": "Falha ao adicionar o link de serviço",
|
||||
"disk": "Disco",
|
||||
"viewServerDetails": "Ver detalhes do servidor"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Mínimo 1h · Máximo 720h",
|
||||
"monitoringDefaults": "Monitoramento de padrões",
|
||||
"statusCheck": "Verificação de status",
|
||||
"metrics": "Métricas",
|
||||
"sec": "seg",
|
||||
"logLevel": "Nível de registro",
|
||||
"enableGuacamole": "Ativar Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Host/Porta do proxy",
|
||||
"hostDefaultsSocks5Username": "Nome de usuário do proxy",
|
||||
"hostDefaultsSocks5Password": "Senha do proxy",
|
||||
"hostDefaultsMetrics": "Métricas do host",
|
||||
"hostDefaultsMetricsEnabled": "Ativar métricas",
|
||||
"hostDefaultsMetricsEnabledDesc": "Coletar por padrão estatísticas de CPU, memória e outros dados em novos hosts.",
|
||||
"hostDefaultsStatusCheckEnabled": "Ativar verificação de status",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Verificar o status online/offline de novos hosts por padrão",
|
||||
"hostDefaultsTerminal": "terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Mostrar utilização do disco",
|
||||
"addToFolder": "Adicionar widget à pasta",
|
||||
"noHostSelected": "Nenhum anfitrião selecionado",
|
||||
"metricsNotAvailable": "Métricas não disponíveis",
|
||||
"selectHost": "Selecionar um host...",
|
||||
"displayedMetrics": "Métricas Exibidas",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disco",
|
||||
"metricUptime": "Tempo de atividade",
|
||||
"metricSystem": "Sistema",
|
||||
"metricOs": "SO",
|
||||
"metricKernel": "Kernel",
|
||||
"metricHostname": "Nome do host",
|
||||
"metricNetwork": "Rede",
|
||||
"metricProcesses": "Processos",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "em execução",
|
||||
"categoryMonitoring": "Monitorização",
|
||||
"loading": "A carregar...",
|
||||
"allClear": "Tudo em ordem",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Descartar alterações",
|
||||
"movedToFolder": "Movido(s) {{count}} host(s) para \"{{folder}}\"",
|
||||
"editHostTooltip": "Editar host",
|
||||
"statusChecks": "Verificações de estado",
|
||||
"metricsCollection": "Recolha de métricas",
|
||||
"metricsInterval": "Intervalo de recolha de métricas",
|
||||
"metricsIntervalDesc": "Com que frequência recolher estatísticas do servidor (5s - 1h)",
|
||||
"behavior": "Comportamento",
|
||||
"themePreview": "Pré-visualização do tema",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Estatísticas do Servidor",
|
||||
"loadingServerStats": "A carregar estatísticas do servidor...",
|
||||
"noServerData": "Nenhum dado do servidor disponível",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Personalizar Dashboard",
|
||||
"dashboardSettings": "Definições do Dashboard",
|
||||
"enableDisableCards": "Ativar/Desativar Cartões",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Insira um endereço web válido",
|
||||
"serviceLinksAddFailed": "Falha ao adicionar link de serviço",
|
||||
"disk": "Disco",
|
||||
"viewServerDetails": "Ver detalhes do servidor"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Mín. 1h · Máx. 720h",
|
||||
"monitoringDefaults": "Padrões de Monitorização",
|
||||
"statusCheck": "Verificação de Estado",
|
||||
"metrics": "Métricas",
|
||||
"sec": "seg",
|
||||
"logLevel": "Nível de Registo",
|
||||
"enableGuacamole": "Ativar Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Host / Porta do proxy",
|
||||
"hostDefaultsSocks5Username": "Nome de utilizador do proxy",
|
||||
"hostDefaultsSocks5Password": "Palavra-passe do proxy",
|
||||
"hostDefaultsMetrics": "Métricas do Host",
|
||||
"hostDefaultsMetricsEnabled": "Ativar Métricas",
|
||||
"hostDefaultsMetricsEnabledDesc": "Recolher CPU, memória e outras estatísticas nos novos hosts por predefinição",
|
||||
"hostDefaultsStatusCheckEnabled": "Ativar Verificação de Estado",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Consultar o estado online/offline nos novos hosts por predefinição",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Afișează utilizarea discului",
|
||||
"addToFolder": "Adăugați widgetul în folder",
|
||||
"noHostSelected": "Nicio gazdă selectată",
|
||||
"metricsNotAvailable": "Indicatorii nu sunt disponibili",
|
||||
"selectHost": "Selectați o gazdă...",
|
||||
"displayedMetrics": "Metrici afișate",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disc",
|
||||
"metricUptime": "Timp de funcționare",
|
||||
"metricSystem": "Sistem",
|
||||
"metricOs": "Sistem de operare",
|
||||
"metricKernel": "Nucleu",
|
||||
"metricHostname": "Nume gazdă",
|
||||
"metricNetwork": "Reţea",
|
||||
"metricProcesses": "Procese",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "funcţionare",
|
||||
"categoryMonitoring": "Monitorizare",
|
||||
"loading": "Încărcare...",
|
||||
"allClear": "Totul este clar",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Anulează modificările",
|
||||
"movedToFolder": "Gazdele {{count}} au fost mutate la „{{folder}}”",
|
||||
"editHostTooltip": "Editați gazda",
|
||||
"statusChecks": "Verificări de stare",
|
||||
"metricsCollection": "Colecție de metrici",
|
||||
"metricsInterval": "Interval de colectare a metricilor",
|
||||
"metricsIntervalDesc": "Cât de des se colectează statisticile serverului (5s - 1h)",
|
||||
"behavior": "Comportament",
|
||||
"themePreview": "Previzualizare temă",
|
||||
"theme": "Temă",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Statistici server",
|
||||
"loadingServerStats": "Se încarcă statisticile serverului...",
|
||||
"noServerData": "Nu sunt disponibile date de server",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Personalizați tabloul de bord",
|
||||
"dashboardSettings": "Setări tablou de bord",
|
||||
"enableDisableCards": "Activare/Dezactivare carduri",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Introduceți o adresă web validă",
|
||||
"serviceLinksAddFailed": "Nu s-a putut adăuga linkul de serviciu",
|
||||
"disk": "Disc",
|
||||
"viewServerDetails": "Vizualizați detaliile serverului"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min 1 oră · Max 720 ore",
|
||||
"monitoringDefaults": "Monitorizarea setărilor implicite",
|
||||
"statusCheck": "Verificare stare",
|
||||
"metrics": "Metrici",
|
||||
"sec": "sec",
|
||||
"logLevel": "Nivel de jurnal",
|
||||
"enableGuacamole": "Activează Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Gazdă / Port proxy",
|
||||
"hostDefaultsSocks5Username": "Nume de utilizator proxy",
|
||||
"hostDefaultsSocks5Password": "Parolă proxy",
|
||||
"hostDefaultsMetrics": "Metrici ale gazdei",
|
||||
"hostDefaultsMetricsEnabled": "Activați metricile",
|
||||
"hostDefaultsMetricsEnabledDesc": "Colectați în mod implicit statistici despre procesor, memorie și alte date despre gazdele noi",
|
||||
"hostDefaultsStatusCheckEnabled": "Activează verificarea stării",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Sondarea implicită a stării online/offline pentru gazdele noi",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Показывать использование диска",
|
||||
"addToFolder": "Добавить виджет в папку",
|
||||
"noHostSelected": "Хост не выбран",
|
||||
"metricsNotAvailable": "Метрики недоступны",
|
||||
"selectHost": "Выберите хост...",
|
||||
"displayedMetrics": "Отображаемые метрики",
|
||||
"metricCpu": "ЦП",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Диск",
|
||||
"metricUptime": "Время работы",
|
||||
"metricSystem": "Система",
|
||||
"metricOs": "ОС",
|
||||
"metricKernel": "Ядро",
|
||||
"metricHostname": "Имя хоста",
|
||||
"metricNetwork": "Сеть",
|
||||
"metricProcesses": "Процессы",
|
||||
"metricProcessesTotal": "всего",
|
||||
"metricProcessesRunning": "запущено",
|
||||
"categoryMonitoring": "Мониторинг",
|
||||
"loading": "Загрузка...",
|
||||
"allClear": "Всё в порядке",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Отменить изменения",
|
||||
"movedToFolder": "Перемещено {{count}} хост(а/ов) в \"{{folder}}\"",
|
||||
"editHostTooltip": "Редактировать хост",
|
||||
"statusChecks": "Проверки статуса",
|
||||
"metricsCollection": "Сбор метрик",
|
||||
"metricsInterval": "Интервал сбора метрик",
|
||||
"metricsIntervalDesc": "Как часто собирать статистику сервера (5с - 1ч)",
|
||||
"behavior": "Поведение",
|
||||
"themePreview": "Предпросмотр темы",
|
||||
"theme": "Тема",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Статистика сервера",
|
||||
"loadingServerStats": "Загрузка статистики сервера...",
|
||||
"noServerData": "Нет данных о сервере",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Настроить дашборд",
|
||||
"dashboardSettings": "Настройки дашборда",
|
||||
"enableDisableCards": "Включить/отключить карточки",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Введите действительный веб-адрес",
|
||||
"serviceLinksAddFailed": "Не удалось добавить сервисную ссылку",
|
||||
"disk": "Диск",
|
||||
"viewServerDetails": "Просмотреть сведения о сервере"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Мин. 1 ч · Макс. 720 ч",
|
||||
"monitoringDefaults": "Мониторинг по умолчанию",
|
||||
"statusCheck": "Проверка состояния",
|
||||
"metrics": "Метрики",
|
||||
"sec": "сек",
|
||||
"logLevel": "Уровень логирования",
|
||||
"enableGuacamole": "Включить Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Хост / порт прокси",
|
||||
"hostDefaultsSocks5Username": "Имя пользователя прокси",
|
||||
"hostDefaultsSocks5Password": "Пароль прокси",
|
||||
"hostDefaultsMetrics": "Метрики хоста",
|
||||
"hostDefaultsMetricsEnabled": "Включить метрики",
|
||||
"hostDefaultsMetricsEnabledDesc": "По умолчанию собирать данные о ЦП, памяти и другую статистику с новых хостов",
|
||||
"hostDefaultsStatusCheckEnabled": "Включить проверку состояния",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "По умолчанию опрашивать состояние новых хостов (онлайн/офлайн)",
|
||||
"hostDefaultsTerminal": "Терминал",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Прикажи коришћење диска",
|
||||
"addToFolder": "Додај виџет у фасциклу",
|
||||
"noHostSelected": "Није изабран домаћин",
|
||||
"metricsNotAvailable": "Метрике нису доступне",
|
||||
"selectHost": "Изаберите хост...",
|
||||
"displayedMetrics": "Приказане метрике",
|
||||
"metricCpu": "Процесор",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Диск",
|
||||
"metricUptime": "Време непрекидног рада",
|
||||
"metricSystem": "Систем",
|
||||
"metricOs": "ОС",
|
||||
"metricKernel": "Језгро",
|
||||
"metricHostname": "Име хоста",
|
||||
"metricNetwork": "Мрежа",
|
||||
"metricProcesses": "Процеси",
|
||||
"metricProcessesTotal": "укупно",
|
||||
"metricProcessesRunning": "трчање",
|
||||
"categoryMonitoring": "Праћење",
|
||||
"loading": "Учитавање...",
|
||||
"allClear": "Све је чисто",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Одбаци измене",
|
||||
"movedToFolder": "Премештено {{count}} хост(ова) на „{{folder}}“",
|
||||
"editHostTooltip": "Измени хоста",
|
||||
"statusChecks": "Провере статуса",
|
||||
"metricsCollection": "Колекција метрика",
|
||||
"metricsInterval": "Интервал прикупљања метрика",
|
||||
"metricsIntervalDesc": "Колико често прикупљати статистику сервера (5 с - 1 сат)",
|
||||
"behavior": "Понашање",
|
||||
"themePreview": "Преглед теме",
|
||||
"theme": "Тема",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Статистика сервера",
|
||||
"loadingServerStats": "Учитавање статистике сервера...",
|
||||
"noServerData": "Нема доступних података са сервера",
|
||||
"cpu": "Процесор",
|
||||
"ram": "РАМ меморија",
|
||||
"customizeLayout": "Прилагоди контролну таблу",
|
||||
"dashboardSettings": "Подешавања контролне табле",
|
||||
"enableDisableCards": "Омогући/онемогући картице",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "хттп://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Унесите важећу веб адресу",
|
||||
"serviceLinksAddFailed": "Додавање линка услуге није успело",
|
||||
"disk": "Диск",
|
||||
"viewServerDetails": "Погледајте детаље сервера"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Мин. 1 сат · Макс. 720 сати",
|
||||
"monitoringDefaults": "Праћење подразумеваних подешавања",
|
||||
"statusCheck": "Провера статуса",
|
||||
"metrics": "Метрике",
|
||||
"sec": "сек",
|
||||
"logLevel": "Ниво логова",
|
||||
"enableGuacamole": "Омогући гуакамоле",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Прокси хост / порт",
|
||||
"hostDefaultsSocks5Username": "Корисничко име проксија",
|
||||
"hostDefaultsSocks5Password": "Лозинка проксија",
|
||||
"hostDefaultsMetrics": "Метрике хоста",
|
||||
"hostDefaultsMetricsEnabled": "Омогући метрике",
|
||||
"hostDefaultsMetricsEnabledDesc": "Подразумевано прикупљајте статистику процесора, меморије и друге статистике на новим хостовима",
|
||||
"hostDefaultsStatusCheckEnabled": "Омогући проверу статуса",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Подразумевано проверава статус анкете на мрежи/офлајн на новим хостовима",
|
||||
"hostDefaultsTerminal": "Терминал",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Visa diskanvändning",
|
||||
"addToFolder": "Lägg till widget i mapp",
|
||||
"noHostSelected": "Ingen värd vald",
|
||||
"metricsNotAvailable": "Mätvärden är inte tillgängliga",
|
||||
"selectHost": "Välj en värd...",
|
||||
"displayedMetrics": "Visade mätvärden",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Drifttid",
|
||||
"metricSystem": "System",
|
||||
"metricOs": "operativsystem",
|
||||
"metricKernel": "Kärna",
|
||||
"metricHostname": "Värdnamn",
|
||||
"metricNetwork": "Nätverk",
|
||||
"metricProcesses": "Processer",
|
||||
"metricProcessesTotal": "total",
|
||||
"metricProcessesRunning": "spring",
|
||||
"categoryMonitoring": "Övervakning",
|
||||
"loading": "Belastning...",
|
||||
"allClear": "Faran över",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Ignorera ändringar",
|
||||
"movedToFolder": "Flyttade {{count}} värd(ar) till \"{{folder}}\"",
|
||||
"editHostTooltip": "Redigera värd",
|
||||
"statusChecks": "Statuskontroller",
|
||||
"metricsCollection": "Metriksamling",
|
||||
"metricsInterval": "Intervall för mätvärdeninsamling",
|
||||
"metricsIntervalDesc": "Hur ofta serverstatistik ska samlas in (5 sekunder - 1 timme)",
|
||||
"behavior": "Beteende",
|
||||
"themePreview": "Förhandsvisning av tema",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Serverstatistik",
|
||||
"loadingServerStats": "Laddar serverstatistik...",
|
||||
"noServerData": "Ingen serverdata tillgänglig",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM-minne",
|
||||
"customizeLayout": "Anpassa instrumentpanelen",
|
||||
"dashboardSettings": "Inställningar för instrumentpanelen",
|
||||
"enableDisableCards": "Aktivera/inaktivera kort",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Ange en giltig webbadress",
|
||||
"serviceLinksAddFailed": "Misslyckades med att lägga till tjänstelänk",
|
||||
"disk": "Disk",
|
||||
"viewServerDetails": "Visa serverinformation"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Min 1 timme · Max 720 timmar",
|
||||
"monitoringDefaults": "Övervakning av standardvärden",
|
||||
"statusCheck": "Statuskontroll",
|
||||
"metrics": "Metrik",
|
||||
"sec": "sek",
|
||||
"logLevel": "Loggnivå",
|
||||
"enableGuacamole": "Aktivera guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxyvärd/port",
|
||||
"hostDefaultsSocks5Username": "Proxy-användarnamn",
|
||||
"hostDefaultsSocks5Password": "Proxylösenord",
|
||||
"hostDefaultsMetrics": "Värdstatistik",
|
||||
"hostDefaultsMetricsEnabled": "Aktivera mätvärden",
|
||||
"hostDefaultsMetricsEnabledDesc": "Samla in CPU-, minnes- och annan statistik på nya värdar som standard",
|
||||
"hostDefaultsStatusCheckEnabled": "Aktivera statuskontroll",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Avläs online/offline-status på nya värdar som standard",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "แสดงการใช้งานดิสก์",
|
||||
"addToFolder": "เพิ่มวิดเจ็ตลงในโฟลเดอร์",
|
||||
"noHostSelected": "ไม่ได้เลือกโฮสต์",
|
||||
"metricsNotAvailable": "ข้อมูลตัวชี้วัดไม่พร้อมใช้งาน",
|
||||
"selectHost": "เลือกโฮสต์...",
|
||||
"displayedMetrics": "ตัวชี้วัดที่แสดงผล",
|
||||
"metricCpu": "ซีพียู",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "ดิสก์",
|
||||
"metricUptime": "เวลาใช้งาน",
|
||||
"metricSystem": "ระบบ",
|
||||
"metricOs": "โอเอส",
|
||||
"metricKernel": "เคอร์เนล",
|
||||
"metricHostname": "ชื่อโฮสต์",
|
||||
"metricNetwork": "เครือข่าย",
|
||||
"metricProcesses": "กระบวนการ",
|
||||
"metricProcessesTotal": "ทั้งหมด",
|
||||
"metricProcessesRunning": "วิ่ง",
|
||||
"categoryMonitoring": "การตรวจสอบ",
|
||||
"loading": "กำลังโหลด...",
|
||||
"allClear": "ทุกอย่างเรียบร้อย",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "ยกเลิกการเปลี่ยนแปลง",
|
||||
"movedToFolder": "ย้ายโฮสต์ {{count}} ไปยัง \"{{folder}}\"",
|
||||
"editHostTooltip": "แก้ไขโฮสต์",
|
||||
"statusChecks": "การตรวจสอบสถานะ",
|
||||
"metricsCollection": "การรวบรวมเมตริก",
|
||||
"metricsInterval": "ช่วงเวลาการเก็บรวบรวมเมตริก",
|
||||
"metricsIntervalDesc": "ควรเก็บรวบรวมสถิติเซิร์ฟเวอร์บ่อยแค่ไหน (5 วินาที - 1 ชั่วโมง)",
|
||||
"behavior": "พฤติกรรม",
|
||||
"themePreview": "ตัวอย่างธีม",
|
||||
"theme": "ธีม",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "สถิติเซิร์ฟเวอร์",
|
||||
"loadingServerStats": "กำลังโหลดข้อมูลสถิติเซิร์ฟเวอร์...",
|
||||
"noServerData": "ไม่มีข้อมูลเซิร์ฟเวอร์",
|
||||
"cpu": "ซีพียู",
|
||||
"ram": "แรม",
|
||||
"customizeLayout": "ปรับแต่งแดชบอร์ด",
|
||||
"dashboardSettings": "การตั้งค่าแดชบอร์ด",
|
||||
"enableDisableCards": "เปิดใช้งาน/ปิดใช้งานการ์ด",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "ป้อนที่อยู่เว็บที่ถูกต้อง",
|
||||
"serviceLinksAddFailed": "ไม่สามารถเพิ่มลิงก์บริการได้",
|
||||
"disk": "ดิสก์",
|
||||
"viewServerDetails": "ดูรายละเอียดเซิร์ฟเวอร์"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "ขั้นต่ำ 1 ชั่วโมง · ขั้นสูงสุด 720 ชั่วโมง",
|
||||
"monitoringDefaults": "การตรวจสอบค่าเริ่มต้น",
|
||||
"statusCheck": "ตรวจสอบสถานะ",
|
||||
"metrics": "ตัวชี้วัด",
|
||||
"sec": "วินาที",
|
||||
"logLevel": "ระดับบันทึก",
|
||||
"enableGuacamole": "เปิดใช้งาน Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "พร็อกซีโฮสต์ / พอร์ต",
|
||||
"hostDefaultsSocks5Username": "ชื่อผู้ใช้พร็อกซี",
|
||||
"hostDefaultsSocks5Password": "รหัสผ่านพร็อกซี",
|
||||
"hostDefaultsMetrics": "เมตริกโฮสต์",
|
||||
"hostDefaultsMetricsEnabled": "เปิดใช้งานเมตริก",
|
||||
"hostDefaultsMetricsEnabledDesc": "โดยค่าเริ่มต้น ระบบจะรวบรวมข้อมูล CPU, หน่วยความจำ และสถิติอื่นๆ บนโฮสต์ใหม่",
|
||||
"hostDefaultsStatusCheckEnabled": "เปิดใช้งานการตรวจสอบสถานะ",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "โดยค่าเริ่มต้น ระบบจะตรวจสอบสถานะออนไลน์/ออฟไลน์ของโฮสต์ใหม่",
|
||||
"hostDefaultsTerminal": "เทอร์มินัล",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Disk Kullanımını Göster",
|
||||
"addToFolder": "Widget'ı klasöre ekle",
|
||||
"noHostSelected": "Sunucu seçilmedi",
|
||||
"metricsNotAvailable": "Metrikler kullanılamıyor",
|
||||
"selectHost": "Bir sunucu seçin...",
|
||||
"displayedMetrics": "Gösterilen Metrikler",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Disk",
|
||||
"metricUptime": "Çalışma Süresi",
|
||||
"metricSystem": "Sistem",
|
||||
"metricOs": "İşletim Sistemi",
|
||||
"metricKernel": "Çekirdek",
|
||||
"metricHostname": "Ana Bilgisayar Adı",
|
||||
"metricNetwork": "Ağ",
|
||||
"metricProcesses": "İşlemler",
|
||||
"metricProcessesTotal": "toplam",
|
||||
"metricProcessesRunning": "çalışan",
|
||||
"categoryMonitoring": "İzleme",
|
||||
"loading": "Yükleniyor...",
|
||||
"allClear": "Her şey normal",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Değişiklikleri iptal et",
|
||||
"movedToFolder": "{{count}} sunucu \"{{folder}}\" klasörüne taşındı.",
|
||||
"editHostTooltip": "Sunucuyu düzenle",
|
||||
"statusChecks": "Durum Denetimleri",
|
||||
"metricsCollection": "Ölçüm Toplama",
|
||||
"metricsInterval": "Ölçüm Toplama Aralığı",
|
||||
"metricsIntervalDesc": "Sunucu istatistiklerinin ne sıklıkta toplanacağı (5s - 1h)",
|
||||
"behavior": "Davranış",
|
||||
"themePreview": "Tema Önizlemesi",
|
||||
"theme": "Tema",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Sunucu İstatistikleri",
|
||||
"loadingServerStats": "Sunucu istatistikleri yükleniyor...",
|
||||
"noServerData": "Sunucu verisi mevcut değil",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Panoyu Özelleştir",
|
||||
"dashboardSettings": "Pano Ayarları",
|
||||
"enableDisableCards": "Kartları Etkinleştir/Devre Dışı Bırak",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Geçerli bir web adresi girin",
|
||||
"serviceLinksAddFailed": "Servis bağlantısı eklenemedi",
|
||||
"disk": "Disk",
|
||||
"viewServerDetails": "Sunucu ayrıntılarını görüntüle"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "En az 1 saat · En fazla 720 saat",
|
||||
"monitoringDefaults": "İzleme Varsayılanları",
|
||||
"statusCheck": "Durum Kontrolü",
|
||||
"metrics": "Metrikler",
|
||||
"sec": "sn",
|
||||
"logLevel": "Günlük Düzeyi",
|
||||
"enableGuacamole": "Guacamole'yi Etkinleştir",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Proxy Sunucusu / Bağlantı Noktası",
|
||||
"hostDefaultsSocks5Username": "Proxy Kullanıcı Adı",
|
||||
"hostDefaultsSocks5Password": "Proxy Parolası",
|
||||
"hostDefaultsMetrics": "Ana Bilgisayar Ölçümleri",
|
||||
"hostDefaultsMetricsEnabled": "Ölçümleri Etkinleştir",
|
||||
"hostDefaultsMetricsEnabledDesc": "Yeni ana bilgisayarlarda varsayılan olarak CPU, bellek ve diğer istatistikleri topla",
|
||||
"hostDefaultsStatusCheckEnabled": "Durum Denetimini Etkinleştir",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Yeni ana bilgisayarlarda varsayılan olarak çevrimiçi/çevrimdışı durumunu sorgula",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Показати використання диска",
|
||||
"addToFolder": "Додати віджет до папки",
|
||||
"noHostSelected": "Хост не вибрано",
|
||||
"metricsNotAvailable": "Метрики недоступні",
|
||||
"selectHost": "Виберіть хоста...",
|
||||
"displayedMetrics": "Відображені показники",
|
||||
"metricCpu": "Процесор",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Диск",
|
||||
"metricUptime": "Час безперебійної роботи",
|
||||
"metricSystem": "Система",
|
||||
"metricOs": "ОС",
|
||||
"metricKernel": "Ядро",
|
||||
"metricHostname": "Ім'я хоста",
|
||||
"metricNetwork": "Мережа",
|
||||
"metricProcesses": "Процеси",
|
||||
"metricProcessesTotal": "загальна сума",
|
||||
"metricProcessesRunning": "біг",
|
||||
"categoryMonitoring": "Моніторинг",
|
||||
"loading": "Завантаження...",
|
||||
"allClear": "Все чисто",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Відхилити зміни",
|
||||
"movedToFolder": "Переміщено {{count}} хост(ів) до \"{{folder}}\"",
|
||||
"editHostTooltip": "Редагувати хост",
|
||||
"statusChecks": "Перевірки статусу",
|
||||
"metricsCollection": "Колекція показників",
|
||||
"metricsInterval": "Інтервал збору показників",
|
||||
"metricsIntervalDesc": "Як часто збирати статистику сервера (5 с - 1 год)",
|
||||
"behavior": "Поведінка",
|
||||
"themePreview": "Попередній перегляд теми",
|
||||
"theme": "Тема",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Статистика сервера",
|
||||
"loadingServerStats": "Завантаження статистики сервера...",
|
||||
"noServerData": "Дані сервера недоступні",
|
||||
"cpu": "Процесор",
|
||||
"ram": "Оперативна пам'ять",
|
||||
"customizeLayout": "Налаштувати інформаційну панель",
|
||||
"dashboardSettings": "Налаштування панелі інструментів",
|
||||
"enableDisableCards": "Увімкнути/вимкнути картки",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Введіть дійсну веб-адресу",
|
||||
"serviceLinksAddFailed": "Не вдалося додати посилання на послугу",
|
||||
"disk": "Диск",
|
||||
"viewServerDetails": "Переглянути деталі сервера"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Мін. 1 год · Макс. 720 год",
|
||||
"monitoringDefaults": "Моніторинг налаштувань за замовчуванням",
|
||||
"statusCheck": "Перевірка стану",
|
||||
"metrics": "Метрики",
|
||||
"sec": "сек",
|
||||
"logLevel": "Рівень журналу",
|
||||
"enableGuacamole": "Увімкнути гуакамоле",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Проксі-хост / порт",
|
||||
"hostDefaultsSocks5Username": "Ім'я користувача проксі-сервера",
|
||||
"hostDefaultsSocks5Password": "Пароль проксі-сервера",
|
||||
"hostDefaultsMetrics": "Метрики хоста",
|
||||
"hostDefaultsMetricsEnabled": "Увімкнути метрики",
|
||||
"hostDefaultsMetricsEnabledDesc": "Збирати статистику процесора, пам'яті та інші дані на нових хостах за замовчуванням",
|
||||
"hostDefaultsStatusCheckEnabled": "Увімкнути перевірку стану",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Опитувати статус онлайн/офлайн на нових хостах за замовчуванням",
|
||||
"hostDefaultsTerminal": "Термінал",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "Hiển thị mức sử dụng ổ đĩa",
|
||||
"addToFolder": "Thêm widget vào thư mục",
|
||||
"noHostSelected": "Chưa chọn máy chủ",
|
||||
"metricsNotAvailable": "Chỉ số không khả dụng",
|
||||
"selectHost": "Chọn máy chủ...",
|
||||
"displayedMetrics": "Chỉ số hiển thị",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "Đĩa",
|
||||
"metricUptime": "Thời gian hoạt động",
|
||||
"metricSystem": "Hệ thống",
|
||||
"metricOs": "Hệ điều hành",
|
||||
"metricKernel": "Nhân",
|
||||
"metricHostname": "Tên máy chủ",
|
||||
"metricNetwork": "Mạng",
|
||||
"metricProcesses": "Tiến trình",
|
||||
"metricProcessesTotal": "tổng",
|
||||
"metricProcessesRunning": "đang chạy",
|
||||
"categoryMonitoring": "Giám sát",
|
||||
"loading": "Đang tải...",
|
||||
"allClear": "Tất cả ổn",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "Hủy bỏ các thay đổi",
|
||||
"movedToFolder": "Đã chuyển {{count}} máy chủ vào thư mục \"{{folder}}\"",
|
||||
"editHostTooltip": "Chỉnh sửa máy chủ",
|
||||
"statusChecks": "Kiểm tra trạng thái",
|
||||
"metricsCollection": "Thu thập chỉ số",
|
||||
"metricsInterval": "Khoảng thời gian thu thập chỉ số",
|
||||
"metricsIntervalDesc": "Tần suất thu thập thống kê máy chủ (5s - 1h)",
|
||||
"behavior": "Hành vi",
|
||||
"themePreview": "Xem trước chủ đề",
|
||||
"theme": "Chủ đề",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "Thống kê máy chủ",
|
||||
"loadingServerStats": "Đang tải thống kê máy chủ...",
|
||||
"noServerData": "Không có dữ liệu máy chủ",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "Tùy chỉnh Bảng điều khiển",
|
||||
"dashboardSettings": "Cài đặt Bảng điều khiển",
|
||||
"enableDisableCards": "Bật/Tắt Thẻ",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "Nhập địa chỉ web hợp lệ",
|
||||
"serviceLinksAddFailed": "Thêm liên kết dịch vụ thất bại",
|
||||
"disk": "Ổ đĩa",
|
||||
"viewServerDetails": "Xem chi tiết máy chủ"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "Tối thiểu 1h · Tối đa 720h",
|
||||
"monitoringDefaults": "Mặc định giám sát",
|
||||
"statusCheck": "Kiểm tra trạng thái",
|
||||
"metrics": "Số liệu",
|
||||
"sec": "giây",
|
||||
"logLevel": "Mức nhật ký",
|
||||
"enableGuacamole": "Bật Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "Máy chủ / Cổng proxy",
|
||||
"hostDefaultsSocks5Username": "Tên đăng nhập proxy",
|
||||
"hostDefaultsSocks5Password": "Mật khẩu proxy",
|
||||
"hostDefaultsMetrics": "Số liệu máy chủ",
|
||||
"hostDefaultsMetricsEnabled": "Bật số liệu",
|
||||
"hostDefaultsMetricsEnabledDesc": "Mặc định thu thập CPU, bộ nhớ và các số liệu khác trên máy chủ mới",
|
||||
"hostDefaultsStatusCheckEnabled": "Bật kiểm tra trạng thái",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "Mặc định kiểm tra trạng thái trực tuyến/ngoại tuyến trên máy chủ mới",
|
||||
"hostDefaultsTerminal": "Terminal",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "显示磁盘使用情况",
|
||||
"addToFolder": "添加小组件到文件夹",
|
||||
"noHostSelected": "未选择主机",
|
||||
"metricsNotAvailable": "指标不可用",
|
||||
"selectHost": "选择主机...",
|
||||
"displayedMetrics": "显示指标",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "磁盘",
|
||||
"metricUptime": "运行时间",
|
||||
"metricSystem": "系统",
|
||||
"metricOs": "操作系统",
|
||||
"metricKernel": "内核",
|
||||
"metricHostname": "主机名",
|
||||
"metricNetwork": "网络",
|
||||
"metricProcesses": "进程",
|
||||
"metricProcessesTotal": "总数",
|
||||
"metricProcessesRunning": "运行中",
|
||||
"categoryMonitoring": "监控",
|
||||
"loading": "加载中...",
|
||||
"allClear": "一切正常",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "放弃更改",
|
||||
"movedToFolder": "已将 {{count}} 台主机移至 \"{{folder}}\"",
|
||||
"editHostTooltip": "编辑主机",
|
||||
"statusChecks": "状态检查",
|
||||
"metricsCollection": "指标采集",
|
||||
"metricsInterval": "指标采集间隔",
|
||||
"metricsIntervalDesc": "采集服务器统计信息的频率(5 秒 - 1 小时)",
|
||||
"behavior": "行为",
|
||||
"themePreview": "主题预览",
|
||||
"theme": "主题",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "服务器统计",
|
||||
"loadingServerStats": "正在加载服务器统计...",
|
||||
"noServerData": "无服务器数据可用",
|
||||
"cpu": "CPU",
|
||||
"ram": "内存",
|
||||
"customizeLayout": "自定义仪表盘",
|
||||
"dashboardSettings": "仪表盘设置",
|
||||
"enableDisableCards": "启用/禁用卡片",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "输入有效的网址",
|
||||
"serviceLinksAddFailed": "添加服务链接失败",
|
||||
"disk": "磁盘",
|
||||
"viewServerDetails": "查看服务器详情"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "最短 1 小时 · 最长 720 小时",
|
||||
"monitoringDefaults": "监控默认值",
|
||||
"statusCheck": "状态检查",
|
||||
"metrics": "指标",
|
||||
"sec": "秒",
|
||||
"logLevel": "日志级别",
|
||||
"enableGuacamole": "启用 Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "代理主机 / 端口",
|
||||
"hostDefaultsSocks5Username": "代理用户名",
|
||||
"hostDefaultsSocks5Password": "代理密码",
|
||||
"hostDefaultsMetrics": "主机指标",
|
||||
"hostDefaultsMetricsEnabled": "启用指标",
|
||||
"hostDefaultsMetricsEnabledDesc": "默认情况下,在新主机上收集 CPU、内存及其他统计数据",
|
||||
"hostDefaultsStatusCheckEnabled": "启用状态检查",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "默认轮询新主机的在线/离线状态",
|
||||
"hostDefaultsTerminal": "终端",
|
||||
|
||||
@@ -268,7 +268,6 @@
|
||||
"showDisk": "顯示磁碟使用量",
|
||||
"addToFolder": "新增小工具至資料夾",
|
||||
"noHostSelected": "未選取主機",
|
||||
"metricsNotAvailable": "效能指標無法使用",
|
||||
"selectHost": "選擇主機...",
|
||||
"displayedMetrics": "顯示的指標",
|
||||
"metricCpu": "CPU",
|
||||
@@ -276,13 +275,8 @@
|
||||
"metricDisk": "磁碟",
|
||||
"metricUptime": "運作時間",
|
||||
"metricSystem": "系統",
|
||||
"metricOs": "作業系統",
|
||||
"metricKernel": "核心",
|
||||
"metricHostname": "主機名稱",
|
||||
"metricNetwork": "網路",
|
||||
"metricProcesses": "程序",
|
||||
"metricProcessesTotal": "總數",
|
||||
"metricProcessesRunning": "執行中",
|
||||
"categoryMonitoring": "監控",
|
||||
"loading": "載入中...",
|
||||
"allClear": "一切正常",
|
||||
@@ -775,10 +769,6 @@
|
||||
"discardChanges": "放棄更改",
|
||||
"movedToFolder": "已將 {{count}} 台主機移至 \"{{folder}}\"",
|
||||
"editHostTooltip": "編輯主機",
|
||||
"statusChecks": "狀態檢查",
|
||||
"metricsCollection": "指標收集",
|
||||
"metricsInterval": "指標收集間隔",
|
||||
"metricsIntervalDesc": "收集伺服器統計資料的頻率 (5秒 - 1小時)",
|
||||
"behavior": "行為",
|
||||
"themePreview": "主題預覽",
|
||||
"theme": "主題",
|
||||
@@ -1899,8 +1889,6 @@
|
||||
"serverStats": "伺服器統計",
|
||||
"loadingServerStats": "正在載入伺服器統計...",
|
||||
"noServerData": "沒有可用的伺服器資料",
|
||||
"cpu": "CPU",
|
||||
"ram": "RAM",
|
||||
"customizeLayout": "自訂儀表板",
|
||||
"dashboardSettings": "儀表板設定",
|
||||
"enableDisableCards": "啟用/停用卡片",
|
||||
@@ -1952,7 +1940,6 @@
|
||||
"serviceLinksUrlPlaceholder": "http://192.168.1.10:8080",
|
||||
"serviceLinksInvalidUrl": "輸入有效的網址",
|
||||
"serviceLinksAddFailed": "新增服務連結失敗",
|
||||
"disk": "磁碟",
|
||||
"viewServerDetails": "檢視伺服器詳細資訊"
|
||||
},
|
||||
"admin": {
|
||||
@@ -2091,7 +2078,6 @@
|
||||
"sessionTimeoutRange": "最短 1 小時,最長 720 小時",
|
||||
"monitoringDefaults": "監控預設值",
|
||||
"statusCheck": "狀態檢查",
|
||||
"metrics": "指標",
|
||||
"sec": "秒",
|
||||
"logLevel": "日誌等級",
|
||||
"enableGuacamole": "啟用 Guacamole",
|
||||
@@ -2353,9 +2339,6 @@
|
||||
"hostDefaultsSocks5Host": "代理主機 / 連接埠",
|
||||
"hostDefaultsSocks5Username": "代理使用者名稱",
|
||||
"hostDefaultsSocks5Password": "代理密碼",
|
||||
"hostDefaultsMetrics": "主機指標",
|
||||
"hostDefaultsMetricsEnabled": "啟用指標",
|
||||
"hostDefaultsMetricsEnabledDesc": "預設在新主機上收集 CPU、記憶體及其他統計資料",
|
||||
"hostDefaultsStatusCheckEnabled": "啟用狀態檢查",
|
||||
"hostDefaultsStatusCheckEnabledDesc": "預設輪詢新主機的線上/離線狀態",
|
||||
"hostDefaultsTerminal": "終端機",
|
||||
|
||||
Loaded 100 of 308 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user