feat: add Proxmox integration/widget (#1903)
This commit is contained in:
@@ -1,38 +1,141 @@
|
|||||||
{
|
{
|
||||||
"descriptor": {
|
"descriptor": {
|
||||||
"name": "System Health Monitoring",
|
"name": "System Health Monitoring",
|
||||||
"description": "Information about your NAS",
|
"description": "Displays information showing the health and status of your system(s).",
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "System Health Monitoring",
|
"title": "Settings for system health monitoring",
|
||||||
"fahrenheit": {
|
"fahrenheit": {
|
||||||
"label": "Fahrenheit"
|
"label": "CPU Temp in Fahrenheit"
|
||||||
}
|
},
|
||||||
}
|
|
||||||
},
|
|
||||||
"cpu": {
|
"cpu": {
|
||||||
"label": "CPU",
|
"label": "Show CPU Info",
|
||||||
"load": "Load Average",
|
"load": "Load Average",
|
||||||
"minute": "{{minute}} minute",
|
"minute": "{{minute}} minute",
|
||||||
"minutes": "{{minutes}} minutes"
|
"minutes": "{{minutes}} minutes"
|
||||||
},
|
},
|
||||||
"memory": {
|
"memory": {
|
||||||
"label": "Memory",
|
"label": "Show Memory Info"
|
||||||
"totalMem": "Total memory: {{total}}GB",
|
|
||||||
"available": "Available: {{available}}GB - {{percentage}}%"
|
|
||||||
},
|
},
|
||||||
"fileSystem": {
|
"fileSystem": {
|
||||||
"label": "File System",
|
"label": "Show Filesystem Info"
|
||||||
"available": "Available: {{available}} - {{percentage}}%"
|
|
||||||
},
|
},
|
||||||
"info": {
|
"node": {
|
||||||
"uptime": "Uptime",
|
"label": "Filter by node name",
|
||||||
"updates": "Updates",
|
"info": "Enter your Proxmox node name to only show metrics for that node. By default, the entire cluster is shown."
|
||||||
"reboot": "Reboot"
|
|
||||||
},
|
},
|
||||||
"errors": {
|
"defaultViewState": {
|
||||||
"general": {
|
"label": "Section open by default",
|
||||||
"title": "Unable to find your NAS",
|
"data": {
|
||||||
"text": "There was a problem connecting to your NAS. Please verify your configuration/integration(s)."
|
"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',
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/openmediavault.png',
|
||||||
label: 'OpenMediaVault',
|
label: 'OpenMediaVault',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'proxmox',
|
||||||
|
image: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/proxmox.png',
|
||||||
|
label: 'Proxmox',
|
||||||
|
}
|
||||||
] as const satisfies Readonly<SelectItem[]>;
|
] as const satisfies Readonly<SelectItem[]>;
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import { dashDotRouter } from './routers/dash-dot';
|
|||||||
import { dnsHoleRouter } from './routers/dns-hole/router';
|
import { dnsHoleRouter } from './routers/dns-hole/router';
|
||||||
import { dockerRouter } from './routers/docker/router';
|
import { dockerRouter } from './routers/docker/router';
|
||||||
import { downloadRouter } from './routers/download';
|
import { downloadRouter } from './routers/download';
|
||||||
|
import { healthMonitoringRouter } from './routers/health-monitoring/router';
|
||||||
import { iconRouter } from './routers/icon';
|
import { iconRouter } from './routers/icon';
|
||||||
import { indexerManagerRouter } from './routers/indexer-manager';
|
import { indexerManagerRouter } from './routers/indexer-manager';
|
||||||
import { inviteRouter } from './routers/invite/invite-router';
|
import { inviteRouter } from './routers/invite/invite-router';
|
||||||
import { mediaRequestsRouter } from './routers/media-request';
|
import { mediaRequestsRouter } from './routers/media-request';
|
||||||
import { mediaServerRouter } from './routers/media-server';
|
import { mediaServerRouter } from './routers/media-server';
|
||||||
import { notebookRouter } from './routers/notebook';
|
import { notebookRouter } from './routers/notebook';
|
||||||
import { openmediavaultRouter } from './routers/openmediavault';
|
|
||||||
import { overseerrRouter } from './routers/overseerr';
|
import { overseerrRouter } from './routers/overseerr';
|
||||||
import { passwordRouter } from './routers/password';
|
import { passwordRouter } from './routers/password';
|
||||||
import { rssRouter } from './routers/rss';
|
import { rssRouter } from './routers/rss';
|
||||||
@@ -50,7 +50,7 @@ export const rootRouter = createTRPCRouter({
|
|||||||
password: passwordRouter,
|
password: passwordRouter,
|
||||||
notebook: notebookRouter,
|
notebook: notebookRouter,
|
||||||
smartHomeEntityState: smartHomeEntityStateRouter,
|
smartHomeEntityState: smartHomeEntityStateRouter,
|
||||||
openmediavault: openmediavaultRouter,
|
healthMonitoring: healthMonitoringRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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'
|
| 'pihole'
|
||||||
| 'adGuardHome'
|
| 'adGuardHome'
|
||||||
| 'homeAssistant'
|
| 'homeAssistant'
|
||||||
| 'openmediavault';
|
| 'openmediavault'
|
||||||
|
| 'proxmox';
|
||||||
|
|
||||||
export type AppIntegrationType = {
|
export type AppIntegrationType = {
|
||||||
type: IntegrationType | null;
|
type: IntegrationType | null;
|
||||||
@@ -103,6 +104,7 @@ export const integrationFieldProperties: {
|
|||||||
adGuardHome: ['username', 'password'],
|
adGuardHome: ['username', 'password'],
|
||||||
homeAssistant: ['apiKey'],
|
homeAssistant: ['apiKey'],
|
||||||
openmediavault: ['username', 'password'],
|
openmediavault: ['username', 'password'],
|
||||||
|
proxmox: ['apiKey'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IntegrationFieldDefinitionType = {
|
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 {
|
import {
|
||||||
|
IconAlertTriangle,
|
||||||
IconCloudDownload,
|
IconCloudDownload,
|
||||||
IconHeartRateMonitor,
|
IconHeartRateMonitor,
|
||||||
IconInfoSquare,
|
IconInfoSquare,
|
||||||
@@ -15,6 +16,16 @@ import { IWidget } from '../widgets';
|
|||||||
import HealthMonitoringCpu from './HealthMonitoringCpu';
|
import HealthMonitoringCpu from './HealthMonitoringCpu';
|
||||||
import HealthMonitoringFileSystem from './HealthMonitoringFileSystem';
|
import HealthMonitoringFileSystem from './HealthMonitoringFileSystem';
|
||||||
import HealthMonitoringMemory from './HealthMonitoringMemory';
|
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({
|
const definition = defineWidget({
|
||||||
id: 'health-monitoring',
|
id: 'health-monitoring',
|
||||||
@@ -36,12 +47,59 @@ const definition = defineWidget({
|
|||||||
type: 'switch',
|
type: 'switch',
|
||||||
defaultValue: true,
|
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: {
|
gridstack: {
|
||||||
minWidth: 1,
|
minWidth: 2,
|
||||||
minHeight: 1,
|
minHeight: 2,
|
||||||
maxWidth: 6,
|
maxWidth: 12,
|
||||||
maxHeight: 6,
|
maxHeight: 12,
|
||||||
},
|
},
|
||||||
component: HealthMonitoringWidgetTile,
|
component: HealthMonitoringWidgetTile,
|
||||||
});
|
});
|
||||||
@@ -53,60 +111,127 @@ interface HealthMonitoringWidgetProps {
|
|||||||
}
|
}
|
||||||
function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) {
|
function HealthMonitoringWidgetTile({ widget }: HealthMonitoringWidgetProps) {
|
||||||
const { t } = useTranslation('modules/health-monitoring');
|
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 />;
|
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 formatUptime = (uptime: number) => {
|
||||||
const days = Math.floor(uptime / (60 * 60 * 24));
|
const days = Math.floor(uptime / (60 * 60 * 24));
|
||||||
const remainingHours = Math.floor((uptime % (60 * 60 * 24)) / 3600);
|
const remainingHours = Math.floor((uptime % (60 * 60 * 24)) / 3600);
|
||||||
return `${days} days, ${remainingHours} hours`;
|
return t('info.uptimeFormat', { days: days, hours: remainingHours})
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex h="100%" w="100%" direction="column">
|
<Stack>
|
||||||
<ScrollArea>
|
<Card>
|
||||||
<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" />
|
|
||||||
<Group position="center">
|
<Group position="center">
|
||||||
{widget?.properties.cpu && (
|
<IconInfoSquare size={40} />
|
||||||
<HealthMonitoringCpu
|
<Text fz="lg" tt="uppercase" fw={700} c="dimmed" align="center">
|
||||||
info={data.systemInfo}
|
{t('info.uptime')}:
|
||||||
cpuTemp={data.cpuTemp}
|
<br />
|
||||||
fahrenheit={widget?.properties.fahrenheit}
|
{formatUptime(data.systemInfo.uptime)}
|
||||||
/>
|
</Text>
|
||||||
)}
|
<Group position="center">
|
||||||
{widget?.properties.memory && <HealthMonitoringMemory info={data.systemInfo} />}
|
{data.systemInfo.availablePkgUpdates === 0 ? (
|
||||||
|
''
|
||||||
|
) : (
|
||||||
|
<IconCloudDownload size={40} color="red" />
|
||||||
|
)}
|
||||||
|
{data.systemInfo.rebootRequired ? <IconStatusChange size={40} color="red" /> : ''}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
{widget?.properties.fileSystem && (
|
</Card>
|
||||||
<>
|
<Divider my="sm" />
|
||||||
<Divider my="sm" />
|
<Group position="center">
|
||||||
<HealthMonitoringFileSystem fileSystem={data.fileSystem} />
|
{properties.cpu && (
|
||||||
</>
|
<HealthMonitoringCpu
|
||||||
|
info={data.systemInfo}
|
||||||
|
cpuTemp={data.cpuTemp}
|
||||||
|
fahrenheit={properties.fahrenheit}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
{properties.memory && <HealthMonitoringMemory info={data.systemInfo} />}
|
||||||
</Flex>
|
</Group>
|
||||||
|
{properties.fileSystem && (
|
||||||
|
<>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<HealthMonitoringFileSystem fileSystem={data.fileSystem} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const ringColor = (percentage: number) => {
|
export const ringColor = (percentage: number) => {
|
||||||
if (percentage < 30) return 'green';
|
if (percentage < 30) return 'green';
|
||||||
@@ -115,12 +240,27 @@ export const ringColor = (percentage: number) => {
|
|||||||
else return 'red';
|
else return 'red';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useOpenmediavaultQuery = () => {
|
export const getIntegrations = () => {
|
||||||
const { name: configName } = useConfigContext();
|
const { name: configName } = useConfigContext();
|
||||||
return api.openmediavault.fetchData.useQuery(
|
return api.healthMonitoring.integrations.useQuery(
|
||||||
{
|
{
|
||||||
configName: configName!,
|
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,
|
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