mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat: add dashdot integration (#1541)
This commit is contained in:
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -14,6 +14,11 @@
|
||||
"gridstack",
|
||||
"homarr",
|
||||
"jellyfin",
|
||||
"llen",
|
||||
"lpop",
|
||||
"lpush",
|
||||
"lrange",
|
||||
"ltrim",
|
||||
"mantine",
|
||||
"manuel-rw",
|
||||
"Meierschlumpf",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"pretty-print-error": "patches/pretty-print-error.patch"
|
||||
}
|
||||
},
|
||||
"allowNonAppliedPatches": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import type { HealthMonitoring } from "@homarr/integrations";
|
||||
import { systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
|
||||
|
||||
@@ -8,7 +9,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const healthMonitoringRouter = createTRPCRouter({
|
||||
getHealthStatus: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
|
||||
.query(async ({ ctx }) => {
|
||||
return await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
@@ -26,7 +27,7 @@ export const healthMonitoringRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
subscribeHealthStatus: publicProcedure
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", "openmediavault"))
|
||||
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("healthMonitoring")))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ integrationId: string; healthInfo: HealthMonitoring; timestamp: Date }>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
|
||||
@@ -144,6 +144,13 @@ export const integrationDefs = {
|
||||
category: ["healthMonitoring"],
|
||||
supportsSearch: false,
|
||||
},
|
||||
dashDot: {
|
||||
name: "Dash.",
|
||||
secretKinds: [[]],
|
||||
category: ["healthMonitoring"],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/dashdot.png",
|
||||
supportsSearch: false,
|
||||
},
|
||||
} as const satisfies Record<string, integrationDefinition>;
|
||||
|
||||
export const integrationKinds = objectKeys(integrationDefs) as AtLeastOneOf<IntegrationKind>;
|
||||
|
||||
@@ -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";
|
||||
@@ -68,4 +69,5 @@ export const integrationCreators = {
|
||||
openmediavault: OpenMediaVaultIntegration,
|
||||
lidarr: LidarrIntegration,
|
||||
readarr: ReadarrIntegration,
|
||||
dashDot: DashDotIntegration,
|
||||
} satisfies Record<IntegrationKind, new (integration: IntegrationInput) => Integration>;
|
||||
|
||||
148
packages/integrations/src/dashdot/dashdot-integration.ts
Normal file
148
packages/integrations/src/dashdot/dashdot-integration.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { humanFileSize } from "@homarr/common";
|
||||
|
||||
import "@homarr/redis";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createChannelEventHistory } from "../../../redis/src/lib/channel";
|
||||
import { Integration } from "../base/integration";
|
||||
import type { HealthMonitoring } from "../types";
|
||||
|
||||
export class DashDotIntegration extends Integration {
|
||||
public async testConnectionAsync(): Promise<void> {
|
||||
const response = await fetch(this.url("/info"));
|
||||
await response.json();
|
||||
}
|
||||
|
||||
public async getSystemInfoAsync(): Promise<HealthMonitoring> {
|
||||
const info = await this.getInfoAsync();
|
||||
const cpuLoad = await this.getCurrentCpuLoadAsync();
|
||||
const memoryLoad = await this.getCurrentMemoryLoadAsync();
|
||||
const storageLoad = await this.getCurrentStorageLoadAsync();
|
||||
|
||||
const channel = this.getChannel();
|
||||
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
|
||||
|
||||
return {
|
||||
cpuUtilization: cpuLoad.sumLoad,
|
||||
memUsed: `${memoryLoad.loadInBytes}`,
|
||||
memAvailable: `${info.maxAvailableMemoryBytes - memoryLoad.loadInBytes}`,
|
||||
fileSystem: info.storage.map((storage, index) => ({
|
||||
deviceName: `Storage ${index + 1}: (${storage.disks.map((disk) => disk.device).join(", ")})`,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
used: humanFileSize(storageLoad[index]!),
|
||||
available: `${storage.size}`,
|
||||
percentage: storageLoad[index] ? (storageLoad[index] / storage.size) * 100 : 0,
|
||||
})),
|
||||
cpuModelName: info.cpuModel === "" ? `Unknown Model (${info.cpuBrand})` : `${info.cpuModel} (${info.cpuBrand})`,
|
||||
cpuTemp: cpuLoad.averageTemperature,
|
||||
availablePkgUpdates: 0,
|
||||
rebootRequired: false,
|
||||
smart: [],
|
||||
uptime: info.uptime,
|
||||
version: `${info.operatingSystemVersion}`,
|
||||
loadAverage: {
|
||||
"1min": Math.round(this.getAverageOfCpu(history[0])),
|
||||
"5min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 4))),
|
||||
"15min": Math.round(this.getAverageOfCpuFlat(history.slice(0, 14))),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async getInfoAsync() {
|
||||
const infoResponse = await fetch(this.url("/info"));
|
||||
const serverInfo = await internalServerInfoApi.parseAsync(await infoResponse.json());
|
||||
return {
|
||||
maxAvailableMemoryBytes: serverInfo.ram.size,
|
||||
storage: serverInfo.storage,
|
||||
cpuBrand: serverInfo.cpu.brand,
|
||||
cpuModel: serverInfo.cpu.model,
|
||||
operatingSystemVersion: `${serverInfo.os.distro} ${serverInfo.os.release} (${serverInfo.os.kernel})`,
|
||||
uptime: serverInfo.os.uptime,
|
||||
};
|
||||
}
|
||||
|
||||
private async getCurrentCpuLoadAsync() {
|
||||
const channel = this.getChannel();
|
||||
const cpu = await fetch(this.url("/load/cpu"));
|
||||
const data = await cpuLoadPerCoreApiList.parseAsync(await cpu.json());
|
||||
await channel.pushAsync(data);
|
||||
return {
|
||||
sumLoad: this.getAverageOfCpu(data),
|
||||
averageTemperature: data.reduce((acc, current) => acc + current.temp, 0) / data.length,
|
||||
};
|
||||
}
|
||||
|
||||
private getAverageOfCpuFlat(cpuLoad: z.infer<typeof cpuLoadPerCoreApiList>[]) {
|
||||
const averages = cpuLoad.map((load) => this.getAverageOfCpu(load));
|
||||
return averages.reduce((acc, current) => acc + current, 0) / averages.length;
|
||||
}
|
||||
|
||||
private getAverageOfCpu(cpuLoad?: z.infer<typeof cpuLoadPerCoreApiList>) {
|
||||
if (!cpuLoad) {
|
||||
return 0;
|
||||
}
|
||||
return cpuLoad.reduce((acc, current) => acc + current.load, 0) / cpuLoad.length;
|
||||
}
|
||||
|
||||
private async getCurrentStorageLoadAsync() {
|
||||
const storageLoad = await fetch(this.url("/load/storage"));
|
||||
return (await storageLoad.json()) as number[];
|
||||
}
|
||||
|
||||
private async getCurrentMemoryLoadAsync() {
|
||||
const memoryLoad = await fetch(this.url("/load/ram"));
|
||||
const data = await memoryLoadApi.parseAsync(await memoryLoad.json());
|
||||
return {
|
||||
loadInBytes: data.load,
|
||||
};
|
||||
}
|
||||
|
||||
private getChannel() {
|
||||
return createChannelEventHistory<z.infer<typeof cpuLoadPerCoreApiList>>(
|
||||
`integration:${this.integration.id}:history:cpu`,
|
||||
100,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const cpuLoadPerCoreApi = z.object({
|
||||
load: z.number().min(0),
|
||||
temp: z.number().min(0),
|
||||
});
|
||||
|
||||
const memoryLoadApi = z.object({
|
||||
load: z.number().min(0),
|
||||
});
|
||||
|
||||
const internalServerInfoApi = z.object({
|
||||
os: z.object({
|
||||
distro: z.string(),
|
||||
kernel: z.string(),
|
||||
release: z.string(),
|
||||
uptime: z.number().min(0),
|
||||
}),
|
||||
cpu: z.object({
|
||||
brand: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
ram: z.object({
|
||||
size: z.number().min(0),
|
||||
}),
|
||||
storage: z.array(
|
||||
z.object({
|
||||
size: z.number().min(0),
|
||||
disks: z.array(
|
||||
z.object({
|
||||
device: z.string(),
|
||||
brand: z.string(),
|
||||
type: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const cpuLoadPerCoreApiList = z.array(cpuLoadPerCoreApi);
|
||||
@@ -187,6 +187,67 @@ export const createItemChannel = <TData>(itemId: string) => {
|
||||
return createChannelWithLatestAndEvents<TData>(`item:${itemId}`);
|
||||
};
|
||||
|
||||
export const createChannelEventHistory = <TData>(channelName: string, maxElements = 15) => {
|
||||
const popElementsOverMaxAsync = async () => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
if (length <= maxElements) {
|
||||
return;
|
||||
}
|
||||
await getSetClient.ltrim(channelName, length - maxElements, length);
|
||||
};
|
||||
|
||||
return {
|
||||
subscribe: (callback: (data: TData) => void) => {
|
||||
return ChannelSubscriptionTracker.subscribe(channelName, (message) => {
|
||||
callback(superjson.parse(message));
|
||||
});
|
||||
},
|
||||
publishAndPushAsync: async (data: TData) => {
|
||||
await publisher.publish(channelName, superjson.stringify(data));
|
||||
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
await popElementsOverMaxAsync();
|
||||
},
|
||||
pushAsync: async (data: TData) => {
|
||||
await getSetClient.lpush(channelName, superjson.stringify({ data, timestamp: new Date() }));
|
||||
await popElementsOverMaxAsync();
|
||||
},
|
||||
clearAsync: async () => {
|
||||
await getSetClient.del(channelName);
|
||||
},
|
||||
getLastAsync: async () => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
const data = await getSetClient.lrange(channelName, length - 1, length);
|
||||
if (data.length !== 1) return null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return superjson.parse<{ data: TData; timestamp: Date }>(data[0]!);
|
||||
},
|
||||
getSliceAsync: async (startIndex: number, endIndex: number) => {
|
||||
const range = await getSetClient.lrange(channelName, startIndex, endIndex);
|
||||
return range.map((item) => superjson.parse<{ data: TData; timestamp: Date }>(item));
|
||||
},
|
||||
getSliceUntilTimeAsync: async (time: Date) => {
|
||||
const length = await getSetClient.llen(channelName);
|
||||
const items: TData[] = [];
|
||||
const itemsInCollection = await getSetClient.lrange(channelName, 0, length - 1);
|
||||
|
||||
for (let i = 0; i < length - 1; i++) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const deserializedItem = superjson.parse<{ data: TData; timestamp: Date }>(itemsInCollection[i]!);
|
||||
if (deserializedItem.timestamp < time) {
|
||||
continue;
|
||||
}
|
||||
items.push(deserializedItem.data);
|
||||
}
|
||||
return items;
|
||||
},
|
||||
getLengthAsync: async () => {
|
||||
return await getSetClient.llen(channelName);
|
||||
},
|
||||
name: channelName,
|
||||
};
|
||||
};
|
||||
|
||||
export const createChannelWithLatestAndEvents = <TData>(channelName: string) => {
|
||||
return {
|
||||
subscribe: (callback: (data: TData) => void) => {
|
||||
|
||||
@@ -169,15 +169,15 @@ export default function HealthMonitoringWidget({ options, integrationIds }: Widg
|
||||
</List.Item>
|
||||
<List m="0.5cqmin" withPadding center spacing="0.5cqmin" icon={<IconCpu size="1cqmin" />}>
|
||||
<List.Item className="health-monitoring-information-load-average-1min">
|
||||
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}
|
||||
{t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-load-average-5min">
|
||||
{t("widget.healthMonitoring.popover.minutes", { count: 5 })}{" "}
|
||||
{healthInfo.loadAverage["5min"]}
|
||||
{healthInfo.loadAverage["5min"]}%
|
||||
</List.Item>
|
||||
<List.Item className="health-monitoring-information-load-average-15min">
|
||||
{t("widget.healthMonitoring.popover.minutes", { count: 15 })}{" "}
|
||||
{healthInfo.loadAverage["15min"]}
|
||||
{healthInfo.loadAverage["15min"]}%
|
||||
</List.Item>
|
||||
</List>
|
||||
</List>
|
||||
@@ -363,7 +363,7 @@ const CpuTempRing = ({ fahrenheit, cpuTemp }: { fahrenheit: boolean; cpuTemp: nu
|
||||
label={
|
||||
<Center style={{ flexDirection: "column" }}>
|
||||
<Text className="health-monitoring-cpu-temp-value" size="3cqmin">
|
||||
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp}°C`}
|
||||
{fahrenheit ? `${(cpuTemp * 1.8 + 32).toFixed(1)}°F` : `${cpuTemp.toFixed(1)}°C`}
|
||||
</Text>
|
||||
<IconCpu className="health-monitoring-cpu-temp-icon" size="7cqmin" />
|
||||
</Center>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { IconHeartRateMonitor, IconServerOff } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
@@ -19,7 +21,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
|
||||
defaultValue: true,
|
||||
}),
|
||||
})),
|
||||
supportedIntegrations: ["openmediavault"],
|
||||
supportedIntegrations: getIntegrationKindsByCategory("healthMonitoring"),
|
||||
errors: {
|
||||
INTERNAL_SERVER_ERROR: {
|
||||
icon: IconServerOff,
|
||||
|
||||
Reference in New Issue
Block a user