Add OMV integration / widget (#1879)

feat: Add health monitoring widget (OMV)
OpenMediaVault as first supported integration.
This commit is contained in:
Yossi Hillali
2024-02-27 21:44:52 +02:00
committed by GitHub
parent db2501633d
commit b51fcdb342
12 changed files with 523 additions and 2 deletions

View File

@@ -11,4 +11,4 @@ NEXTAUTH_SECRET="anything"
# Disable analytics
NEXT_PUBLIC_DISABLE_ANALYTICS="true"
DEFAULT_COLOR_SCHEME="light"
DEFAULT_COLOR_SCHEME="light"

View File

@@ -0,0 +1,37 @@
{
"descriptor": {
"name": "System Health Monitoring",
"description": "Information about your NAS",
"settings": {
"title": "System Health Monitoring",
"fahrenheit": {
"label": "Fahrenheit"
}
}
},
"cpu": {
"label": "CPU",
"load": "Load Average",
"minute": "{{minute}} minute"
},
"memory": {
"label": "Memory",
"totalMem": "Total memory: {{total}}GB",
"available": "Available: {{available}}GB - {{percentage}}%"
},
"fileSystem": {
"label": "File System",
"available": "Available: {{available}} - {{percentage}}%"
},
"info": {
"uptime": "Uptime",
"updates": "Updates",
"reboot": "Reboot"
},
"errors": {
"general": {
"title": "Unable to find your NAS",
"text": "There was a problem connecting to your NAS. Please verify your configuration/integration(s)."
}
}
}

View File

@@ -193,4 +193,9 @@ export const availableIntegrations = [
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/home-assistant.png',
label: 'Home Assistant',
},
{
value: 'openmediavault',
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png',
label: 'OpenMediaVault',
},
] as const satisfies Readonly<SelectItem[]>;

View File

@@ -14,6 +14,7 @@ import { inviteRouter } from './routers/invite/invite-router';
import { mediaRequestsRouter } from './routers/media-request';
import { mediaServerRouter } from './routers/media-server';
import { notebookRouter } from './routers/notebook';
import { openmediavaultRouter } from './routers/openmediavault';
import { overseerrRouter } from './routers/overseerr';
import { passwordRouter } from './routers/password';
import { rssRouter } from './routers/rss';
@@ -49,6 +50,7 @@ export const rootRouter = createTRPCRouter({
password: passwordRouter,
notebook: notebookRouter,
smartHomeEntityState: smartHomeEntityStateRouter,
openmediavault: openmediavaultRouter,
});
// export type definition of API

View File

@@ -0,0 +1,119 @@
import axios from 'axios';
import Consola from 'consola';
import { z } from 'zod';
import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties';
import { getConfig } from '~/tools/config/getConfig';
import { createTRPCRouter, publicProcedure } from '../trpc';
let sessionId: string | null = null;
let loginToken: string | null = null;
async function makeOpenMediaVaultRPCCall(
serviceName: string,
method: string,
params: Record<string, any>,
headers: Record<string, string>,
input: { configName: string }
) {
const config = getConfig(input.configName);
const app = config.apps.find((app) => checkIntegrationsType(app.integration, ['openmediavault']));
if (!app) {
Consola.error(`App not found for configName '${input.configName}'`);
return null;
}
const appUrl = new URL(app.url);
const response = await axios.post(
`${appUrl.origin}/rpc.php`,
{
service: serviceName,
method: method,
params: params,
},
{
headers: {
'Content-Type': 'application/json',
...headers,
},
}
);
return response;
}
export const openmediavaultRouter = createTRPCRouter({
fetchData: publicProcedure
.input(
z.object({
configName: z.string(),
})
)
.query(async ({ input }) => {
let authResponse: any = null;
let app: any;
if (!sessionId || !loginToken) {
app = getConfig(input.configName)?.apps.find((app) =>
checkIntegrationsType(app.integration, ['openmediavault'])
);
if (!app) {
Consola.error(
`Failed to process request to app '${app.integration}' (${app.id}). Please check username & password`
);
return null;
}
authResponse = await makeOpenMediaVaultRPCCall(
'session',
'login',
{
username: findAppProperty(app, 'username'),
password: findAppProperty(app, 'password'),
},
{},
input
);
const cookies = authResponse.headers['set-cookie'] || [];
sessionId = cookies
.find((cookie: any) => cookie.includes('X-OPENMEDIAVAULT-SESSIONID'))
?.split(';')[0];
loginToken = cookies
.find((cookie: any) => cookie.includes('X-OPENMEDIAVAULT-LOGIN'))
?.split(';')[0];
}
const [systemInfoResponse, fileSystemResponse, cpuTempResponse] = await Promise.all([
makeOpenMediaVaultRPCCall(
'system',
'getInformation',
{},
{ Cookie: `${loginToken};${sessionId}` },
input
),
makeOpenMediaVaultRPCCall(
'filesystemmgmt',
'enumerateMountedFilesystems',
{ includeroot: true },
{ Cookie: `${loginToken};${sessionId}` },
input
),
makeOpenMediaVaultRPCCall(
'cputemp',
'get',
{},
{ Cookie: `${loginToken};${sessionId}` },
input
),
]);
return {
authenticated: authResponse ? authResponse.data.response.authenticated : true,
systemInfo: systemInfoResponse?.data.response,
fileSystem: fileSystemResponse?.data.response,
cpuTemp: cpuTempResponse?.data.response,
};
}),
});

View File

@@ -21,6 +21,7 @@ export const boardNamespaces = [
'modules/docker',
'modules/dashdot',
'modules/overseerr',
'modules/health-monitoring',
'modules/media-server',
'modules/indexer-manager',
'modules/common-media-cards',

View File

@@ -57,7 +57,8 @@ export type IntegrationType =
| 'nzbGet'
| 'pihole'
| 'adGuardHome'
| 'homeAssistant';
| 'homeAssistant'
| 'openmediavault';
export type AppIntegrationType = {
type: IntegrationType | null;
@@ -101,6 +102,7 @@ export const integrationFieldProperties: {
pihole: ['apiKey'],
adGuardHome: ['username', 'password'],
homeAssistant: ['apiKey'],
openmediavault: ['username', 'password'],
};
export type IntegrationFieldDefinitionType = {

View File

@@ -0,0 +1,111 @@
import { Center, Flex, Group, HoverCard, RingProgress, Text } from '@mantine/core';
import { IconCpu } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
const HealthMonitoringCpu = ({ info, cpuTemp, fahrenheit }: any) => {
const { t } = useTranslation('modules/health-monitoring');
const toFahrenheit = (value: number) => {
return Math.round(value * 1.8 + 32);
};
interface LoadDataItem {
label: string;
stats: number;
progress: number;
color: string;
}
const loadData = [
{
label: `${t('cpu.minute', { minute: 1 })}`,
stats: info.loadAverage['1min'],
progress: info.loadAverage['1min'],
color: 'teal',
},
{
label: `${t('cpu.minute', { minute: 5 })}`,
stats: info.loadAverage['5min'],
progress: info.loadAverage['5min'],
color: 'blue',
},
{
label: `${t('cpu.minute', { minute: 15 })}`,
stats: info.loadAverage['15min'],
progress: info.loadAverage['15min'],
color: 'red',
},
] as const;
return (
<Group position="center">
<RingProgress
roundCaps
size={140}
thickness={12}
label={
<Center style={{ flexDirection: 'column' }}>
{info.cpuUtilization.toFixed(2)}%
<HoverCard width={280} shadow="md" position="top">
<HoverCard.Target>
<IconCpu size={40} />
</HoverCard.Target>
<HoverCard.Dropdown>
<Text fz="lg" tt="uppercase" fw={700} c="dimmed" align="center">
{t('cpu.load')}
</Text>
<Flex
direction={{ base: 'column', sm: 'row' }}
gap={{ base: 'sm', sm: 'lg' }}
justify={{ sm: 'center' }}
>
{loadData.map((load: LoadDataItem) => (
<RingProgress
size={80}
roundCaps
thickness={8}
label={
<Text color={load.color} weight={700} align="center" size="xl">
{load.progress}
</Text>
}
sections={[{ value: load.progress, color: load.color, tooltip: load.label }]}
/>
))}
</Flex>
</HoverCard.Dropdown>
</HoverCard>
</Center>
}
sections={[
{
value: info.cpuUtilization.toFixed(2),
color: info.cpuUtilization.toFixed(2) > 70 ? 'red' : 'green',
},
]}
/>
<RingProgress
roundCaps
size={140}
thickness={12}
label={
<Center
style={{
flexDirection: 'column',
}}
>
{fahrenheit ? `${toFahrenheit(cpuTemp.cputemp)}°F` : `${cpuTemp.cputemp}°C`}
<IconCpu size={40} />
</Center>
}
sections={[
{
value: cpuTemp.cputemp,
color: cpuTemp.cputemp < 60 ? 'green' : 'red',
},
]}
/>
</Group>
);
};
export default HealthMonitoringCpu;

View File

@@ -0,0 +1,62 @@
import { Center, Flex, Group, HoverCard, RingProgress, Text } from '@mantine/core';
import { IconServer } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { humanFileSize } from '~/tools/humanFileSize';
import { ringColor } from './HealthMonitoringTile';
const HealthMonitoringFileSystem = ({ fileSystem }: any) => {
const { t } = useTranslation('modules/health-monitoring');
interface FileSystemDisk {
devicename: string;
used: string;
percentage: number;
available: number;
}
return (
<Group position="center">
<Flex
direction={{ base: 'column', sm: 'row' }}
gap={{ base: 'sm', sm: 'lg' }}
justify={{ sm: 'center' }}
>
{fileSystem.map((disk: FileSystemDisk) => (
<RingProgress
size={140}
roundCaps
thickness={12}
label={
<Center style={{ flexDirection: 'column' }}>
{disk.devicename}
<HoverCard width={280} shadow="md" position="top">
<HoverCard.Target>
<IconServer size={40} />
</HoverCard.Target>
<HoverCard.Dropdown>
<Text fz="lg" tt="uppercase" fw={700} c="dimmed" align="center">
{t('fileSystem.available', {
available: humanFileSize(disk.available),
percentage: 100 - disk.percentage,
})}
</Text>
</HoverCard.Dropdown>
</HoverCard>
</Center>
}
sections={[
{
value: disk.percentage,
color: ringColor(disk.percentage),
tooltip: disk.used,
},
]}
/>
))}
</Flex>
</Group>
);
};
export default HealthMonitoringFileSystem;

View File

@@ -0,0 +1,50 @@
import { Center, Group, HoverCard, RingProgress, Text } from '@mantine/core';
import { IconBrain } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { ringColor } from './HealthMonitoringTile';
const HealthMonitoringMemory = ({ info }: any) => {
const { t } = useTranslation('modules/health-monitoring');
const totalMemoryGB: any = (info.memTotal / 1024 ** 3).toFixed(2);
const freeMemoryGB: any = (info.memAvailable / 1024 ** 3).toFixed(2);
const usedMemoryGB: any = ((info.memTotal - info.memAvailable) / 1024 ** 3).toFixed(2);
const percentageUsed: any = ((usedMemoryGB / totalMemoryGB) * 100).toFixed(2);
const percentageFree: any = (100 - percentageUsed).toFixed(2);
return (
<Group position="center">
<RingProgress
roundCaps
size={140}
thickness={12}
label={
<Center style={{ flexDirection: 'column' }}>
{usedMemoryGB}GiB
<HoverCard width={280} shadow="md" position="top">
<HoverCard.Target>
<IconBrain size={40} />
</HoverCard.Target>
<HoverCard.Dropdown>
<Text fz="lg" tt="uppercase" fw={700} c="dimmed" align="center">
{t('memory.totalMem', { total: totalMemoryGB })}
</Text>
<Text fz="lg" fw={500} align="center">
{t('memory.available', { available: freeMemoryGB, percentage: percentageFree })}
</Text>
</HoverCard.Dropdown>
</HoverCard>
</Center>
}
sections={[
{
value: percentageUsed,
color: ringColor(percentageUsed),
},
]}
/>
</Group>
);
};
export default HealthMonitoringMemory;

View File

@@ -0,0 +1,130 @@
import { Card, Divider, Flex, Group, ScrollArea, Text } from '@mantine/core';
import {
IconCloudDownload,
IconHeartRateMonitor,
IconInfoSquare,
IconStatusChange,
} from '@tabler/icons-react';
import { useTranslation } from 'next-i18next';
import { useConfigContext } from '~/config/provider';
import { api } from '~/utils/api';
import { defineWidget } from '../helper';
import { WidgetLoading } from '../loading';
import { IWidget } from '../widgets';
import HealthMonitoringCpu from './HealthMonitoringCpu';
import HealthMonitoringFileSystem from './HealthMonitoringFileSystem';
import HealthMonitoringMemory from './HealthMonitoringMemory';
const definition = defineWidget({
id: 'health-monitoring',
icon: IconHeartRateMonitor,
options: {
fahrenheit: {
type: 'switch',
defaultValue: false,
},
cpu: {
type: 'switch',
defaultValue: true,
},
memory: {
type: 'switch',
defaultValue: true,
},
fileSystem: {
type: 'switch',
defaultValue: true,
},
},
gridstack: {
minWidth: 1,
minHeight: 1,
maxWidth: 6,
maxHeight: 6,
},
component: HealthMonitoringWidgetTile,
});
export type IHealthMonitoringWidget = IWidget<(typeof definition)['id'], typeof definition>;
interface HealthMonitoringWidgetProps {
widget: IHealthMonitoringWidget;
}
function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) {
const { t } = useTranslation('modules/health-monitoring');
const { isInitialLoading, data } = useOpenmediavaultQuery();
if (isInitialLoading || !data) {
return <WidgetLoading />;
}
const formatUptime = (uptime: number) => {
const days = Math.floor(uptime / (60 * 60 * 24));
const remainingHours = Math.floor((uptime % (60 * 60 * 24)) / 3600);
return `${days} days, ${remainingHours} hours`;
};
return (
<Flex h="100%" w="100%" direction="column">
<ScrollArea>
<Card>
<Group position="center">
<IconInfoSquare size={40} />
<Text fz="lg" tt="uppercase" fw={700} c="dimmed" align="center">
{t('info.uptime')}:
<br />
{formatUptime(data.systemInfo.uptime)}
</Text>
<Group position="center">
{data.systemInfo.availablePkgUpdates === 0 ? (
''
) : (
<IconCloudDownload size={40} color="red" />
)}
{data.systemInfo.rebootRequired ? <IconStatusChange size={40} color="red" /> : ''}
</Group>
</Group>
</Card>
<Divider my="sm" />
<Group position="center">
{widget?.properties.cpu && (
<HealthMonitoringCpu
info={data.systemInfo}
cpuTemp={data.cpuTemp}
fahrenheit={widget?.properties.fahrenheit}
/>
)}
{widget?.properties.memory && <HealthMonitoringMemory info={data.systemInfo} />}
</Group>
{widget?.properties.fileSystem && (
<>
<Divider my="sm" />
<HealthMonitoringFileSystem fileSystem={data.fileSystem} />
</>
)}
</ScrollArea>
</Flex>
);
}
export const ringColor = (percentage: number) => {
if (percentage < 30) return 'green';
else if (percentage < 60) return 'yellow';
else if (percentage < 90) return 'orange';
else return 'red';
};
export const useOpenmediavaultQuery = () => {
const { name: configName } = useConfigContext();
return api.openmediavault.fetchData.useQuery(
{
configName: configName!,
},
{
staleTime: 1000 * 10,
}
);
};
export default definition;

View File

@@ -5,6 +5,7 @@ import date from './date/DateTile';
import dnsHoleControls from './dnshole/DnsHoleControls';
import dnsHoleSummary from './dnshole/DnsHoleSummary';
import torrentNetworkTraffic from './download-speed/TorrentNetworkTrafficTile';
import healthMonitoring from './health-monitoring/HealthMonitoringTile';
import iframe from './iframe/IFrameTile';
import indexerManager from './indexer-manager/IndexerManagerTile';
import mediaRequestsList from './media-requests/MediaRequestListTile';
@@ -40,4 +41,5 @@ export default {
notebook,
'smart-home/entity-state': smartHomeEntityState,
'smart-home/trigger-automation': smartHomeTriggerAutomation,
'health-monitoring': healthMonitoring,
};