mirror of
https://github.com/ajnart/homarr.git
synced 2025-11-18 03:01:09 +01:00
feat: add Proxmox integration/widget (#1903)
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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
|
||||
|
||||
127
src/server/api/routers/health-monitoring/openmediavault.ts
Normal file
127
src/server/api/routers/health-monitoring/openmediavault.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
109
src/server/api/routers/health-monitoring/proxmox.ts
Normal file
109
src/server/api/routers/health-monitoring/proxmox.ts
Normal 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;
|
||||
}
|
||||
77
src/server/api/routers/health-monitoring/router.ts
Normal file
77
src/server/api/routers/health-monitoring/router.ts
Normal 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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
30
src/widgets/health-monitoring/cluster/types.ts
Normal file
30
src/widgets/health-monitoring/cluster/types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user