diff --git a/public/locales/en/modules/health-monitoring.json b/public/locales/en/modules/health-monitoring.json index ab284e799fc..be6e085e414 100644 --- a/public/locales/en/modules/health-monitoring.json +++ b/public/locales/en/modules/health-monitoring.json @@ -1,38 +1,141 @@ { - "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": "Settings for system health monitoring", + "fahrenheit": { + "label": "CPU Temp in Fahrenheit" + }, "cpu": { - "label": "CPU", + "label": "Show CPU Info", "load": "Load Average", "minute": "{{minute}} minute", "minutes": "{{minutes}} minutes" }, "memory": { - "label": "Memory", - "totalMem": "Total memory: {{total}}GB", - "available": "Available: {{available}}GB - {{percentage}}%" + "label": "Show Memory Info" }, "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)." + "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." + }, + "defaultViewState": { + "label": "Section open by default", + "data": { + "none": "None", + "node": "Nodes", + "vm": "VMs", + "lxc": "LXCs", + "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" + }, + "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." } - } \ No newline at end of file + } + }, + "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", + "uptimeFormat": "{{days}} days, {{hours}} hours", + "updates": "Updates Available", + "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)." + } + }, + "headings": { + "system": "System", + "cluster": "Cluster" + }, + "cluster": { + "summary": { + "cpu": "CPU", + "ram": "RAM" + }, + "accordion": { + "title": { + "nodes": "Nodes", + "vms": "VMs", + "lxcs": "LXCs", + "storage": "Storage" + } + }, + "table": { + "header": { + "name": "Name", + "cpu": "CPU", + "ram": "RAM", + "node": "Node" + } + }, + "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 bedb543a2ee..f6273209274 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/health-monitoring/openmediavault.ts b/src/server/api/routers/health-monitoring/openmediavault.ts new file mode 100644 index 00000000000..3a3991171bd --- /dev/null +++ b/src/server/api/routers/health-monitoring/openmediavault.ts @@ -0,0 +1,127 @@ +import axios from 'axios'; +import Consola from 'consola'; +import { checkIntegrationsType, findAppProperty } from '~/tools/client/app-properties'; +import { getConfig } from '~/tools/config/getConfig'; +import { ConfigAppType } from '~/types/app'; + +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 'openmediavault' 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 async function makeOpenMediaVaultCalls(app: ConfigAppType, input: any) { + let authResponse: any = 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 + ); + + if (authResponse.data.response.sessionid) { + sessionId = authResponse.data.response.sessionid; + } else { + 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 responses = await Promise.allSettled([ + makeOpenMediaVaultRPCCall( + 'system', + 'getInformation', + {}, + loginToken + ? { Cookie: `${loginToken};${sessionId}` } + : { 'X-OPENMEDIAVAULT-SESSIONID': sessionId as string }, + input + ), + makeOpenMediaVaultRPCCall( + 'filesystemmgmt', + 'enumerateMountedFilesystems', + { includeroot: true }, + loginToken + ? { Cookie: `${loginToken};${sessionId}` } + : { 'X-OPENMEDIAVAULT-SESSIONID': sessionId as string }, + input + ), + makeOpenMediaVaultRPCCall( + 'cputemp', + 'get', + {}, + loginToken + ? { Cookie: `${loginToken};${sessionId}` } + : { 'X-OPENMEDIAVAULT-SESSIONID': sessionId as string }, + input + ), + ]); + + const systemInfoResponse = + responses[0].status === 'fulfilled' && responses[0].value + ? responses[0].value.data?.response + : null; + const fileSystemResponse = + responses[1].status === 'fulfilled' && responses[1].value + ? responses[1].value.data?.response + : null; + const cpuTempResponse = + responses[2].status === 'fulfilled' && responses[2].value + ? responses[2].value.data?.response + : null; + + return { + systemInfo: systemInfoResponse, + fileSystem: fileSystemResponse, + cpuTemp: cpuTempResponse, + }; + } +} 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..5fe6121bb86 --- /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 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: `PVEAPIToken=${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..35213302a9e --- /dev/null +++ b/src/server/api/routers/health-monitoring/router.ts @@ -0,0 +1,77 @@ +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; + + try { + const results = await Promise.all([ + omvApp ? makeOpenMediaVaultCalls(omvApp, input) : null, + proxApp ? makeProxmoxStatusAPICall(proxApp, input) : null, + ]); + + for (const response of results) { + if (response) { + if ('systemInfo' in response && response.systemInfo != null) { + systemData = response; + } else if ('nodes' in response) { + clusterData = response; + } + } + } + } catch (error) { + Consola.error(`Error executing health monitoring requests(s): ${error}`); + return null; + } + + return { + system: systemData, + cluster: clusterData, + }; + }), +}); diff --git a/src/server/api/routers/openmediavault.ts b/src/server/api/routers/openmediavault.ts deleted file mode 100644 index 66a2f84ae2d..00000000000 --- a/src/server/api/routers/openmediavault.ts +++ /dev/null @@ -1,142 +0,0 @@ -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 - ); - - if (authResponse.data.response.sessionid) { - sessionId = authResponse.data.response.sessionid; - } else { - 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 responses = await Promise.allSettled([ - makeOpenMediaVaultRPCCall( - 'system', - 'getInformation', - {}, - loginToken - ? { Cookie: `${loginToken};${sessionId}` } - : { 'X-OPENMEDIAVAULT-SESSIONID': sessionId as string }, - input - ), - makeOpenMediaVaultRPCCall( - 'filesystemmgmt', - 'enumerateMountedFilesystems', - { includeroot: true }, - loginToken - ? { Cookie: `${loginToken};${sessionId}` } - : { 'X-OPENMEDIAVAULT-SESSIONID': sessionId as string }, - input - ), - makeOpenMediaVaultRPCCall( - 'cputemp', - 'get', - {}, - loginToken - ? { Cookie: `${loginToken};${sessionId}` } - : { 'X-OPENMEDIAVAULT-SESSIONID': sessionId as string }, - input - ), - ]); - - const systemInfoResponse = - responses[0].status === 'fulfilled' && responses[0].value - ? responses[0].value.data?.response - : null; - const fileSystemResponse = - responses[1].status === 'fulfilled' && responses[1].value - ? responses[1].value.data?.response - : null; - const cpuTempResponse = - responses[2].status === 'fulfilled' && responses[2].value - ? responses[2].value.data?.response - : null; - - return { - systemInfo: systemInfoResponse, - fileSystem: fileSystemResponse, - cpuTemp: cpuTempResponse, - }; - }), -}); 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 8bd0e6bc4b0..71bddeeed3e 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, Group, ScrollArea, Stack, Tabs, Text, Title } from '@mantine/core'; import { + IconAlertTriangle, IconCloudDownload, IconHeartRateMonitor, IconInfoSquare, @@ -15,6 +16,16 @@ 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 defaultTabStates = ['system', 'cluster'] as const; +type DefaultTabStates = (typeof defaultTabStates)[number]; const definition = defineWidget({ id: 'health-monitoring', @@ -36,12 +47,59 @@ const definition = defineWidget({ type: 'switch', defaultValue: true, }, + defaultTabState: { + type: 'select', + defaultValue: 'system' as DefaultTabStates, + data: defaultTabStates.map((stateValue) => ({ value: stateValue })), + info: true, + }, + node: { + type: 'text', + defaultValue: '', + info: true, + }, + defaultViewState: { + type: 'select', + defaultValue: 'none' as DefaultViewState, + data: defaultViewStates.map((stateValue) => ({ value: stateValue })), + }, + 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((sectionColor) => ({ value: sectionColor })), + 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,60 +111,127 @@ interface HealthMonitoringWidgetProps { } function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) { const { t } = useTranslation('modules/health-monitoring'); - const { isInitialLoading, data } = useOpenmediavaultQuery(); + let { data, isInitialLoading, isError } = useStatusQuery( + widget.properties.node, + widget.properties.ignoreCert + ); - if (isInitialLoading || !data) { + if (isInitialLoading) { return ; } + if (isError || !data) { + return ( +
+ + + {t('errors.general.title')} + {t('errors.general.text')} + +
+ ); + } + + if (data.system && data.cluster) { + return ( + + + + + {t('headings.system')} + + + {t('headings.cluster')} + + + + + + + + + + + ); + } else { + 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); - return `${days} days, ${remainingHours} hours`; + return t('info.uptimeFormat', { days: days, hours: remainingHours}) }; 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,11 +240,26 @@ 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: 5000, diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterDetailPopover.tsx new file mode 100644 index 00000000000..4e8be016ed5 --- /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'); +}; diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterResourceRow.tsx new file mode 100644 index 00000000000..0ac96e44765 --- /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}} + + + + + + + ); +}; diff --git a/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx new file mode 100644 index 00000000000..2ad96cb402c --- /dev/null +++ b/src/widgets/health-monitoring/cluster/HealthMonitoringClusterTile.tsx @@ -0,0 +1,169 @@ +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 { ResourceType } from './HealthMonitoringClusterResourceRow'; + +export const ClusterStatusTile = ({ + data, + properties, +}: { + data: ResourceSummary; + properties: any; +}) => { + const { t } = useTranslation('modules/health-monitoring'); + + 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