mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-12 16:35:49 +01:00
Add OMV integration / widget (#1879)
feat: Add health monitoring widget (OMV) OpenMediaVault as first supported integration.
This commit is contained in:
@@ -11,4 +11,4 @@ NEXTAUTH_SECRET="anything"
|
||||
# Disable analytics
|
||||
NEXT_PUBLIC_DISABLE_ANALYTICS="true"
|
||||
|
||||
DEFAULT_COLOR_SCHEME="light"
|
||||
DEFAULT_COLOR_SCHEME="light"
|
||||
37
public/locales/en/modules/health-monitoring.json
Normal file
37
public/locales/en/modules/health-monitoring.json
Normal 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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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
|
||||
|
||||
119
src/server/api/routers/openmediavault.ts
Normal file
119
src/server/api/routers/openmediavault.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
111
src/widgets/health-monitoring/HealthMonitoringCpu.tsx
Normal file
111
src/widgets/health-monitoring/HealthMonitoringCpu.tsx
Normal 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;
|
||||
62
src/widgets/health-monitoring/HealthMonitoringFileSystem.tsx
Normal file
62
src/widgets/health-monitoring/HealthMonitoringFileSystem.tsx
Normal 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;
|
||||
50
src/widgets/health-monitoring/HealthMonitoringMemory.tsx
Normal file
50
src/widgets/health-monitoring/HealthMonitoringMemory.tsx
Normal 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;
|
||||
130
src/widgets/health-monitoring/HealthMonitoringTile.tsx
Normal file
130
src/widgets/health-monitoring/HealthMonitoringTile.tsx
Normal 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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user