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

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

No files matched your search

@@ -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);