feat: add system instance info panel (#5716)

* feat: add system instance reporting

* feat: show system instance resources

* fix: update translations for heartbeat messages in Russian and Vietnamese
This commit is contained in:
Calcium-Ion
2026-06-24 19:16:56 +08:00
committed by GitHub
parent 5377192293
commit 2cbdfa0398
20 changed files with 1133 additions and 35 deletions
+27
View File
@@ -0,0 +1,27 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { api } from '@/lib/api'
import type { SystemInstanceListResponse } from './types'
export async function listSystemInstances() {
const res = await api.get<SystemInstanceListResponse>(
'/api/system-info/instances'
)
return res.data
}
@@ -0,0 +1,521 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle, RefreshCw, ServerCog } from 'lucide-react'
import type { ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { ErrorState } from '@/components/error-state'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from '@/components/ui/popover'
import { Skeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { listSystemInstances } from '../api'
import type { SystemInstance, SystemInstanceStatus } from '../types'
const INSTANCE_POLL_INTERVAL_MS = 30_000
const STATUS_CLASS_NAME: Record<SystemInstanceStatus, string> = {
online:
'bg-emerald-50 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300',
stale: 'bg-amber-50 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300',
}
const STATUS_DOT_CLASS_NAME: Record<SystemInstanceStatus, string> = {
online: 'bg-emerald-500',
stale: 'bg-amber-500',
}
function roleLabel(instance: SystemInstance) {
if (instance.info?.role?.is_master) return 'master'
return 'worker'
}
function roleDescriptionKey(instance: SystemInstance) {
if (instance.info?.role?.is_master) {
return 'Master instances run scheduled background tasks.'
}
return 'Worker instances do not run master-only background tasks.'
}
function runtimeLabel(instance: SystemInstance) {
const runtime = instance.info?.runtime
if (!runtime?.goos && !runtime?.goarch) return '-'
const parts: string[] = []
if (runtime.goos || runtime.goarch) {
parts.push([runtime.goos, runtime.goarch].filter(Boolean).join('/'))
}
return parts.join(' · ')
}
function getNodeName(instance: SystemInstance) {
return instance.info?.node?.name || instance.node_name
}
function formatPercent(value?: number) {
if (typeof value !== 'number' || Number.isNaN(value)) return '-'
return `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
}).format(value)}%`
}
function formatBytes(bytes?: number): string {
if (typeof bytes !== 'number' || Number.isNaN(bytes)) return '-'
if (bytes === 0) return '0 B'
if (bytes < 0) return `-${formatBytes(-bytes)}`
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const index = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1
)
const value = bytes / 1024 ** index
return `${new Intl.NumberFormat(undefined, {
maximumFractionDigits: index === 0 ? 0 : 1,
}).format(value)} ${units[index]}`
}
function ringColorClass(percent: number | null) {
if (percent === null) return 'text-muted-foreground/40'
if (percent >= 90) return 'text-red-500'
if (percent >= 70) return 'text-amber-500'
return 'text-emerald-500'
}
type RingProgressProps = {
percent: number | null
size?: number
}
function RingProgress(props: RingProgressProps) {
const size = props.size ?? 22
const stroke = 2.5
const radius = (size - stroke) / 2
const circumference = 2 * Math.PI * radius
const offset =
props.percent === null
? circumference
: circumference - (props.percent / 100) * circumference
return (
<svg
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
className='shrink-0 -rotate-90'
aria-hidden='true'
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill='none'
strokeWidth={stroke}
stroke='currentColor'
className='text-muted'
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill='none'
strokeWidth={stroke}
strokeLinecap='round'
stroke='currentColor'
strokeDasharray={circumference}
strokeDashoffset={offset}
className={cn(
'transition-[stroke-dashoffset] duration-500',
ringColorClass(props.percent)
)}
/>
</svg>
)
}
type ResourceCellProps = {
value?: number
tooltip?: ReactNode
}
function ResourceCell(props: ResourceCellProps) {
const percent =
typeof props.value === 'number' && !Number.isNaN(props.value)
? Math.max(0, Math.min(100, props.value))
: null
const content = (
<div className='flex items-center gap-2'>
<RingProgress percent={percent} />
<span className='font-mono text-[11px] tabular-nums'>
{formatPercent(props.value)}
</span>
</div>
)
if (!props.tooltip) return content
return (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger className='block w-full rounded-sm text-left focus-visible:ring-2 focus-visible:outline-none'>
{content}
</TooltipTrigger>
<TooltipContent className='max-w-80'>{props.tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
type SystemInstancesTableProps = {
instances: SystemInstance[]
}
function SystemInstancesList(props: SystemInstancesTableProps) {
const { t, i18n } = useTranslation()
return (
<div className='overflow-x-auto rounded-md border'>
<Table className='min-w-[1140px]'>
<TableHeader>
<TableRow className='bg-muted/40 hover:bg-muted/40'>
<TableHead className='h-9 min-w-[240px] px-4 text-xs'>
{t('Instances')}
</TableHead>
<TableHead className='h-9 w-[110px] text-xs'>
{t('Status')}
</TableHead>
<TableHead className='h-9 w-[100px] text-xs'>{t('Role')}</TableHead>
<TableHead className='h-9 w-[96px] text-xs'>{t('CPU')}</TableHead>
<TableHead className='h-9 w-[96px] text-xs'>{t('Memory')}</TableHead>
<TableHead className='h-9 w-[96px] text-xs'>
{t('Storage')}
</TableHead>
<TableHead className='h-9 w-[100px] text-xs'>
{t('Version')}
</TableHead>
<TableHead className='h-9 w-[140px] text-xs'>
{t('Runtime')}
</TableHead>
<TableHead className='h-9 w-[170px] text-xs'>
{t('Started')}
</TableHead>
<TableHead className='h-9 w-[170px] pr-4 text-xs'>
{t('Last Seen')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{props.instances.map((instance) => {
const shouldConfigure =
instance.info?.node?.should_configure_manually === true
const resources = instance.info?.resources
const storage = resources?.storage
return (
<TableRow key={instance.node_name} className='hover:bg-muted/30'>
<TableCell className='px-4 py-2.5 align-middle'>
<div className='flex min-w-0 items-center gap-2'>
<span
className={cn(
'size-2 shrink-0 rounded-full',
STATUS_DOT_CLASS_NAME[instance.status]
)}
aria-hidden='true'
/>
<div className='min-w-0'>
<div className='flex min-w-0 items-center gap-1.5'>
<span className='truncate text-sm font-medium'>
{getNodeName(instance)}
</span>
{shouldConfigure && (
<Popover>
<PopoverTrigger
className='inline-flex shrink-0 rounded-full focus-visible:ring-2 focus-visible:outline-none'
aria-label={t('Configure NODE_NAME')}
>
<Badge
variant='outline'
className='border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300'
>
<AlertTriangle
className='size-3'
aria-hidden='true'
/>
</Badge>
</PopoverTrigger>
<PopoverContent align='start' className='w-80'>
<PopoverHeader>
<PopoverTitle>
{t('Configure NODE_NAME')}
</PopoverTitle>
<PopoverDescription>
{t(
'This instance is using an automatic hostname. Set NODE_NAME to a stable unique value for multi-instance management.'
)}
</PopoverDescription>
</PopoverHeader>
<div className='space-y-2 text-xs'>
<div>
<div className='mb-1 font-medium'>
{t('Example')}
</div>
<code className='bg-muted block rounded-md px-2 py-1.5 font-mono text-[11px] break-all'>
NODE_NAME=new-api-master-1
</code>
</div>
<p className='text-muted-foreground'>
{t(
'Use a different stable value for each instance, then restart the service.'
)}
</p>
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className='text-muted-foreground truncate font-mono text-[11px]'>
{instance.info?.host?.hostname || '-'}
</div>
</div>
</div>
</TableCell>
<TableCell className='py-2.5 align-middle'>
<Badge
variant='secondary'
className={cn('gap-1.5', STATUS_CLASS_NAME[instance.status])}
>
<span
className={cn(
'size-1.5 rounded-full',
STATUS_DOT_CLASS_NAME[instance.status]
)}
aria-hidden='true'
/>
{t(instance.status)}
</Badge>
</TableCell>
<TableCell className='py-2.5 align-middle'>
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger
className='inline-flex shrink-0 rounded-full focus-visible:ring-2 focus-visible:outline-none'
aria-label={t('Node role')}
>
<Badge variant='outline'>{roleLabel(instance)}</Badge>
</TooltipTrigger>
<TooltipContent>
{t(roleDescriptionKey(instance))}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell className='py-2.5 align-middle'>
<ResourceCell value={resources?.cpu?.usage_percent} />
</TableCell>
<TableCell className='py-2.5 align-middle'>
<ResourceCell value={resources?.memory?.usage_percent} />
</TableCell>
<TableCell className='py-2.5 align-middle'>
<ResourceCell
value={storage?.used_percent}
tooltip={
storage ? (
<div className='space-y-1 text-xs'>
<div className='grid grid-cols-[auto_1fr] gap-x-3 gap-y-1'>
<span className='text-muted-foreground'>
{t('Used')}
</span>
<span className='font-mono'>
{formatBytes(storage.used_bytes)}
</span>
<span className='text-muted-foreground'>
{t('Free')}
</span>
<span className='font-mono'>
{formatBytes(storage.free_bytes)}
</span>
<span className='text-muted-foreground'>
{t('Total')}
</span>
<span className='font-mono'>
{formatBytes(storage.total_bytes)}
</span>
</div>
</div>
) : undefined
}
/>
</TableCell>
<TableCell className='py-2.5 align-middle'>
<div className='truncate font-mono text-xs'>
{instance.info?.runtime?.version || '-'}
</div>
</TableCell>
<TableCell className='py-2.5 align-middle'>
<div className='truncate font-mono text-xs'>
{runtimeLabel(instance)}
</div>
</TableCell>
<TableCell className='text-muted-foreground py-2.5 text-xs whitespace-nowrap align-middle'>
{formatTimestampToDate(instance.started_at)}
</TableCell>
<TableCell
className='text-muted-foreground py-2.5 pr-4 text-xs whitespace-nowrap align-middle'
title={formatTimestampToDate(instance.last_seen_at)}
>
{formatTimestampRelative(
instance.last_seen_at,
'seconds',
i18n.language
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
)
}
export function SystemInstancesPanel() {
const { t } = useTranslation()
const instancesQuery = useQuery({
queryKey: ['system-info', 'instances'],
queryFn: async () => {
const res = await listSystemInstances()
if (!res.success || !Array.isArray(res.data)) {
throw new Error(res.message || t('We could not load instances.'))
}
return res.data
},
staleTime: 30 * 1000,
retry: false,
refetchInterval: INSTANCE_POLL_INTERVAL_MS,
})
const instances = instancesQuery.data ?? []
const loading = instancesQuery.isLoading
const refreshing = instancesQuery.isFetching && !instancesQuery.isLoading
return (
<section className='bg-card overflow-hidden rounded-lg border shadow-xs'>
<div className='flex flex-col gap-3 border-b px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-5'>
<div className='min-w-0'>
<div className='flex items-center gap-2'>
<span className='bg-muted text-muted-foreground inline-flex size-7 items-center justify-center rounded-md'>
<ServerCog className='size-4' aria-hidden='true' />
</span>
<div className='min-w-0'>
<h3 className='text-sm font-semibold'>{t('Instances')}</h3>
<p className='text-muted-foreground mt-0.5 text-xs'>
{t(
'Nodes reporting from this deployment and their latest heartbeat.'
)}
</p>
</div>
</div>
</div>
<div className='flex shrink-0 items-center gap-3'>
<span className='text-muted-foreground text-xs' aria-live='polite'>
{t('Auto-refreshing every {{seconds}}s', {
seconds: INSTANCE_POLL_INTERVAL_MS / 1000,
})}
</span>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => void instancesQuery.refetch()}
disabled={instancesQuery.isFetching}
aria-label={t('Refresh')}
>
<RefreshCw
data-icon='inline-start'
className={cn('size-3.5', refreshing && 'animate-spin')}
aria-hidden='true'
/>
{refreshing ? t('Refreshing...') : t('Refresh')}
</Button>
</div>
</div>
<div aria-busy={instancesQuery.isFetching}>
{loading ? (
<div className='space-y-2 p-4 sm:p-5'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-9 w-full rounded-md' />
))}
</div>
) : instancesQuery.isError ? (
<ErrorState
title={t('We could not load instances.')}
description={
instancesQuery.error instanceof Error
? instancesQuery.error.message
: undefined
}
onRetry={() => {
void instancesQuery.refetch()
}}
className='min-h-[220px]'
/>
) : instances.length === 0 ? (
<div className='px-4 py-10 text-center sm:px-5'>
<div className='bg-muted mx-auto mb-3 flex size-10 items-center justify-center rounded-lg'>
<ServerCog
className='text-muted-foreground size-5'
aria-hidden='true'
/>
</div>
<p className='text-muted-foreground text-sm'>
{t('No instances have reported yet.')}
</p>
</div>
) : (
<div className='p-4 sm:p-5'>
<SystemInstancesList instances={instances} />
</div>
)}
</div>
</section>
)
}
@@ -19,7 +19,7 @@ For commercial licensing, please contact support@quantumnous.com
import { useQuery } from '@tanstack/react-query'
import { ListChecks, RefreshCw } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { formatTimestampToDate } from '@/lib/format'
import { formatTimestampRelative, formatTimestampToDate } from '@/lib/format'
import { cn } from '@/lib/utils'
import { ErrorState } from '@/components/error-state'
import { Badge } from '@/components/ui/badge'
@@ -98,7 +98,7 @@ type SystemTasksTableProps = {
}
function SystemTasksTable(props: SystemTasksTableProps) {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
return (
<div className='overflow-x-auto rounded-md border'>
@@ -172,8 +172,15 @@ function SystemTasksTable(props: SystemTasksTableProps) {
<TableCell className='text-muted-foreground max-w-[280px] truncate py-3 font-mono text-xs align-middle'>
{task.locked_by || '-'}
</TableCell>
<TableCell className='text-muted-foreground py-3 text-xs whitespace-nowrap align-middle'>
{formatTimestampToDate(task.updated_at)}
<TableCell
className='text-muted-foreground py-3 text-xs whitespace-nowrap align-middle'
title={formatTimestampToDate(task.updated_at)}
>
{formatTimestampRelative(
task.updated_at,
'seconds',
i18n.language
)}
</TableCell>
<TableCell
className='text-destructive max-w-[220px] truncate py-3 pr-4 text-xs align-middle'
+14 -2
View File
@@ -18,6 +18,8 @@ For commercial licensing, please contact support@quantumnous.com
*/
import { useTranslation } from 'react-i18next'
import { SectionPageLayout } from '@/components/layout'
import { Badge } from '@/components/ui/badge'
import { SystemInstancesPanel } from './components/system-instances-panel'
import { SystemTasksPanel } from './components/system-tasks-panel'
export function SystemInfo() {
@@ -25,9 +27,19 @@ export function SystemInfo() {
return (
<SectionPageLayout>
<SectionPageLayout.Title>{t('System Info')}</SectionPageLayout.Title>
<SectionPageLayout.Title>
<span className='inline-flex min-w-0 items-center gap-2'>
<span className='truncate'>{t('System Info')}</span>
<Badge variant='outline' className='shrink-0'>
Root
</Badge>
</span>
</SectionPageLayout.Title>
<SectionPageLayout.Content>
<SystemTasksPanel />
<div className='space-y-4'>
<SystemInstancesPanel />
<SystemTasksPanel />
</div>
</SectionPageLayout.Content>
</SectionPageLayout>
)
+79
View File
@@ -0,0 +1,79 @@
/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
export type SystemInstanceStatus = 'online' | 'stale'
export type SystemInstanceInfo = {
schema_version?: number
node?: {
name?: string
source?: string
manually_configured?: boolean
should_configure_manually?: boolean
[key: string]: unknown
}
role?: {
is_master?: boolean
[key: string]: unknown
}
runtime?: {
version?: string
goos?: string
goarch?: string
started_at?: number
[key: string]: unknown
}
host?: {
hostname?: string
[key: string]: unknown
}
resources?: {
cpu?: {
usage_percent?: number
[key: string]: unknown
}
memory?: {
usage_percent?: number
[key: string]: unknown
}
storage?: {
total_bytes?: number
used_bytes?: number
free_bytes?: number
used_percent?: number
[key: string]: unknown
}
[key: string]: unknown
}
[key: string]: unknown
}
export type SystemInstance = {
node_name: string
status: SystemInstanceStatus
stale_after_seconds: number
started_at: number
last_seen_at: number
info?: SystemInstanceInfo
}
export type SystemInstanceListResponse = {
success: boolean
message: string
data?: SystemInstance[]
}