mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25: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
@@ -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);
|
||||
|
||||
|
||||
Reference in new issue
Block a user