feat: add glances integration (#5102)
Co-authored-by: Meier Lukas <meierschlumpf@gmail.com>
This commit is contained in:
1 parent
c75c38e391
commit
91846bc000
6 files changed
+159
-3
No files matched your search
@@ -9,7 +9,9 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const healthMonitoringRouter = createTRPCRouter({
|
||||
getSystemHealthStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
|
||||
.concat(
|
||||
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
|
||||
)
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
@@ -26,7 +28,9 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
);
|
||||
}),
|
||||
subscribeSystemHealthStatus: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "mock"))
|
||||
.concat(
|
||||
createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
|
||||
)
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; healthInfo: SystemHealthMonitoring; timestamp: Date }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
|
||||
@@ -178,6 +178,13 @@ export const integrationDefs = {
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/dash-dot"),
|
||||
},
|
||||
glances: {
|
||||
name: "Glances",
|
||||
secretKinds: [[]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg",
|
||||
category: ["healthMonitoring"],
|
||||
documentationUrl: null,
|
||||
},
|
||||
tdarr: {
|
||||
name: "Tdarr",
|
||||
secretKinds: [[], ["apiKey"]],
|
||||
|
||||
@@ -15,6 +15,7 @@ import { EmbyIntegration } from "../emby/emby-integration";
|
||||
import { GitHubContainerRegistryIntegration } from "../github-container-registry/github-container-registry-integration";
|
||||
import { GithubIntegration } from "../github/github-integration";
|
||||
import { GitlabIntegration } from "../gitlab/gitlab-integration";
|
||||
import { GlancesIntegration } from "../glances/glances-integration";
|
||||
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
|
||||
import { ICalIntegration } from "../ical/ical-integration";
|
||||
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
|
||||
@@ -105,6 +106,7 @@ export const integrationCreators = {
|
||||
truenas: TrueNasIntegration,
|
||||
unraid: UnraidIntegration,
|
||||
coolify: CoolifyIntegration,
|
||||
glances: GlancesIntegration,
|
||||
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import dayjs from "dayjs";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import type { IntegrationInput, IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { SessionStore } from "../base/session-store";
|
||||
import { createSessionStore } from "../base/session-store";
|
||||
import { TestConnectionError } from "../base/test-connection/test-connection-error";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||
import type { SystemHealthMonitoring } from "../types";
|
||||
|
||||
export class GlancesIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||
private readonly sessionStore: SessionStore<{ version: string }>;
|
||||
|
||||
constructor(integration: IntegrationInput) {
|
||||
super(integration);
|
||||
this.sessionStore = createSessionStore(integration);
|
||||
}
|
||||
|
||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||
let session = await this.sessionStore.getAsync();
|
||||
|
||||
if (session == null) {
|
||||
await this.sessionStore.setAsync({
|
||||
version: await this.getGlancesVersionAsync(),
|
||||
});
|
||||
|
||||
// update the store to re-use the values below.
|
||||
session = await this.sessionStore.getAsync();
|
||||
}
|
||||
|
||||
if (session == null) throw new Error("Session was unexpectitly null");
|
||||
|
||||
const stats = await this.getAllStatsAsync();
|
||||
|
||||
return {
|
||||
cpuUtilization: stats.cpu.total,
|
||||
memUsedInBytes: stats.mem.used,
|
||||
memAvailableInBytes: stats.mem.total - stats.mem.used,
|
||||
network: {
|
||||
down: stats.network.reduce((acc, net) => net.bytes_recv_rate_per_sec + acc, 0),
|
||||
up: stats.network.reduce((acc, net) => net.bytes_sent_rate_per_sec + acc, 0),
|
||||
},
|
||||
availablePkgUpdates: 0,
|
||||
version: session.version,
|
||||
fileSystem: stats.fs.map((fileSystem) => ({
|
||||
deviceName: fileSystem.device_name,
|
||||
used: `${fileSystem.used}`,
|
||||
available: `${fileSystem.free}`,
|
||||
percentage: fileSystem.percent,
|
||||
})),
|
||||
uptime: stats.uptime.as("seconds"),
|
||||
rebootRequired: false,
|
||||
cpuModelName: stats.quicklook.cpu_name,
|
||||
loadAverage: null,
|
||||
smart: [],
|
||||
cpuTemp: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async getGlancesVersionAsync() {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/version"));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
private async getAllStatsAsync() {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all"));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
return allSchema.parseAsync(await response.json());
|
||||
}
|
||||
|
||||
protected async testingAsync(_: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/status"), {
|
||||
headers: { "User-Agent": "Homarr" },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return TestConnectionError.StatusResult(response);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
|
||||
const regex = /^(?:(?<days>\d+) days, )?(?<hours>\d+):(?<minutes>\d+):(?<seconds>\d+)$/m;
|
||||
|
||||
const allSchema = z.object({
|
||||
cpu: z.object({
|
||||
total: z.number().min(0).max(100),
|
||||
}),
|
||||
mem: z.object({
|
||||
total: z.number().min(0),
|
||||
used: z.number().min(0),
|
||||
free: z.number().min(0),
|
||||
}),
|
||||
network: z.array(
|
||||
z.object({
|
||||
bytes_sent_rate_per_sec: z.number().min(0),
|
||||
bytes_recv_rate_per_sec: z.number().min(0),
|
||||
}),
|
||||
),
|
||||
fs: z.array(
|
||||
z.object({
|
||||
device_name: z.string(),
|
||||
used: z.number().min(0),
|
||||
free: z.number().min(0),
|
||||
percent: z.number().min(0).max(100),
|
||||
}),
|
||||
),
|
||||
uptime: z
|
||||
.string()
|
||||
.regex(new RegExp(regex))
|
||||
.transform((uptime) => {
|
||||
const match = regex.exec(uptime);
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Unable to parse uptime value '${uptime}' with regex.`);
|
||||
}
|
||||
|
||||
const days = "days" in match.groups ? Number(match.groups.days) : undefined;
|
||||
const hours = "hours" in match.groups ? Number(match.groups.hours) : undefined;
|
||||
const minutes = "minutes" in match.groups ? Number(match.groups.minutes) : undefined;
|
||||
const seconds = "seconds" in match.groups ? Number(match.groups.seconds) : undefined;
|
||||
|
||||
return dayjs.duration({ days, hours, minutes, seconds });
|
||||
}),
|
||||
quicklook: z.object({
|
||||
cpu_name: z.string(),
|
||||
}),
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
|
||||
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
|
||||
export { NTFYIntegration } from "./ntfy/ntfy-integration";
|
||||
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
|
||||
export { GlancesIntegration } from "./glances/glances-integration";
|
||||
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
|
||||
export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
|
||||
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
||||
|
||||
@@ -14,7 +14,7 @@ const labelDisplayModeOptions = {
|
||||
|
||||
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
|
||||
icon: IconGraphFilled,
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
|
||||
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
hasShadow: factory.switch({ defaultValue: true }),
|
||||
|
||||
Reference in new issue
Block a user