@renseiai/agentfactory-dashboard 0.8.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -0
  3. package/components.json +16 -0
  4. package/package.json +78 -0
  5. package/src/components/fleet/agent-card.tsx +97 -0
  6. package/src/components/fleet/fleet-overview.tsx +139 -0
  7. package/src/components/fleet/provider-icon.tsx +61 -0
  8. package/src/components/fleet/stat-card.tsx +77 -0
  9. package/src/components/fleet/status-dot.tsx +35 -0
  10. package/src/components/layout/bottom-bar.tsx +58 -0
  11. package/src/components/layout/shell.tsx +65 -0
  12. package/src/components/layout/sidebar.tsx +97 -0
  13. package/src/components/layout/top-bar.tsx +63 -0
  14. package/src/components/pipeline/pipeline-card.tsx +65 -0
  15. package/src/components/pipeline/pipeline-column.tsx +44 -0
  16. package/src/components/pipeline/pipeline-view.tsx +85 -0
  17. package/src/components/sessions/session-detail.tsx +153 -0
  18. package/src/components/sessions/session-list.tsx +125 -0
  19. package/src/components/sessions/session-timeline.tsx +76 -0
  20. package/src/components/sessions/token-chart.tsx +51 -0
  21. package/src/components/settings/settings-view.tsx +175 -0
  22. package/src/components/shared/empty-state.tsx +34 -0
  23. package/src/components/shared/logo.tsx +37 -0
  24. package/src/components/ui/badge.tsx +33 -0
  25. package/src/components/ui/button.tsx +54 -0
  26. package/src/components/ui/card.tsx +57 -0
  27. package/src/components/ui/dropdown-menu.tsx +77 -0
  28. package/src/components/ui/scroll-area.tsx +45 -0
  29. package/src/components/ui/separator.tsx +25 -0
  30. package/src/components/ui/sheet.tsx +88 -0
  31. package/src/components/ui/skeleton.tsx +15 -0
  32. package/src/components/ui/tabs.tsx +54 -0
  33. package/src/components/ui/tooltip.tsx +29 -0
  34. package/src/hooks/use-sessions.ts +13 -0
  35. package/src/hooks/use-stats.ts +13 -0
  36. package/src/hooks/use-workers.ts +17 -0
  37. package/src/index.ts +82 -0
  38. package/src/lib/format.ts +36 -0
  39. package/src/lib/status-config.ts +72 -0
  40. package/src/lib/utils.ts +6 -0
  41. package/src/lib/work-type-config.ts +116 -0
  42. package/src/pages/dashboard-page.tsx +11 -0
  43. package/src/pages/pipeline-page.tsx +11 -0
  44. package/src/pages/session-page.tsx +29 -0
  45. package/src/pages/settings-page.tsx +7 -0
  46. package/src/styles/globals.css +218 -0
  47. package/src/types/api.ts +48 -0
  48. package/tailwind.config.ts +139 -0
@@ -0,0 +1,153 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { Badge } from '../../components/ui/badge'
5
+ import { StatusDot } from '../../components/fleet/status-dot'
6
+ import { SessionTimeline, type TimelineEvent } from './session-timeline'
7
+ import { TokenChart } from './token-chart'
8
+ import { formatDuration, formatCost } from '../../lib/format'
9
+ import { getStatusConfig } from '../../lib/status-config'
10
+ import { getWorkTypeConfig } from '../../lib/work-type-config'
11
+ import type { PublicSessionResponse } from '../../types/api'
12
+ import { ArrowLeft, Clock, Coins, Calendar, Tag } from 'lucide-react'
13
+ import { Button } from '../../components/ui/button'
14
+
15
+ interface SessionDetailProps {
16
+ session: PublicSessionResponse
17
+ onBack?: () => void
18
+ className?: string
19
+ }
20
+
21
+ export function SessionDetail({ session, onBack, className }: SessionDetailProps) {
22
+ const statusConfig = getStatusConfig(session.status)
23
+ const workTypeConfig = getWorkTypeConfig(session.workType)
24
+
25
+ const events: TimelineEvent[] = [
26
+ {
27
+ id: 'started',
28
+ label: 'Session started',
29
+ timestamp: session.startedAt,
30
+ type: 'info',
31
+ },
32
+ ]
33
+
34
+ if (session.status === 'completed') {
35
+ const endTime = new Date(new Date(session.startedAt).getTime() + session.duration * 1000)
36
+ events.push({
37
+ id: 'completed',
38
+ label: 'Session completed',
39
+ timestamp: endTime.toISOString(),
40
+ type: 'success',
41
+ })
42
+ } else if (session.status === 'failed') {
43
+ const endTime = new Date(new Date(session.startedAt).getTime() + session.duration * 1000)
44
+ events.push({
45
+ id: 'failed',
46
+ label: 'Session failed',
47
+ timestamp: endTime.toISOString(),
48
+ type: 'error',
49
+ })
50
+ }
51
+
52
+ return (
53
+ <div className={cn('space-y-6 p-6 animate-fade-in', className)}>
54
+ {/* Back button */}
55
+ {onBack && (
56
+ <Button
57
+ variant="ghost"
58
+ size="sm"
59
+ onClick={onBack}
60
+ className="gap-1.5 text-af-text-secondary hover:text-af-text-primary font-body -ml-2"
61
+ >
62
+ <ArrowLeft className="h-3.5 w-3.5" />
63
+ Back
64
+ </Button>
65
+ )}
66
+
67
+ {/* Header */}
68
+ <div className="flex items-center gap-3">
69
+ <StatusDot status={session.status} showHeartbeat={session.status === 'working'} className="h-3 w-3" />
70
+ <h1 className="font-display text-xl font-bold font-mono text-af-text-primary tracking-tight">
71
+ {session.identifier}
72
+ </h1>
73
+ <Badge
74
+ variant="outline"
75
+ className={cn(
76
+ 'text-xs border',
77
+ statusConfig.bgColor, statusConfig.textColor, statusConfig.borderColor
78
+ )}
79
+ >
80
+ {statusConfig.label}
81
+ </Badge>
82
+ </div>
83
+
84
+ <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
85
+ {/* Left: Details */}
86
+ <div className="lg:col-span-2 rounded-xl border border-af-surface-border/40 bg-af-surface/30 p-6">
87
+ <h3 className="font-display text-sm font-semibold text-af-text-primary tracking-tight mb-5">
88
+ Session Details
89
+ </h3>
90
+
91
+ <div className="grid grid-cols-2 gap-6">
92
+ <div className="space-y-1">
93
+ <dt className="flex items-center gap-1.5 text-2xs font-body uppercase tracking-wider text-af-text-tertiary">
94
+ <Tag className="h-3 w-3" />
95
+ Work Type
96
+ </dt>
97
+ <dd>
98
+ <Badge
99
+ variant="outline"
100
+ className={cn('text-xs border', workTypeConfig.bgColor, workTypeConfig.color, workTypeConfig.borderColor)}
101
+ >
102
+ {workTypeConfig.label}
103
+ </Badge>
104
+ </dd>
105
+ </div>
106
+
107
+ <div className="space-y-1">
108
+ <dt className="flex items-center gap-1.5 text-2xs font-body uppercase tracking-wider text-af-text-tertiary">
109
+ <Clock className="h-3 w-3" />
110
+ Duration
111
+ </dt>
112
+ <dd className="font-display text-lg font-bold tabular-nums text-af-text-primary">
113
+ {formatDuration(session.duration)}
114
+ </dd>
115
+ </div>
116
+
117
+ <div className="space-y-1">
118
+ <dt className="flex items-center gap-1.5 text-2xs font-body uppercase tracking-wider text-af-text-tertiary">
119
+ <Coins className="h-3 w-3" />
120
+ Cost
121
+ </dt>
122
+ <dd className="font-display text-lg font-bold tabular-nums font-mono text-af-accent">
123
+ {formatCost(session.costUsd)}
124
+ </dd>
125
+ </div>
126
+
127
+ <div className="space-y-1">
128
+ <dt className="flex items-center gap-1.5 text-2xs font-body uppercase tracking-wider text-af-text-tertiary">
129
+ <Calendar className="h-3 w-3" />
130
+ Started
131
+ </dt>
132
+ <dd className="text-sm font-body text-af-text-primary">
133
+ {new Date(session.startedAt).toLocaleString()}
134
+ </dd>
135
+ </div>
136
+ </div>
137
+
138
+ <div className="mt-8">
139
+ <TokenChart />
140
+ </div>
141
+ </div>
142
+
143
+ {/* Right: Timeline */}
144
+ <div className="rounded-xl border border-af-surface-border/40 bg-af-surface/30 p-6">
145
+ <h3 className="font-display text-sm font-semibold text-af-text-primary tracking-tight mb-5">
146
+ Timeline
147
+ </h3>
148
+ <SessionTimeline events={events} />
149
+ </div>
150
+ </div>
151
+ </div>
152
+ )
153
+ }
@@ -0,0 +1,125 @@
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-6', 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-6">
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-6', className)}>
45
+ <div className="mb-5 flex items-center gap-3">
46
+ <h2 className="font-display text-lg font-bold text-af-text-primary tracking-tight">
47
+ Sessions
48
+ </h2>
49
+ <span className="rounded-full bg-af-surface/60 px-2 py-0.5 text-2xs font-body font-medium tabular-nums text-af-text-secondary">
50
+ {sessions.length}
51
+ </span>
52
+ </div>
53
+
54
+ <div className="rounded-xl border border-af-surface-border/40 overflow-hidden">
55
+ <table className="w-full text-sm font-body">
56
+ <thead>
57
+ <tr className="border-b border-af-surface-border/40 bg-af-bg-secondary/50">
58
+ <th className="px-4 py-3 text-left text-2xs font-semibold uppercase tracking-wider text-af-text-tertiary">Issue</th>
59
+ <th className="px-4 py-3 text-left text-2xs font-semibold uppercase tracking-wider text-af-text-tertiary">Status</th>
60
+ <th className="px-4 py-3 text-left text-2xs font-semibold uppercase tracking-wider text-af-text-tertiary">Type</th>
61
+ <th className="px-4 py-3 text-left text-2xs font-semibold uppercase tracking-wider text-af-text-tertiary">Duration</th>
62
+ <th className="px-4 py-3 text-left text-2xs font-semibold uppercase tracking-wider text-af-text-tertiary">Cost</th>
63
+ <th className="px-4 py-3 text-left text-2xs font-semibold uppercase tracking-wider text-af-text-tertiary">Started</th>
64
+ </tr>
65
+ </thead>
66
+ <tbody>
67
+ {sessions.map((session, i) => {
68
+ const statusConfig = getStatusConfig(session.status)
69
+ const workTypeConfig = getWorkTypeConfig(session.workType)
70
+ return (
71
+ <tr
72
+ key={session.id}
73
+ className={cn(
74
+ 'border-b border-af-surface-border/20 last:border-0 cursor-pointer transition-all duration-200',
75
+ 'hover:bg-af-surface/30'
76
+ )}
77
+ onClick={() => onSelect?.(session.id)}
78
+ style={{ animationDelay: `${i * 30}ms` }}
79
+ >
80
+ <td className="px-4 py-3">
81
+ <span className="font-mono text-sm text-af-text-primary">{session.identifier}</span>
82
+ </td>
83
+ <td className="px-4 py-3">
84
+ <div className="flex items-center gap-2">
85
+ <StatusDot status={session.status} />
86
+ <span className={cn('text-xs', statusConfig.textColor)}>
87
+ {statusConfig.label}
88
+ </span>
89
+ </div>
90
+ </td>
91
+ <td className="px-4 py-3">
92
+ <Badge
93
+ variant="outline"
94
+ className={cn(
95
+ 'text-2xs border',
96
+ workTypeConfig.bgColor, workTypeConfig.color, workTypeConfig.borderColor
97
+ )}
98
+ >
99
+ {workTypeConfig.label}
100
+ </Badge>
101
+ </td>
102
+ <td className="px-4 py-3">
103
+ <span className="text-xs text-af-text-secondary tabular-nums">
104
+ {formatDuration(session.duration)}
105
+ </span>
106
+ </td>
107
+ <td className="px-4 py-3">
108
+ <span className="text-xs text-af-text-secondary tabular-nums font-mono">
109
+ {formatCost(session.costUsd)}
110
+ </span>
111
+ </td>
112
+ <td className="px-4 py-3">
113
+ <span className="text-xs text-af-text-tertiary">
114
+ {formatRelativeTime(session.startedAt)}
115
+ </span>
116
+ </td>
117
+ </tr>
118
+ )
119
+ })}
120
+ </tbody>
121
+ </table>
122
+ </div>
123
+ </div>
124
+ )
125
+ }
@@ -0,0 +1,76 @@
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 typeStyles = {
17
+ info: {
18
+ dot: 'bg-af-blue',
19
+ glow: 'shadow-[0_0_8px_2px_rgba(75,139,245,0.3)]',
20
+ line: 'bg-af-blue/20',
21
+ },
22
+ success: {
23
+ dot: 'bg-af-status-success',
24
+ glow: 'shadow-[0_0_8px_2px_rgba(34,197,94,0.3)]',
25
+ line: 'bg-af-status-success/20',
26
+ },
27
+ warning: {
28
+ dot: 'bg-af-status-warning',
29
+ glow: 'shadow-[0_0_8px_2px_rgba(245,158,11,0.3)]',
30
+ line: 'bg-af-status-warning/20',
31
+ },
32
+ error: {
33
+ dot: 'bg-af-status-error',
34
+ glow: 'shadow-[0_0_8px_2px_rgba(239,68,68,0.3)]',
35
+ line: 'bg-af-status-error/20',
36
+ },
37
+ }
38
+
39
+ export function SessionTimeline({ events, className }: SessionTimelineProps) {
40
+ return (
41
+ <div className={cn('relative space-y-0', className)}>
42
+ {/* Vertical connector line */}
43
+ {events.length > 1 && (
44
+ <div className="absolute left-[5px] top-3 bottom-3 w-px bg-af-surface-border/40" />
45
+ )}
46
+
47
+ {events.map((event) => {
48
+ const styles = typeStyles[event.type ?? 'info']
49
+ return (
50
+ <div key={event.id} className="relative flex gap-3.5 py-2.5">
51
+ {/* Glowing dot */}
52
+ <span
53
+ className={cn(
54
+ 'relative z-10 mt-1.5 h-[10px] w-[10px] shrink-0 rounded-full',
55
+ styles.dot,
56
+ styles.glow
57
+ )}
58
+ />
59
+
60
+ <div className="flex-1 min-w-0">
61
+ <div className="flex items-baseline justify-between gap-2">
62
+ <span className="text-sm font-body text-af-text-primary">{event.label}</span>
63
+ <span className="text-2xs font-body text-af-text-tertiary whitespace-nowrap tabular-nums">
64
+ {new Date(event.timestamp).toLocaleTimeString()}
65
+ </span>
66
+ </div>
67
+ {event.detail && (
68
+ <p className="mt-0.5 text-xs font-body text-af-text-tertiary">{event.detail}</p>
69
+ )}
70
+ </div>
71
+ </div>
72
+ )
73
+ })}
74
+ </div>
75
+ )
76
+ }
@@ -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-3', className)}>
19
+ <div className="flex items-center justify-between text-2xs font-body">
20
+ <span className="uppercase tracking-wider text-af-text-tertiary">Token Usage</span>
21
+ <span className="tabular-nums font-mono text-af-text-secondary">{formatTokens(total)} total</span>
22
+ </div>
23
+
24
+ {/* Bar */}
25
+ <div className="flex h-2.5 w-full overflow-hidden rounded-full bg-af-bg-primary/60">
26
+ <div
27
+ className="bg-af-blue rounded-l-full transition-all duration-500"
28
+ style={{ width: `${inputPct}%` }}
29
+ />
30
+ <div
31
+ className="bg-af-accent transition-all duration-500"
32
+ style={{ width: `${outputPct}%` }}
33
+ />
34
+ </div>
35
+
36
+ {/* Legend */}
37
+ <div className="flex items-center gap-5 text-2xs font-body">
38
+ <div className="flex items-center gap-1.5">
39
+ <span className="h-2 w-2 rounded-full bg-af-blue shadow-[0_0_6px_1px_rgba(75,139,245,0.3)]" />
40
+ <span className="text-af-text-tertiary">Input</span>
41
+ <span className="tabular-nums font-mono text-af-text-secondary">{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 shadow-[0_0_6px_1px_rgba(255,107,53,0.3)]" />
45
+ <span className="text-af-text-tertiary">Output</span>
46
+ <span className="tabular-nums font-mono text-af-text-secondary">{formatTokens(outputTokens)}</span>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ )
51
+ }
@@ -0,0 +1,175 @@
1
+ 'use client'
2
+
3
+ import { cn } from '../../lib/utils'
4
+ import { Badge } from '../../components/ui/badge'
5
+ import { Separator } from '../../components/ui/separator'
6
+ import { useStats } from '../../hooks/use-stats'
7
+ import { useWorkers } from '../../hooks/use-workers'
8
+ import { ProviderIcon } from '../../components/fleet/provider-icon'
9
+ import { StatusDot } from '../../components/fleet/status-dot'
10
+ import { CheckCircle2, AlertCircle, Settings2, Webhook, Server, Shield } from 'lucide-react'
11
+
12
+ interface SettingsViewProps {
13
+ className?: string
14
+ }
15
+
16
+ export function SettingsView({ className }: SettingsViewProps) {
17
+ const { data: stats } = useStats()
18
+ const { data: workersData } = useWorkers()
19
+
20
+ const workers = workersData?.workers ?? []
21
+ const hasWorkerAuth = workersData !== null
22
+
23
+ return (
24
+ <div className={cn('space-y-6 p-6 max-w-3xl', className)}>
25
+ {/* Page header */}
26
+ <div>
27
+ <h1 className="font-display text-xl font-bold text-af-text-primary tracking-tight">Settings</h1>
28
+ <p className="mt-1 text-sm font-body text-af-text-secondary">
29
+ Configuration and integration status for your AgentFactory instance.
30
+ </p>
31
+ </div>
32
+
33
+ {/* Integration Status */}
34
+ <div className="rounded-xl border border-af-surface-border/40 bg-af-surface/30 overflow-hidden">
35
+ <div className="px-6 py-4 border-b border-af-surface-border/30">
36
+ <h3 className="flex items-center gap-2 font-display text-sm font-semibold text-af-text-primary tracking-tight">
37
+ <Webhook className="h-4 w-4 text-af-text-tertiary" />
38
+ Integration Status
39
+ </h3>
40
+ <p className="mt-0.5 text-xs font-body text-af-text-tertiary">Connected services and API endpoints</p>
41
+ </div>
42
+
43
+ <div className="px-6 py-4 space-y-4">
44
+ <div className="flex items-center justify-between">
45
+ <div className="flex items-center gap-3">
46
+ <CheckCircle2 className="h-4 w-4 text-af-status-success" />
47
+ <div>
48
+ <p className="text-sm font-body text-af-text-primary">Linear Webhook</p>
49
+ <p className="text-2xs font-mono text-af-text-tertiary">/webhook</p>
50
+ </div>
51
+ </div>
52
+ <Badge variant="success">Connected</Badge>
53
+ </div>
54
+
55
+ <Separator className="bg-af-surface-border/30" />
56
+
57
+ <div className="flex items-center justify-between">
58
+ <div className="flex items-center gap-3">
59
+ <CheckCircle2 className="h-4 w-4 text-af-status-success" />
60
+ <div>
61
+ <p className="text-sm font-body text-af-text-primary">Public API</p>
62
+ <p className="text-2xs font-mono text-af-text-tertiary">/api/public/stats</p>
63
+ </div>
64
+ </div>
65
+ <Badge variant="success">Active</Badge>
66
+ </div>
67
+
68
+ <Separator className="bg-af-surface-border/30" />
69
+
70
+ <div className="flex items-center justify-between">
71
+ <div className="flex items-center gap-3">
72
+ {hasWorkerAuth ? (
73
+ <CheckCircle2 className="h-4 w-4 text-af-status-success" />
74
+ ) : (
75
+ <AlertCircle className="h-4 w-4 text-af-text-tertiary" />
76
+ )}
77
+ <div>
78
+ <p className="text-sm font-body text-af-text-primary">Worker API</p>
79
+ <p className="text-2xs font-mono text-af-text-tertiary">/api/workers</p>
80
+ </div>
81
+ </div>
82
+ <Badge variant={hasWorkerAuth ? 'success' : 'secondary'}>
83
+ {hasWorkerAuth ? 'Authenticated' : 'No Auth Key'}
84
+ </Badge>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ {/* Workers */}
90
+ <div className="rounded-xl border border-af-surface-border/40 bg-af-surface/30 overflow-hidden">
91
+ <div className="px-6 py-4 border-b border-af-surface-border/30">
92
+ <h3 className="flex items-center gap-2 font-display text-sm font-semibold text-af-text-primary tracking-tight">
93
+ <Server className="h-4 w-4 text-af-text-tertiary" />
94
+ Workers
95
+ </h3>
96
+ <p className="mt-0.5 text-xs font-body text-af-text-tertiary">
97
+ {workers.length > 0
98
+ ? `${workers.length} worker${workers.length !== 1 ? 's' : ''} registered`
99
+ : !hasWorkerAuth && (stats?.workersOnline ?? 0) > 0
100
+ ? `${stats!.workersOnline} worker${stats!.workersOnline !== 1 ? 's' : ''} online`
101
+ : 'No workers connected'}
102
+ </p>
103
+ </div>
104
+
105
+ <div className="px-6 py-4">
106
+ {!hasWorkerAuth && (stats?.workersOnline ?? 0) > 0 ? (
107
+ <div className="space-y-2">
108
+ <p className="text-sm font-body text-af-text-secondary">
109
+ {stats!.workersOnline} worker{stats!.workersOnline !== 1 ? 's' : ''} connected to the fleet.
110
+ </p>
111
+ <p className="text-xs font-body text-af-text-tertiary">
112
+ Set <code className="font-mono text-2xs px-1 py-0.5 rounded bg-af-surface-border/30">WORKER_API_KEY</code> to view detailed worker information.
113
+ </p>
114
+ </div>
115
+ ) : workers.length === 0 ? (
116
+ <p className="text-sm font-body text-af-text-tertiary">
117
+ Workers will appear here once they register with the server.
118
+ </p>
119
+ ) : (
120
+ <div className="space-y-3">
121
+ {workers.map((worker) => (
122
+ <div key={worker.id} className="flex items-center justify-between py-1">
123
+ <div className="flex items-center gap-3">
124
+ <StatusDot status={worker.status === 'active' ? 'working' : 'stopped'} />
125
+ <div>
126
+ <p className="text-sm font-mono text-af-text-primary">
127
+ {worker.hostname ?? worker.id.slice(0, 8)}
128
+ </p>
129
+ <p className="text-2xs font-body text-af-text-tertiary">
130
+ {worker.activeSessions}/{worker.capacity} slots
131
+ </p>
132
+ </div>
133
+ </div>
134
+ <div className="flex items-center gap-2.5">
135
+ <ProviderIcon provider={worker.provider} size={14} />
136
+ <Badge variant={worker.status === 'active' ? 'success' : 'secondary'}>
137
+ {worker.status}
138
+ </Badge>
139
+ </div>
140
+ </div>
141
+ ))}
142
+ </div>
143
+ )}
144
+ </div>
145
+ </div>
146
+
147
+ {/* Fleet Stats */}
148
+ <div className="rounded-xl border border-af-surface-border/40 bg-af-surface/30 overflow-hidden">
149
+ <div className="px-6 py-4 border-b border-af-surface-border/30">
150
+ <h3 className="flex items-center gap-2 font-display text-sm font-semibold text-af-text-primary tracking-tight">
151
+ <Shield className="h-4 w-4 text-af-text-tertiary" />
152
+ Fleet Configuration
153
+ </h3>
154
+ </div>
155
+
156
+ <div className="px-6 py-4">
157
+ <div className="grid grid-cols-2 gap-6">
158
+ <div className="space-y-1">
159
+ <dt className="text-2xs font-body uppercase tracking-wider text-af-text-tertiary">Total Capacity</dt>
160
+ <dd className="font-display text-lg font-bold tabular-nums text-af-text-primary">
161
+ {stats?.availableCapacity ?? '—'}
162
+ </dd>
163
+ </div>
164
+ <div className="space-y-1">
165
+ <dt className="text-2xs font-body uppercase tracking-wider text-af-text-tertiary">Workers Online</dt>
166
+ <dd className="font-display text-lg font-bold tabular-nums text-af-text-primary">
167
+ {stats?.workersOnline ?? '—'}
168
+ </dd>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+ )
175
+ }
@@ -0,0 +1,34 @@
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(
21
+ 'flex flex-col items-center justify-center py-16 text-center',
22
+ className
23
+ )}>
24
+ <div className="mb-4 rounded-xl border border-af-surface-border/30 bg-af-surface/20 p-4">
25
+ <div className="text-af-text-tertiary">
26
+ {icon ?? <Inbox className="h-8 w-8" />}
27
+ </div>
28
+ </div>
29
+ <h3 className="font-display text-sm font-semibold text-af-text-primary tracking-tight">{title}</h3>
30
+ <p className="mt-1.5 max-w-xs text-xs font-body text-af-text-tertiary">{description}</p>
31
+ {children && <div className="mt-5">{children}</div>}
32
+ </div>
33
+ )
34
+ }
@@ -0,0 +1,37 @@
1
+ import { cn } from '../../lib/utils'
2
+
3
+ interface LogoProps {
4
+ className?: string
5
+ size?: number
6
+ }
7
+
8
+ export function Logo({ className, size = 32 }: LogoProps) {
9
+ return (
10
+ <svg
11
+ width={size}
12
+ height={size}
13
+ viewBox="0 0 512 512"
14
+ fill="none"
15
+ xmlns="http://www.w3.org/2000/svg"
16
+ className={cn('text-af-accent', className)}
17
+ >
18
+ <g transform="matrix(1.28321,0,0,1.28321,-56.4612,-56.0276)">
19
+ <g transform="matrix(1,0,0,1,-278,-269)">
20
+ <path
21
+ d="M458,365L448,370L440,378L436,388L436,401L441,412L448,419L459,425L459,569L505,616L504.5,650.092L494.5,655.092L486.5,664.092L482.5,674.092L482.5,686.092L488.5,699.092L496.5,706.092L502.5,709.092L516.5,710.092L530.5,704.092L539.5,693.092L541.5,687.092L541.5,673.092L537.5,664.092L529.5,655.092L519.5,650.092L519,614L564,569L564,507L573,499L577,489L576,481L572,474L562,468L549,469L539,479L539,495L542,500L550,506L550,563L524,589L520,589L519,476L555,447L560,447L648,518L655,521L668,330L693.779,330L695,335L707,467L707,696L704,698L566,698L564,696L565,632L610,587L610,546L620,537L623,524L620,516L613,509L599,507L592,510L584,521L585,535L596,546L596,581L550,627L550,711.779L721,712L721,467L709,334L706,312.662L655,312.662L644,491L637,491L564,431L564,429L575,423L585,411L587,405L587,392L584,384L574,373L565,369L547,370L533,381L528,392L528,405L530,411L541,424L550,428L550,433L524,454L519,453L519,374L528,370L537,361L541,351L540,335L535,326L529,320L517,315L502,316L494,320L488,326L482,339L482,351L487,362L494,369L503,373L505,373.559L504,588L499,589L473,563L473,424L482,420L492,409L495,401L495,388L490,377L482,369L473,365L458,365ZM528.287,679.998C528.287,671.063 520.935,663.711 512,663.711C503.065,663.711 495.713,671.063 495.713,679.998C495.713,688.933 503.065,696.285 512,696.285C520.935,696.285 528.287,688.933 528.287,679.998ZM527.607,344.629C527.607,344.587 527.608,344.545 527.608,344.502C527.608,335.705 520.368,328.465 511.57,328.465C502.773,328.465 495.534,335.705 495.534,344.502C495.534,344.545 495.534,344.587 495.534,344.629C495.534,344.671 495.534,344.713 495.534,344.756C495.534,353.553 502.773,360.793 511.57,360.793C520.368,360.793 527.608,353.553 527.608,344.756C527.608,344.713 527.607,344.671 527.607,344.629ZM573.561,398.285C573.561,389.447 566.288,382.174 557.45,382.174C548.612,382.174 541.339,389.447 541.339,398.285L541.339,398.285C541.339,407.123 548.612,414.396 557.45,414.396C566.288,414.396 573.561,407.123 573.561,398.285L573.561,398.285ZM481.681,394.375C481.681,385.567 474.433,378.319 465.625,378.319C456.817,378.319 449.569,385.567 449.569,394.375L449.569,394.375C449.569,403.183 456.817,410.431 465.625,410.431C474.433,410.431 481.681,403.183 481.681,394.375L481.681,394.375Z"
22
+ fill="currentColor"
23
+ fillRule="evenodd"
24
+ clipRule="evenodd"
25
+ />
26
+ </g>
27
+ <g transform="matrix(1,0,0,1,-278,-269)">
28
+ <path
29
+ d="M427,365L424,365L322,469L322,712L473,711.779L473,628L427,581L427,520L438,509L439,495L431,484L424,481L416,481L409,484L403,490L400,499L403,511L413,520L413,587L459,634L459,696L457,698L339,698L337,696L337,476L408,401.455L413,397L413,460L426,460L427,365Z"
30
+ fill="currentColor"
31
+ fillRule="nonzero"
32
+ />
33
+ </g>
34
+ </g>
35
+ </svg>
36
+ )
37
+ }