feat: add Proxmox integration/widget (#1903)

This commit is contained in:
Dylan Slattery
2024-03-23 10:19:38 -05:00
committed by GitHub
parent 4a8b7377a8
commit 06772713ce
13 changed files with 1181 additions and 217 deletions

View File

@@ -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}}%"
"label": "Show Filesystem Info"
},
"info": {
"uptime": "Uptime",
"updates": "Updates",
"reboot": "Reboot"
"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."
},
"errors": {
"general": {
"title": "Unable to find your NAS",
"text": "There was a problem connecting to your NAS. Please verify your configuration/integration(s)."
"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."
}
}
}
},
"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"
}
}
}

View File

@@ -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<SelectItem[]>;

View File

@@ -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

View File

@@ -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<string, any>,
headers: Record<string, string>,
input: { configName: string }
) {
const config = getConfig(input.configName);
const app = config.apps.find((app) => checkIntegrationsType(app.integration, ['openmediavault']));
if (!app) {
Consola.error(`App '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,
};
}
}

View File

@@ -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;
}

View File

@@ -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,
};
}),
});

View File

@@ -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<string, any>,
headers: Record<string, string>,
input: { configName: string }
) {
const config = getConfig(input.configName);
const app = config.apps.find((app) => checkIntegrationsType(app.integration, ['openmediavault']));
if (!app) {
Consola.error(`App not found for configName '${input.configName}'`);
return null;
}
const appUrl = new URL(app.url);
const response = await axios.post(
`${appUrl.origin}/rpc.php`,
{
service: serviceName,
method: method,
params: params,
},
{
headers: {
'Content-Type': 'application/json',
...headers,
},
}
);
return response;
}
export const openmediavaultRouter = createTRPCRouter({
fetchData: publicProcedure
.input(
z.object({
configName: z.string(),
})
)
.query(async ({ input }) => {
let authResponse: any = null;
let app: any;
if (!sessionId || !loginToken) {
app = getConfig(input.configName)?.apps.find((app) =>
checkIntegrationsType(app.integration, ['openmediavault'])
);
if (!app) {
Consola.error(
`Failed to process request to app '${app.integration}' (${app.id}). Please check username & password`
);
return null;
}
authResponse = await makeOpenMediaVaultRPCCall(
'session',
'login',
{
username: findAppProperty(app, 'username'),
password: findAppProperty(app, 'password'),
},
{},
input
);
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,
};
}),
});

View File

@@ -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 = {

View File

@@ -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 <WidgetLoading />;
}
if (isError || !data) {
return (
<Center>
<Stack align="center">
<IconAlertTriangle />
<Title order={6}>{t('errors.general.title')}</Title>
<Text>{t('errors.general.text')}</Text>
</Stack>
</Center>
);
}
if (data.system && data.cluster) {
return (
<ScrollArea
h="100%"
styles={{
viewport: {
'& div[style="min-width: 100%"]': {
display: 'flex !important',
height: '100%',
},
},
}}
>
<Tabs defaultValue={widget.properties.defaultTabState} variant="outline">
<Tabs.List grow>
<Tabs.Tab value="system">
<b>{t('headings.system')}</b>
</Tabs.Tab>
<Tabs.Tab value="cluster">
<b>{t('headings.cluster')}</b>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel mt="lg" value="system">
<SystemStatusTile data={data.system} properties={widget.properties} />
</Tabs.Panel>
<Tabs.Panel mt="lg" value="cluster">
<ClusterStatusTile data={data.cluster} properties={widget.properties} />
</Tabs.Panel>
</Tabs>
</ScrollArea>
);
} else {
return (
<ScrollArea
h="100%"
styles={{
viewport: {
'& div[style="min-width: 100%"]': {
display: 'flex !important',
height: '100%',
},
},
}}
>
{data.system && <SystemStatusTile data={data.system} properties={widget.properties} />}
{data.cluster && <ClusterStatusTile data={data.cluster} properties={widget.properties} />}
</ScrollArea>
);
}
}
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 (
<Flex h="100%" w="100%" direction="column">
<ScrollArea>
<Card>
<Group position="center">
<IconInfoSquare size={40} />
<Text fz="lg" tt="uppercase" fw={700} c="dimmed" align="center">
{t('info.uptime')}:
<br />
{formatUptime(data.systemInfo.uptime)}
</Text>
<Group position="center">
{data.systemInfo.availablePkgUpdates === 0 ? (
''
) : (
<IconCloudDownload size={40} color="red" />
)}
{data.systemInfo.rebootRequired ? <IconStatusChange size={40} color="red" /> : ''}
</Group>
</Group>
</Card>
<Divider my="sm" />
<Stack>
<Card>
<Group position="center">
{widget?.properties.cpu && (
<HealthMonitoringCpu
info={data.systemInfo}
cpuTemp={data.cpuTemp}
fahrenheit={widget?.properties.fahrenheit}
/>
)}
{widget?.properties.memory && <HealthMonitoringMemory info={data.systemInfo} />}
<IconInfoSquare size={40} />
<Text fz="lg" tt="uppercase" fw={700} c="dimmed" align="center">
{t('info.uptime')}:
<br />
{formatUptime(data.systemInfo.uptime)}
</Text>
<Group position="center">
{data.systemInfo.availablePkgUpdates === 0 ? (
''
) : (
<IconCloudDownload size={40} color="red" />
)}
{data.systemInfo.rebootRequired ? <IconStatusChange size={40} color="red" /> : ''}
</Group>
</Group>
{widget?.properties.fileSystem && (
<>
<Divider my="sm" />
<HealthMonitoringFileSystem fileSystem={data.fileSystem} />
</>
</Card>
<Divider my="sm" />
<Group position="center">
{properties.cpu && (
<HealthMonitoringCpu
info={data.systemInfo}
cpuTemp={data.cpuTemp}
fahrenheit={properties.fahrenheit}
/>
)}
</ScrollArea>
</Flex>
{properties.memory && <HealthMonitoringMemory info={data.systemInfo} />}
</Group>
{properties.fileSystem && (
<>
<Divider my="sm" />
<HealthMonitoringFileSystem fileSystem={data.fileSystem} />
</>
)}
</Stack>
);
}
};
export const ringColor = (percentage: number) => {
if (percentage < 30) return 'green';
@@ -115,12 +240,27 @@ export const ringColor = (percentage: number) => {
else return 'red';
};
export const useOpenmediavaultQuery = () => {
export const getIntegrations = () => {
const { name: configName } = useConfigContext();
return api.openmediavault.fetchData.useQuery(
return api.healthMonitoring.integrations.useQuery(
{
configName: configName!,
},
{
staleTime: 1000 * 10,
}
);
};
const useStatusQuery = (node: string, ignoreCerts: boolean) => {
const { name: configName } = useConfigContext();
return api.healthMonitoring.fetchData.useQuery(
{
configName: configName!,
filterNode: node!,
ignoreCerts: ignoreCerts!,
},
{
refetchInterval: 5000,
}

View File

@@ -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 (
<Stack spacing={0}>
<Group noWrap align="start" position="apart">
<Group noWrap align="center">
<ResourceIcon entry={entry} size={35} />
<Stack spacing={0}>
<Text fw={700} size="md">
{entry.name}
</Text>
<Text color={entry.running ? 'green' : 'yellow'}>{capitalize(entry.status)}</Text>
</Stack>
</Group>
<Group align="end">
{entry.type !== 'node' && (
<Stack align="end" spacing={0}>
<Text fw={200} size="sm">
{t('cluster.popover.node')}
</Text>
<Text color="dimmed" size="xs">
{entry.node}
</Text>
</Stack>
)}
{(entry.type === 'lxc' || entry.type === 'vm') && (
<Stack align="end" spacing={0}>
<Text fw={200} size="sm">
{t('cluster.popover.vmid')}
</Text>
<Text color="dimmed" size="xs">
{entry.vmId}
</Text>
</Stack>
)}
{entry.type === 'storage' && (
<Stack align="end" spacing={0}>
<Text fw={200} size="sm">
{t('cluster.popover.plugin')}
</Text>
<Text color="dimmed" size="xs">
{entry.storagePlugin}
</Text>
</Stack>
)}
</Group>
</Group>
<Divider mt={0} mb="xs" />
{entry.type !== 'storage' && <ComputeResourceDetails entry={entry} />}
{entry.type === 'storage' && <StorageResourceDetails entry={entry} />}
</Stack>
);
};
const ComputeResourceDetails = ({ entry }: { entry: ResourceData }) => {
const { t } = useTranslation('modules/health-monitoring');
return (
<List>
<List.Item icon={<IconCpu size={16} />}>
{t('cluster.popover.cores', { maxCpu: entry.maxCpu })}
</List.Item>
<List.Item icon={<IconBrain size={16} />}>{displayMemoryText(entry)}</List.Item>
<List.Item icon={<IconDatabase size={16} />}>{displayDiskText(entry)}</List.Item>
<List.Item icon={<IconClockHour3 size={16} />}>
{t('cluster.popover.uptime', { uptime: formatUptime(entry) })}
</List.Item>
{entry.haState && (
<List.Item icon={<IconHeartBolt size={16} />}>
{t('cluster.popover.ha', { haState: capitalize(entry.haState) })}
</List.Item>
)}
<NetStats entry={entry} />
<DiskStats entry={entry} />
</List>
);
};
const StorageResourceDetails = ({ entry }: { entry: ResourceData }) => {
const storagePercent = entry.maxDisk ? (entry.disk / entry.maxDisk) * 100 : 0;
return (
<Stack spacing={0}>
<Center>
<RingProgress
roundCaps
size={100}
thickness={10}
label={<Text ta="center">{storagePercent.toFixed(1)}%</Text>}
sections={[{ value: storagePercent, color: storagePercent > 75 ? 'orange' : 'green' }]}
/>
<Group align="center" spacing={0}>
<Text>{displayDiskText(entry, false)}</Text>
</Group>
</Center>
<Flex gap="sm" mt={0} justify="end">
<StorageType entry={entry} />
</Flex>
</Stack>
);
};
const DiskStats = ({ entry }: { entry: ResourceData }) => {
if (!entry.diskWrite || !entry.diskRead) {
return null;
}
return (
<List.Item icon={<IconDatabase size={16} />}>
<Group spacing="sm">
<Group spacing={0}>
<Text>{humanFileSize(entry.diskWrite, false)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group spacing={0}>
<Text>{humanFileSize(entry.diskRead, false)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
</List.Item>
);
};
const NetStats = ({ entry }: { entry: ResourceData }) => {
if (!entry.netIn || !entry.netOut) {
return null;
}
return (
<List.Item icon={<IconNetwork size={16} />}>
<Group spacing="sm">
<Group spacing={0}>
<Text>{humanFileSize(entry.netIn, false)}</Text>
<IconArrowNarrowDown size={14} />
</Group>
<Group spacing={0}>
<Text>{humanFileSize(entry.netOut, false)}</Text>
<IconArrowNarrowUp size={14} />
</Group>
</Group>
</List.Item>
);
};
const StorageType = ({ entry }: { entry: ResourceData }) => {
const { t } = useTranslation('modules/health-monitoring');
if (entry.storageShared) {
return <Badge color="blue">{t('cluster.popover.sharedStorage')}</Badge>;
} else {
return <Badge color="teal">{t('cluster.popover.localStorage')}</Badge>;
}
};
const capitalize = (input: string) => {
return input[0].toUpperCase() + input.slice(1);
};
const ResourceIcon = ({ entry, size }: { entry: ResourceData; size: number }) => {
if (entry.type === 'node') {
return <IconServer size={size} />;
} else if (entry.type === 'qemu') {
return <IconDeviceLaptop size={size} />;
} else if (entry.type === 'storage') {
return <IconDatabase size={size} />;
} else {
return <IconCube size={size} />;
}
};
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');
};

View File

@@ -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 (
<Accordion.Item value={id}>
<Accordion.Control icon={<item.icon />}>
<Group style={{ rowGap: '0' }}>
<Text>{item.title}</Text>
<Badge variant="dot" color={indicatorColorControl(item)} size="lg">
{item.count} / {item.length}
</Badge>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Table highlightOnHover>
<thead>
<tr>
<th>{t('cluster.table.header.name')}</th>
{tableConfig.showCpu && <th>{t('cluster.table.header.cpu')}</th>}
{tableConfig.showRam && <th>{t('cluster.table.header.ram')}</th>}
{tableConfig.showNode && <th>{t('cluster.table.header.node')}</th>}
</tr>
</thead>
<tbody>
{item.data.map((data) => {
return <ResourceTypeEntry entry={data} tableConfig={tableConfig} />;
})}
</tbody>
</Table>
</Accordion.Panel>
</Accordion.Item>
);
};
interface ResourceTypeEntryProps {
entry: ResourceData;
tableConfig: TableViewConfig;
}
const ResourceTypeEntry = ({ entry, tableConfig }: ResourceTypeEntryProps) => {
return (
<Popover
withArrow
withinPortal
radius="lg"
shadow="sm"
transitionProps={{
transition: 'pop',
}}
>
<Popover.Target>
<tr>
<td>
<Group noWrap>
<Indicator size={14} children={null} color={entry.running ? 'green' : 'yellow'} />
<Text lineClamp={1}>{entry.name}</Text>
</Group>
</td>
{tableConfig.showCpu && (
<td style={{ whiteSpace: 'nowrap' }}>{(entry.cpu * 100).toFixed(1)}%</td>
)}
{tableConfig.showRam && (
<td style={{ whiteSpace: 'nowrap' }}>
{(entry.maxMem ? (entry.mem / entry.maxMem) * 100 : 0).toFixed(1)}%
</td>
)}
{tableConfig.showNode && <td style={{ WebkitLineClamp: '1' }}>{entry.node}</td>}
</tr>
</Popover.Target>
<Popover.Dropdown>
<ResourceTypeEntryDetails entry={entry} />
</Popover.Dropdown>
</Popover>
);
};

View File

@@ -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 (
<Stack h="100%">
<SummaryHeader cpu={cpuPercent} memory={memPercent} include={properties.summary} />
<Accordion
variant="contained"
chevronPosition="right"
defaultValue={properties.defaultViewState}
>
<ResourceType
item={{
data: data.nodes,
icon: IconServer,
title: t('cluster.accordion.title.nodes'),
count: activeNodes,
length: data.nodes.length,
indicatorColorControl: properties.sectionIndicatorColor,
}}
id={'node'}
include={properties.showNode}
tableConfig={{ showCpu: true, showRam: true, showNode: false }}
/>
<ResourceType
item={{
data: data.vms,
icon: IconDeviceLaptop,
title: t('cluster.accordion.title.vms'),
count: activeVMs,
length: data.vms.length,
indicatorColorControl: properties.sectionIndicatorColor,
}}
id={'vm'}
include={properties.showVM}
tableConfig={{ showCpu: true, showRam: true, showNode: false }}
/>
<ResourceType
item={{
data: data.lxcs,
icon: IconCube,
title: t('cluster.accordion.title.lxcs'),
count: activeLXCs,
length: data.lxcs.length,
indicatorColorControl: properties.sectionIndicatorColor,
}}
id={'lxc'}
include={properties.showLXCs}
tableConfig={{ showCpu: true, showRam: true, showNode: false }}
/>
<ResourceType
item={{
data: data.storage,
icon: IconDatabase,
title: t('cluster.accordion.title.storage'),
count: activeStorage,
length: data.storage.length,
indicatorColorControl: properties.sectionIndicatorColor,
}}
id={'storage'}
include={properties.showStorage}
tableConfig={{ showCpu: false, showRam: false, showNode: true }}
/>
</Accordion>
</Stack>
);
};
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 (
<Center>
<Group noWrap>
<Flex direction="row">
<RingProgress
roundCaps
size={60}
thickness={6}
label={
<Center>
<IconCpu />
</Center>
}
sections={[{ value: cpu, color: cpu > 75 ? 'orange' : 'green' }]}
/>
<Stack align="center" justify="center" spacing={0}>
<Text>{t('cluster.summary.cpu')}</Text>
<Text>{cpu.toFixed(1)}%</Text>
</Stack>
</Flex>
<Flex>
<RingProgress
roundCaps
size={60}
thickness={6}
label={
<Center>
<IconBrain />
</Center>
}
sections={[{ value: memory, color: memory > 75 ? 'orange' : 'green' }]}
/>
<Stack align="center" justify="center" spacing={0}>
<Text>{t('cluster.summary.ram')}</Text>
<Text>{memory.toFixed(1)}%</Text>
</Stack>
</Flex>
</Group>
</Center>
);
};

View File

@@ -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;
};