From 1a5efb44ea010c92ec067e41dcbfdcb0e32ff445 Mon Sep 17 00:00:00 2001 From: Manuel <30572287+manuel-rw@users.noreply.github.com> Date: Sun, 25 Feb 2024 19:43:57 +0100 Subject: [PATCH] feat: open media vault widget --- .env.example | 14 -- .vscode/settings.json | 2 +- .../locales/en/modules/health-monitoring.json | 35 +++++ .../InputElements/IntegrationSelector.tsx | 5 + src/server/api/root.ts | 2 + src/server/api/routers/openmediavault.ts | 119 +++++++++++++++++ src/tools/server/translation-namespaces.ts | 1 + src/types/app.ts | 4 +- .../health-monitoring/HealthMonitoringCpu.tsx | 118 +++++++++++++++++ .../HealthMonitoringFileSystem.tsx | 58 +++++++++ .../HealthMonitoringMemory.tsx | 55 ++++++++ .../HealthMonitoringTile.tsx | 123 ++++++++++++++++++ src/widgets/index.ts | 2 + 13 files changed, 522 insertions(+), 16 deletions(-) delete mode 100644 .env.example create mode 100644 public/locales/en/modules/health-monitoring.json create mode 100644 src/server/api/routers/openmediavault.ts create mode 100644 src/widgets/health-monitoring/HealthMonitoringCpu.tsx create mode 100644 src/widgets/health-monitoring/HealthMonitoringFileSystem.tsx create mode 100644 src/widgets/health-monitoring/HealthMonitoringMemory.tsx create mode 100644 src/widgets/health-monitoring/HealthMonitoringTile.tsx diff --git a/.env.example b/.env.example deleted file mode 100644 index 2de2526be16..00000000000 --- a/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -DATABASE_URL="file:./database/db.sqlite" - -# Next Auth -# You can generate a new secret on the command line with: -# openssl rand -base64 32 -# https://next-auth.js.org/configuration/options#secret -NEXTAUTH_URL="http://localhost:3000" - -NEXTAUTH_SECRET="anything" - -# Disable analytics -NEXT_PUBLIC_DISABLE_ANALYTICS="true" - -DEFAULT_COLOR_SCHEME="light" \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 9a066dae06d..025acb66238 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,7 +11,7 @@ "layout.manage.navigation.**", ], "editor.codeActionsOnSave": { - "source.organizeImports": true + "source.organizeImports": "explicit" }, "typescript.tsdk": "node_modules/typescript/lib", "explorer.fileNesting.patterns": { diff --git a/public/locales/en/modules/health-monitoring.json b/public/locales/en/modules/health-monitoring.json new file mode 100644 index 00000000000..284c721be35 --- /dev/null +++ b/public/locales/en/modules/health-monitoring.json @@ -0,0 +1,35 @@ +{ + "descriptor": { + "name": "System Health Monitoring", + "description": "Information about your NAS", + "settings": { + "title": "System Health Monitoring", + "fahrenheit": { + "label": "Fahrenheit" + }, + "cpu": { + "label": "CPU" + }, + "memory": { + "label": "Memory" + }, + "fileSystem": { + "label": "File System" + }, + } + }, + "info": { + "uptime": "Uptime", + "updates": "Updates", + "reboot": "Reboot", + "load": "Load Avg", + "totalMem": "Total memory", + "available": "Available", + }, + "errors": { + "general": { + "title": "Unable to find your NAS", + "text": "There was a problem connecting to your NAS. Please verify your configuration/integration(s)." + } + } + } \ No newline at end of file diff --git a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx index 21ad225009c..7f79bf2cd19 100644 --- a/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx +++ b/src/components/Dashboard/Modals/EditAppModal/Tabs/IntegrationTab/Components/InputElements/IntegrationSelector.tsx @@ -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; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 0d12680791c..2f12f6a3745 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -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 diff --git a/src/server/api/routers/openmediavault.ts b/src/server/api/routers/openmediavault.ts new file mode 100644 index 00000000000..76b11ba9268 --- /dev/null +++ b/src/server/api/routers/openmediavault.ts @@ -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, + headers: Record, + 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, + }; + }), +}); diff --git a/src/tools/server/translation-namespaces.ts b/src/tools/server/translation-namespaces.ts index 33487f83d95..fe698859f4b 100644 --- a/src/tools/server/translation-namespaces.ts +++ b/src/tools/server/translation-namespaces.ts @@ -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', diff --git a/src/types/app.ts b/src/types/app.ts index 19258278cd4..ec3e4848a75 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -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 = { diff --git a/src/widgets/health-monitoring/HealthMonitoringCpu.tsx b/src/widgets/health-monitoring/HealthMonitoringCpu.tsx new file mode 100644 index 00000000000..affb8149b4e --- /dev/null +++ b/src/widgets/health-monitoring/HealthMonitoringCpu.tsx @@ -0,0 +1,118 @@ +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('info.load')} (1min)`, + stats: info.loadAverage['1min'], + progress: info.loadAverage['1min'], + color: 'teal', + }, + { + label: `${t('info.load')} (5min)`, + stats: info.loadAverage['5min'], + progress: info.loadAverage['5min'], + color: 'blue', + }, + { + label: `${t('info.load')} (15min)`, + stats: info.loadAverage['15min'], + progress: info.loadAverage['15min'], + color: 'red', + }, + ] as const; + + return ( + + + {info.cpuUtilization.toFixed(2)}% + + + + + + + {t('info.load')} + + + {loadData.map((load: LoadDataItem) => ( + + {load.progress} + + } + sections={[{ value: load.progress, color: load.color, tooltip: load.label }]} + /> + ))} + + + + + } + sections={[ + { + value: info.cpuUtilization.toFixed(2), + color: info.cpuUtilization.toFixed(2) > 70 ? 'red' : 'green', + }, + ]} + /> + + {fahrenheit ? `${toFahrenheit(cpuTemp.cputemp)}°F` : `${cpuTemp.cputemp}°C`} + + + } + sections={[ + { + value: cpuTemp.cputemp, + color: + cpuTemp.cputemp < 35 + ? 'green' + : cpuTemp.cputemp > 35 && cpuTemp.cputemp < 60 + ? 'yellow' + : cpuTemp.cputemp > 60 && cpuTemp.cputemp < 70 + ? 'orange' + : 'red', + }, + ]} + /> + + ); +}; + +export default HealthMonitoringCpu; diff --git a/src/widgets/health-monitoring/HealthMonitoringFileSystem.tsx b/src/widgets/health-monitoring/HealthMonitoringFileSystem.tsx new file mode 100644 index 00000000000..ac7b0b56263 --- /dev/null +++ b/src/widgets/health-monitoring/HealthMonitoringFileSystem.tsx @@ -0,0 +1,58 @@ +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'; + +const HealthMonitoringFileSystem = ({ fileSystem }: any) => { + const { t } = useTranslation('modules/health-monitoring'); + + interface FileSystemDisk { + devicename: string; + used: string; + percentage: number; + available: number; + } + + return ( + + + {fileSystem.map((disk: FileSystemDisk) => ( + + {disk.devicename} + + + + + + + {t('info.available')}: {humanFileSize(disk.available)} -{' '} + {100 - disk.percentage}% + + + + + } + sections={[ + { + value: disk.percentage, + color: disk.percentage > 80 ? 'red' : 'green', + tooltip: disk.used, + }, + ]} + /> + ))} + + + ); +}; + +export default HealthMonitoringFileSystem; diff --git a/src/widgets/health-monitoring/HealthMonitoringMemory.tsx b/src/widgets/health-monitoring/HealthMonitoringMemory.tsx new file mode 100644 index 00000000000..9739fabfef9 --- /dev/null +++ b/src/widgets/health-monitoring/HealthMonitoringMemory.tsx @@ -0,0 +1,55 @@ +import { Center, Group, HoverCard, RingProgress, Text } from '@mantine/core'; +import { IconBrain } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; + +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 ( + + + {usedMemoryGB}GiB + + + + + + + {t('info.totalMem')}: {totalMemoryGB}GB + + + {t('info.available')}: {freeMemoryGB}GB - {percentageFree}% + + + + + } + sections={[ + { + value: percentageUsed, + color: + percentageUsed < 10 + ? 'green' + : percentageUsed > 10 && percentageUsed < 70 + ? 'yellow' + : percentageUsed > 70 && percentageUsed < 90 + ? 'orange' + : 'red', + }, + ]} + /> + + ); +}; + +export default HealthMonitoringMemory; diff --git a/src/widgets/health-monitoring/HealthMonitoringTile.tsx b/src/widgets/health-monitoring/HealthMonitoringTile.tsx new file mode 100644 index 00000000000..25ef74f1c53 --- /dev/null +++ b/src/widgets/health-monitoring/HealthMonitoringTile.tsx @@ -0,0 +1,123 @@ +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 ; + } + + 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 ( + + + + + + + {t('info.uptime')}: +
+ {formatUptime(data.systemInfo.uptime)} +
+ + {data.systemInfo.availablePkgUpdates === 0 ? ( + '' + ) : ( + + )} + {data.systemInfo.rebootRequired ? : ''} + +
+
+ + + {widget?.properties.cpu && ( + + )} + {widget?.properties.memory && } + + {widget?.properties.fileSystem && ( + <> + + + + )} +
+
+ ); +} + +export const useOpenmediavaultQuery = () => { + const { name: configName } = useConfigContext(); + return api.openmediavault.fetchData.useQuery( + { + configName: configName!, + }, + { + staleTime: 1000 * 10, + } + ); +}; + +export default definition; diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 1bc9bd18fe7..1666722bf8b 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -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, };