Skip to content

Commit

Permalink
feat: add hardware usage widget
Browse files Browse the repository at this point in the history
  • Loading branch information
manuel-rw committed Nov 2, 2024
1 parent f8bdd9c commit b98d876
Show file tree
Hide file tree
Showing 29 changed files with 1,068 additions and 22 deletions.
2 changes: 1 addition & 1 deletion apps/tasks/src/undici-log-agent-override.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class LoggingAgent extends Agent {
url.searchParams.set(key, "REDACTED");
});

logger.info(
logger.debug(
`Dispatching request ${url.toString().replaceAll("=&", "&")} (${Object.keys(options.headers as object).length} headers)`,
);
return super.dispatch(options, handler);
Expand Down
53 changes: 53 additions & 0 deletions packages/api/src/router/widgets/hardware-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { observable } from "@trpc/server/observable";

import type { CpuLoad, MemoryLoad, NetworkLoad, ServerInfo } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";

import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";

export const hardwareUsageRouter = createTRPCRouter({
getServerInfo: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "getDashDot"))
.query(async ({ ctx }) => {
const channel = createItemAndIntegrationChannel<{
info: ServerInfo;
}>("hardwareUsage", ctx.integration.id);
const data = await channel.getAsync();
return {
info: data?.data.info ?? ({} as ServerInfo),
};
}),
getHardwareInformationHistory: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "getDashDot"))
.query(async ({ ctx }) => {
const channel = createItemAndIntegrationChannel<{
cpuLoad: CpuLoad;
memoryLoad: MemoryLoad;
networkLoad: NetworkLoad;
}>("hardwareUsage", ctx.integration.id);
const data = await channel.getAsync();
return {
cpuLoad: data?.data.cpuLoad ?? ({} as CpuLoad),
memoryLoad: data?.data.memoryLoad ?? ({} as MemoryLoad),
networkLoad: data?.data.networkLoad ?? ({} as NetworkLoad),
};
}),
subscribeCpu: publicProcedure
.unstable_concat(createOneIntegrationMiddleware("query", "getDashDot"))
.subscription(({ ctx }) => {
return observable<{ cpuLoad: CpuLoad; memoryLoad: MemoryLoad; networkLoad: NetworkLoad }>((emit) => {
const channel = createItemAndIntegrationChannel<{
cpuLoad: CpuLoad;
memoryLoad: MemoryLoad;
networkLoad: NetworkLoad;
}>("hardwareUsage", ctx.integration.id);
const unsubscribe = channel.subscribe((data) => {
emit.next(data);
});
return () => {
unsubscribe();
};
});
}),
});
2 changes: 2 additions & 0 deletions packages/api/src/router/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { appRouter } from "./app";
import { calendarRouter } from "./calendar";
import { dnsHoleRouter } from "./dns-hole";
import { downloadsRouter } from "./downloads";
import { hardwareUsageRouter } from "./hardware-usage";
import { healthMonitoringRouter } from "./health-monitoring";
import { indexerManagerRouter } from "./indexer-manager";
import { mediaRequestsRouter } from "./media-requests";
Expand All @@ -20,6 +21,7 @@ export const widgetRouter = createTRPCRouter({
smartHome: smartHomeRouter,
mediaServer: mediaServerRouter,
calendar: calendarRouter,
hardwareUsage: hardwareUsageRouter,
downloads: downloadsRouter,
mediaRequests: mediaRequestsRouter,
rssFeed: rssFeedRouter,
Expand Down
2 changes: 1 addition & 1 deletion packages/cron-jobs-core/src/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
const beforeCallbackTook = stopwatch.getElapsedInHumanWords();
await callback();
const callbackTook = stopwatch.getElapsedInHumanWords();
creatorOptions.logger.logInfo(
creatorOptions.logger.logDebug(
`The callback of '${name}' cron job succeeded (before callback took ${beforeCallbackTook}, callback took ${callbackTook})`,
);
await creatorOptions.onCallbackSuccess?.(name);
Expand Down
1 change: 1 addition & 0 deletions packages/cron-jobs-core/src/expressions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { checkCron } from "./validation";

export const EVERY_5_SECONDS = checkCron("*/5 * * * * *") satisfies string;
export const EVERY_SECOND = checkCron("*/1 * * * * *") satisfies string;
export const EVERY_MINUTE = checkCron("* * * * *") satisfies string;
export const EVERY_5_MINUTES = checkCron("*/5 * * * *") satisfies string;
export const EVERY_10_MINUTES = checkCron("*/10 * * * *") satisfies string;
Expand Down
2 changes: 2 additions & 0 deletions packages/cron-jobs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { analyticsJob } from "./jobs/analytics";
import { iconsUpdaterJob } from "./jobs/icons-updater";
import { dnsHoleJob } from "./jobs/integrations/dns-hole";
import { downloadsJob } from "./jobs/integrations/downloads";
import { hardwareUsageJob } from "./jobs/integrations/hardware-usage";
import { healthMonitoringJob } from "./jobs/integrations/health-monitoring";
import { smartHomeEntityStateJob } from "./jobs/integrations/home-assistant";
import { indexerManagerJob } from "./jobs/integrations/indexer-manager";
Expand All @@ -24,6 +25,7 @@ export const jobGroup = createCronJobGroup({
dnsHole: dnsHoleJob,
mediaRequests: mediaRequestsJob,
rssFeeds: rssFeedsJob,
hardwareUsage: hardwareUsageJob,
indexerManager: indexerManagerJob,
healthMonitoring: healthMonitoringJob,
});
Expand Down
67 changes: 67 additions & 0 deletions packages/cron-jobs/src/jobs/integrations/hardware-usage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { decryptSecret } from "@homarr/common/server";
import { EVERY_SECOND } from "@homarr/cron-jobs-core/expressions";
import { db, eq } from "@homarr/db";
import { items } from "@homarr/db/schema/sqlite";
import type { CpuLoad, MemoryLoad, NetworkLoad, ServerInfo } from "@homarr/integrations";
import { DashDotIntegration } from "@homarr/integrations";
import { createItemAndIntegrationChannel } from "@homarr/redis";

import { createCronJob } from "../../lib";

export const hardwareUsageJob = createCronJob("hardwareUsage", EVERY_SECOND).withCallback(async () => {
const itemsForIntegration = await db.query.items.findMany({
where: eq(items.kind, "hardwareUsage"),
with: {
integrations: {
with: {
integration: {
with: {
secrets: {
columns: {
kind: true,
value: true,
},
},
},
},
},
},
},
});

for (const itemForIntegration of itemsForIntegration) {
for (const integration of itemForIntegration.integrations) {
const dashDotIntegration = new DashDotIntegration({
...integration.integration,
decryptedSecrets: integration.integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
})),
});

const info = await dashDotIntegration.getInfoAsync();
const cpuLoad = await dashDotIntegration.getCurrentCpuLoadAsync();
const memoryLoad = await dashDotIntegration.getCurrentMemoryLoadAsync();
const networkLoad = await dashDotIntegration.getCurrentNetworkLoadAsync();

const cache = createItemAndIntegrationChannel<{
info: ServerInfo;
cpuLoad: CpuLoad;
memoryLoad: MemoryLoad;
networkLoad: NetworkLoad;
}>("hardwareUsage", integration.integrationId);
await cache.setAsync({
memoryLoad,
networkLoad,
cpuLoad,
info,
});
await cache.publishAndUpdateLastStateAsync({
cpuLoad,
networkLoad,
memoryLoad,
info,
});
}
}
});
11 changes: 9 additions & 2 deletions packages/definitions/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const integrationSecretKinds = objectKeys(integrationSecretKindObject);
interface integrationDefinition {
name: string;
iconUrl: string;
secretKinds: AtLeastOneOf<IntegrationSecretKind[]>; // at least one secret kind set is required
secretKinds: IntegrationSecretKind[][];
category: AtLeastOneOf<IntegrationCategory>;
}

Expand Down Expand Up @@ -119,6 +119,12 @@ export const integrationDefs = {
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png",
category: ["smartHomeServer"],
},
getDashDot: {
name: "Dash.",
secretKinds: [[]],
category: ["hardware"],
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
},
openmediavault: {
name: "OpenMediaVault",
secretKinds: [["username", "password"]],
Expand Down Expand Up @@ -175,4 +181,5 @@ export type IntegrationCategory =
| "torrent"
| "smartHomeServer"
| "indexerManager"
| "healthMonitoring";
| "healthMonitoring"
| "hardware";
1 change: 1 addition & 0 deletions packages/definitions/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const widgetKinds = [
"smartHome-executeAutomation",
"mediaServer",
"calendar",
"hardwareUsage",
"downloads",
"mediaRequests-requestList",
"mediaRequests-requestStats",
Expand Down
2 changes: 2 additions & 0 deletions packages/integrations/src/base/creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Integration as DbIntegration } from "@homarr/db/schema/sqlite";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";

import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
Expand Down Expand Up @@ -63,5 +64,6 @@ export const integrationCreators = {
jellyseerr: JellyseerrIntegration,
overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration,
getDashDot: DashDotIntegration,
openmediavault: OpenMediaVaultIntegration,
} satisfies Partial<Record<IntegrationKind, new (integration: IntegrationInput) => Integration>>;
8 changes: 8 additions & 0 deletions packages/integrations/src/base/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export abstract class Integration {
return secret.value;
}

protected appendPathToUrlWithEndingSlash(basename: string, path: string) {
if (basename.endsWith("/")) {
return `${basename}${path}`;
}

return `${basename}/${path}`;
}

/**
* Test the connection to the integration
* @throws {IntegrationTestConnectionError} if the connection fails
Expand Down
93 changes: 93 additions & 0 deletions packages/integrations/src/dashdot/dashdot-integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Integration } from "../base/integration";
import type { CpuLoad } from "../interfaces/hardware-usage/cpu-load";
import type { MemoryLoad } from "../interfaces/hardware-usage/memory-load";
import type { NetworkLoad } from "../interfaces/hardware-usage/network-load";
import type { ServerInfo } from "../interfaces/hardware-usage/server-info";

export class DashDotIntegration extends Integration {
public async testConnectionAsync(): Promise<void> {
const response = await fetch(this.appendPathToUrlWithEndingSlash(this.integration.url, "info"));
await response.json();
}

public async getInfoAsync(): Promise<ServerInfo> {
const infoResponse = await fetch(this.appendPathToUrlWithEndingSlash(this.integration.url, "info"));
const serverInfo = (await infoResponse.json()) as InternalServerInfo;
return {
maxAvailableMemoryBytes: serverInfo.ram.size,
};
}

public async getCurrentCpuLoadAsync(): Promise<CpuLoad> {
const cpu = await fetch(this.appendPathToUrlWithEndingSlash(this.integration.url, "load/cpu"));
const data = (await cpu.json()) as CpuLoadApi[];
return {
sumLoad: data.reduce((acc, current) => acc + current.load, 0) / data.length,
};
}

public async getCurrentMemoryLoadAsync(): Promise<MemoryLoad> {
const memoryLoad = await fetch(this.appendPathToUrlWithEndingSlash(this.integration.url, "load/ram"));
const data = (await memoryLoad.json()) as MemoryLoadApi;
return {
loadInBytes: data.load,
};
}

public async getCurrentNetworkLoadAsync(): Promise<NetworkLoad> {
const memoryLoad = await fetch(this.appendPathToUrlWithEndingSlash(this.integration.url, "load/network"));
const data = (await memoryLoad.json()) as NetworkLoadApi;
return {
down: data.down,
up: data.up,
};
}
}

/**
* CPU load per core
*/
interface CpuLoadApi {
load: number;
}

interface MemoryLoadApi {
load: number;
}

interface NetworkLoadApi {
up: number;
down: number;
}

interface InternalServerInfo {
ram: {
/**
* Available memory in bytes
*/
size: number;
};
storage: {
/**
* Size of storage in bytes
*/
size: number;
disks: {
/**
* Name of the device, e.g. sda
*/
device: string;

/**
* Brand name of the device
*/
brand: string;

/**
* Type of the device.
* See option "physical" of https://systeminformation.io/filesystem.html
*/
type: string;
}[];
}[];
}
7 changes: 6 additions & 1 deletion packages/integrations/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ export { TransmissionIntegration } from "./download-client/transmission/transmis
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { DownloadClientIntegration } from "./interfaces/downloads/download-client-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { DashDotIntegration } from "./dashdot/dashdot-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { PiHoleIntegration } from "./pi-hole/pi-hole-integration";
Expand All @@ -22,6 +23,10 @@ export type { IntegrationInput } from "./base/integration";
export type { DownloadClientJobsAndStatus } from "./interfaces/downloads/download-client-data";
export type { ExtendedDownloadClientItem } from "./interfaces/downloads/download-client-items";
export type { ExtendedClientStatus } from "./interfaces/downloads/download-client-status";
export type { CpuLoad } from "./interfaces/hardware-usage/cpu-load";
export type { MemoryLoad } from "./interfaces/hardware-usage/memory-load";
export type { NetworkLoad } from "./interfaces/hardware-usage/network-load";
export type { ServerInfo } from "./interfaces/hardware-usage/server-info";
export type { HealthMonitoring } from "./interfaces/health-monitoring/healt-monitoring";
export { MediaRequestStatus } from "./interfaces/media-requests/media-request";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface CpuLoad {
sumLoad: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface MemoryLoad {
loadInBytes: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface NetworkLoad {
up: number;
down: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface ServerInfo {
maxAvailableMemoryBytes: number;
}
1 change: 1 addition & 0 deletions packages/old-import/src/widgets/definitions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export const widgetKindMapping = {
"mediaRequests-requestStats": "media-requests-stats",
indexerManager: "indexer-manager",
healthMonitoring: "health-monitoring",
hardwareUsage: "dashdot",
} satisfies Record<WidgetKind, OldmarrWidgetDefinitions["id"] | null>;
// Use null for widgets that did not exist in oldmarr
// TODO: revert assignment so that only old widgets are needed in the object,
Expand Down
1 change: 1 addition & 0 deletions packages/old-import/src/widgets/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const optionMapping: OptionMapping = {
fileSystem: (oldOptions) => oldOptions.fileSystem,
},
app: null,
hardwareUsage: {},
};

/**
Expand Down
Loading

0 comments on commit b98d876

Please sign in to comment.