feat: system info page redesign

This commit is contained in:
Jake Turner 2025-12-01 21:13:44 -08:00
parent f4a69ea401
commit dc2bae1065
No known key found for this signature in database
GPG Key ID: 694BC38EF2ED4844
9 changed files with 612 additions and 58 deletions

View File

@ -75,11 +75,14 @@ export class SystemService {
async getSystemInfo(): Promise<SystemInformationResponse | undefined> {
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)

View File

@ -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 (
<div className="flex flex-col items-center gap-3">
<div className={classNames('relative', config.container)}>
<svg
className="transform -rotate-90"
width={center * 2}
height={center * 2}
viewBox={`0 0 ${center * 2} ${center * 2}`}
>
{/* Background circle */}
<circle
cx={center}
cy={center}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.strokeWidth}
className="text-desert-green-lighter opacity-30"
/>
{/* Progress circle */}
<circle
cx={center}
cy={center}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className={classNames(
`text-${color}`,
'transition-all duration-1000 ease-out',
'drop-shadow-[0_0_1px_currentColor]'
)}
style={{
filter: 'drop-shadow(0 0 1px currentColor)',
}}
/>
{/* 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 (
<line
key={i}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="currentColor"
strokeWidth="2"
className="text-desert-stone opacity-30"
/>
)
})}
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
{icon && <div className="text-desert-green opacity-60 mb-1">{icon}</div>}
<div className={classNames('font-bold text-desert-green', config.fontSize)}>
{Math.round(displayValue)}%
</div>
{subtext && (
<div className="text-xs text-desert-stone-dark opacity-70 font-mono mt-0.5">
{subtext}
</div>
)}
</div>
</div>
<div className={classNames('font-semibold text-desert-green text-center', config.labelSize)}>
{label}
</div>
</div>
)
}

View File

@ -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 (
<div className="space-y-6">
{items.map((item, index) => (
<div key={index} className="space-y-2">
<div className="flex justify-between items-baseline">
<div className="flex items-center gap-2">
<span className="font-semibold text-desert-green">{item.label}</span>
{item.type && (
<span className="text-xs px-2 py-0.5 rounded bg-desert-stone-lighter text-desert-stone-dark font-mono">
{item.type}
</span>
)}
</div>
<div className="text-sm text-desert-stone-dark font-mono">
{item.used} / {item.total}
</div>
</div>
<div className="relative">
<div className="h-8 bg-desert-green-lighter bg-opacity-20 rounded-lg border border-desert-stone-light overflow-hidden">
<div
className={classNames(
'h-full rounded-lg transition-all duration-1000 ease-out relative overflow-hidden',
getBarColor(item.value),
'shadow-lg',
getGlowColor(item.value)
)}
style={{
width: `${item.value}%`,
animationDelay: `${index * 100}ms`,
}}
>
{/* Animated shine effect */}
{/* <div
className="absolute inset-0 bg-gradient-to-r from-transparent via-white to-transparent opacity-20 animate-shimmer"
style={{
animation: 'shimmer 3s infinite',
animationDelay: `${index * 0.5}s`,
}}
/> */}
{/* <div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `repeating-linear-gradient(
90deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.1) 10px,
rgba(255, 255, 255, 0.1) 11px
)`,
}}
/> */}
</div>
</div>
<div
className={classNames(
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
item.value > 15
? 'left-3 text-desert-white drop-shadow-md'
: 'right-3 text-desert-green'
)}
>
{Math.round(item.value)}%
</div>
</div>
<div className="flex items-center gap-2">
<div
className={classNames(
'w-2 h-2 rounded-full animate-pulse',
item.value >= 90
? 'bg-desert-red'
: item.value >= 75
? 'bg-desert-orange'
: 'bg-desert-olive'
)}
/>
<span className="text-xs text-desert-stone">
{item.value >= 90
? 'Critical - Disk Almost Full'
: item.value >= 75
? 'Warning - Usage High'
: 'Normal'}
</span>
</div>
</div>
))}
</div>
)
}

View File

@ -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 (
<div
className={classNames(
'rounded-lg overflow-hidden transition-all duration-200 hover:shadow-xl',
getVariantStyles()
)}
>
<div className="relative bg-desert-green px-6 py-4 overflow-hidden">
{/* Diagonal line pattern */}
<div
className="absolute inset-0 opacity-10"
style={{
backgroundImage: `repeating-linear-gradient(
45deg,
transparent,
transparent 10px,
rgba(255, 255, 255, 0.1) 10px,
rgba(255, 255, 255, 0.1) 20px
)`,
}}
/>
<div className="relative flex items-center gap-3">
{icon && <div className="text-desert-white opacity-80">{icon}</div>}
<h3 className="text-lg font-bold text-desert-white uppercase tracking-wide">{title}</h3>
</div>
<div className="absolute top-0 right-0 w-24 h-24 transform translate-x-8 -translate-y-8">
<div className="w-full h-full bg-desert-green-dark opacity-30 transform rotate-45" />
</div>
</div>
<div className="p-6">
<dl className="grid grid-cols-1 gap-4">
{data.map((item, index) => (
<div
key={index}
className={classNames(
'flex justify-between items-center py-2 border-b border-desert-stone-lighter last:border-b-0'
)}
>
<dt className="text-sm font-medium text-desert-stone-dark flex items-center gap-2">
{item.label}
</dt>
<dd className={classNames('text-sm font-semibold text-right text-desert-green-dark')}>
{item.value || 'N/A'}
</dd>
</div>
))}
</dl>
</div>
<div className="h-1 bg-desert-green" />
</div>
)
}

View File

@ -0,0 +1,16 @@
export type StatusCardProps = {
title: string
value: string | number
}
export default function StatusCard({ title, value }: StatusCardProps) {
return (
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-desert-stone-dark">{title}</span>
<div className="w-2 h-2 bg-desert-olive rounded-full animate-pulse" />
</div>
<div className="text-2xl font-bold text-desert-green">{value}</div>
</div>
)
}

View File

@ -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<SystemInformationResponse>,
'queryKey' | 'queryFn'
> & {}
export const useSystemInfo = (props: UseSystemInfoProps) => {
const queryData = useQuery<SystemInformationResponse>({
...props,
queryKey: ['system-info'],
queryFn: () => api.getSystemInfo(),
refetchInterval: 45000, // Refetch every 45 seconds
})
return queryData
}

View File

@ -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<SystemInformationResponse>('/system/info')
return response.data
} catch (error) {
console.error('Error fetching system info:', error)
throw error
}
}
}
export default new API()

View File

@ -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 (
<div className="px-4 sm:px-0 mt-8">
<h3 className="text-base/7 font-semibold text-gray-900">{title}</h3>
<div className="mt-1 border-t border-gray-300">
<dl className="divide-y divide-gray-200">{children}</dl>
</div>
</div>
)
}
const Row = ({ label, value }: { label: string; value: string | number | undefined }) => {
return (
<div className="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0">
<dt className="text-sm/6 font-medium text-gray-900">{label}</dt>
<dd className="mt-1 text-sm/6 text-gray-700 sm:col-span-2 sm:mt-0">{value}</dd>
</div>
)
}
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 (
<SettingsLayout>
<Head title="Settings" />
<Head title="System Information" />
<div className="xl:pl-72 w-full">
<main className="px-12 py-6">
<h1 className="text-4xl font-semibold mb-6">System Information</h1>
<div>
<Section title="OS Information">
<Row label="Distro" value={props.system.info?.os.distro} />
<Row label="Kernel" value={props.system.info?.os.kernel} />
<Row label="Architecture" value={props.system.info?.os.arch} />
<Row label="Hostname" value={props.system.info?.os.hostname} />
</Section>
<Section title="CPU Manufacturer">
<Row label="Manufacturer" value={props.system.info?.cpu.manufacturer} />
<Row label="Brand Name" value={props.system.info?.cpu.brand} />
<Row label="Cores" value={props.system.info?.cpu.cores} />
<Row
label="Virtualization Enabled"
value={props.system.info?.cpu.virtualization ? 'Yes' : 'No'}
/>
</Section>
<Section title="Memory Information">
<Row label="Total" value={formatBytes(props.system.info?.mem.total || 0)} />
<Row label="Used" value={formatBytes(props.system.info?.mem.used || 0)} />
<Row label="Free" value={formatBytes(props.system.info?.mem.free || 0)} />
<Row label="Swap Total" value={formatBytes(props.system.info?.mem.swaptotal || 0)} />
<Row label="Swap Used" value={formatBytes(props.system.info?.mem.swapused || 0)} />
</Section>
<Section title="Disk Information">
{props.system.info?.disk.map((disk, index) => (
<div key={index}>
<Row label={`Disk ${index + 1} Name`} value={disk.name} />
<Row label={`Disk ${index + 1} Size`} value={formatBytes(disk.size || 0)} />
<Row label={`Disk ${index + 1} Type`} value={disk.type} />
</div>
))}
</Section>
<main className="px-6 lg:px-12 py-6 lg:py-8">
<div className="mb-8">
<h1 className="text-4xl font-bold text-desert-green mb-2">System Information</h1>
<p className="text-desert-stone-dark">
Real-time monitoring and diagnostics Last updated: {new Date().toLocaleString()}
Refreshing data every 30 seconds
</p>
</div>
{Number(memoryUsagePercent) > 90 && (
<div className="mb-6">
<Alert
type="error"
title="Very High Memory Usage Detected"
message="System memory usage exceeds 90%. Performance degradation may occur."
variant="bordered"
/>
</div>
)}
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Resource Usage
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={info?.currentLoad.currentLoad || 0}
label="CPU Usage"
size="lg"
variant="cpu"
subtext={`${info?.cpu.cores || 0} cores`}
icon={<CpuChipIcon className="w-8 h-8" />}
/>
</div>
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={Number(memoryUsagePercent)}
label="Memory Usage"
size="lg"
variant="memory"
subtext={`${formatBytes(info?.mem.used || 0)} / ${formatBytes(info?.mem.total || 0)}`}
icon={<CircleStackIcon className="w-8 h-8" />}
/>
</div>
<div className="bg-desert-white rounded-lg p-6 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<CircularGauge
value={Number(swapUsagePercent)}
label="Swap Usage"
size="lg"
variant="disk"
subtext={`${formatBytes(info?.mem.swapused || 0)} / ${formatBytes(info?.mem.swaptotal || 0)}`}
icon={<ServerIcon className="w-8 h-8" />}
/>
</div>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
System Details
</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<InfoCard
title="Operating System"
icon={<ComputerDesktopIcon className="w-6 h-6" />}
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 },
]}
/>
<InfoCard
title="Processor"
icon={<CpuChipIcon className="w-6 h-6" />}
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',
},
]}
/>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Memory Allocation
</h2>
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
<div className="text-center">
<div className="text-3xl font-bold text-desert-green mb-1">
{formatBytes(info?.mem.total || 0)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
Total RAM
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-desert-orange mb-1">
{formatBytes(info?.mem.used || 0)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
RAM in Use
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-desert-olive mb-1">
{formatBytes(info?.mem.free || 0)}
</div>
<div className="text-sm text-desert-stone-dark uppercase tracking-wide">
Free RAM
</div>
</div>
</div>
<div className="relative h-12 bg-desert-stone-lighter rounded-lg overflow-hidden border border-desert-stone-light">
<div
className="absolute left-0 top-0 h-full bg-desert-orange transition-all duration-1000"
style={{ width: `${memoryUsagePercent}%` }}
></div>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-desert-white drop-shadow-md z-10">
{memoryUsagePercent}% Utilized
</span>
</div>
</div>
</div>
</section>
<section className="mb-12">
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
Storage Devices
</h2>
<div className="bg-desert-white rounded-lg p-8 border border-desert-stone-light shadow-sm hover:shadow-lg transition-shadow">
{diskData && diskData.length > 0 ? (
<HorizontalBarChart items={diskData} />
) : (
<div className="text-center text-desert-stone-dark py-8">
No storage devices detected
</div>
)}
</div>
</section>
<section>
<h2 className="text-2xl font-bold text-desert-green mb-6 flex items-center gap-2">
<div className="w-1 h-6 bg-desert-green" />
System Status
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatusCard title="System Uptime" value={`${uptimeMinutes}m`} />
<StatusCard title="CPU Cores" value={info?.cpu.cores || 0} />
<StatusCard title="Storage Devices" value={info?.disk.length || 0} />
</div>
</section>
</main>
</div>
</SettingsLayout>

View File

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