From 4b539e4cf5e052588397248a61f813de539ba7dd Mon Sep 17 00:00:00 2001 From: Dylan Date: Sun, 3 Mar 2024 23:00:42 -0600 Subject: [PATCH 01/11] add proxmox status to health monitoring widget --- .../locales/en/modules/health-monitoring.json | 145 ++++++++--- .../InputElements/IntegrationSelector.tsx | 5 + src/server/api/root.ts | 4 +- .../{ => health-monitoring}/openmediavault.ts | 24 +- .../api/routers/health-monitoring/proxmox.ts | 109 ++++++++ .../api/routers/health-monitoring/router.ts | 62 +++++ src/types/app.ts | 4 +- .../HealthMonitoringTile.tsx | 184 ++++++++++---- .../HealthMonitoringClusterDetailPopover.tsx | 232 ++++++++++++++++++ .../HealthMonitoringClusterResourceRow.tsx | 112 +++++++++ .../cluster/HealthMonitoringClusterTile.tsx | 157 ++++++++++++ .../health-monitoring/cluster/types.ts | 30 +++ 12 files changed, 969 insertions(+), 99 deletions(-) rename src/server/api/routers/{ => health-monitoring}/openmediavault.ts (80%) create mode 100644 src/server/api/routers/health-monitoring/proxmox.ts create mode 100644 src/server/api/routers/health-monitoring/router.ts create mode 100644 src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx create mode 100644 src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx create mode 100644 src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx create mode 100644 src/widgets/health-monitoring/cluster/types.ts diff --git a/public/locales/en/modules/health-monitoring.json b/public/locales/en/modules/health-monitoring.json index 8642591e69d..f43255323fc 100644 --- a/public/locales/en/modules/health-monitoring.json +++ b/public/locales/en/modules/health-monitoring.json @@ -1,37 +1,118 @@ { - "descriptor": { - "name": "System Health Monitoring", - "description": "Information about your NAS", - "settings": { - "title": "System Health Monitoring", - "fahrenheit": { - "label": "Fahrenheit" + "descriptor": { + "name": "System Health Monitoring", + "description": "Displays information showing the health and status of your system(s).", + "settings": { + "title": "System Health Monitoring", + "fahrenheit": { + "label": "Fahrenheit" + }, + "proxmox": { + "node": { + "label": "Filter by node name", + "info": "Enter your Proxmox node name to only show metrics for that node. By default, the entire cluster is shown." + }, + "defaultViewState": { + "label": "Section open by default", + "data": { + "none": "None", + "node": "Nodes", + "vm": "VMs", + "lxc": "LXCs", + "storage": "Storage" + } + }, + "summary": { + "label": "Show summary section" + }, + "showNode": { + "label": "Show nodes section" + }, + "showVM": { + "label": "Show VMs section" + }, + "showLXCs": { + "label": "Show LXCs section" + }, + "showStorage": { + "label": "Show storage section" + }, + "sectionIndicatorColor": { + "label": "Requirement for section status indicator to be 'OK'", + "info": "'All' requires that all items be online for the indicator to be green. 'Any' requires at least one item to be online.", + "data": { + "any": "Any Active", + "all": "All Active" + } + }, + "ignoreCert": { + "label": "Ignore Certificate Errors", + "info": "If enabled, the widget will ignore certificate errors when accessing the Proxmox API. This can be helpful when accessing Proxmox through HTTPS." } } + } + }, + "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 system(s).", + "text": "There was a problem connecting to your system. Please verify your configuration/integration(s)." + } + }, + "cluster": { + "summary": { + "cpu": "CPU", + "ram": "RAM" }, - "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)." - } + "accordion": { + "title": { + "nodes": "Nodes", + "vms": "VMs", + "lxcs": "LXCs", + "storage": "Storage" + } + }, + "table": { + "header": { + "name": "Name", + "cpu": "CPU", + "ram": "RAM", + "node": "Node" } - } \ No newline at end of file + }, + "popover": { + "node": "Node", + "vmid": "VMID", + "details": "Details", + "cores": "Cores - {{maxCpu}}", + "memSize": "Memory - {{maxMem}}", + "memRatio": "Memory - {{usedMem}} / {{maxMem}}", + "diskSize": "Disk - {{maxDisk}}", + "diskRatio": "Disk - {{usedDisk}} / {{maxDisk}}", + "uptime": "Uptime - {{uptime}}", + "plugin": "Plugin", + "ha": "HA State - {{haState}}", + "sharedStorage": "Shared Storage", + "localStorage": "Local Storage", + "na": "N/A" + } + } +} \ 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 7f79bf2cd19..61800dda209 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 @@ -198,4 +198,9 @@ export const availableIntegrations = [ image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png', label: 'OpenMediaVault', }, + { + value: 'proxmox', + image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png', + label: 'Proxmox', + } ] as const satisfies Readonly; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 2f12f6a3745..053cc7d98f4 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -8,13 +8,13 @@ import { dashDotRouter } from './routers/dash-dot'; import { dnsHoleRouter } from './routers/dns-hole/router'; import { dockerRouter } from './routers/docker/router'; import { downloadRouter } from './routers/download'; +import { healthMonitoringRouter } from './routers/health-monitoring/router'; import { iconRouter } from './routers/icon'; import { indexerManagerRouter } from './routers/indexer-manager'; 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'; @@ -50,7 +50,7 @@ export const rootRouter = createTRPCRouter({ password: passwordRouter, notebook: notebookRouter, smartHomeEntityState: smartHomeEntityStateRouter, - openmediavault: openmediavaultRouter, + healthMonitoring: healthMonitoringRouter, }); // export type definition of API diff --git a/src/server/api/routers/openmediavault.ts b/src/server/api/routers/health-monitoring/openmediavault.ts similarity index 80% rename from src/server/api/routers/openmediavault.ts rename to src/server/api/routers/health-monitoring/openmediavault.ts index 76b11ba9268..3cac6a96660 100644 --- a/src/server/api/routers/openmediavault.ts +++ b/src/server/api/routers/health-monitoring/openmediavault.ts @@ -1,10 +1,9 @@ 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 { ConfigAppType } from '~/types/app'; -import { createTRPCRouter, publicProcedure } from '../trpc'; let sessionId: string | null = null; let loginToken: string | null = null; @@ -20,7 +19,7 @@ async function makeOpenMediaVaultRPCCall( const app = config.apps.find((app) => checkIntegrationsType(app.integration, ['openmediavault'])); if (!app) { - Consola.error(`App not found for configName '${input.configName}'`); + Consola.error(`App 'openmediavault' not found for configName '${input.configName}'`); return null; } @@ -42,25 +41,13 @@ async function makeOpenMediaVaultRPCCall( return response; } -export const openmediavaultRouter = createTRPCRouter({ - fetchData: publicProcedure - .input( - z.object({ - configName: z.string(), - }) - ) - .query(async ({ input }) => { +export async function makeOpenMediaVaultCalls(app: ConfigAppType, input: any) { 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` + `Failed to process request to app 'openmediavault'. Please check username & password` ); return null; } @@ -115,5 +102,4 @@ export const openmediavaultRouter = createTRPCRouter({ fileSystem: fileSystemResponse?.data.response, cpuTemp: cpuTempResponse?.data.response, }; - }), -}); + } diff --git a/src/server/api/routers/health-monitoring/proxmox.ts b/src/server/api/routers/health-monitoring/proxmox.ts new file mode 100644 index 00000000000..c89e35e07a3 --- /dev/null +++ b/src/server/api/routers/health-monitoring/proxmox.ts @@ -0,0 +1,109 @@ +import axios from 'axios'; +import Consola from 'consola'; +import https from 'https'; +import { findAppProperty } from '~/tools/client/app-properties'; +import { ConfigAppType } from '~/types/app'; +import { ResourceData, ResourceSummary } from '~/widgets/health-monitoring/cluster/types'; + +export async function makeProxmoxStatusAPICall(app: ConfigAppType, input: any) { + if (!app) { + Consola.error(`App 'proxmox' not found for configName '${input.configName}'`); + return null; + } + + const apiKey = findAppProperty(app, 'apiKey'); + if (!apiKey) { + Consola.error('Proxmox: Missing or API key. Please check the configuration.'); + return null; + } + + const appUrl = new URL('api2/json/cluster/resources', app.url); + const agent = input.ignoreCerts + ? new https.Agent({ rejectUnauthorized: false, requestCert: false }) + : new https.Agent(); + + const result = await axios + .get(appUrl.toString(), { + headers: { + Authorization: apiKey, + }, + httpsAgent: agent, + }) + .catch((error) => { + Consola.error( + `Proxmox: Error accessing service API: '${appUrl}'. Please check the configuration.` + ); + return null; + }) + .then((res) => { + let resources: ResourceSummary = { vms: [], lxcs: [], nodes: [], storage: [] }; + + if (!res) return null; + + res.data.data.forEach((item: any) => { + if (input.filterNode === '' || input.filterNode === item.node) { + let resource: ResourceData = { + id: item.id, + cpu: item.cpu ? item.cpu : 0, + maxCpu: item.maxcpu ? item.maxcpu : 0, + maxMem: item.maxmem ? item.maxmem : 0, + mem: item.mem ? item.mem : 0, + name: item.name, + node: item.node, + status: item.status, + running: false, + type: item.type, + uptime: item.uptime, + vmId: item.vmid, + netIn: item.netin, + netOut: item.netout, + diskRead: item.diskread, + diskWrite: item.diskwrite, + disk: item.disk, + maxDisk: item.maxdisk, + haState: item.hastate, + storagePlugin: item.plugintype, + storageShared: item.shared == 1, + }; + if (item.template == 0) { + if (item.type === 'qemu') { + resource.running = resource.status === 'running'; + resources.vms.push(resource); + } else if (item.type === 'lxc') { + resource.running = resource.status === 'running'; + resources.lxcs.push(resource); + } + } else if (item.type === 'node') { + resource.name = item.node; + resource.running = resource.status === 'online'; + resources.nodes.push(resource); + } else if (item.type === 'storage') { + resource.name = item.storage; + resource.running = resource.status === 'available'; + resources.storage.push(resource); + } + } + }); + + // results must be sorted; proxmox api result order can change dynamically, + // so sort the data to keep the item positions consistent + const sorter = (a: ResourceData, b: ResourceData) => { + if (a.id < b.id) { + return -1; + } + if (a.id > b.id) { + return 1; + } + return 0; + }; + + resources.nodes.sort(sorter); + resources.lxcs.sort(sorter); + resources.storage.sort(sorter); + resources.vms.sort(sorter); + + return resources; + }); + + return result; +} diff --git a/src/server/api/routers/health-monitoring/router.ts b/src/server/api/routers/health-monitoring/router.ts new file mode 100644 index 00000000000..41ef0e29c54 --- /dev/null +++ b/src/server/api/routers/health-monitoring/router.ts @@ -0,0 +1,62 @@ +import Consola from 'consola'; +import { z } from 'zod'; +import { checkIntegrationsType } from '~/tools/client/app-properties'; +import { getConfig } from '~/tools/config/getConfig'; + +import { createTRPCRouter, publicProcedure } from '../../trpc'; +import { makeOpenMediaVaultCalls } from './openmediavault'; +import { makeProxmoxStatusAPICall } from './proxmox'; + +export const healthMonitoringRouter = createTRPCRouter({ + integrations: publicProcedure + .input( + z.object({ + configName: z.string(), + }) + ) + .query(async ({ input }) => { + const config = getConfig(input.configName); + const apps = config.apps.map((app) => { + if (checkIntegrationsType(app.integration, ['proxmox', 'openmediavault'])) { return app.integration.type; } + }); + + return apps; + }), + fetchData: publicProcedure + .input( + z.object({ + configName: z.string(), + filterNode: z.string(), + ignoreCerts: z.boolean(), + }) + ) + .query(async ({ input }) => { + + const config = getConfig(input.configName); + const omvApp = config.apps.find((app) => checkIntegrationsType(app.integration, ['openmediavault'])); + const proxApp = config.apps.find((app) => checkIntegrationsType(app.integration, ['proxmox'])); + + if (!omvApp && !proxApp) { + Consola.error(`No valid integrations found for health monitoring in '${input.configName}'`); + return null; + } + + let systemData: any; + let clusterData: any; + + if (omvApp) { + const data = await makeOpenMediaVaultCalls(omvApp, input); + if (data) { systemData = data; } + } + + if (proxApp) { + const data = await makeProxmoxStatusAPICall(proxApp, input); + if (data != null) { clusterData = data; } + } + + return { + system: systemData, + cluster: clusterData, + } + }), +}); diff --git a/src/types/app.ts b/src/types/app.ts index ec3e4848a75..c161060089c 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -58,7 +58,8 @@ export type IntegrationType = | 'pihole' | 'adGuardHome' | 'homeAssistant' - | 'openmediavault'; + | 'openmediavault' + | 'proxmox'; export type AppIntegrationType = { type: IntegrationType | null; @@ -103,6 +104,7 @@ export const integrationFieldProperties: { adGuardHome: ['username', 'password'], homeAssistant: ['apiKey'], openmediavault: ['username', 'password'], + proxmox: ['apiKey'], }; export type IntegrationFieldDefinitionType = { diff --git a/src/widgets/health-monitoring/HealthMonitoringTile.tsx b/src/widgets/health-monitoring/HealthMonitoringTile.tsx index 251936bc6a9..b4eabc22446 100644 --- a/src/widgets/health-monitoring/HealthMonitoringTile.tsx +++ b/src/widgets/health-monitoring/HealthMonitoringTile.tsx @@ -1,5 +1,6 @@ -import { Card, Divider, Flex, Group, ScrollArea, Text } from '@mantine/core'; +import { Card, Center, Divider, Flex, Group, ScrollArea, Stack, Text, Title } from '@mantine/core'; import { + IconAlertTriangle, IconCloudDownload, IconHeartRateMonitor, IconInfoSquare, @@ -9,12 +10,20 @@ import { useTranslation } from 'next-i18next'; import { useConfigContext } from '~/config/provider'; import { api } from '~/utils/api'; +import Consola from 'consola'; import { defineWidget } from '../helper'; import { WidgetLoading } from '../loading'; import { IWidget } from '../widgets'; import HealthMonitoringCpu from './HealthMonitoringCpu'; import HealthMonitoringFileSystem from './HealthMonitoringFileSystem'; import HealthMonitoringMemory from './HealthMonitoringMemory'; +import { ClusterStatusTile } from './cluster/HealthMonitoringClusterTile'; + +const defaultViewStates = ['none', 'node', 'vm', 'lxc', 'storage'] as const; +type DefaultViewState = (typeof defaultViewStates)[number]; + +const indicatorColorControls = ['all', 'any'] as const; +type IndicatorColorControl = (typeof indicatorColorControls)[number]; const definition = defineWidget({ id: 'health-monitoring', @@ -36,12 +45,53 @@ const definition = defineWidget({ type: 'switch', defaultValue: true, }, + node: { + type: 'text', + defaultValue: '', + info: true, + }, + defaultViewState: { + type: 'select', + defaultValue: 'none' as DefaultViewState, + data: defaultViewStates.map((x) => ({ value: x })), + }, + summary: { + type: 'switch', + defaultValue: true, + }, + showNode: { + type: 'switch', + defaultValue: true, + }, + showVM: { + type: 'switch', + defaultValue: true, + }, + showLXCs: { + type: 'switch', + defaultValue: true, + }, + showStorage: { + type: 'switch', + defaultValue: true, + }, + sectionIndicatorColor: { + type: 'select', + defaultValue: 'all' as IndicatorColorControl, + data: indicatorColorControls.map((x) => ({ value: x })), + info: true, + }, + ignoreCert: { + type: 'switch', + defaultValue: true, + info: true, + }, }, gridstack: { - minWidth: 1, - minHeight: 1, - maxWidth: 6, - maxHeight: 6, + minWidth: 2, + minHeight: 2, + maxWidth: 12, + maxHeight: 12, }, component: HealthMonitoringWidgetTile, }); @@ -53,12 +103,42 @@ interface HealthMonitoringWidgetProps { } function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) { const { t } = useTranslation('modules/health-monitoring'); - const { isInitialLoading, data } = useOpenmediavaultQuery(); + const { data, isInitialLoading, isError } = useStatusQuery( + widget.properties.node, + widget.properties.ignoreCert + ); + + Consola.log(data) - if (isInitialLoading || !data) { + if (isInitialLoading) { return ; } + if (isError || !data) { + return ( +
+ + + {t('errors.general.title')} + {t('errors.general.text')} + +
+ ); + } + + return ( + + + {!!data.system && } + {!!data.cluster && } + + + ); +} + +const SystemStatusTile = ({data, properties }: { data: any, properties: any}) => { + const { t } = useTranslation('modules/health-monitoring'); + const formatUptime = (uptime: number) => { const days = Math.floor(uptime / (60 * 60 * 24)); const remainingHours = Math.floor((uptime % (60 * 60 * 24)) / 3600); @@ -66,47 +146,45 @@ function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) { }; return ( - - - + + + + + + {t('info.uptime')}: +
+ {formatUptime(data.systemInfo.uptime)} +
- - - {t('info.uptime')}: -
- {formatUptime(data.systemInfo.uptime)} -
- - {data.systemInfo.availablePkgUpdates === 0 ? ( - '' - ) : ( - - )} - {data.systemInfo.rebootRequired ? : ''} - + {data.systemInfo.availablePkgUpdates === 0 ? ( + '' + ) : ( + + )} + {data.systemInfo.rebootRequired ? : ''}
-
- - - {widget?.properties.cpu && ( - - )} - {widget?.properties.memory && } - {widget?.properties.fileSystem && ( - <> - - - +
+ + + {properties.cpu && ( + )} -
-
+ {properties.memory && } + + {properties.fileSystem && ( + <> + + + + )} + ); -} +}; export const ringColor = (percentage: number) => { if (percentage < 30) return 'green'; @@ -115,13 +193,29 @@ export const ringColor = (percentage: number) => { else return 'red'; }; -export const useOpenmediavaultQuery = () => { +export const getIntegrations = () => { + const { name: configName } = useConfigContext(); + return api.healthMonitoring.integrations.useQuery( + { + configName: configName!, + }, + { + staleTime: 1000 * 10, + } + ); +}; + +const useStatusQuery = (node: string, ignoreCerts: boolean) => { const { name: configName } = useConfigContext(); - return api.openmediavault.fetchData.useQuery( + + return api.healthMonitoring.fetchData.useQuery( { configName: configName!, + filterNode: node!, + ignoreCerts: ignoreCerts!, }, { + //refetchInterval: 10000, staleTime: 1000 * 10, } ); diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx new file mode 100644 index 00000000000..0982bcbfc43 --- /dev/null +++ b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx @@ -0,0 +1,232 @@ +import { + Badge, + Center, + Divider, + Flex, + Group, + List, + RingProgress, + Stack, + Text, +} from '@mantine/core'; +import { + IconArrowNarrowDown, + IconArrowNarrowUp, + IconBrain, + IconClockHour3, + IconCpu, + IconCube, + IconDatabase, + IconDeviceLaptop, + IconHeartBolt, + IconNetwork, + IconServer, +} from '@tabler/icons-react'; +import dayjs from 'dayjs'; +import duration from 'dayjs/plugin/duration'; +import { useTranslation } from 'react-i18next'; +import { humanFileSize } from '~/tools/humanFileSize'; +import { ResourceData } from '~/widgets/health-monitoring/cluster/types'; + +dayjs.extend(duration); + +export const ResourceTypeEntryDetails = ({ entry }: { entry: ResourceData }) => { + const { t } = useTranslation('modules/health-monitoring'); + return ( + + + + + + + {entry.name} + + {capitalize(entry.status)} + + + + {entry.type !== 'node' && ( + + + {t('cluster.popover.node')} + + + {entry.node} + + + )} + {(entry.type === 'lxc' || entry.type === 'vm') && ( + + + {t('cluster.popover.vmid')} + + + {entry.vmId} + + + )} + {entry.type === 'storage' && ( + + + {t('cluster.popover.plugin')} + + + {entry.storagePlugin} + + + )} + + + + {entry.type !== 'storage' && } + {entry.type === 'storage' && } + + ); +}; + +const ComputeResourceDetails = ({ entry }: { entry: ResourceData }) => { + const { t } = useTranslation('modules/health-monitoring'); + return ( + + }> + {t('cluster.popover.cores', { maxCpu: entry.maxCpu })} + + }>{displayMemoryText(entry)} + }>{displayDiskText(entry)} + }> + {t('cluster.popover.uptime', { uptime: formatUptime(entry) })} + + {entry.haState && ( + }> + {t('cluster.popover.ha', { haState: capitalize(entry.haState) })} + + )} + + + + ); +}; + +const StorageResourceDetails = ({ entry }: { entry: ResourceData }) => { + const storagePercent = entry.maxDisk ? (entry.disk / entry.maxDisk) * 100 : 0; + return ( + +
+ {storagePercent.toFixed(1)}%} + sections={[{ value: storagePercent, color: storagePercent > 75 ? 'orange' : 'green' }]} + /> + + {displayDiskText(entry, false)} + +
+ + + +
+ ); +}; + +const DiskStats = ({ entry }: { entry: ResourceData }) => { + if (!entry.diskWrite || !entry.diskRead) { + return null; + } + return ( + }> + + + {humanFileSize(entry.diskWrite, false)} + + + + {humanFileSize(entry.diskRead, false)} + + + + + ); +}; + +const NetStats = ({ entry }: { entry: ResourceData }) => { + if (!entry.netIn || !entry.netOut) { + return null; + } + return ( + }> + + + {humanFileSize(entry.netIn, false)} + + + + {humanFileSize(entry.netOut, false)} + + + + + ); +}; + +const StorageType = ({ entry }: { entry: ResourceData }) => { + const { t } = useTranslation('modules/health-monitoring'); + if (entry.storageShared) { + return {t('cluster.popover.sharedStorage')}; + } else { + return {t('cluster.popover.localStorage')}; + } +}; + +const capitalize = (input: string) => { + return input[0].toUpperCase() + input.slice(1); +}; + +const ResourceIcon = ({ entry, size }: { entry: ResourceData; size: number }) => { + if (entry.type === 'node') { + return ; + } else if (entry.type === 'qemu') { + return ; + } else if (entry.type === 'storage') { + return ; + } else { + return ; + } +}; + +const displayMemoryText = (entry: ResourceData) => { + const { t } = useTranslation('modules/health-monitoring'); + if (!entry.maxMem) { + return t('cluster.popover.memSize', { maxMem: humanFileSize(0, false) }); + } else if (!entry.mem) { + return t('cluster.popover.memSize', { maxMem: humanFileSize(entry.maxMem, false) }); + } else { + return t('cluster.popover.memRatio', { + usedMem: humanFileSize(entry.mem, false), + maxMem: humanFileSize(entry.maxMem, false), + }); + } +}; + +const displayDiskText = (entry: ResourceData, useTrans: boolean = true) => { + const { t } = useTranslation('modules/health-monitoring'); + const maxDisk = !entry.maxDisk ? humanFileSize(0, false) : humanFileSize(entry.maxDisk, false); + const disk = !entry.disk ? humanFileSize(0, false) : humanFileSize(entry.disk, false); + + if (!entry.maxDisk || !entry.disk) { + return useTrans ? t('cluster.popover.diskSize', { maxDisk: maxDisk }) : maxDisk; + } else { + return useTrans + ? t('cluster.popover.diskRatio', { usedDisk: disk, maxDisk: maxDisk }) + : disk + ' / ' + maxDisk; + } +}; + +const formatUptime = (entry: ResourceData) => { + const { t } = useTranslation('modules/health-monitoring'); + if (entry.uptime > 0) { + return dayjs.duration(entry.uptime * 1000).humanize(); + } + return t('cluster.popover.na'); +}; \ No newline at end of file diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx new file mode 100644 index 00000000000..5d7b6ba8c2f --- /dev/null +++ b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx @@ -0,0 +1,112 @@ +import { Accordion, Badge, Group, Indicator, Popover, Table, Text } from '@mantine/core'; +import { TablerIconsProps } from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { ResourceTypeEntryDetails } from '~/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover'; +import { ResourceData } from '~/widgets/health-monitoring/cluster/types'; + +interface ResourceType { + data: ResourceData[]; + icon: (props: TablerIconsProps) => JSX.Element; + title: string; + count: number; + length: number; + indicatorColorControl: string; +} + +interface ResourceTypeProps { + item: ResourceType; + id: string; + include: boolean; + tableConfig: TableViewConfig; +} + +interface TableViewConfig { + showCpu: boolean; + showRam: boolean; + showNode: boolean; +} + +const indicatorColorControl = (entry: ResourceType) => { + return (entry.indicatorColorControl === 'all' && entry.count == entry.length) || + (entry.indicatorColorControl === 'any' && entry.count > 0) + ? 'green' + : 'orange'; +}; + +export const ResourceType = ({ item, id, include, tableConfig }: ResourceTypeProps) => { + const { t } = useTranslation('modules/health-monitoring'); + if (!include) { + return null; + } + return ( + + }> + + {item.title} + + {item.count} / {item.length} + + + + + + + + + {tableConfig.showCpu && } + {tableConfig.showRam && } + {tableConfig.showNode && } + + + + {item.data.map((data) => { + return ; + })} + +
{t('cluster.table.header.name')}{t('cluster.table.header.cpu')}{t('cluster.table.header.ram')}{t('cluster.table.header.node')}
+
+
+ ); +}; + +interface ResourceTypeEntryProps { + entry: ResourceData; + tableConfig: TableViewConfig; +} + +const ResourceTypeEntry = ({ entry, tableConfig }: ResourceTypeEntryProps) => { + return ( + + + + + + + {entry.name} + + + {tableConfig.showCpu && ( + {(entry.cpu * 100).toFixed(1)}% + )} + {tableConfig.showRam && ( + + {(entry.maxMem ? (entry.mem / entry.maxMem) * 100 : 0).toFixed(1)}% + + )} + {tableConfig.showNode && {entry.node}} + + + + + + + ); +}; \ No newline at end of file diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx new file mode 100644 index 00000000000..69e9a229154 --- /dev/null +++ b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx @@ -0,0 +1,157 @@ +import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from '@mantine/core'; +import { + IconBrain, + IconCpu, + IconCube, + IconDatabase, + IconDeviceLaptop, + IconServer +} from '@tabler/icons-react'; +import { useTranslation } from 'react-i18next'; +import { ResourceData, ResourceSummary } from "~/widgets/health-monitoring/cluster/types"; + +import Consola from 'consola'; +import { ResourceType } from './HealthMonitoringClusterResourceRow'; + + +export const ClusterStatusTile = ({data, properties }: { data: ResourceSummary, properties: any}) => { + const { t } = useTranslation('modules/health-monitoring'); + Consola.log(data) + + const running = (total: number, current: ResourceData) => { + return current.running ? total + 1 : total; + }; + + const activeNodes = data.nodes.reduce(running, 0); + const activeVMs = data.vms.reduce(running, 0); + const activeLXCs = data.lxcs.reduce(running, 0); + const activeStorage = data.storage.reduce(running, 0); + + const usedMem = data.nodes.reduce((sum: number, item: ResourceData) => (item.running ? item.mem + sum : sum), 0); + const maxMem = data.nodes.reduce((sum: number, item: ResourceData) => (item.running ? item.maxMem + sum : sum), 0); + const maxCpu = data.nodes.reduce((sum: number, item: ResourceData) => (item.running ? item.maxCpu + sum : sum), 0); + const usedCpu = data.nodes.reduce( + (sum: number, item: ResourceData) => (item.running ? item.cpu * item.maxCpu + sum : sum), + 0 + ); + + const cpuPercent = (usedCpu / maxCpu) * 100; + const memPercent = (usedMem / maxMem) * 100; + + return ( + + + + + + + + + + ); +} + +interface SummaryHeaderProps { + cpu: number; + memory: number; + include: boolean; +} + +const SummaryHeader = ({ cpu, memory, include }: SummaryHeaderProps) => { + const { t } = useTranslation('modules/health-monitoring'); + if (!include) { + return null; + } + return ( +
+ + + + +
+ } + sections={[{ value: cpu, color: cpu > 75 ? 'orange' : 'green' }]} + /> + + {t('cluster.summary.cpu')} + {cpu.toFixed(1)}% + + + + + + + } + sections={[{ value: memory, color: memory > 75 ? 'orange' : 'green' }]} + /> + + {t('cluster.summary.ram')} + {memory.toFixed(1)}% + + + + + ); +}; diff --git a/src/widgets/health-monitoring/cluster/types.ts b/src/widgets/health-monitoring/cluster/types.ts new file mode 100644 index 00000000000..eff55a46f35 --- /dev/null +++ b/src/widgets/health-monitoring/cluster/types.ts @@ -0,0 +1,30 @@ +export type ResourceSummary = { + vms: ResourceData[]; + lxcs: ResourceData[]; + nodes: ResourceData[]; + storage: ResourceData[]; +}; + +export type ResourceData = { + id: string; + cpu: number; + maxCpu: number; + maxMem: number; + mem: number; + name: string; + node: string; + status: string; + running: boolean; + type: string; + uptime: number; + vmId: number; + netIn: number; + netOut: number; + diskRead: number; + diskWrite: number; + disk: number; + maxDisk: number; + haState: string; + storagePlugin: string; + storageShared: boolean; +}; \ No newline at end of file From 46e721d8099e99f185f4ddd735a2ac2ba3f4ff12 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 4 Mar 2024 12:05:59 -0600 Subject: [PATCH 02/11] optimize router calls; make omv cputemp optional --- .../locales/en/modules/health-monitoring.json | 80 +++++++++---------- .../health-monitoring/openmediavault.ts | 22 +++-- .../api/routers/health-monitoring/router.ts | 26 ++++-- .../health-monitoring/HealthMonitoringCpu.tsx | 4 +- 4 files changed, 74 insertions(+), 58 deletions(-) diff --git a/public/locales/en/modules/health-monitoring.json b/public/locales/en/modules/health-monitoring.json index f43255323fc..daf1bd1ccb4 100644 --- a/public/locales/en/modules/health-monitoring.json +++ b/public/locales/en/modules/health-monitoring.json @@ -7,48 +7,46 @@ "fahrenheit": { "label": "Fahrenheit" }, - "proxmox": { - "node": { - "label": "Filter by node name", - "info": "Enter your Proxmox node name to only show metrics for that node. By default, the entire cluster is shown." - }, - "defaultViewState": { - "label": "Section open by default", - "data": { - "none": "None", - "node": "Nodes", - "vm": "VMs", - "lxc": "LXCs", - "storage": "Storage" - } - }, - "summary": { - "label": "Show summary section" - }, - "showNode": { - "label": "Show nodes section" - }, - "showVM": { - "label": "Show VMs section" - }, - "showLXCs": { - "label": "Show LXCs section" - }, - "showStorage": { - "label": "Show storage section" - }, - "sectionIndicatorColor": { - "label": "Requirement for section status indicator to be 'OK'", - "info": "'All' requires that all items be online for the indicator to be green. 'Any' requires at least one item to be online.", - "data": { - "any": "Any Active", - "all": "All Active" - } - }, - "ignoreCert": { - "label": "Ignore Certificate Errors", - "info": "If enabled, the widget will ignore certificate errors when accessing the Proxmox API. This can be helpful when accessing Proxmox through HTTPS." + "node": { + "label": "Filter by node name", + "info": "Enter your Proxmox node name to only show metrics for that node. By default, the entire cluster is shown." + }, + "defaultViewState": { + "label": "Section open by default", + "data": { + "none": "None", + "node": "Nodes", + "vm": "VMs", + "lxc": "LXCs", + "storage": "Storage" } + }, + "summary": { + "label": "Show summary section" + }, + "showNode": { + "label": "Show nodes section" + }, + "showVM": { + "label": "Show VMs section" + }, + "showLXCs": { + "label": "Show LXCs section" + }, + "showStorage": { + "label": "Show storage section" + }, + "sectionIndicatorColor": { + "label": "Requirement for section status indicator to be 'OK'", + "info": "'All' requires that all items be online for the indicator to be green. 'Any' requires at least one item to be online.", + "data": { + "any": "Any Active", + "all": "All Active" + } + }, + "ignoreCert": { + "label": "Ignore Certificate Errors", + "info": "If enabled, the widget will ignore certificate errors when accessing the Proxmox API. This can be helpful when accessing Proxmox through HTTPS." } } }, diff --git a/src/server/api/routers/health-monitoring/openmediavault.ts b/src/server/api/routers/health-monitoring/openmediavault.ts index 3cac6a96660..65b224b0bd5 100644 --- a/src/server/api/routers/health-monitoring/openmediavault.ts +++ b/src/server/api/routers/health-monitoring/openmediavault.ts @@ -72,7 +72,16 @@ export async function makeOpenMediaVaultCalls(app: ConfigAppType, input: any) { ?.split(';')[0]; } - const [systemInfoResponse, fileSystemResponse, cpuTempResponse] = await Promise.all([ + let cpuTempResponse: any; + const cpuTempResponsePromise = makeOpenMediaVaultRPCCall( + 'cputemp', + 'get', + {}, + { Cookie: `${loginToken};${sessionId}` }, + input + ) + + const [systemInfoResponse, fileSystemResponse, ] = await Promise.all([ makeOpenMediaVaultRPCCall( 'system', 'getInformation', @@ -87,15 +96,12 @@ export async function makeOpenMediaVaultCalls(app: ConfigAppType, input: any) { { Cookie: `${loginToken};${sessionId}` }, input ), - makeOpenMediaVaultRPCCall( - 'cputemp', - 'get', - {}, - { Cookie: `${loginToken};${sessionId}` }, - input - ), ]); + cpuTempResponsePromise.then((response) => { + cpuTempResponse = response; + }) + return { authenticated: authResponse ? authResponse.data.response.authenticated : true, systemInfo: systemInfoResponse?.data.response, diff --git a/src/server/api/routers/health-monitoring/router.ts b/src/server/api/routers/health-monitoring/router.ts index 41ef0e29c54..87b4b2c5bce 100644 --- a/src/server/api/routers/health-monitoring/router.ts +++ b/src/server/api/routers/health-monitoring/router.ts @@ -44,14 +44,26 @@ export const healthMonitoringRouter = createTRPCRouter({ let systemData: any; let clusterData: any; - if (omvApp) { - const data = await makeOpenMediaVaultCalls(omvApp, input); - if (data) { systemData = data; } - } + try { + const results = await Promise.all([ + omvApp ? makeOpenMediaVaultCalls(omvApp, input) : null, + proxApp ? makeProxmoxStatusAPICall(proxApp, input) : null, + ]) - if (proxApp) { - const data = await makeProxmoxStatusAPICall(proxApp, input); - if (data != null) { clusterData = data; } + for (const response of results) { + if (response) { + if ('authenticated' in response) { + Consola.log('got omv data') + systemData = response + } else if ('nodes' in response) { + Consola.log('got prox data') + clusterData = response + } + } + } + } catch (error) { + Consola.error(`Error executing health monitoring requests(s): ${error}`) + return null } return { diff --git a/src/widgets/health-monitoring/HealthMonitoringCpu.tsx b/src/widgets/health-monitoring/HealthMonitoringCpu.tsx index d023d7d43cb..8ef59a17856 100644 --- a/src/widgets/health-monitoring/HealthMonitoringCpu.tsx +++ b/src/widgets/health-monitoring/HealthMonitoringCpu.tsx @@ -83,7 +83,7 @@ const HealthMonitoringCpu = ({ info, cpuTemp, fahrenheit }: any) => { }, ]} /> - { color: cpuTemp.cputemp < 60 ? 'green' : 'red', }, ]} - /> + />} ); }; From 40fec180d4f47ad0a31cc0051f0b642b497c3716 Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 5 Mar 2024 08:56:31 -0600 Subject: [PATCH 03/11] move cluster info in tab; minor translation fixes --- .../locales/en/modules/health-monitoring.json | 19 ++++- .../health-monitoring/openmediavault.ts | 8 +- .../api/routers/health-monitoring/router.ts | 2 - .../HealthMonitoringTile.tsx | 73 +++++++++++++++---- 4 files changed, 85 insertions(+), 17 deletions(-) diff --git a/public/locales/en/modules/health-monitoring.json b/public/locales/en/modules/health-monitoring.json index daf1bd1ccb4..a88222eb077 100644 --- a/public/locales/en/modules/health-monitoring.json +++ b/public/locales/en/modules/health-monitoring.json @@ -3,10 +3,19 @@ "name": "System Health Monitoring", "description": "Displays information showing the health and status of your system(s).", "settings": { - "title": "System Health Monitoring", + "title": "Settings for system health monitoring widget", "fahrenheit": { "label": "Fahrenheit" }, + "cpu": { + "label": "Show CPU Info" + }, + "memory": { + "label": "Show Memory Info" + }, + "fileSystem": { + "label": "Show Filesystem Info" + }, "node": { "label": "Filter by node name", "info": "Enter your Proxmox node name to only show metrics for that node. By default, the entire cluster is shown." @@ -21,6 +30,14 @@ "storage": "Storage" } }, + "defaultTabState": { + "label": "Tab open by default", + "info": "Tab open by default. Only used when multiple integrations are available.", + "data": { + "system": "System", + "cluster": "Cluster" + } + }, "summary": { "label": "Show summary section" }, diff --git a/src/server/api/routers/health-monitoring/openmediavault.ts b/src/server/api/routers/health-monitoring/openmediavault.ts index 65b224b0bd5..bf439eaca76 100644 --- a/src/server/api/routers/health-monitoring/openmediavault.ts +++ b/src/server/api/routers/health-monitoring/openmediavault.ts @@ -37,7 +37,13 @@ async function makeOpenMediaVaultRPCCall( ...headers, }, } - ); + ).catch((error) => { + if (serviceName === 'cputemp') { + // ignore cputemp errors; not always supported + } else { + Consola.error(error) + } + }); return response; } diff --git a/src/server/api/routers/health-monitoring/router.ts b/src/server/api/routers/health-monitoring/router.ts index 87b4b2c5bce..097a8b182d0 100644 --- a/src/server/api/routers/health-monitoring/router.ts +++ b/src/server/api/routers/health-monitoring/router.ts @@ -53,10 +53,8 @@ export const healthMonitoringRouter = createTRPCRouter({ for (const response of results) { if (response) { if ('authenticated' in response) { - Consola.log('got omv data') systemData = response } else if ('nodes' in response) { - Consola.log('got prox data') clusterData = response } } diff --git a/src/widgets/health-monitoring/HealthMonitoringTile.tsx b/src/widgets/health-monitoring/HealthMonitoringTile.tsx index b4eabc22446..fa28b58959f 100644 --- a/src/widgets/health-monitoring/HealthMonitoringTile.tsx +++ b/src/widgets/health-monitoring/HealthMonitoringTile.tsx @@ -1,4 +1,4 @@ -import { Card, Center, Divider, Flex, Group, ScrollArea, Stack, Text, Title } from '@mantine/core'; +import { Card, Center, Divider, Group, ScrollArea, Stack, Tabs, Text, Title } from '@mantine/core'; import { IconAlertTriangle, IconCloudDownload, @@ -10,7 +10,6 @@ import { useTranslation } from 'next-i18next'; import { useConfigContext } from '~/config/provider'; import { api } from '~/utils/api'; -import Consola from 'consola'; import { defineWidget } from '../helper'; import { WidgetLoading } from '../loading'; import { IWidget } from '../widgets'; @@ -25,6 +24,9 @@ type DefaultViewState = (typeof defaultViewStates)[number]; const indicatorColorControls = ['all', 'any'] as const; type IndicatorColorControl = (typeof indicatorColorControls)[number]; +const defaultTabStates = ['system', 'cluster'] as const; +type DefaultTabStates = (typeof defaultTabStates)[number]; + const definition = defineWidget({ id: 'health-monitoring', icon: IconHeartRateMonitor, @@ -50,6 +52,12 @@ const definition = defineWidget({ defaultValue: '', info: true, }, + defaultTabState: { + type: 'select', + defaultValue: 'system' as DefaultTabStates, + data: defaultTabStates.map((x) => ({ value: x })), + info: true, + }, defaultViewState: { type: 'select', defaultValue: 'none' as DefaultViewState, @@ -103,13 +111,11 @@ interface HealthMonitoringWidgetProps { } function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) { const { t } = useTranslation('modules/health-monitoring'); - const { data, isInitialLoading, isError } = useStatusQuery( + let { data, isInitialLoading, isError } = useStatusQuery( widget.properties.node, widget.properties.ignoreCert ); - Consola.log(data) - if (isInitialLoading) { return ; } @@ -126,17 +132,58 @@ function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) { ); } - return ( - - - {!!data.system && } - {!!data.cluster && } + if (data.system && data.cluster) { + return ( + + + + + System + + + Cluster + + + + + + + + + - - ); + ); + } else { + return ( + + {data.system && } + {data.cluster && } + + ); + } } -const SystemStatusTile = ({data, properties }: { data: any, properties: any}) => { +const SystemStatusTile = ({ data, properties }: { data: any; properties: any }) => { const { t } = useTranslation('modules/health-monitoring'); const formatUptime = (uptime: number) => { From bcbe6872ed11d34068f2418b9024fac10ce45b57 Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 5 Mar 2024 10:24:49 -0600 Subject: [PATCH 04/11] formatting and cleanup --- .../health-monitoring/openmediavault.ts | 161 +++++++++--------- .../api/routers/health-monitoring/router.ts | 27 +-- .../HealthMonitoringTile.tsx | 1 - .../HealthMonitoringClusterDetailPopover.tsx | 2 +- .../HealthMonitoringClusterResourceRow.tsx | 2 +- .../cluster/HealthMonitoringClusterTile.tsx | 32 ++-- 6 files changed, 121 insertions(+), 104 deletions(-) diff --git a/src/server/api/routers/health-monitoring/openmediavault.ts b/src/server/api/routers/health-monitoring/openmediavault.ts index bf439eaca76..5701f05b671 100644 --- a/src/server/api/routers/health-monitoring/openmediavault.ts +++ b/src/server/api/routers/health-monitoring/openmediavault.ts @@ -4,7 +4,6 @@ import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-prope import { getConfig } from '~/tools/config/getConfig'; import { ConfigAppType } from '~/types/app'; - let sessionId: string | null = null; let loginToken: string | null = null; @@ -24,94 +23,96 @@ async function makeOpenMediaVaultRPCCall( } 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, + const response = await axios + .post( + `${appUrl.origin}/rpc.php`, + { + service: serviceName, + method: method, + params: params, }, - } - ).catch((error) => { - if (serviceName === 'cputemp') { - // ignore cputemp errors; not always supported - } else { - Consola.error(error) - } - }); + { + headers: { + 'Content-Type': 'application/json', + ...headers, + }, + } + ) + .catch((error) => { + if (serviceName === 'cputemp') { + // ignore cputemp errors; not always supported + } else { + Consola.error(error); + } + }); return response; } export async function makeOpenMediaVaultCalls(app: ConfigAppType, input: any) { - let authResponse: any = null; + let authResponse: any = null; - if (!sessionId || !loginToken) { - if (!app) { - Consola.error( - `Failed to process request to app 'openmediavault'. Please check username & password` - ); - return null; - } + if (!sessionId || !loginToken) { + if (!app) { + Consola.error( + `Failed to process request to app 'openmediavault'. Please check username & password` + ); + return null; + } - authResponse = await makeOpenMediaVaultRPCCall( - 'session', - 'login', - { - username: findAppProperty(app, 'username'), - password: findAppProperty(app, 'password'), - }, - {}, - input - ); + 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 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]; + } - let cpuTempResponse: any; - const cpuTempResponsePromise = makeOpenMediaVaultRPCCall( - 'cputemp', - 'get', - {}, - { Cookie: `${loginToken};${sessionId}` }, - input - ) + let cpuTempResponse: any; + const cpuTempResponsePromise = makeOpenMediaVaultRPCCall( + 'cputemp', + 'get', + {}, + { Cookie: `${loginToken};${sessionId}` }, + input + ); - const [systemInfoResponse, fileSystemResponse, ] = await Promise.all([ - makeOpenMediaVaultRPCCall( - 'system', - 'getInformation', - {}, - { Cookie: `${loginToken};${sessionId}` }, - input - ), - makeOpenMediaVaultRPCCall( - 'filesystemmgmt', - 'enumerateMountedFilesystems', - { includeroot: true }, - { Cookie: `${loginToken};${sessionId}` }, - input - ), - ]); + const [systemInfoResponse, fileSystemResponse] = await Promise.all([ + makeOpenMediaVaultRPCCall( + 'system', + 'getInformation', + {}, + { Cookie: `${loginToken};${sessionId}` }, + input + ), + makeOpenMediaVaultRPCCall( + 'filesystemmgmt', + 'enumerateMountedFilesystems', + { includeroot: true }, + { Cookie: `${loginToken};${sessionId}` }, + input + ), + ]); - cpuTempResponsePromise.then((response) => { - cpuTempResponse = response; - }) + cpuTempResponsePromise.then((response) => { + cpuTempResponse = response; + }); - return { - authenticated: authResponse ? authResponse.data.response.authenticated : true, - systemInfo: systemInfoResponse?.data.response, - fileSystem: fileSystemResponse?.data.response, - cpuTemp: cpuTempResponse?.data.response, - }; - } + return { + authenticated: authResponse ? authResponse.data.response.authenticated : true, + systemInfo: systemInfoResponse?.data.response, + fileSystem: fileSystemResponse?.data.response, + cpuTemp: cpuTempResponse?.data.response, + }; +} diff --git a/src/server/api/routers/health-monitoring/router.ts b/src/server/api/routers/health-monitoring/router.ts index 097a8b182d0..f3b8587c754 100644 --- a/src/server/api/routers/health-monitoring/router.ts +++ b/src/server/api/routers/health-monitoring/router.ts @@ -17,8 +17,10 @@ export const healthMonitoringRouter = createTRPCRouter({ .query(async ({ input }) => { const config = getConfig(input.configName); const apps = config.apps.map((app) => { - if (checkIntegrationsType(app.integration, ['proxmox', 'openmediavault'])) { return app.integration.type; } - }); + if (checkIntegrationsType(app.integration, ['proxmox', 'openmediavault'])) { + return app.integration.type; + } + }); return apps; }), @@ -31,10 +33,13 @@ export const healthMonitoringRouter = createTRPCRouter({ }) ) .query(async ({ input }) => { - const config = getConfig(input.configName); - const omvApp = config.apps.find((app) => checkIntegrationsType(app.integration, ['openmediavault'])); - const proxApp = config.apps.find((app) => checkIntegrationsType(app.integration, ['proxmox'])); + const omvApp = config.apps.find((app) => + checkIntegrationsType(app.integration, ['openmediavault']) + ); + const proxApp = config.apps.find((app) => + checkIntegrationsType(app.integration, ['proxmox']) + ); if (!omvApp && !proxApp) { Consola.error(`No valid integrations found for health monitoring in '${input.configName}'`); @@ -48,25 +53,25 @@ export const healthMonitoringRouter = createTRPCRouter({ const results = await Promise.all([ omvApp ? makeOpenMediaVaultCalls(omvApp, input) : null, proxApp ? makeProxmoxStatusAPICall(proxApp, input) : null, - ]) + ]); for (const response of results) { if (response) { if ('authenticated' in response) { - systemData = response + systemData = response; } else if ('nodes' in response) { - clusterData = response + clusterData = response; } } } } catch (error) { - Consola.error(`Error executing health monitoring requests(s): ${error}`) - return null + Consola.error(`Error executing health monitoring requests(s): ${error}`); + return null; } return { system: systemData, cluster: clusterData, - } + }; }), }); diff --git a/src/widgets/health-monitoring/HealthMonitoringTile.tsx b/src/widgets/health-monitoring/HealthMonitoringTile.tsx index fa28b58959f..58e6144f258 100644 --- a/src/widgets/health-monitoring/HealthMonitoringTile.tsx +++ b/src/widgets/health-monitoring/HealthMonitoringTile.tsx @@ -262,7 +262,6 @@ const useStatusQuery = (node: string, ignoreCerts: boolean) => { ignoreCerts: ignoreCerts!, }, { - //refetchInterval: 10000, staleTime: 1000 * 10, } ); diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx index 0982bcbfc43..4e8be016ed5 100644 --- a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx +++ b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx @@ -229,4 +229,4 @@ const formatUptime = (entry: ResourceData) => { return dayjs.duration(entry.uptime * 1000).humanize(); } return t('cluster.popover.na'); -}; \ No newline at end of file +}; diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx index 5d7b6ba8c2f..0ac96e44765 100644 --- a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx +++ b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx @@ -109,4 +109,4 @@ const ResourceTypeEntry = ({ entry, tableConfig }: ResourceTypeEntryProps) => { ); -}; \ No newline at end of file +}; diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx index 69e9a229154..2ad96cb402c 100644 --- a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx +++ b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx @@ -5,18 +5,21 @@ import { IconCube, IconDatabase, IconDeviceLaptop, - IconServer + IconServer, } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; -import { ResourceData, ResourceSummary } from "~/widgets/health-monitoring/cluster/types"; +import { ResourceData, ResourceSummary } from '~/widgets/health-monitoring/cluster/types'; -import Consola from 'consola'; import { ResourceType } from './HealthMonitoringClusterResourceRow'; - -export const ClusterStatusTile = ({data, properties }: { data: ResourceSummary, properties: any}) => { +export const ClusterStatusTile = ({ + data, + properties, +}: { + data: ResourceSummary; + properties: any; +}) => { const { t } = useTranslation('modules/health-monitoring'); - Consola.log(data) const running = (total: number, current: ResourceData) => { return current.running ? total + 1 : total; @@ -27,9 +30,18 @@ export const ClusterStatusTile = ({data, properties }: { data: ResourceSummary, const activeLXCs = data.lxcs.reduce(running, 0); const activeStorage = data.storage.reduce(running, 0); - const usedMem = data.nodes.reduce((sum: number, item: ResourceData) => (item.running ? item.mem + sum : sum), 0); - const maxMem = data.nodes.reduce((sum: number, item: ResourceData) => (item.running ? item.maxMem + sum : sum), 0); - const maxCpu = data.nodes.reduce((sum: number, item: ResourceData) => (item.running ? item.maxCpu + sum : sum), 0); + const usedMem = data.nodes.reduce( + (sum: number, item: ResourceData) => (item.running ? item.mem + sum : sum), + 0 + ); + const maxMem = data.nodes.reduce( + (sum: number, item: ResourceData) => (item.running ? item.maxMem + sum : sum), + 0 + ); + const maxCpu = data.nodes.reduce( + (sum: number, item: ResourceData) => (item.running ? item.maxCpu + sum : sum), + 0 + ); const usedCpu = data.nodes.reduce( (sum: number, item: ResourceData) => (item.running ? item.cpu * item.maxCpu + sum : sum), 0 @@ -101,7 +113,7 @@ export const ClusterStatusTile = ({data, properties }: { data: ResourceSummary, ); -} +}; interface SummaryHeaderProps { cpu: number; From d5f65e67689d5668575809327ac0b9469f2b7cde Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 5 Mar 2024 14:59:35 -0600 Subject: [PATCH 05/11] minor cleanup --- public/locales/en/modules/health-monitoring.json | 6 +++--- src/server/api/routers/health-monitoring/proxmox.ts | 4 ++-- src/widgets/health-monitoring/HealthMonitoringTile.tsx | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/public/locales/en/modules/health-monitoring.json b/public/locales/en/modules/health-monitoring.json index a88222eb077..dd53b45d269 100644 --- a/public/locales/en/modules/health-monitoring.json +++ b/public/locales/en/modules/health-monitoring.json @@ -3,9 +3,9 @@ "name": "System Health Monitoring", "description": "Displays information showing the health and status of your system(s).", "settings": { - "title": "Settings for system health monitoring widget", + "title": "Settings for system health monitoring", "fahrenheit": { - "label": "Fahrenheit" + "label": "CPU Temp in Fahrenheit" }, "cpu": { "label": "Show CPU Info" @@ -83,7 +83,7 @@ }, "info": { "uptime": "Uptime", - "updates": "Updates", + "updates": "Updates Available", "reboot": "Reboot" }, "errors": { diff --git a/src/server/api/routers/health-monitoring/proxmox.ts b/src/server/api/routers/health-monitoring/proxmox.ts index c89e35e07a3..1245a2be259 100644 --- a/src/server/api/routers/health-monitoring/proxmox.ts +++ b/src/server/api/routers/health-monitoring/proxmox.ts @@ -13,7 +13,7 @@ export async function makeProxmoxStatusAPICall(app: ConfigAppType, input: any) { const apiKey = findAppProperty(app, 'apiKey'); if (!apiKey) { - Consola.error('Proxmox: Missing or API key. Please check the configuration.'); + Consola.error(`'proxmox': Missing API key. Please check the configuration.`); return null; } @@ -31,7 +31,7 @@ export async function makeProxmoxStatusAPICall(app: ConfigAppType, input: any) { }) .catch((error) => { Consola.error( - `Proxmox: Error accessing service API: '${appUrl}'. Please check the configuration.` + `'proxmox': Error accessing service API: '${appUrl}'. Please check the configuration.` ); return null; }) diff --git a/src/widgets/health-monitoring/HealthMonitoringTile.tsx b/src/widgets/health-monitoring/HealthMonitoringTile.tsx index 58e6144f258..ef5f35b5e26 100644 --- a/src/widgets/health-monitoring/HealthMonitoringTile.tsx +++ b/src/widgets/health-monitoring/HealthMonitoringTile.tsx @@ -47,17 +47,17 @@ const definition = defineWidget({ type: 'switch', defaultValue: true, }, - node: { - type: 'text', - defaultValue: '', - info: true, - }, defaultTabState: { type: 'select', defaultValue: 'system' as DefaultTabStates, data: defaultTabStates.map((x) => ({ value: x })), info: true, }, + node: { + type: 'text', + defaultValue: '', + info: true, + }, defaultViewState: { type: 'select', defaultValue: 'none' as DefaultViewState, From 4188c45f32412195ba53f23387b22b83b7be2473 Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 5 Mar 2024 15:36:47 -0600 Subject: [PATCH 06/11] tweak error checking --- src/server/api/routers/health-monitoring/router.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/api/routers/health-monitoring/router.ts b/src/server/api/routers/health-monitoring/router.ts index f3b8587c754..95d672e84c2 100644 --- a/src/server/api/routers/health-monitoring/router.ts +++ b/src/server/api/routers/health-monitoring/router.ts @@ -57,9 +57,9 @@ export const healthMonitoringRouter = createTRPCRouter({ for (const response of results) { if (response) { - if ('authenticated' in response) { + if (response?.authenticated && response?.systemInfo) { systemData = response; - } else if ('nodes' in response) { + } else if (response?.nodes) { clusterData = response; } } From ec0aac19aad12592f7428cd03e16d9f76080ec73 Mon Sep 17 00:00:00 2001 From: Dylan Date: Tue, 12 Mar 2024 09:25:39 -0500 Subject: [PATCH 07/11] address review comments --- public/locales/en/modules/health-monitoring.json | 5 +++++ .../api/routers/health-monitoring/openmediavault.ts | 5 +++-- .../health-monitoring/HealthMonitoringTile.tsx | 12 ++++++------ 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/public/locales/en/modules/health-monitoring.json b/public/locales/en/modules/health-monitoring.json index dd53b45d269..f0b4d6435b3 100644 --- a/public/locales/en/modules/health-monitoring.json +++ b/public/locales/en/modules/health-monitoring.json @@ -83,6 +83,7 @@ }, "info": { "uptime": "Uptime", + "uptimeFormat": "{{days}} days, {{hours}} hours", "updates": "Updates Available", "reboot": "Reboot" }, @@ -92,6 +93,10 @@ "text": "There was a problem connecting to your system. Please verify your configuration/integration(s)." } }, + "headings": { + "system": "System", + "cluster": "Cluster" + }, "cluster": { "summary": { "cpu": "CPU", diff --git a/src/server/api/routers/health-monitoring/openmediavault.ts b/src/server/api/routers/health-monitoring/openmediavault.ts index 5701f05b671..de0f4972952 100644 --- a/src/server/api/routers/health-monitoring/openmediavault.ts +++ b/src/server/api/routers/health-monitoring/openmediavault.ts @@ -40,9 +40,10 @@ async function makeOpenMediaVaultRPCCall( ) .catch((error) => { if (serviceName === 'cputemp') { - // ignore cputemp errors; not always supported + // handle cputemp errors differently; not always supported + Consola.info(`Error fetching cputemp from openmediavault. Disabling CPU Temp display.`); } else { - Consola.error(error); + Consola.error(`Error while fetching from openmediavault: ${error}`); } }); return response; diff --git a/src/widgets/health-monitoring/HealthMonitoringTile.tsx b/src/widgets/health-monitoring/HealthMonitoringTile.tsx index ef5f35b5e26..d1a90f1d357 100644 --- a/src/widgets/health-monitoring/HealthMonitoringTile.tsx +++ b/src/widgets/health-monitoring/HealthMonitoringTile.tsx @@ -50,7 +50,7 @@ const definition = defineWidget({ defaultTabState: { type: 'select', defaultValue: 'system' as DefaultTabStates, - data: defaultTabStates.map((x) => ({ value: x })), + data: defaultTabStates.map((stateValue) => ({ value: stateValue })), info: true, }, node: { @@ -61,7 +61,7 @@ const definition = defineWidget({ defaultViewState: { type: 'select', defaultValue: 'none' as DefaultViewState, - data: defaultViewStates.map((x) => ({ value: x })), + data: defaultViewStates.map((stateValue) => ({ value: stateValue })), }, summary: { type: 'switch', @@ -86,7 +86,7 @@ const definition = defineWidget({ sectionIndicatorColor: { type: 'select', defaultValue: 'all' as IndicatorColorControl, - data: indicatorColorControls.map((x) => ({ value: x })), + data: indicatorColorControls.map((sectionColor) => ({ value: sectionColor })), info: true, }, ignoreCert: { @@ -148,10 +148,10 @@ function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) { - System + {t('headings.system')} - Cluster + {t('headings.cluster')} @@ -189,7 +189,7 @@ const SystemStatusTile = ({ data, properties }: { data: any; properties: any }) 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.uptimeFormat', { days: days, hours: remainingHours}) }; return ( From 248e797e48c6c5fa7741a431febb5a73332b573f Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 13 Mar 2024 17:05:30 -0500 Subject: [PATCH 08/11] fix build error --- src/server/api/routers/health-monitoring/router.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server/api/routers/health-monitoring/router.ts b/src/server/api/routers/health-monitoring/router.ts index 95d672e84c2..35213302a9e 100644 --- a/src/server/api/routers/health-monitoring/router.ts +++ b/src/server/api/routers/health-monitoring/router.ts @@ -57,9 +57,9 @@ export const healthMonitoringRouter = createTRPCRouter({ for (const response of results) { if (response) { - if (response?.authenticated && response?.systemInfo) { + if ('systemInfo' in response && response.systemInfo != null) { systemData = response; - } else if (response?.nodes) { + } else if ('nodes' in response) { clusterData = response; } } From 16f8831b4ac131023877198f3e9d934b06135500 Mon Sep 17 00:00:00 2001 From: Dylan Date: Thu, 14 Mar 2024 18:14:03 -0500 Subject: [PATCH 09/11] simplify api key setup --- src/server/api/routers/health-monitoring/proxmox.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/server/api/routers/health-monitoring/proxmox.ts b/src/server/api/routers/health-monitoring/proxmox.ts index 1245a2be259..5fe6121bb86 100644 --- a/src/server/api/routers/health-monitoring/proxmox.ts +++ b/src/server/api/routers/health-monitoring/proxmox.ts @@ -25,7 +25,7 @@ export async function makeProxmoxStatusAPICall(app: ConfigAppType, input: any) { const result = await axios .get(appUrl.toString(), { headers: { - Authorization: apiKey, + Authorization: `PVEAPIToken=${apiKey}`, }, httpsAgent: agent, }) From ccc5c997a65c8da5bfa59ebd12bf14f8c68e4681 Mon Sep 17 00:00:00 2001 From: Dylan Date: Fri, 15 Mar 2024 20:14:15 -0500 Subject: [PATCH 10/11] omv7 changes --- .../api/routers/health-monitoring/openmediavault.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/server/api/routers/health-monitoring/openmediavault.ts b/src/server/api/routers/health-monitoring/openmediavault.ts index de0f4972952..34dbf464ce5 100644 --- a/src/server/api/routers/health-monitoring/openmediavault.ts +++ b/src/server/api/routers/health-monitoring/openmediavault.ts @@ -73,10 +73,17 @@ export async function makeOpenMediaVaultCalls(app: ConfigAppType, input: any) { const cookies = authResponse.headers['set-cookie'] || []; sessionId = cookies - .find((cookie: any) => cookie.includes('X-OPENMEDIAVAULT-SESSIONID')) + .find( + (cookie: any) => + cookie.includes('X-OPENMEDIAVAULT-SESSIONID') || + cookie.includes('OPENMEDIAVAULT-SESSIONID') + ) ?.split(';')[0]; loginToken = cookies - .find((cookie: any) => cookie.includes('X-OPENMEDIAVAULT-LOGIN')) + .find( + (cookie: any) => + cookie.includes('X-OPENMEDIAVAULT-LOGIN') || cookie.includes('OPENMEDIAVAULT-LOGIN') + ) ?.split(';')[0]; } From 465dbec15e16f3ab7b82b370cf8f6d351ded261e Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 18 Mar 2024 18:36:53 -0500 Subject: [PATCH 11/11] fix swapped request configs --- src/widgets/health-monitoring/HealthMonitoringTile.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/health-monitoring/HealthMonitoringTile.tsx b/src/widgets/health-monitoring/HealthMonitoringTile.tsx index 052f4391a30..71bddeeed3e 100644 --- a/src/widgets/health-monitoring/HealthMonitoringTile.tsx +++ b/src/widgets/health-monitoring/HealthMonitoringTile.tsx @@ -247,7 +247,7 @@ export const getIntegrations = () => { configName: configName!, }, { - refetchInterval: 5000, + staleTime: 1000 * 10, } ); }; @@ -262,7 +262,7 @@ const useStatusQuery = (node: string, ignoreCerts: boolean) => { ignoreCerts: ignoreCerts!, }, { - staleTime: 1000 * 10, + refetchInterval: 5000, } ); };