Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions src/components/home/SystemStatValue.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { IconArrowDown, IconArrowUp } from '@tabler/icons-react'
import { type FieldPath, Render } from 'juststore'
import { formatDuration } from '@/lib/format'
import { formatBytes, formatDuration } from '@/lib/format'
import { cn } from '@/lib/utils'
import { Progress } from '../ui/progress'
import { type Store, store } from './store'

export type SystemStatValueType = 'text' | 'progress' | 'duration' | 'upload' | 'download'

export default function SystemStatValue({
valueKey,
descriptionKey,
type,
}: {
valueKey: FieldPath<Store['systemInfo']>
descriptionKey?: FieldPath<Store['systemInfo']>
type: 'text' | 'progress' | 'duration'
type: SystemStatValueType
}) {
const state = store.systemInfo[valueKey]
return (
Expand All @@ -35,7 +38,7 @@ function DisplayValue({
type,
}: {
valueKey: FieldPath<Store['systemInfo']>
type: 'text' | 'progress' | 'duration'
type: SystemStatValueType
}) {
const displayValue = store.systemInfo[valueKey].useCompute(value => {
if (type === 'duration') {
Expand All @@ -44,10 +47,37 @@ function DisplayValue({
if (type === 'progress') {
return `${value}%`
}
if (type === 'upload') {
type = 'text'
return (
<>
<IconArrowUp className="size-4 text-green-500" />{' '}
{formatBytes(Number(value), { precision: 0, unit: '/s' })}
</>
)
}
if (type === 'download') {
type = 'text'
return (
<>
<IconArrowDown className="size-4 text-red-500" />{' '}
{formatBytes(Number(value), { precision: 0, unit: '/s' })}
</>
)
}
return String(value)
})
const isTextLike = type === 'text' || type === 'upload' || type === 'download'
return (
<div className="text-base sm:text-xl leading-none font-semibold tracking-tight tabular-nums">
<div
className={cn(
'font-semibold tracking-tight tabular-nums',
isTextLike
? 'text-base sm:text-lg leading-tight whitespace-nowrap'
: 'text-base sm:text-xl leading-none',
isTextLike && 'flex items-center gap-1'
)}
>
{displayValue}
</div>
)
Expand All @@ -58,7 +88,7 @@ function Description({
type,
}: {
descriptionKey?: FieldPath<Store['systemInfo']>
type: 'text' | 'progress' | 'duration'
type: SystemStatValueType
}) {
const value = store.systemInfo[descriptionKey ?? 'uptime'].use()
return (
Expand Down
88 changes: 73 additions & 15 deletions src/components/home/SystemStats.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { FieldPath } from 'juststore'
import { Clock, Cpu, HardDrive, type LucideIcon, MemoryStick } from 'lucide-react'
import { Clock, Cpu, HardDrive, type LucideIcon, MemoryStick, Wifi } from 'lucide-react'
import { Suspense } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { useIsMobile } from '@/hooks/use-mobile'
import { formatDuration } from '@/lib/format'
import { cn } from '@/lib/utils'
import SystemStatsProvider from './SystemStatsProvider'
import SystemStatValue from './SystemStatValue'
import SystemStatValue, { type SystemStatValueType } from './SystemStatValue'
import { type Store, store } from './store'

export default function SystemStats() {
Expand All @@ -21,10 +21,13 @@ export default function SystemStats() {
}

function SystemStatsMobile() {
const stats = statsProps.filter(stat => {
return !('hideOnMobile' in stat && stat.hideOnMobile)
})
return (
<Card size="sm" className="mx-1 block sm:hidden">
<CardContent className="grid grid-cols-5 gap-y-2 [&>*:nth-child(odd)]:col-start-1 [&>*:nth-child(even)]:col-start-4">
{statsProps.map(stat => (
{stats.map(stat => (
<MobileStatRow key={stat.key} stat={stat} />
))}
</CardContent>
Expand All @@ -33,10 +36,16 @@ function SystemStatsMobile() {
}

function SystemStatsDesktop() {
const hasSecondaryDisk = Boolean(store.systemInfo.secondaryPartitionUsageDesc.use())

return (
<div className="hidden sm:grid grid-cols-2 gap-2 px-1 sm:grid-cols-4 sm:gap-4">
<div className="hidden sm:grid grid-cols-2 gap-2 px-1 sm:grid-cols-4 md:grid-cols-5 sm:gap-4">
{statsProps.map(stat => (
<Card key={stat.key} size="sm" className="h-full">
<Card
key={stat.key}
size="sm"
className={cn('h-full', stat.key === 'networkSpeedUpload' && 'hidden md:flex')}
>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-xs font-medium text-muted-foreground sm:text-sm">
Expand All @@ -46,11 +55,35 @@ function SystemStatsDesktop() {
</div>
</CardHeader>
<CardContent className="space-y-1.5 sm:space-y-2">
<SystemStatValue
valueKey={stat.key}
descriptionKey={stat.descriptionKey}
type={stat.type}
/>
{stat.key === 'rootPartitionUsage' && hasSecondaryDisk ? (
<div className="flex justify-between gap-3">
<div className="space-y-1.5 sm:space-y-2 w-full xl:w-auto">
<SystemStatValue
valueKey={stat.key}
descriptionKey={stat.descriptionKey}
type={stat.type}
/>
</div>
<div className="space-y-1.5 sm:space-y-2 hidden xl:block">
<SystemStatValue
valueKey={stat.secondaryKey}
descriptionKey={stat.secondaryDescriptionKey}
type={stat.type}
/>
</div>
</div>
) : stat.key === 'networkSpeedUpload' ? (
<div className="flex flex-col gap-0.5">
<SystemStatValue valueKey={stat.key} type="upload" />
<SystemStatValue valueKey={stat.secondaryKey} type="download" />
</div>
) : (
<SystemStatValue
valueKey={stat.key}
descriptionKey={stat.descriptionKey}
type={stat.type}
/>
)}
</CardContent>
</Card>
))}
Expand All @@ -59,14 +92,17 @@ function SystemStatsDesktop() {
}

type StatProp = {
hideOnMobile?: boolean
label: string
mobileLabel: string
icon: LucideIcon
mobileValueMode?: 'percent' | 'description' | 'duration'
type: 'text' | 'progress' | 'duration'
mobileValueMode?: 'percent' | 'description' | 'duration' | 'text' | 'networkSpeed'
type: SystemStatValueType
color: string
key: FieldPath<Store['systemInfo']>
descriptionKey?: FieldPath<Store['systemInfo']>
secondaryKey?: FieldPath<Store['systemInfo']>
secondaryDescriptionKey?: FieldPath<Store['systemInfo']>
format?: (value: number) => string
}

Expand All @@ -77,6 +113,12 @@ function MobileStatRow({ stat }: { stat: StatProp }) {
if (stat.mobileValueMode === 'duration') {
return formatDuration(Number(value), { unit: 's' })
}
if (stat.mobileValueMode === 'text') {
return String(value)
}
if (stat.mobileValueMode === 'networkSpeed') {
return null
}
return `${value}%`
})()

Expand All @@ -91,7 +133,7 @@ function MobileStatRow({ stat }: { stat: StatProp }) {
)
}

const statsProps: StatProp[] = [
const statsProps = [
{
label: 'Uptime',
mobileLabel: 'Up',
Expand All @@ -100,6 +142,7 @@ const statsProps: StatProp[] = [
type: 'duration',
color: 'text-primary',
key: 'uptime',
descriptionKey: undefined,
},
{
label: 'CPU Usage',
Expand All @@ -109,6 +152,7 @@ const statsProps: StatProp[] = [
type: 'progress',
color: 'bg-chart-1',
key: 'cpuAverage',
descriptionKey: undefined,
},
{
label: 'Memory',
Expand All @@ -129,5 +173,19 @@ const statsProps: StatProp[] = [
color: 'bg-chart-3',
key: 'rootPartitionUsage',
descriptionKey: 'rootPartitionUsageDesc',
secondaryKey: 'secondaryPartitionUsage',
secondaryDescriptionKey: 'secondaryPartitionUsageDesc',
},
{
hideOnMobile: true,
label: 'Network',
mobileLabel: 'Net',
icon: Wifi,
mobileValueMode: 'networkSpeed',
type: 'text',
color: 'bg-chart-4',
key: 'networkSpeedUpload',
secondaryKey: 'networkSpeedDownload',
descriptionKey: undefined,
},
] as const
] as const satisfies StatProp[]
42 changes: 41 additions & 1 deletion src/components/home/SystemStatsProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useWebSocketApi } from '@/hooks/websocket'
import type { DiskUsageStat, MemVirtualMemoryStat, StatsResponse, SystemInfo } from '@/lib/api'
import { store } from './store'
import { formatBytes } from '@/lib/format'
import { store } from './store'

export default function SystemStatsProvider() {
useWebSocketApi<SystemInfo>({
Expand All @@ -15,8 +15,12 @@ export default function SystemStatsProvider() {
cpuAverage: Math.round(data.cpu_average * 100) / 100,
rootPartitionUsage: Math.round(getDiskUsage(data.disks, '/') ?? 0 * 100),
rootPartitionUsageDesc: getDiskUsageDesc(data.disks, '/'),
secondaryPartitionUsage: Math.round(getSecondaryDiskUsage(data.disks, '/') ?? 0 * 100),
secondaryPartitionUsageDesc: getSecondaryDiskUsageDesc(data.disks, '/'),
Comment on lines 16 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Operator precedence bug: * 100 is never applied.

Due to operator precedence, getDiskUsage(data.disks, '/') ?? 0 * 100 evaluates as getDiskUsage(...) ?? (0 * 100) which equals getDiskUsage(...) ?? 0. The multiplication is only applied to the fallback 0, not to the disk usage value.

If used_percent from DiskUsageStat is already 0-100, remove * 100. If it's 0-1, add parentheses.

🐛 Proposed fix (assuming used_percent is already 0-100)
-        rootPartitionUsage: Math.round(getDiskUsage(data.disks, '/') ?? 0 * 100),
+        rootPartitionUsage: Math.round(getDiskUsage(data.disks, '/') ?? 0),
         rootPartitionUsageDesc: getDiskUsageDesc(data.disks, '/'),
-        secondaryPartitionUsage: Math.round(getSecondaryDiskUsage(data.disks, '/') ?? 0 * 100),
+        secondaryPartitionUsage: Math.round(getSecondaryDiskUsage(data.disks, '/') ?? 0),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rootPartitionUsage: Math.round(getDiskUsage(data.disks, '/') ?? 0 * 100),
rootPartitionUsageDesc: getDiskUsageDesc(data.disks, '/'),
secondaryPartitionUsage: Math.round(getSecondaryDiskUsage(data.disks, '/') ?? 0 * 100),
secondaryPartitionUsageDesc: getSecondaryDiskUsageDesc(data.disks, '/'),
rootPartitionUsage: Math.round(getDiskUsage(data.disks, '/') ?? 0),
rootPartitionUsageDesc: getDiskUsageDesc(data.disks, '/'),
secondaryPartitionUsage: Math.round(getSecondaryDiskUsage(data.disks, '/') ?? 0),
secondaryPartitionUsageDesc: getSecondaryDiskUsageDesc(data.disks, '/'),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/home/SystemStatsProvider.tsx` around lines 16 - 19, The
multiplication by 100 is misplaced due to operator precedence: update the
rootPartitionUsage and secondaryPartitionUsage assignments to apply Math.round
to the disk usage value (or remove the * 100 entirely if getDiskUsage /
getSecondaryDiskUsage already return 0–100). Specifically, change the
expressions that use getDiskUsage(data.disks, '/') and
getSecondaryDiskUsage(data.disks, '/') so that either you remove the trailing "*
100" and use Math.round(getDiskUsage(...) ?? 0) and
Math.round(getSecondaryDiskUsage(...) ?? 0), or wrap the call in parentheses
before multiplying (Math.round((getDiskUsage(...) ?? 0) * 100)) if the functions
return 0–1; adjust both rootPartitionUsage and secondaryPartitionUsage
accordingly.

memoryUsage: Math.round(data.memory.used_percent * 100) / 100,
memoryUsageDesc: getMemoryUsageDesc(data.memory),
networkSpeedUpload: data.network?.upload_speed ?? 0,
networkSpeedDownload: data.network?.download_speed ?? 0,
}),
})

Expand Down Expand Up @@ -44,6 +48,42 @@ function getDiskUsageDesc(disks: Record<string, DiskUsageStat>, path: string) {
return `${formatBytes(disk.used, { precision: 1 })} / ${formatBytes(disk.total, { precision: 1 })}`
}

function getSecondaryDisk(disks: Record<string, DiskUsageStat>, path: string) {
const allDisks = Object.values(disks)
const primaryDisk = getDisk(disks, path)
if (!primaryDisk) {
return undefined
}
return allDisks.find(d => !isSameDisk(d, primaryDisk))
}

function isSameDisk(disk: DiskUsageStat, otherDisk: DiskUsageStat) {
if (disk.path === otherDisk.path) {
return true
}
if (
disk.fstype === otherDisk.fstype &&
disk.total === otherDisk.total &&
disk.used === otherDisk.used
) {
return true
}
return false
}
Comment on lines +60 to +72

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fragile comparison: disk.used changes frequently.

Comparing disk.used is unreliable because disk usage is dynamic. Two different disks could momentarily have identical used values, causing false positives. Consider comparing only stable properties.

🛡️ Proposed fix using stable properties only
 function isSameDisk(disk: DiskUsageStat, otherDisk: DiskUsageStat) {
   if (disk.path === otherDisk.path) {
     return true
   }
-  if (
-    disk.fstype === otherDisk.fstype &&
-    disk.total === otherDisk.total &&
-    disk.used === otherDisk.used
-  ) {
+  // Same filesystem type and total size likely indicates same physical disk
+  // (e.g., different mount points on the same partition)
+  if (disk.fstype === otherDisk.fstype && disk.total === otherDisk.total) {
     return true
   }
   return false
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/home/SystemStatsProvider.tsx` around lines 60 - 72, The
isSameDisk function currently treats disks as identical if path matches or if
fstype, total and used match; remove the volatile disk.used comparison and
compare only stable identifiers: keep the path equality check first, then fall
back to comparing stable properties such as fstype and total (and any stable
id/label/uuid on DiskUsageStat if available) — update the function (isSameDisk
and any call sites if necessary) to omit disk.used from the equality branch so
transient usage values don't cause false positives.


function getSecondaryDiskUsage(disks: Record<string, DiskUsageStat>, path: string) {
return getSecondaryDisk(disks, path)?.used_percent
}

function getSecondaryDiskUsageDesc(disks: Record<string, DiskUsageStat>, path: string) {
const disk = getSecondaryDisk(disks, path)
if (!disk) {
return ''
}
const key = Object.entries(disks).find(([_, d]) => Object.is(d, disk))?.[0]
return `${key}: ${formatBytes(disk.used, { precision: 1 })} / ${formatBytes(disk.total, { precision: 1 })}`
}

function getMemoryUsageDesc(memory: MemVirtualMemoryStat) {
return `${formatBytes(memory.used, { precision: 1 })} / ${formatBytes(memory.total, { precision: 1 })}`
}
8 changes: 8 additions & 0 deletions src/components/home/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ type SystemInfoSimple = {
cpuAverage: number
rootPartitionUsage: number
rootPartitionUsageDesc: string
secondaryPartitionUsage: number
secondaryPartitionUsageDesc: string
memoryUsage: number
memoryUsageDesc: string
networkSpeedUpload: number
networkSpeedDownload: number
}

export const store = createStore<Store>('homepage', {
Expand All @@ -50,8 +54,12 @@ export const store = createStore<Store>('homepage', {
cpuAverage: 0,
rootPartitionUsage: 0,
rootPartitionUsageDesc: '',
secondaryPartitionUsage: 0,
secondaryPartitionUsageDesc: '',
memoryUsage: 0,
memoryUsageDesc: '',
networkSpeedUpload: 0,
networkSpeedDownload: 0,
},
homepageCategories: [],
searchQuery: '',
Expand Down