feat: polish release v2 experience (#6800)

This commit is contained in:
Thomas authored and GitHub committed 2026-09-07 19:17:37 +02:00
1 parent 925b0fb801
commit a4a903d7c3
295 files changed
+10627 -7115

No files matched your search

@@ -1,3 +1,4 @@
import { isRecord } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -66,7 +67,7 @@ export const normalizeRealtimeSnapshot = (
const id = `realtime-${receivedAt.getTime()}`;
const events: LiveStatsEvent[] = [];
if (payload.stats && typeof payload.stats === "object" && !Array.isArray(payload.stats)) {
if (isRecord(payload.stats)) {
events.push({
type: "system_stats",
record: { id, system: systemId, stats: payload.stats, type: "1m", created, updated: created },
@@ -40,7 +40,7 @@ export interface BeszelSystemInfo {
dp: number;
/** battery [level%, charging state] */
bat?: [number, number];
/** bandwidth — public interface (Mbps, legacy). Use bb when available */
/** bandwidth — public interface (MiB/s, legacy). Use bb when available */
b?: number;
/** bandwidth — public interface (bytes/s, newer format). Preferred over b */
bb?: number;
@@ -94,9 +94,9 @@ export interface BeszelSystemDetails {
*
* Units:
* - CPU/GPU/memory/disk percentages: 0–100
* - Memory/disk absolute values (mu, du, m, d, etc.): GB
* - Disk I/O (dr, dw): MB/s
* - Network (ns, nr): bytes/s — all interfaces (including loopback, docker bridges)
* - Memory/disk absolute values (mu, du, m, d, etc.): GiB
* - Disk I/O (dr, dw): MiB/s
* - Legacy network (ns, nr): MiB/s — all interfaces (including loopback, docker bridges)
* - Bandwidth (b): bytes/s [sent, recv] — public interfaces only. Prefer b over ns/nr for user-facing charts
*/
export interface BeszelSystemStats {
@@ -110,49 +110,49 @@ export interface BeszelSystemStats {
cpus?: number[];
/** load average [1m, 5m, 15m] */
la?: [number, number, number];
/** total memory (GB) */
/** total memory (GiB) */
m: number;
/** memory used (GB) */
/** memory used (GiB) */
mu: number;
/** memory usage (%) */
mp: number;
/** memory buffer/cache (GB) */
/** memory buffer/cache (GiB) */
mb: number;
/** memory max used (GB) */
/** memory max used (GiB) */
mm?: number;
/** memory zfs arc (GB) */
/** memory zfs arc (GiB) */
mz?: number;
/** total swap (GB) */
/** total swap (GiB) */
s: number;
/** swap used (GB) */
/** swap used (GiB) */
su: number;
/** total disk (GB) */
/** total disk (GiB) */
d: number;
/** disk used (GB) */
/** disk used (GiB) */
du: number;
/** disk usage (%) */
dp: number;
/** disk read (MB/s) */
/** disk read (MiB/s) */
dr?: number;
/** disk write (MB/s) */
/** disk write (MiB/s) */
dw?: number;
/** disk read max (MB/s) */
/** disk read max (MiB/s) */
drm?: number;
/** disk write max (MB/s) */
/** disk write max (MiB/s) */
dwm?: number;
/** disk IOPS [read, write] */
/** disk throughput [read bytes/s, write bytes/s]. Prefer over dr/dw */
dio?: [number, number];
/** disk IOPS max [read, write] */
/** disk throughput max [read bytes/s, write bytes/s] */
diom?: [number, number];
/** network sent — all interfaces (bytes/s) */
/** legacy network sent — all interfaces (MiB/s) */
ns?: number;
/** network received — all interfaces (bytes/s) */
/** legacy network received — all interfaces (MiB/s) */
nr?: number;
/** bandwidth — public interfaces only (bytes/s) [sent, recv]. Prefer over ns/nr */
b?: [number, number];
/** network sent max (bytes/s) */
/** legacy network sent max (MiB/s) */
nsm?: number;
/** network received max (bytes/s) */
/** legacy network received max (MiB/s) */
nrm?: number;
/** bandwidth max [sent, recv] (bytes/s) */
bm?: [number, number];
@@ -173,21 +173,21 @@ export interface BeszelExtraFsStats {
d: number;
/** disk used (GiB) */
du: number;
/** read (bytes/s) */
/** legacy disk read (MiB/s) */
r: number;
/** write (bytes/s) */
/** legacy disk write (MiB/s) */
w: number;
/** read max (bytes/s) */
/** legacy disk read max (MiB/s) */
rm?: number;
/** write max (bytes/s) */
/** legacy disk write max (MiB/s) */
wm?: number;
/** read (IOPS) */
/** disk read (bytes/s) */
rb?: number;
/** write (IOPS) */
/** disk write (bytes/s) */
wb?: number;
/** read max (IOPS) */
/** disk read max (bytes/s) */
rbm?: number;
/** write max (IOPS) */
/** disk write max (bytes/s) */
wbm?: number;
}
@@ -236,18 +236,18 @@ export interface BeszelContainer {
* One entry per container in a BeszelContainerStatsRecord.
*
* Network fields: b (bandwidth) is preferred when available; fall back to ns/nr.
* Units: m = MB, c = %, b/ns/nr = bytes/s
* Units: m/ns/nr = MiB or MiB/s, c = %, b = bytes/s
*/
export interface BeszelContainerStats {
/** container name */
n: string;
/** CPU usage (%) */
c: number;
/** memory usage (MB) */
/** memory usage (MiB) */
m: number;
/** network sent (bytes/s) — legacy, use b when available */
/** network sent (MiB/s) — legacy, use b when available */
ns?: number;
/** network received (bytes/s) — legacy, use b when available */
/** network received (MiB/s) — legacy, use b when available */
nr?: number;
/** network bandwidth [sent, recv] (bytes/s) — preferred over ns/nr */
b?: [number, number];
@@ -388,7 +388,7 @@ export interface BeszelSystemRow {
gpu: number;
/** load average [1m, 5m, 15m] */
loadAvg: [number, number, number] | null;
/** public interface bandwidth (bytes/s). From info.bb or info.b * 1M */
/** public interface bandwidth (bytes/s). From info.bb or legacy info.b normalized from MiB/s */
netBytes: number;
/** temperature (°C) */
temp: number | null;
@@ -37,7 +37,8 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
const { history } = historyResult;
const status: DownloadClientStatus = {
paused: queue.paused,
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
// SABnzbd's legacy kbpersec field is kibibytes per second; normalize it to bytes per second.
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) },
types: [type],
};
const items = queue.slots
+35 -31
View File
@@ -10,8 +10,9 @@ import type {
LiveStatsEvent,
} from "../../beszel/beszel-types";
const GB = 1024 * 1024 * 1024;
const KB = 1024;
const KIBIBYTE = 1024;
const MEBIBYTE = 1024 * KIBIBYTE;
const GIBIBYTE = 1024 * MEBIBYTE;
interface MockSystemDef {
name: string;
@@ -34,7 +35,7 @@ const mockSystems: MockSystemDef[] = [
os: "Ubuntu 24.04",
cpu: "AMD Ryzen 9 7950X",
cores: 16,
mem: 64 * GB,
mem: 64 * GIBIBYTE,
arch: "amd64",
baseCpu: 35,
baseMem: 55,
@@ -47,7 +48,7 @@ const mockSystems: MockSystemDef[] = [
os: "Debian 12",
cpu: "Intel Xeon E-2388G",
cores: 8,
mem: 32 * GB,
mem: 32 * GIBIBYTE,
arch: "amd64",
baseCpu: 12,
baseMem: 70,
@@ -60,7 +61,7 @@ const mockSystems: MockSystemDef[] = [
os: "Raspberry Pi OS",
cpu: "ARM Cortex-A76",
cores: 4,
mem: 8 * GB,
mem: 8 * GIBIBYTE,
arch: "aarch64",
baseCpu: 45,
baseMem: 82,
@@ -73,7 +74,7 @@ const mockSystems: MockSystemDef[] = [
os: "Proxmox VE 8.2",
cpu: "Intel i7-13700K",
cores: 16,
mem: 128 * GB,
mem: 128 * GIBIBYTE,
arch: "amd64",
baseCpu: 22,
baseMem: 48,
@@ -86,7 +87,7 @@ const mockSystems: MockSystemDef[] = [
os: "TrueNAS SCALE",
cpu: "Intel Xeon E5-2680",
cores: 12,
mem: 48 * GB,
mem: 48 * GIBIBYTE,
arch: "amd64",
baseCpu: 8,
baseMem: 30,
@@ -95,15 +96,15 @@ const mockSystems: MockSystemDef[] = [
},
];
const containerProfiles: { name: string; cpuBase: number; memMB: number; netKB: number }[] = [
{ name: "nginx", cpuBase: 2, memMB: 120, netKB: 800 },
{ name: "postgres", cpuBase: 8, memMB: 450, netKB: 200 },
{ name: "redis", cpuBase: 3, memMB: 180, netKB: 500 },
{ name: "grafana", cpuBase: 5, memMB: 280, netKB: 150 },
{ name: "prometheus", cpuBase: 6, memMB: 350, netKB: 300 },
{ name: "traefik", cpuBase: 4, memMB: 90, netKB: 1200 },
{ name: "minio", cpuBase: 3, memMB: 200, netKB: 600 },
{ name: "gitea", cpuBase: 4, memMB: 250, netKB: 100 },
const containerProfiles: { name: string; cpuBase: number; memMiB: number; netKiB: number }[] = [
{ name: "nginx", cpuBase: 2, memMiB: 120, netKiB: 800 },
{ name: "postgres", cpuBase: 8, memMiB: 450, netKiB: 200 },
{ name: "redis", cpuBase: 3, memMiB: 180, netKiB: 500 },
{ name: "grafana", cpuBase: 5, memMiB: 280, netKiB: 150 },
{ name: "prometheus", cpuBase: 6, memMiB: 350, netKiB: 300 },
{ name: "traefik", cpuBase: 4, memMiB: 90, netKiB: 1200 },
{ name: "minio", cpuBase: 3, memMiB: 200, netKiB: 600 },
{ name: "gitea", cpuBase: 4, memMiB: 250, netKiB: 100 },
];
const rand = (min: number, max: number) => min + Math.random() * (max - min);
@@ -144,30 +145,30 @@ function generateSystemStatsSeries(count: number, sys: MockSystemDef): BeszelSys
const netSendWalk = smoothWalk(500, 200, count, 10, 8000);
const netRecvWalk = smoothWalk(800, 300, count, 10, 12000);
const diskTotal = rand(200, 2000) * GB;
const diskTotal = rand(200, 2000) * GIBIBYTE;
return cpuWalk.map((cpu, i) => {
const memPct = memPctWalk[i] ?? sys.baseMem;
const diskPct = diskPctWalk[i] ?? sys.baseDisk;
const memTotalGB = sys.mem / GB;
const diskTotalGB = diskTotal / GB;
const memTotalGiB = sys.mem / GIBIBYTE;
const diskTotalGiB = diskTotal / GIBIBYTE;
return {
cpu,
la: [cpu / 20 + rand(0, 0.5), cpu / 25 + rand(0, 0.3), cpu / 30 + rand(0, 0.2)] as [number, number, number],
m: memTotalGB,
mu: (memTotalGB * memPct) / 100,
m: memTotalGiB,
mu: (memTotalGiB * memPct) / 100,
mp: memPct,
mb: rand(0.5, 4),
s: 4,
su: rand(0, 2),
d: diskTotalGB,
du: (diskTotalGB * diskPct) / 100,
d: diskTotalGiB,
du: (diskTotalGiB * diskPct) / 100,
dp: diskPct,
dr: diskReadWalk[i] ?? 5,
dw: diskWriteWalk[i] ?? 3,
ns: (netSendWalk[i] ?? 500) * KB,
nr: (netRecvWalk[i] ?? 800) * KB,
b: [(netSendWalk[i] ?? 500) * KB * 0.7, (netRecvWalk[i] ?? 800) * KB * 0.7] as [number, number],
ns: ((netSendWalk[i] ?? 500) * KIBIBYTE) / MEBIBYTE,
nr: ((netRecvWalk[i] ?? 800) * KIBIBYTE) / MEBIBYTE,
b: [(netSendWalk[i] ?? 500) * KIBIBYTE * 0.7, (netRecvWalk[i] ?? 800) * KIBIBYTE * 0.7] as [number, number],
};
});
}
@@ -176,16 +177,19 @@ function generateContainerStatsSeries(count: number, profiles: typeof containerP
const walks = profiles.map((p) => ({
profile: p,
cpuWalk: smoothWalk(p.cpuBase, 1.5, count, 0, 50),
memWalk: smoothWalk(p.memMB, p.memMB * 0.1, count, p.memMB * 0.5, p.memMB * 2),
netWalk: smoothWalk(p.netKB, p.netKB * 0.3, count, 0, p.netKB * 4),
memWalk: smoothWalk(p.memMiB, p.memMiB * 0.1, count, p.memMiB * 0.5, p.memMiB * 2),
netWalk: smoothWalk(p.netKiB, p.netKiB * 0.3, count, 0, p.netKiB * 4),
}));
return Array.from({ length: count }, (_, i) =>
walks.map(({ profile, cpuWalk, memWalk, netWalk }) => ({
n: profile.name,
c: cpuWalk[i] ?? profile.cpuBase,
m: memWalk[i] ?? profile.memMB,
b: [(netWalk[i] ?? profile.netKB) * KB * 0.4, (netWalk[i] ?? profile.netKB) * KB * 0.6] as [number, number],
m: memWalk[i] ?? profile.memMiB,
b: [(netWalk[i] ?? profile.netKiB) * KIBIBYTE * 0.4, (netWalk[i] ?? profile.netKiB) * KIBIBYTE * 0.6] as [
number,
number,
],
})),
);
}
@@ -227,7 +231,7 @@ export class BeszelMockService {
mp: clamp(sys.baseMem + rand(-5, 5), 0, 100),
dp: clamp(sys.baseDisk + rand(-2, 2), 0, 100),
efs: i === 0 ? { "/mnt/sda": 86.9, "/mnt/sdb": 70.5, "/mnt/sdc": 42.7 } : undefined,
bb: rand(200, 3000) * KB,
bb: rand(200, 3000) * KIBIBYTE,
v: "0.8.2",
g: i === 3 ? rand(15, 45) : 0,
dt: rand(32, 58),
@@ -1,3 +1,4 @@
import { isRecord } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
@@ -38,7 +39,7 @@ export class PiHoleIntegrationV5 extends Integration implements DnsHoleSummaryIn
const data = await response.json();
// Pi-hole v5 returned an empty array if the API key is wrong
if (typeof data !== "object" || Array.isArray(data)) {
if (!isRecord(data)) {
return TestConnectionError.UnauthorizedResult(401);
}
@@ -10,6 +10,7 @@ import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types"
import {
AUTH_API_NAME,
INFO_API_NAME,
parseSynologyNumber,
REQUEST_TIMEOUT_MS,
SESSION_ERROR_CODES,
synologyApiInfoResponseSchema,
@@ -174,8 +175,8 @@ export class SynologyClient {
status: string | undefined,
displayName?: string,
): SynologyVolumeRecord | null {
const usedBytes = parseNumericValue(usedValue);
const totalBytes = parseNumericValue(totalValue);
const usedBytes = parseSynologyNumber(usedValue);
const totalBytes = parseSynologyNumber(totalValue);
if (usedBytes === null || totalBytes === null || totalBytes <= 0) {
return null;
}
@@ -194,7 +195,7 @@ export class SynologyClient {
identifier: disk.id ?? disk.name ?? disk.display_name ?? "unknown-disk",
name: disk.display_name ?? disk.name ?? disk.id ?? "unknown-disk",
status: disk.smart_status ?? disk.status,
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
temperature: parseSynologyNumber(disk.temp ?? disk.temperature),
volumeName: disk.volume_id ?? disk.vol_path,
}));
}
@@ -205,7 +206,7 @@ export class SynologyClient {
identifier: disk.disk_id ?? disk.id ?? disk.name ?? "unknown-disk",
name: disk.name ?? disk.disk_id ?? disk.id ?? "unknown-disk",
status: disk.overall_status ?? disk.status,
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
temperature: parseSynologyNumber(disk.temp ?? disk.temperature),
volumeName: undefined,
}));
}
@@ -394,16 +395,3 @@ function extractCookieHeader(headers: Headers): string | null {
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
}
function parseNumericValue(value: number | string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
function parseOptionalNumericValue(value: number | string | undefined): number | null {
const parsed = parseNumericValue(value);
return parsed === null ? null : parsed;
}
@@ -10,6 +10,7 @@ import type { TestingResult } from "../base/test-connection/test-connection-serv
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
import { SynologyClient } from "./synology-client";
import { parseSynologyNumber } from "./synology-types";
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
import type { synologySystemInfoDataSchema, synologyUtilizationDataSchema } from "./synology-types";
@@ -125,16 +126,16 @@ function mapVersion(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): s
}
function mapCpuUtilization(utilization: z.infer<typeof synologyUtilizationDataSchema>): number {
const userLoad = parseNumericValue(utilization.cpu?.user_load) ?? 0;
const systemLoad = parseNumericValue(utilization.cpu?.system_load) ?? 0;
const userLoad = parseSynologyNumber(utilization.cpu?.user_load) ?? 0;
const systemLoad = parseSynologyNumber(utilization.cpu?.system_load) ?? 0;
const totalLoad = userLoad + systemLoad;
return Math.min(totalLoad, 100);
}
function mapMemoryBytes(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
const memory = utilization.memory;
const totalRealKib = parseNumericValue(memory?.total_real);
const realUsagePercent = parseNumericValue(memory?.real_usage);
const totalRealKib = parseSynologyNumber(memory?.total_real);
const realUsagePercent = parseSynologyNumber(memory?.real_usage);
// DSM utilization API reports memory sizes in KiB, not bytes.
if (totalRealKib !== null && realUsagePercent !== null) {
@@ -146,8 +147,8 @@ function mapMemoryBytes(utilization: z.infer<typeof synologyUtilizationDataSchem
};
}
const availableKib = parseNumericValue(memory?.avail_real) ?? 0;
const cachedKib = parseNumericValue(memory?.cached) ?? 0;
const availableKib = parseSynologyNumber(memory?.avail_real) ?? 0;
const cachedKib = parseSynologyNumber(memory?.cached) ?? 0;
const totalKib = totalRealKib ?? availableKib + cachedKib;
const usedKib = Math.max(totalKib - availableKib, 0);
@@ -163,8 +164,8 @@ function mapNetworkTraffic(utilization: z.infer<typeof synologyUtilizationDataSc
return null;
}
const received = parseNumericValue(totalNetwork.rx);
const transmitted = parseNumericValue(totalNetwork.tx);
const received = parseSynologyNumber(totalNetwork.rx);
const transmitted = parseSynologyNumber(totalNetwork.tx);
if (received === null || transmitted === null) {
return null;
}
@@ -176,9 +177,9 @@ function mapNetworkTraffic(utilization: z.infer<typeof synologyUtilizationDataSc
}
function mapLoadAverage(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
const oneMinute = parseNumericValue(utilization.cpu?.["1min_load"]);
const fiveMinute = parseNumericValue(utilization.cpu?.["5min_load"]);
const fifteenMinute = parseNumericValue(utilization.cpu?.["15min_load"]);
const oneMinute = parseSynologyNumber(utilization.cpu?.["1min_load"]);
const fiveMinute = parseSynologyNumber(utilization.cpu?.["5min_load"]);
const fifteenMinute = parseSynologyNumber(utilization.cpu?.["15min_load"]);
if (oneMinute === null && fiveMinute === null && fifteenMinute === null) {
return null;
@@ -210,7 +211,7 @@ function mapUptimeSeconds(systemInfo: z.infer<typeof synologySystemInfoDataSchem
}
function mapCpuTemperature(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number | undefined {
const temperature = parseNumericValue(systemInfo.sys_temp ?? systemInfo.temperature);
const temperature = parseSynologyNumber(systemInfo.sys_temp ?? systemInfo.temperature);
return temperature === null ? undefined : temperature;
}
@@ -313,11 +314,3 @@ function isHealthyStatus(status: string | undefined): boolean {
normalizedStatus.includes(unhealthyToken),
);
}
function parseNumericValue(value: number | string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
@@ -36,6 +36,14 @@ export const synologyAuthResponseSchema = synologyEnvelopeSchema.extend({
const numericString = z.union([z.number(), z.string()]);
export function parseSynologyNumber(value: number | string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : null;
}
export const synologySystemInfoDataSchema = z
.object({
up_time: z.string().optional(),
@@ -2,7 +2,7 @@ import type { RawData } from "ws";
import { WebSocket } from "ws";
import z from "zod";
import { createId } from "@homarr/common";
import { createId, isRecord } from "@homarr/common";
import { matchErrorCode, RequestError, ResponseError } from "@homarr/common/server";
import {
getAllTrustedCertificatesAsync,
@@ -306,9 +306,7 @@ const resolveCertificateOptionsAsync = async (): Promise<CertificateOptions> =>
const parseMessage = (raw: RawData): Record<string, unknown> | undefined => {
try {
const parsed: unknown = JSON.parse(raw.toString());
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: undefined;
return isRecord(parsed) ? parsed : undefined;
} catch {
return undefined;
}
@@ -1,5 +1,6 @@
import dayjs from "dayjs";
import { isRecord } from "@homarr/common";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
@@ -39,8 +40,8 @@ const pad = (number: number) => String(number).padStart(2, "0");
const extractDataArray = (json: unknown): unknown[] => {
if (Array.isArray(json)) return json;
if (json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)) {
return (json as { data: unknown[] }).data;
if (isRecord(json) && Array.isArray(json.data)) {
return json.data;
}
return [];
};