Skip to content
This repository was archived by the owner on Oct 19, 2026. It is now read-only.

Commit 74044f6

Browse files
authored
feat: implement Orion client UI design (#1727)
1 parent a9c086c commit 74044f6

7 files changed

Lines changed: 467 additions & 0 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client'
2+
3+
import { Badge, UIText } from '@gitmono/ui'
4+
5+
export function CapabilitiesBadgeList({ capabilities }: { capabilities?: string | string[] }) {
6+
if (!capabilities || (Array.isArray(capabilities) && capabilities.length === 0)) {
7+
return <UIText color='text-muted'>—</UIText>
8+
}
9+
10+
const list = Array.isArray(capabilities)
11+
? capabilities
12+
: capabilities
13+
.split(',')
14+
.map((c) => c.trim())
15+
.filter(Boolean)
16+
17+
return (
18+
<div className='flex flex-wrap gap-1'>
19+
{list.map((cap) => (
20+
<Badge key={cap} color='default' className='capitalize'>
21+
{cap}
22+
</Badge>
23+
))}
24+
</div>
25+
)
26+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
'use client'
2+
3+
import React from 'react'
4+
import { DataTable } from '@primer/react/experimental'
5+
6+
import { Select, SelectTrigger, SelectValue, UIText } from '@gitmono/ui'
7+
8+
import { StatusBadge } from './StatusBadge'
9+
import { deriveStatus, OrionClient, OrionClientStatus } from './types'
10+
11+
interface ClientsTableProps {
12+
clients: OrionClient[]
13+
isLoading?: boolean
14+
statusFilter: OrionClientStatus | 'all'
15+
onStatusChange: (v: OrionClientStatus | 'all') => void
16+
statusOptions: { value: OrionClientStatus | 'all'; label: string }[]
17+
capabilityFilter?: string
18+
capabilityOptions?: string[]
19+
onCapabilityChange?: (v: string) => void
20+
}
21+
22+
type Row = OrionClient & { statusDerived: OrionClientStatus }
23+
type UniqueRow = Row & { id: string }
24+
25+
export function ClientsTable({ clients, isLoading, statusFilter, onStatusChange, statusOptions }: ClientsTableProps) {
26+
const rows = React.useMemo<UniqueRow[]>(() => {
27+
return clients.map((c) => ({
28+
...c,
29+
statusDerived: deriveStatus(c),
30+
id: c.client_id
31+
}))
32+
}, [clients])
33+
34+
const columns = React.useMemo(
35+
() => [
36+
{
37+
header: 'Client ID',
38+
field: 'client_id',
39+
rowHeader: true,
40+
width: '18%',
41+
renderCell: (row: Row) => (
42+
<div className='min-w-0'>
43+
<UIText weight='font-semibold' className='block truncate text-sm'>
44+
{row.client_id}
45+
</UIText>
46+
</div>
47+
)
48+
},
49+
{
50+
header: 'Hostname',
51+
field: 'hostname',
52+
width: '18%',
53+
renderCell: (row: Row) => <div className='min-w-0 truncate'>{row.hostname || '—'}</div>
54+
},
55+
{ header: 'Version', field: 'orion_version', width: '10%' },
56+
{
57+
header: 'Start Time',
58+
field: 'start_time',
59+
width: '18%',
60+
renderCell: (row: Row) => <div className='whitespace-normal break-words'>{formatDateTime(row.start_time)}</div>
61+
},
62+
{
63+
header: 'Last Heartbeat',
64+
field: 'last_heartbeat',
65+
width: '22%',
66+
renderCell: (row: Row) => (
67+
<div className='flex flex-col gap-0.5 leading-tight'>
68+
<div className='whitespace-normal break-words'>{formatDateTime(row.last_heartbeat)}</div>
69+
<UIText color='text-muted' size='text-xs' className='whitespace-nowrap'>
70+
{formatRelative(row.last_heartbeat)}
71+
</UIText>
72+
</div>
73+
)
74+
},
75+
{
76+
header: () => (
77+
<Select
78+
value={statusFilter}
79+
options={statusOptions}
80+
onChange={(v) => onStatusChange(v as OrionClientStatus | 'all')}
81+
>
82+
<SelectTrigger className='text-muted-foreground h-auto w-full justify-start gap-1 border-none bg-transparent p-0 text-[11px] font-semibold uppercase shadow-none ring-0 focus:outline-none focus:ring-0'>
83+
<SelectValue placeholder='Status' />
84+
</SelectTrigger>
85+
</Select>
86+
),
87+
field: 'statusDerived',
88+
width: '14%',
89+
renderCell: (row: Row) => <StatusBadge status={row.statusDerived} />
90+
}
91+
],
92+
[onStatusChange, statusFilter, statusOptions]
93+
)
94+
95+
if (isLoading) {
96+
return (
97+
<div className='flex h-40 items-center justify-center'>
98+
<UIText color='text-muted'>Loading clients…</UIText>
99+
</div>
100+
)
101+
}
102+
103+
const isEmpty = !rows || rows.length === 0
104+
105+
return (
106+
<div className='border-border overflow-hidden rounded-md border [&_table]:w-full [&_table]:table-fixed [&_tbody_tr:last-child]:border-b-0 [&_tbody_tr]:border-b [&_td]:py-4 [&_th]:py-4 [&_thead_tr]:border-b'>
107+
<DataTable aria-label='Orion clients' data={rows} columns={columns as any} />
108+
{isEmpty ? (
109+
<div className='border-border flex h-40 items-center justify-center border-t'>
110+
<UIText color='text-muted'>No Orion clients</UIText>
111+
</div>
112+
) : null}
113+
</div>
114+
)
115+
}
116+
117+
function formatDateTime(iso: string) {
118+
if (!iso) return '—'
119+
const d = new Date(iso)
120+
121+
if (Number.isNaN(d.getTime())) return iso
122+
return d.toLocaleString()
123+
}
124+
125+
function formatRelative(iso: string) {
126+
const d = new Date(iso)
127+
const ts = d.getTime()
128+
129+
if (Number.isNaN(ts)) return 'invalid'
130+
131+
const diffMs = Date.now() - ts
132+
const diffSec = Math.max(0, Math.floor(diffMs / 1000))
133+
134+
if (diffSec < 60) return `${diffSec}s ago`
135+
const diffMin = Math.floor(diffSec / 60)
136+
137+
if (diffMin < 60) return `${diffMin}m ago`
138+
const diffHour = Math.floor(diffMin / 60)
139+
140+
if (diffHour < 24) return `${diffHour}h ago`
141+
const diffDay = Math.floor(diffHour / 24)
142+
143+
return `${diffDay}d ago`
144+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
'use client'
2+
3+
import { Badge } from '@gitmono/ui'
4+
5+
import { OrionClientStatus } from './types'
6+
7+
const STATUS_META: Record<
8+
OrionClientStatus,
9+
{ color: 'default' | 'blue' | 'green' | 'brand' | 'orange' | 'amber'; label: string }
10+
> = {
11+
idle: { color: 'green', label: 'Idle' },
12+
busy: { color: 'orange', label: 'Busy ' },
13+
running: { color: 'blue', label: 'Running build' },
14+
downloading: { color: 'blue', label: 'Downloading source' },
15+
preparing: { color: 'blue', label: 'Preparing environment' },
16+
uploading: { color: 'blue', label: 'Uploading artifacts' },
17+
error: { color: 'brand', label: 'Error ' },
18+
offline: { color: 'default', label: 'Lost / Offline' }
19+
}
20+
21+
export function StatusBadge({ status }: { status: OrionClientStatus }) {
22+
const meta = STATUS_META[status] ?? STATUS_META.idle
23+
24+
return (
25+
<Badge className='min-w-[140px] justify-center px-3 py-0.5 text-xs' color={meta.color}>
26+
{meta.label}
27+
</Badge>
28+
)
29+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export * from './types'
2+
export * from './StatusBadge'
3+
export * from './CapabilitiesBadgeList'
4+
export * from './ClientsTable'
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client'
2+
3+
export type OrionClientStatus =
4+
| 'idle'
5+
| 'busy'
6+
| 'running'
7+
| 'downloading'
8+
| 'preparing'
9+
| 'uploading'
10+
| 'error'
11+
| 'offline'
12+
13+
export interface OrionClient {
14+
client_id: string
15+
hostname: string
16+
instance_id: string
17+
orion_version: string
18+
start_time: string
19+
last_heartbeat: string
20+
supported_capabilities?: string | string[]
21+
status?: OrionClientStatus
22+
}
23+
24+
export const OFFLINE_THRESHOLD_SECONDS = 30
25+
26+
export function deriveStatus(client: OrionClient, now: number = Date.now()): OrionClientStatus {
27+
if (client.status) return client.status
28+
29+
const lastHeartbeat = new Date(client.last_heartbeat).getTime()
30+
const diffSeconds = (now - lastHeartbeat) / 1000
31+
32+
if (Number.isFinite(diffSeconds) && diffSeconds > OFFLINE_THRESHOLD_SECONDS) {
33+
return 'offline'
34+
}
35+
36+
return 'idle'
37+
}

0 commit comments

Comments
 (0)