@supaku/agentfactory-dashboard 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/components.json +16 -0
  3. package/package.json +47 -0
  4. package/src/components/fleet/agent-card.tsx +62 -0
  5. package/src/components/fleet/fleet-overview.tsx +89 -0
  6. package/src/components/fleet/provider-icon.tsx +61 -0
  7. package/src/components/fleet/stat-card.tsx +43 -0
  8. package/src/components/fleet/status-dot.tsx +32 -0
  9. package/src/components/layout/bottom-bar.tsx +50 -0
  10. package/src/components/layout/shell.tsx +57 -0
  11. package/src/components/layout/sidebar.tsx +81 -0
  12. package/src/components/layout/top-bar.tsx +55 -0
  13. package/src/components/pipeline/pipeline-card.tsx +42 -0
  14. package/src/components/pipeline/pipeline-column.tsx +40 -0
  15. package/src/components/pipeline/pipeline-view.tsx +75 -0
  16. package/src/components/sessions/session-detail.tsx +132 -0
  17. package/src/components/sessions/session-list.tsx +109 -0
  18. package/src/components/sessions/session-timeline.tsx +54 -0
  19. package/src/components/sessions/token-chart.tsx +51 -0
  20. package/src/components/settings/settings-view.tsx +155 -0
  21. package/src/components/shared/empty-state.tsx +29 -0
  22. package/src/components/shared/logo.tsx +37 -0
  23. package/src/components/ui/badge.tsx +33 -0
  24. package/src/components/ui/button.tsx +54 -0
  25. package/src/components/ui/card.tsx +50 -0
  26. package/src/components/ui/dropdown-menu.tsx +77 -0
  27. package/src/components/ui/scroll-area.tsx +45 -0
  28. package/src/components/ui/separator.tsx +25 -0
  29. package/src/components/ui/sheet.tsx +88 -0
  30. package/src/components/ui/skeleton.tsx +12 -0
  31. package/src/components/ui/tabs.tsx +54 -0
  32. package/src/components/ui/tooltip.tsx +29 -0
  33. package/src/hooks/use-sessions.ts +13 -0
  34. package/src/hooks/use-stats.ts +13 -0
  35. package/src/hooks/use-workers.ts +17 -0
  36. package/src/index.ts +82 -0
  37. package/src/lib/format.ts +36 -0
  38. package/src/lib/status-config.ts +58 -0
  39. package/src/lib/utils.ts +6 -0
  40. package/src/lib/work-type-config.ts +53 -0
  41. package/src/pages/dashboard-page.tsx +7 -0
  42. package/src/pages/pipeline-page.tsx +7 -0
  43. package/src/pages/session-page.tsx +29 -0
  44. package/src/pages/settings-page.tsx +7 -0
  45. package/src/styles/globals.css +38 -0
  46. package/src/types/api.ts +45 -0
  47. package/tailwind.config.ts +88 -0
@@ -0,0 +1,40 @@
1
+ import { cn } from '../../lib/utils'
2
+ import { ScrollArea } from '../../components/ui/scroll-area'
3
+ import { PipelineCard } from './pipeline-card'
4
+ import type { PublicSessionResponse } from '../../types/api'
5
+
6
+ interface PipelineColumnProps {
7
+ title: string
8
+ sessions: PublicSessionResponse[]
9
+ count: number
10
+ accentColor?: string
11
+ className?: string
12
+ }
13
+
14
+ export function PipelineColumn({ title, sessions, count, accentColor, className }: PipelineColumnProps) {
15
+ return (
16
+ <div
17
+ className={cn(
18
+ 'flex w-72 shrink-0 flex-col rounded-lg border border-af-surface-border bg-af-bg-secondary',
19
+ className
20
+ )}
21
+ >
22
+ <div className="flex items-center justify-between px-3 py-2.5">
23
+ <div className="flex items-center gap-2">
24
+ {accentColor && (
25
+ <span className={cn('h-2 w-2 rounded-full', accentColor)} />
26
+ )}
27
+ <span className="text-xs font-medium text-af-text-primary">{title}</span>
28
+ </div>
29
+ <span className="text-xs tabular-nums text-af-text-secondary">{count}</span>
30
+ </div>
31
+ <ScrollArea className="flex-1 px-2 pb-2">
32
+ <div className="space-y-2">
33
+ {sessions.map((session) => (
34
+ <PipelineCard key={session.id} session={session} />
35
+ ))}
36
+ </div>
37
+ </ScrollArea>
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,75 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { useSessions } from '../../hooks/use-sessions'
5
+ import { PipelineColumn } from './pipeline-column'
6
+ import { EmptyState } from '../../components/shared/empty-state'
7
+ import { Skeleton } from '../../components/ui/skeleton'
8
+ import type { PublicSessionResponse, SessionStatus } from '../../types/api'
9
+
10
+ interface ColumnDef {
11
+ title: string
12
+ statuses: SessionStatus[]
13
+ accentColor: string
14
+ }
15
+
16
+ const columns: ColumnDef[] = [
17
+ { title: 'Backlog', statuses: ['queued', 'parked'], accentColor: 'bg-af-text-secondary' },
18
+ { title: 'Started', statuses: ['working'], accentColor: 'bg-af-status-success' },
19
+ { title: 'Finished', statuses: ['completed'], accentColor: 'bg-blue-400' },
20
+ { title: 'Failed', statuses: ['failed'], accentColor: 'bg-af-status-error' },
21
+ { title: 'Stopped', statuses: ['stopped'], accentColor: 'bg-af-status-warning' },
22
+ ]
23
+
24
+ function groupByColumn(sessions: PublicSessionResponse[]) {
25
+ return columns.map((col) => ({
26
+ ...col,
27
+ sessions: sessions.filter((s) => col.statuses.includes(s.status)),
28
+ }))
29
+ }
30
+
31
+ interface PipelineViewProps {
32
+ className?: string
33
+ }
34
+
35
+ export function PipelineView({ className }: PipelineViewProps) {
36
+ const { data, isLoading } = useSessions()
37
+ const sessions = data?.sessions ?? []
38
+
39
+ if (isLoading) {
40
+ return (
41
+ <div className={cn('flex gap-4 overflow-x-auto p-5', className)}>
42
+ {columns.map((col) => (
43
+ <Skeleton key={col.title} className="h-96 w-72 shrink-0 rounded-lg" />
44
+ ))}
45
+ </div>
46
+ )
47
+ }
48
+
49
+ if (sessions.length === 0) {
50
+ return (
51
+ <div className="p-5">
52
+ <EmptyState
53
+ title="No pipeline data"
54
+ description="Sessions will populate the pipeline as agents work on issues."
55
+ />
56
+ </div>
57
+ )
58
+ }
59
+
60
+ const grouped = groupByColumn(sessions)
61
+
62
+ return (
63
+ <div className={cn('flex gap-4 overflow-x-auto p-5', className)}>
64
+ {grouped.map((col) => (
65
+ <PipelineColumn
66
+ key={col.title}
67
+ title={col.title}
68
+ sessions={col.sessions}
69
+ count={col.sessions.length}
70
+ accentColor={col.accentColor}
71
+ />
72
+ ))}
73
+ </div>
74
+ )
75
+ }
@@ -0,0 +1,132 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { Card, CardContent, CardHeader, CardTitle } from '../../components/ui/card'
5
+ import { Badge } from '../../components/ui/badge'
6
+ import { StatusDot } from '../../components/fleet/status-dot'
7
+ import { SessionTimeline, type TimelineEvent } from './session-timeline'
8
+ import { TokenChart } from './token-chart'
9
+ import { formatDuration, formatCost } from '../../lib/format'
10
+ import { getStatusConfig } from '../../lib/status-config'
11
+ import { getWorkTypeConfig } from '../../lib/work-type-config'
12
+ import type { PublicSessionResponse } from '../../types/api'
13
+ import { ArrowLeft } from 'lucide-react'
14
+ import { Button } from '../../components/ui/button'
15
+
16
+ interface SessionDetailProps {
17
+ session: PublicSessionResponse
18
+ onBack?: () => void
19
+ className?: string
20
+ }
21
+
22
+ export function SessionDetail({ session, onBack, className }: SessionDetailProps) {
23
+ const statusConfig = getStatusConfig(session.status)
24
+ const workTypeConfig = getWorkTypeConfig(session.workType)
25
+
26
+ // Synthesize timeline from available data
27
+ const events: TimelineEvent[] = [
28
+ {
29
+ id: 'started',
30
+ label: 'Session started',
31
+ timestamp: session.startedAt,
32
+ type: 'info',
33
+ },
34
+ ]
35
+
36
+ if (session.status === 'completed') {
37
+ const endTime = new Date(new Date(session.startedAt).getTime() + session.duration * 1000)
38
+ events.push({
39
+ id: 'completed',
40
+ label: 'Session completed',
41
+ timestamp: endTime.toISOString(),
42
+ type: 'success',
43
+ })
44
+ } else if (session.status === 'failed') {
45
+ const endTime = new Date(new Date(session.startedAt).getTime() + session.duration * 1000)
46
+ events.push({
47
+ id: 'failed',
48
+ label: 'Session failed',
49
+ timestamp: endTime.toISOString(),
50
+ type: 'error',
51
+ })
52
+ }
53
+
54
+ return (
55
+ <div className={cn('space-y-4 p-5', className)}>
56
+ {onBack && (
57
+ <Button variant="ghost" size="sm" onClick={onBack} className="gap-1.5 text-af-text-secondary">
58
+ <ArrowLeft className="h-3.5 w-3.5" />
59
+ Back
60
+ </Button>
61
+ )}
62
+
63
+ {/* Header */}
64
+ <div className="flex items-center gap-3">
65
+ <StatusDot status={session.status} showHeartbeat={session.status === 'working'} />
66
+ <h1 className="text-lg font-semibold font-mono text-af-text-primary">
67
+ {session.identifier}
68
+ </h1>
69
+ <Badge
70
+ variant="outline"
71
+ className={cn('text-xs', statusConfig.bgColor, statusConfig.textColor, 'border-transparent')}
72
+ >
73
+ {statusConfig.label}
74
+ </Badge>
75
+ </div>
76
+
77
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
78
+ {/* Left: Details */}
79
+ <Card className="lg:col-span-2">
80
+ <CardHeader>
81
+ <CardTitle>Session Details</CardTitle>
82
+ </CardHeader>
83
+ <CardContent>
84
+ <dl className="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
85
+ <div>
86
+ <dt className="text-af-text-secondary">Work Type</dt>
87
+ <dd className="mt-0.5">
88
+ <Badge
89
+ variant="outline"
90
+ className={cn(workTypeConfig.bgColor, workTypeConfig.color, 'border-transparent')}
91
+ >
92
+ {workTypeConfig.label}
93
+ </Badge>
94
+ </dd>
95
+ </div>
96
+ <div>
97
+ <dt className="text-af-text-secondary">Duration</dt>
98
+ <dd className="mt-0.5 tabular-nums text-af-text-primary">{formatDuration(session.duration)}</dd>
99
+ </div>
100
+ <div>
101
+ <dt className="text-af-text-secondary">Cost</dt>
102
+ <dd className="mt-0.5 tabular-nums font-mono text-af-text-primary">
103
+ {formatCost(session.costUsd)}
104
+ </dd>
105
+ </div>
106
+ <div>
107
+ <dt className="text-af-text-secondary">Started</dt>
108
+ <dd className="mt-0.5 text-af-text-primary">
109
+ {new Date(session.startedAt).toLocaleString()}
110
+ </dd>
111
+ </div>
112
+ </dl>
113
+
114
+ <div className="mt-6">
115
+ <TokenChart />
116
+ </div>
117
+ </CardContent>
118
+ </Card>
119
+
120
+ {/* Right: Timeline */}
121
+ <Card>
122
+ <CardHeader>
123
+ <CardTitle>Timeline</CardTitle>
124
+ </CardHeader>
125
+ <CardContent>
126
+ <SessionTimeline events={events} />
127
+ </CardContent>
128
+ </Card>
129
+ </div>
130
+ </div>
131
+ )
132
+ }
@@ -0,0 +1,109 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { useSessions } from '../../hooks/use-sessions'
5
+ import { StatusDot } from '../../components/fleet/status-dot'
6
+ import { Badge } from '../../components/ui/badge'
7
+ import { Skeleton } from '../../components/ui/skeleton'
8
+ import { EmptyState } from '../../components/shared/empty-state'
9
+ import { formatDuration, formatCost, formatRelativeTime } from '../../lib/format'
10
+ import { getWorkTypeConfig } from '../../lib/work-type-config'
11
+ import { getStatusConfig } from '../../lib/status-config'
12
+
13
+ interface SessionListProps {
14
+ onSelect?: (sessionId: string) => void
15
+ className?: string
16
+ }
17
+
18
+ export function SessionList({ onSelect, className }: SessionListProps) {
19
+ const { data, isLoading } = useSessions()
20
+ const sessions = data?.sessions ?? []
21
+
22
+ if (isLoading) {
23
+ return (
24
+ <div className={cn('space-y-2 p-5', className)}>
25
+ {Array.from({ length: 6 }).map((_, i) => (
26
+ <Skeleton key={i} className="h-14 rounded-lg" />
27
+ ))}
28
+ </div>
29
+ )
30
+ }
31
+
32
+ if (sessions.length === 0) {
33
+ return (
34
+ <div className="p-5">
35
+ <EmptyState
36
+ title="No sessions"
37
+ description="Agent sessions will appear here once they start."
38
+ />
39
+ </div>
40
+ )
41
+ }
42
+
43
+ return (
44
+ <div className={cn('p-5', className)}>
45
+ <div className="rounded-lg border border-af-surface-border overflow-hidden">
46
+ <table className="w-full text-sm">
47
+ <thead>
48
+ <tr className="border-b border-af-surface-border bg-af-bg-secondary">
49
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-af-text-secondary">Issue</th>
50
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-af-text-secondary">Status</th>
51
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-af-text-secondary">Type</th>
52
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-af-text-secondary">Duration</th>
53
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-af-text-secondary">Cost</th>
54
+ <th className="px-4 py-2.5 text-left text-xs font-medium text-af-text-secondary">Started</th>
55
+ </tr>
56
+ </thead>
57
+ <tbody>
58
+ {sessions.map((session) => {
59
+ const statusConfig = getStatusConfig(session.status)
60
+ const workTypeConfig = getWorkTypeConfig(session.workType)
61
+ return (
62
+ <tr
63
+ key={session.id}
64
+ className="border-b border-af-surface-border last:border-0 hover:bg-af-surface/50 cursor-pointer transition-colors"
65
+ onClick={() => onSelect?.(session.id)}
66
+ >
67
+ <td className="px-4 py-2.5">
68
+ <span className="font-mono text-sm text-af-text-primary">{session.identifier}</span>
69
+ </td>
70
+ <td className="px-4 py-2.5">
71
+ <div className="flex items-center gap-1.5">
72
+ <StatusDot status={session.status} />
73
+ <span className={cn('text-xs', statusConfig.textColor)}>
74
+ {statusConfig.label}
75
+ </span>
76
+ </div>
77
+ </td>
78
+ <td className="px-4 py-2.5">
79
+ <Badge
80
+ variant="outline"
81
+ className={cn('text-xs', workTypeConfig.bgColor, workTypeConfig.color, 'border-transparent')}
82
+ >
83
+ {workTypeConfig.label}
84
+ </Badge>
85
+ </td>
86
+ <td className="px-4 py-2.5">
87
+ <span className="text-xs text-af-text-secondary tabular-nums">
88
+ {formatDuration(session.duration)}
89
+ </span>
90
+ </td>
91
+ <td className="px-4 py-2.5">
92
+ <span className="text-xs text-af-text-secondary tabular-nums font-mono">
93
+ {formatCost(session.costUsd)}
94
+ </span>
95
+ </td>
96
+ <td className="px-4 py-2.5">
97
+ <span className="text-xs text-af-text-secondary">
98
+ {formatRelativeTime(session.startedAt)}
99
+ </span>
100
+ </td>
101
+ </tr>
102
+ )
103
+ })}
104
+ </tbody>
105
+ </table>
106
+ </div>
107
+ </div>
108
+ )
109
+ }
@@ -0,0 +1,54 @@
1
+ import { cn } from '../../lib/utils'
2
+
3
+ export interface TimelineEvent {
4
+ id: string
5
+ label: string
6
+ timestamp: string
7
+ detail?: string
8
+ type?: 'info' | 'success' | 'warning' | 'error'
9
+ }
10
+
11
+ interface SessionTimelineProps {
12
+ events: TimelineEvent[]
13
+ className?: string
14
+ }
15
+
16
+ const typeColors = {
17
+ info: 'bg-blue-400',
18
+ success: 'bg-af-status-success',
19
+ warning: 'bg-af-status-warning',
20
+ error: 'bg-af-status-error',
21
+ }
22
+
23
+ export function SessionTimeline({ events, className }: SessionTimelineProps) {
24
+ return (
25
+ <div className={cn('relative space-y-0', className)}>
26
+ {/* Vertical line */}
27
+ <div className="absolute left-[7px] top-2 bottom-2 w-px bg-af-surface-border" />
28
+
29
+ {events.map((event, i) => (
30
+ <div key={event.id} className="relative flex gap-3 py-2">
31
+ {/* Dot */}
32
+ <span
33
+ className={cn(
34
+ 'relative z-10 mt-1 h-[14px] w-[14px] shrink-0 rounded-full border-2 border-af-bg-primary',
35
+ typeColors[event.type ?? 'info']
36
+ )}
37
+ />
38
+
39
+ <div className="flex-1 min-w-0">
40
+ <div className="flex items-baseline justify-between gap-2">
41
+ <span className="text-sm text-af-text-primary">{event.label}</span>
42
+ <span className="text-xs text-af-text-secondary whitespace-nowrap">
43
+ {new Date(event.timestamp).toLocaleTimeString()}
44
+ </span>
45
+ </div>
46
+ {event.detail && (
47
+ <p className="mt-0.5 text-xs text-af-text-secondary">{event.detail}</p>
48
+ )}
49
+ </div>
50
+ </div>
51
+ ))}
52
+ </div>
53
+ )
54
+ }
@@ -0,0 +1,51 @@
1
+ import { cn } from '../../lib/utils'
2
+ import { formatTokens } from '../../lib/format'
3
+
4
+ interface TokenChartProps {
5
+ inputTokens?: number
6
+ outputTokens?: number
7
+ className?: string
8
+ }
9
+
10
+ export function TokenChart({ inputTokens = 0, outputTokens = 0, className }: TokenChartProps) {
11
+ const total = inputTokens + outputTokens
12
+ if (total === 0) return null
13
+
14
+ const inputPct = (inputTokens / total) * 100
15
+ const outputPct = (outputTokens / total) * 100
16
+
17
+ return (
18
+ <div className={cn('space-y-2', className)}>
19
+ <div className="flex items-center justify-between text-xs text-af-text-secondary">
20
+ <span>Token Usage</span>
21
+ <span className="tabular-nums font-mono">{formatTokens(total)} total</span>
22
+ </div>
23
+
24
+ {/* Bar */}
25
+ <div className="flex h-3 w-full overflow-hidden rounded-full bg-af-bg-primary">
26
+ <div
27
+ className="bg-blue-400 transition-all"
28
+ style={{ width: `${inputPct}%` }}
29
+ />
30
+ <div
31
+ className="bg-af-accent transition-all"
32
+ style={{ width: `${outputPct}%` }}
33
+ />
34
+ </div>
35
+
36
+ {/* Legend */}
37
+ <div className="flex items-center gap-4 text-xs">
38
+ <div className="flex items-center gap-1.5">
39
+ <span className="h-2 w-2 rounded-full bg-blue-400" />
40
+ <span className="text-af-text-secondary">Input</span>
41
+ <span className="tabular-nums font-mono text-af-text-primary">{formatTokens(inputTokens)}</span>
42
+ </div>
43
+ <div className="flex items-center gap-1.5">
44
+ <span className="h-2 w-2 rounded-full bg-af-accent" />
45
+ <span className="text-af-text-secondary">Output</span>
46
+ <span className="tabular-nums font-mono text-af-text-primary">{formatTokens(outputTokens)}</span>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ )
51
+ }
@@ -0,0 +1,155 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../../components/ui/card'
5
+ import { Badge } from '../../components/ui/badge'
6
+ import { Separator } from '../../components/ui/separator'
7
+ import { useStats } from '../../hooks/use-stats'
8
+ import { useWorkers } from '../../hooks/use-workers'
9
+ import { ProviderIcon } from '../../components/fleet/provider-icon'
10
+ import { StatusDot } from '../../components/fleet/status-dot'
11
+ import { CheckCircle2, AlertCircle, Settings2 } from 'lucide-react'
12
+
13
+ interface SettingsViewProps {
14
+ className?: string
15
+ }
16
+
17
+ export function SettingsView({ className }: SettingsViewProps) {
18
+ const { data: stats } = useStats()
19
+ const { data: workersData } = useWorkers()
20
+
21
+ const workers = workersData?.workers ?? []
22
+ const hasWorkerAuth = workersData !== null
23
+
24
+ return (
25
+ <div className={cn('space-y-6 p-5 max-w-3xl', className)}>
26
+ <div>
27
+ <h1 className="text-lg font-semibold text-af-text-primary">Settings</h1>
28
+ <p className="text-sm text-af-text-secondary">
29
+ Configuration and integration status for your AgentFactory instance.
30
+ </p>
31
+ </div>
32
+
33
+ {/* Integration Status */}
34
+ <Card>
35
+ <CardHeader>
36
+ <CardTitle>Integration Status</CardTitle>
37
+ <CardDescription>Connected services and API endpoints</CardDescription>
38
+ </CardHeader>
39
+ <CardContent className="space-y-4">
40
+ <div className="flex items-center justify-between">
41
+ <div className="flex items-center gap-3">
42
+ <CheckCircle2 className="h-4 w-4 text-af-status-success" />
43
+ <div>
44
+ <p className="text-sm text-af-text-primary">Linear Webhook</p>
45
+ <p className="text-xs text-af-text-secondary font-mono">/webhook</p>
46
+ </div>
47
+ </div>
48
+ <Badge variant="success">Connected</Badge>
49
+ </div>
50
+
51
+ <Separator />
52
+
53
+ <div className="flex items-center justify-between">
54
+ <div className="flex items-center gap-3">
55
+ <CheckCircle2 className="h-4 w-4 text-af-status-success" />
56
+ <div>
57
+ <p className="text-sm text-af-text-primary">Public API</p>
58
+ <p className="text-xs text-af-text-secondary font-mono">/api/public/stats</p>
59
+ </div>
60
+ </div>
61
+ <Badge variant="success">Active</Badge>
62
+ </div>
63
+
64
+ <Separator />
65
+
66
+ <div className="flex items-center justify-between">
67
+ <div className="flex items-center gap-3">
68
+ {hasWorkerAuth ? (
69
+ <CheckCircle2 className="h-4 w-4 text-af-status-success" />
70
+ ) : (
71
+ <AlertCircle className="h-4 w-4 text-af-text-secondary" />
72
+ )}
73
+ <div>
74
+ <p className="text-sm text-af-text-primary">Worker API</p>
75
+ <p className="text-xs text-af-text-secondary font-mono">/api/workers</p>
76
+ </div>
77
+ </div>
78
+ <Badge variant={hasWorkerAuth ? 'success' : 'secondary'}>
79
+ {hasWorkerAuth ? 'Authenticated' : 'No Auth Key'}
80
+ </Badge>
81
+ </div>
82
+ </CardContent>
83
+ </Card>
84
+
85
+ {/* Workers */}
86
+ <Card>
87
+ <CardHeader>
88
+ <CardTitle>Workers</CardTitle>
89
+ <CardDescription>
90
+ {workers.length > 0
91
+ ? `${workers.length} worker${workers.length !== 1 ? 's' : ''} registered`
92
+ : 'No workers connected'}
93
+ </CardDescription>
94
+ </CardHeader>
95
+ <CardContent>
96
+ {workers.length === 0 ? (
97
+ <p className="text-sm text-af-text-secondary">
98
+ Workers will appear here once they register with the server.
99
+ </p>
100
+ ) : (
101
+ <div className="space-y-3">
102
+ {workers.map((worker) => (
103
+ <div key={worker.id} className="flex items-center justify-between">
104
+ <div className="flex items-center gap-3">
105
+ <StatusDot status={worker.status === 'active' ? 'working' : 'stopped'} />
106
+ <div>
107
+ <p className="text-sm font-mono text-af-text-primary">
108
+ {worker.hostname ?? worker.id.slice(0, 8)}
109
+ </p>
110
+ <p className="text-xs text-af-text-secondary">
111
+ {worker.activeSessions}/{worker.capacity} slots
112
+ </p>
113
+ </div>
114
+ </div>
115
+ <div className="flex items-center gap-2">
116
+ <ProviderIcon provider={worker.provider} size={14} />
117
+ <Badge variant={worker.status === 'active' ? 'success' : 'secondary'}>
118
+ {worker.status}
119
+ </Badge>
120
+ </div>
121
+ </div>
122
+ ))}
123
+ </div>
124
+ )}
125
+ </CardContent>
126
+ </Card>
127
+
128
+ {/* Fleet Stats */}
129
+ <Card>
130
+ <CardHeader>
131
+ <CardTitle className="flex items-center gap-2">
132
+ <Settings2 className="h-4 w-4" />
133
+ Fleet Configuration
134
+ </CardTitle>
135
+ </CardHeader>
136
+ <CardContent>
137
+ <dl className="grid grid-cols-2 gap-4 text-sm">
138
+ <div>
139
+ <dt className="text-af-text-secondary">Total Capacity</dt>
140
+ <dd className="mt-0.5 text-af-text-primary font-medium">
141
+ {stats?.availableCapacity ?? '—'}
142
+ </dd>
143
+ </div>
144
+ <div>
145
+ <dt className="text-af-text-secondary">Workers Online</dt>
146
+ <dd className="mt-0.5 text-af-text-primary font-medium">
147
+ {stats?.workersOnline ?? '—'}
148
+ </dd>
149
+ </div>
150
+ </dl>
151
+ </CardContent>
152
+ </Card>
153
+ </div>
154
+ )
155
+ }
@@ -0,0 +1,29 @@
1
+ import { cn } from '../../lib/utils'
2
+ import { Inbox } from 'lucide-react'
3
+
4
+ interface EmptyStateProps {
5
+ title?: string
6
+ description?: string
7
+ icon?: React.ReactNode
8
+ className?: string
9
+ children?: React.ReactNode
10
+ }
11
+
12
+ export function EmptyState({
13
+ title = 'No data',
14
+ description = 'Nothing to show yet.',
15
+ icon,
16
+ className,
17
+ children,
18
+ }: EmptyStateProps) {
19
+ return (
20
+ <div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
21
+ <div className="mb-4 text-af-text-secondary">
22
+ {icon ?? <Inbox className="h-10 w-10" />}
23
+ </div>
24
+ <h3 className="text-sm font-medium text-af-text-primary">{title}</h3>
25
+ <p className="mt-1 text-sm text-af-text-secondary">{description}</p>
26
+ {children && <div className="mt-4">{children}</div>}
27
+ </div>
28
+ )
29
+ }