From dc2bae1065d71e0707e0a274ddb7f14a95228e49 Mon Sep 17 00:00:00 2001 From: Jake Turner Date: Mon, 1 Dec 2025 21:13:44 -0800 Subject: [PATCH] feat: system info page redesign --- admin/app/services/system_service.ts | 8 +- .../components/systeminfo/CircularGauge.tsx | 157 +++++++++++ .../systeminfo/HorizontalBarChart.tsx | 116 ++++++++ .../components/systeminfo/InfoCard.tsx | 77 ++++++ .../components/systeminfo/StatusCard.tsx | 16 ++ admin/inertia/hooks/useSystemInfo.ts | 19 ++ admin/inertia/lib/api.ts | 15 +- admin/inertia/pages/settings/system.tsx | 259 ++++++++++++++---- admin/types/system.ts | 3 + 9 files changed, 612 insertions(+), 58 deletions(-) create mode 100644 admin/inertia/components/systeminfo/CircularGauge.tsx create mode 100644 admin/inertia/components/systeminfo/HorizontalBarChart.tsx create mode 100644 admin/inertia/components/systeminfo/InfoCard.tsx create mode 100644 admin/inertia/components/systeminfo/StatusCard.tsx create mode 100644 admin/inertia/hooks/useSystemInfo.ts diff --git a/admin/app/services/system_service.ts b/admin/app/services/system_service.ts index 2cc5e57..5d9c816 100644 --- a/admin/app/services/system_service.ts +++ b/admin/app/services/system_service.ts @@ -75,11 +75,14 @@ export class SystemService { async getSystemInfo(): Promise { try { - const [cpu, mem, os, disk] = await Promise.all([ + const [cpu, mem, os, disk, currentLoad, fsSize, uptime] = await Promise.all([ si.cpu(), si.mem(), si.osInfo(), si.diskLayout(), + si.currentLoad(), + si.fsSize(), + si.time(), ]) return { @@ -87,6 +90,9 @@ export class SystemService { mem, os, disk, + currentLoad, + fsSize, + uptime, } } catch (error) { logger.error('Error getting system info:', error) diff --git a/admin/inertia/components/systeminfo/CircularGauge.tsx b/admin/inertia/components/systeminfo/CircularGauge.tsx new file mode 100644 index 0000000..288d060 --- /dev/null +++ b/admin/inertia/components/systeminfo/CircularGauge.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from 'react' +import classNames from '~/lib/classNames' + +interface CircularGaugeProps { + value: number // percentage + label: string + icon?: React.ReactNode + size?: 'sm' | 'md' | 'lg' + variant?: 'cpu' | 'memory' | 'disk' | 'default' + subtext?: string + animated?: boolean +} + +export default function CircularGauge({ + value, + label, + icon, + size = 'md', + variant = 'default', + subtext, + animated = true, +}: CircularGaugeProps) { + const [animatedValue, setAnimatedValue] = useState(animated ? 0 : value) + + useEffect(() => { + if (animated) { + const timeout = setTimeout(() => setAnimatedValue(value), 100) + return () => clearTimeout(timeout) + } + }, [value, animated]) + + const displayValue = animated ? animatedValue : value + + const sizes = { + sm: { + container: 'w-32 h-32', + strokeWidth: 8, + radius: 48, + fontSize: 'text-xl', + labelSize: 'text-xs', + }, + md: { + container: 'w-40 h-40', + strokeWidth: 10, + radius: 60, + fontSize: 'text-2xl', + labelSize: 'text-sm', + }, + lg: { + container: 'w-60 h-60', + strokeWidth: 12, + radius: 110, + fontSize: 'text-4xl', + labelSize: 'text-base', + }, + } + + const config = sizes[size] + const circumference = 2 * Math.PI * config.radius + const offset = circumference - (displayValue / 100) * circumference + + const getColor = () => { + if (value >= 90) return 'desert-red' + if (value >= 75) return 'desert-orange' + if (value >= 50) return 'desert-tan' + return 'desert-olive' + } + + const color = getColor() + + const center = config.radius + config.strokeWidth + + return ( +
+
+ + {/* Background circle */} + + + {/* Progress circle */} + + + {/* Tick marks */} + {Array.from({ length: 12 }).map((_, i) => { + const angle = (i * 30 * Math.PI) / 180 + const ringGap = 8 + const tickLength = 6 + const innerRadius = config.radius - config.strokeWidth - ringGap + const outerRadius = config.radius - config.strokeWidth - ringGap - tickLength + const x1 = center + innerRadius * Math.cos(angle) + const y1 = center + innerRadius * Math.sin(angle) + const x2 = center + outerRadius * Math.cos(angle) + const y2 = center + outerRadius * Math.sin(angle) + + return ( + + ) + })} + +
+ {icon &&
{icon}
} +
+ {Math.round(displayValue)}% +
+ {subtext && ( +
+ {subtext} +
+ )} +
+
+
+ {label} +
+
+ ) +} diff --git a/admin/inertia/components/systeminfo/HorizontalBarChart.tsx b/admin/inertia/components/systeminfo/HorizontalBarChart.tsx new file mode 100644 index 0000000..ea59c63 --- /dev/null +++ b/admin/inertia/components/systeminfo/HorizontalBarChart.tsx @@ -0,0 +1,116 @@ +import classNames from '~/lib/classNames' + +interface HorizontalBarChartProps { + items: Array<{ + label: string + value: number // percentage + total: string + used: string + type?: string + }> + maxValue?: number +} + +export default function HorizontalBarChart({ items, maxValue = 100 }: HorizontalBarChartProps) { + const getBarColor = (value: number) => { + if (value >= 90) return 'bg-desert-red' + if (value >= 75) return 'bg-desert-orange' + if (value >= 50) return 'bg-desert-tan' + return 'bg-desert-olive' + } + + const getGlowColor = (value: number) => { + if (value >= 90) return 'shadow-desert-red/50' + if (value >= 75) return 'shadow-desert-orange/50' + if (value >= 50) return 'shadow-desert-tan/50' + return 'shadow-desert-olive/50' + } + + return ( +
+ {items.map((item, index) => ( +
+
+
+ {item.label} + {item.type && ( + + {item.type} + + )} +
+
+ {item.used} / {item.total} +
+
+
+
+
+ {/* Animated shine effect */} + {/*
*/} + {/*
*/} +
+
+
15 + ? 'left-3 text-desert-white drop-shadow-md' + : 'right-3 text-desert-green' + )} + > + {Math.round(item.value)}% +
+
+
+
= 90 + ? 'bg-desert-red' + : item.value >= 75 + ? 'bg-desert-orange' + : 'bg-desert-olive' + )} + /> + + {item.value >= 90 + ? 'Critical - Disk Almost Full' + : item.value >= 75 + ? 'Warning - Usage High' + : 'Normal'} + +
+
+ ))} +
+ ) +} diff --git a/admin/inertia/components/systeminfo/InfoCard.tsx b/admin/inertia/components/systeminfo/InfoCard.tsx new file mode 100644 index 0000000..7392ee8 --- /dev/null +++ b/admin/inertia/components/systeminfo/InfoCard.tsx @@ -0,0 +1,77 @@ +import classNames from '~/lib/classNames' + +interface InfoCardProps { + title: string + icon?: React.ReactNode + data: Array<{ + label: string + value: string | number | undefined + }> + variant?: 'default' | 'bordered' | 'elevated' +} + +export default function InfoCard({ title, icon, data, variant = 'default' }: InfoCardProps) { + const getVariantStyles = () => { + switch (variant) { + case 'bordered': + return 'border-2 border-desert-green bg-desert-white' + case 'elevated': + return 'bg-desert-white shadow-lg border border-desert-stone-lighter' + default: + return 'bg-desert-white border border-desert-stone-light' + } + } + + return ( +
+
+ {/* Diagonal line pattern */} +
+ +
+ {icon &&
{icon}
} +

{title}

+
+
+
+
+
+
+
+ {data.map((item, index) => ( +
+
+ {item.label} +
+
+ {item.value || 'N/A'} +
+
+ ))} +
+
+
+
+ ) +} diff --git a/admin/inertia/components/systeminfo/StatusCard.tsx b/admin/inertia/components/systeminfo/StatusCard.tsx new file mode 100644 index 0000000..4901be1 --- /dev/null +++ b/admin/inertia/components/systeminfo/StatusCard.tsx @@ -0,0 +1,16 @@ +export type StatusCardProps = { + title: string + value: string | number +} + +export default function StatusCard({ title, value }: StatusCardProps) { + return ( +
+
+ {title} +
+
+
{value}
+
+ ) +} diff --git a/admin/inertia/hooks/useSystemInfo.ts b/admin/inertia/hooks/useSystemInfo.ts new file mode 100644 index 0000000..d188f11 --- /dev/null +++ b/admin/inertia/hooks/useSystemInfo.ts @@ -0,0 +1,19 @@ +import { useQuery, UseQueryOptions } from '@tanstack/react-query' +import { SystemInformationResponse } from '../../types/system' +import api from '~/lib/api' + +export type UseSystemInfoProps = Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' +> & {} + +export const useSystemInfo = (props: UseSystemInfoProps) => { + const queryData = useQuery({ + ...props, + queryKey: ['system-info'], + queryFn: () => api.getSystemInfo(), + refetchInterval: 45000, // Refetch every 45 seconds + }) + + return queryData +} diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index df17985..72034e8 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -1,10 +1,11 @@ -import axios from 'axios' +import axios, { AxiosInstance } from 'axios' import { ListRemoteZimFilesResponse, ListZimFilesResponse } from '../../types/zim' import { ServiceSlim } from '../../types/services' import { FileEntry } from '../../types/files' +import { SystemInformationResponse } from '../../types/system' class API { - private client + private client: AxiosInstance constructor() { this.client = axios.create({ @@ -117,6 +118,16 @@ class API { throw error } } + + async getSystemInfo() { + try { + const response = await this.client.get('/system/info') + return response.data + } catch (error) { + console.error('Error fetching system info:', error) + throw error + } + } } export default new API() diff --git a/admin/inertia/pages/settings/system.tsx b/admin/inertia/pages/settings/system.tsx index 724a593..1615055 100644 --- a/admin/inertia/pages/settings/system.tsx +++ b/admin/inertia/pages/settings/system.tsx @@ -2,69 +2,218 @@ import { Head } from '@inertiajs/react' import SettingsLayout from '~/layouts/SettingsLayout' import { SystemInformationResponse } from '../../../types/system' import { formatBytes } from '~/lib/util' - -const Section = ({ title, children }: { title: string; children: React.ReactNode }) => { - return ( -
-

{title}

-
-
{children}
-
-
- ) -} - -const Row = ({ label, value }: { label: string; value: string | number | undefined }) => { - return ( -
-
{label}
-
{value}
-
- ) -} +import CircularGauge from '~/components/systeminfo/CircularGauge' +import HorizontalBarChart from '~/components/systeminfo/HorizontalBarChart' +import InfoCard from '~/components/systeminfo/InfoCard' +import { + CpuChipIcon, + CircleStackIcon, + ServerIcon, + ComputerDesktopIcon, +} from '@heroicons/react/24/outline' +import Alert from '~/components/Alert' +import { useSystemInfo } from '~/hooks/useSystemInfo' +import StatusCard from '~/components/systeminfo/StatusCard' export default function SettingsPage(props: { system: { info: SystemInformationResponse | undefined } }) { + const { data: info } = useSystemInfo({ + initialData: props.system.info, + }) + + const memoryUsagePercent = info?.mem.total + ? ((info.mem.used / info.mem.total) * 100).toFixed(1) + : 0 + + const swapUsagePercent = info?.mem.swaptotal + ? ((info.mem.swapused / info.mem.swaptotal) * 100).toFixed(1) + : 0 + + const uptimeMinutes = info?.uptime.uptime ? Math.floor(info.uptime.uptime / 60) : 0 + + const diskData = info?.disk.map((disk) => { + const usedBytes = (disk.size || 0) * 0.65 // Estimate - you'd get this from mount points + const usedPercent = disk.size ? (usedBytes / disk.size) * 100 : 0 + + return { + label: disk.name || 'Unknown', + value: usedPercent, + total: formatBytes(disk.size || 0), + used: formatBytes(usedBytes), + type: disk.type, + } + }) + return ( - +
-
-

System Information

-
-
- - - - -
-
- - - - -
-
- - - - - -
-
- {props.system.info?.disk.map((disk, index) => ( -
- - - -
- ))} -
+
+
+

System Information

+

+ Real-time monitoring and diagnostics • Last updated: {new Date().toLocaleString()} • + Refreshing data every 30 seconds +

+ {Number(memoryUsagePercent) > 90 && ( +
+ +
+ )} +
+

+
+ Resource Usage +

+ +
+
+ } + /> +
+
+ } + /> +
+
+ } + /> +
+
+
+
+

+
+ System Details +

+ +
+ } + variant="elevated" + data={[ + { label: 'Distribution', value: info?.os.distro }, + { label: 'Kernel Version', value: info?.os.kernel }, + { label: 'Architecture', value: info?.os.arch }, + { label: 'Hostname', value: info?.os.hostname }, + { label: 'Platform', value: info?.os.platform }, + ]} + /> + } + variant="elevated" + data={[ + { label: 'Manufacturer', value: info?.cpu.manufacturer }, + { label: 'Brand', value: info?.cpu.brand }, + { label: 'Cores', value: info?.cpu.cores }, + { label: 'Physical Cores', value: info?.cpu.physicalCores }, + { + label: 'Virtualization', + value: info?.cpu.virtualization ? 'Enabled' : 'Disabled', + }, + ]} + /> +
+
+
+

+
+ Memory Allocation +

+
+
+
+
+ {formatBytes(info?.mem.total || 0)} +
+
+ Total RAM +
+
+
+
+ {formatBytes(info?.mem.used || 0)} +
+
+ RAM in Use +
+
+
+
+ {formatBytes(info?.mem.free || 0)} +
+
+ Free RAM +
+
+
+
+
+
+ + {memoryUsagePercent}% Utilized + +
+
+
+
+
+

+
+ Storage Devices +

+ +
+ {diskData && diskData.length > 0 ? ( + + ) : ( +
+ No storage devices detected +
+ )} +
+
+
+

+
+ System Status +

+
+ + + +
+
diff --git a/admin/types/system.ts b/admin/types/system.ts index da7d589..f7b1721 100644 --- a/admin/types/system.ts +++ b/admin/types/system.ts @@ -6,6 +6,9 @@ export type SystemInformationResponse = { mem: Systeminformation.MemData os: Systeminformation.OsData disk: Systeminformation.DiskLayoutData[] + currentLoad: Systeminformation.CurrentLoadData + fsSize: Systeminformation.FsSizeData[] + uptime: Systeminformation.TimeData } // Type inferrence is not working properly with usePage and shared props, so we define this type manually