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:
+27
@@ -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
@@ -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
@@ -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[]
|
||||
}
|
||||
Reference in New Issue
Block a user