Skip to content

Commit 24e3e14

Browse files
feat(gui): add M2 features - progress, config editor, dark mode
Backend: - Add --progress_file CLI flag for progress reporting - Add progress watcher service to monitor progress.jsonl - Add progress SSE endpoint for real-time updates - Add config validation and schema endpoints Frontend: - Add ProgressChart component with real-time visualization - Add ConfigEditorPage with INI validation - Add SettingsPage with theme toggle - Add dark mode support across all pages - Add log search with highlighting and match navigation - Update Sidebar with new navigation items Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0d57ebe commit 24e3e14

22 files changed

Lines changed: 1016 additions & 72 deletions

frontend/src/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Layout } from '@/components/layout/Layout'
33
import { RunListPage } from '@/pages/RunListPage'
44
import { NewRunPage } from '@/pages/NewRunPage'
55
import { RunDetailPage } from '@/pages/RunDetailPage'
6+
import { ConfigEditorPage } from '@/pages/ConfigEditorPage'
7+
import { SettingsPage } from '@/pages/SettingsPage'
68

79
function App() {
810
return (
@@ -12,6 +14,8 @@ function App() {
1214
<Route path="runs" element={<RunListPage />} />
1315
<Route path="runs/new" element={<NewRunPage />} />
1416
<Route path="runs/:runId" element={<RunDetailPage />} />
17+
<Route path="config" element={<ConfigEditorPage />} />
18+
<Route path="settings" element={<SettingsPage />} />
1519
</Route>
1620
</Routes>
1721
)

frontend/src/components/layout/Header.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export function Header() {
1212
const isHealthy = health?.status === 'healthy'
1313

1414
return (
15-
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-6">
15+
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-6 dark:border-gray-700 dark:bg-gray-800">
1616
<div>
1717
{/* Breadcrumb or page title could go here */}
1818
</div>
@@ -26,7 +26,7 @@ export function Header() {
2626
isHealthy ? 'text-green-500' : 'text-red-500'
2727
)}
2828
/>
29-
<span className={isHealthy ? 'text-green-600' : 'text-red-600'}>
29+
<span className={isHealthy ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}>
3030
{isHealthy ? 'API Connected' : 'API Disconnected'}
3131
</span>
3232
</div>

frontend/src/components/layout/Layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Header } from './Header'
44

55
export function Layout() {
66
return (
7-
<div className="flex h-screen bg-gray-50">
7+
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
88
<Sidebar />
99
<div className="flex flex-1 flex-col overflow-hidden">
1010
<Header />

frontend/src/components/layout/Sidebar.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import { NavLink } from 'react-router-dom'
2-
import { LayoutDashboard, Play, Settings } from 'lucide-react'
2+
import { LayoutDashboard, Play, Settings, FileCode } from 'lucide-react'
33
import { cn } from '@/lib/utils'
44

55
const navItems = [
66
{ to: '/', icon: LayoutDashboard, label: 'Runs' },
77
{ to: '/runs/new', icon: Play, label: 'New Run' },
8+
{ to: '/config', icon: FileCode, label: 'Config Editor' },
89
]
910

1011
export function Sidebar() {
1112
return (
12-
<aside className="flex w-64 flex-col border-r border-gray-200 bg-white">
13+
<aside className="flex w-64 flex-col border-r border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
1314
{/* Logo */}
14-
<div className="flex h-16 items-center border-b border-gray-200 px-6">
15+
<div className="flex h-16 items-center border-b border-gray-200 px-6 dark:border-gray-700">
1516
<span className="text-xl font-bold text-fusion-600">FUSION</span>
16-
<span className="ml-2 text-sm text-gray-500">GUI</span>
17+
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">GUI</span>
1718
</div>
1819

1920
{/* Navigation */}
@@ -26,8 +27,8 @@ export function Sidebar() {
2627
cn(
2728
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
2829
isActive
29-
? 'bg-fusion-50 text-fusion-700'
30-
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
30+
? 'bg-fusion-50 text-fusion-700 dark:bg-fusion-900/30 dark:text-fusion-400'
31+
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100'
3132
)
3233
}
3334
>
@@ -38,11 +39,21 @@ export function Sidebar() {
3839
</nav>
3940

4041
{/* Footer */}
41-
<div className="border-t border-gray-200 p-4">
42-
<button className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 hover:text-gray-900">
42+
<div className="border-t border-gray-200 p-4 dark:border-gray-700">
43+
<NavLink
44+
to="/settings"
45+
className={({ isActive }) =>
46+
cn(
47+
'flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
48+
isActive
49+
? 'bg-fusion-50 text-fusion-700 dark:bg-fusion-900/30 dark:text-fusion-400'
50+
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-gray-100'
51+
)
52+
}
53+
>
4354
<Settings className="h-5 w-5" />
4455
Settings
45-
</button>
56+
</NavLink>
4657
</div>
4758
</aside>
4859
)
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { useMemo } from 'react'
2+
3+
interface DataPoint {
4+
iteration: number
5+
erlang: number
6+
blocking_prob: number
7+
}
8+
9+
interface ProgressChartProps {
10+
data: DataPoint[]
11+
height?: number
12+
}
13+
14+
export function ProgressChart({ data, height = 200 }: ProgressChartProps) {
15+
const chartData = useMemo(() => {
16+
if (data.length === 0) return null
17+
18+
const maxBlocking = Math.max(...data.map((d) => d.blocking_prob), 0.01)
19+
const width = 100
20+
21+
// Normalize data points
22+
const points = data.map((d, i) => ({
23+
x: (i / Math.max(data.length - 1, 1)) * width,
24+
y: height - (d.blocking_prob / maxBlocking) * (height - 20) - 10,
25+
value: d.blocking_prob,
26+
erlang: d.erlang,
27+
}))
28+
29+
// Create path
30+
const pathD = points
31+
.map((p, i) => `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`)
32+
.join(' ')
33+
34+
return { points, pathD, maxBlocking }
35+
}, [data, height])
36+
37+
if (!chartData || data.length < 2) {
38+
return (
39+
<div
40+
className="flex items-center justify-center bg-gray-50 rounded-lg border border-gray-200"
41+
style={{ height }}
42+
>
43+
<span className="text-sm text-gray-400">
44+
Waiting for progress data...
45+
</span>
46+
</div>
47+
)
48+
}
49+
50+
const latestValue = data[data.length - 1]
51+
52+
return (
53+
<div className="bg-white rounded-lg border border-gray-200 p-4">
54+
<div className="flex items-center justify-between mb-2">
55+
<span className="text-sm font-medium text-gray-700">Blocking Probability</span>
56+
<span className="text-sm text-gray-500">
57+
Current: <span className="font-mono font-medium text-fusion-600">
58+
{(latestValue.blocking_prob * 100).toFixed(4)}%
59+
</span>
60+
</span>
61+
</div>
62+
63+
<svg
64+
viewBox={`0 0 100 ${height}`}
65+
className="w-full"
66+
style={{ height }}
67+
preserveAspectRatio="none"
68+
>
69+
{/* Grid lines */}
70+
<g className="text-gray-200">
71+
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => (
72+
<line
73+
key={ratio}
74+
x1="0"
75+
y1={10 + ratio * (height - 20)}
76+
x2="100"
77+
y2={10 + ratio * (height - 20)}
78+
stroke="currentColor"
79+
strokeWidth="0.5"
80+
/>
81+
))}
82+
</g>
83+
84+
{/* Area fill */}
85+
<path
86+
d={`${chartData.pathD} L 100 ${height - 10} L 0 ${height - 10} Z`}
87+
fill="url(#gradient)"
88+
opacity="0.3"
89+
/>
90+
91+
{/* Line */}
92+
<path
93+
d={chartData.pathD}
94+
fill="none"
95+
stroke="#0284c7"
96+
strokeWidth="2"
97+
strokeLinecap="round"
98+
strokeLinejoin="round"
99+
vectorEffect="non-scaling-stroke"
100+
/>
101+
102+
{/* Latest point */}
103+
{chartData.points.length > 0 && (
104+
<circle
105+
cx={chartData.points[chartData.points.length - 1].x}
106+
cy={chartData.points[chartData.points.length - 1].y}
107+
r="3"
108+
fill="#0284c7"
109+
vectorEffect="non-scaling-stroke"
110+
/>
111+
)}
112+
113+
{/* Gradient definition */}
114+
<defs>
115+
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
116+
<stop offset="0%" stopColor="#0284c7" />
117+
<stop offset="100%" stopColor="#0284c7" stopOpacity="0" />
118+
</linearGradient>
119+
</defs>
120+
</svg>
121+
122+
<div className="flex justify-between text-xs text-gray-400 mt-1">
123+
<span>Erlang: {data[0]?.erlang?.toFixed(1) ?? '-'}</span>
124+
<span>Erlang: {latestValue.erlang?.toFixed(1) ?? '-'}</span>
125+
</div>
126+
</div>
127+
)
128+
}

frontend/src/components/runs/RunCard.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,22 @@ export function RunCard({ run }: RunCardProps) {
3131
return (
3232
<Link
3333
to={`/runs/${run.id}`}
34-
className="card block p-4 transition-shadow hover:shadow-md"
34+
className="card block p-4 transition-shadow hover:shadow-md dark:bg-gray-800 dark:border-gray-700"
3535
>
3636
<div className="flex items-start justify-between">
3737
<div className="flex-1">
3838
<div className="flex items-center gap-2">
39-
<h3 className="font-medium text-gray-900">
39+
<h3 className="font-medium text-gray-900 dark:text-gray-100">
4040
{run.name || `Run ${run.id.slice(0, 6)}`}
4141
</h3>
4242
<RunStatusBadge status={run.status} />
4343
</div>
4444

45-
<p className="mt-1 text-sm text-gray-500">
45+
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
4646
Template: {run.template}
4747
</p>
4848

49-
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400">
49+
<div className="mt-2 flex items-center gap-4 text-xs text-gray-400 dark:text-gray-500">
5050
<span className="flex items-center gap-1">
5151
<Clock className="h-3 w-3" />
5252
{formatDate(run.created_at)}
@@ -56,11 +56,11 @@ export function RunCard({ run }: RunCardProps) {
5656

5757
{run.progress && run.status === 'RUNNING' && (
5858
<div className="mt-3">
59-
<div className="flex items-center justify-between text-xs text-gray-600">
59+
<div className="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
6060
<span>Progress</span>
6161
<span>{run.progress.percent_complete?.toFixed(1) ?? 0}%</span>
6262
</div>
63-
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-gray-200">
63+
<div className="mt-1 h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
6464
<div
6565
className="h-full bg-fusion-500 transition-all"
6666
style={{ width: `${run.progress.percent_complete ?? 0}%` }}
@@ -70,14 +70,14 @@ export function RunCard({ run }: RunCardProps) {
7070
)}
7171

7272
{run.error_message && (
73-
<p className="mt-2 text-sm text-red-600">{run.error_message}</p>
73+
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{run.error_message}</p>
7474
)}
7575
</div>
7676

7777
<button
7878
onClick={handleCancel}
7979
disabled={cancelMutation.isPending}
80-
className="ml-4 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-red-600"
80+
className="ml-4 rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-red-600 dark:hover:bg-gray-700 dark:hover:text-red-400"
8181
title={run.status === 'RUNNING' ? 'Cancel run' : 'Delete run'}
8282
>
8383
<Trash2 className="h-4 w-4" />

frontend/src/hooks/useProgress.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useState, useEffect, useCallback } from 'react'
2+
3+
export interface ProgressEvent {
4+
type: string
5+
ts: string
6+
erlang?: number
7+
iteration?: number
8+
total_iterations?: number
9+
metrics?: {
10+
blocking_prob?: number
11+
[key: string]: unknown
12+
}
13+
}
14+
15+
interface UseProgressOptions {
16+
enabled?: boolean
17+
}
18+
19+
export function useProgress(runId: string | undefined, options: UseProgressOptions = {}) {
20+
const { enabled = true } = options
21+
const [events, setEvents] = useState<ProgressEvent[]>([])
22+
const [latestEvent, setLatestEvent] = useState<ProgressEvent | null>(null)
23+
const [isConnected, setIsConnected] = useState(false)
24+
const [error, setError] = useState<string | null>(null)
25+
26+
const clearEvents = useCallback(() => {
27+
setEvents([])
28+
setLatestEvent(null)
29+
}, [])
30+
31+
useEffect(() => {
32+
if (!runId || !enabled) return
33+
34+
const eventSource = new EventSource(`/api/runs/${runId}/progress`)
35+
36+
eventSource.onopen = () => {
37+
setIsConnected(true)
38+
setError(null)
39+
}
40+
41+
eventSource.addEventListener('progress', (e) => {
42+
try {
43+
const event = JSON.parse(e.data) as ProgressEvent
44+
setEvents((prev) => [...prev, event])
45+
setLatestEvent(event)
46+
} catch {
47+
console.error('Failed to parse progress event:', e.data)
48+
}
49+
})
50+
51+
eventSource.addEventListener('end', () => {
52+
setIsConnected(false)
53+
eventSource.close()
54+
})
55+
56+
eventSource.addEventListener('error', (e) => {
57+
if (e instanceof MessageEvent && e.data) {
58+
setError(e.data)
59+
}
60+
setIsConnected(false)
61+
})
62+
63+
eventSource.onerror = () => {
64+
setIsConnected(false)
65+
eventSource.close()
66+
}
67+
68+
return () => {
69+
eventSource.close()
70+
}
71+
}, [runId, enabled])
72+
73+
// Calculate derived values
74+
const percentComplete = latestEvent?.iteration && latestEvent?.total_iterations
75+
? (latestEvent.iteration / latestEvent.total_iterations) * 100
76+
: null
77+
78+
const blockingProbHistory = events
79+
.filter((e) => e.metrics?.blocking_prob !== undefined)
80+
.map((e) => ({
81+
iteration: e.iteration ?? 0,
82+
erlang: e.erlang ?? 0,
83+
blocking_prob: e.metrics?.blocking_prob ?? 0,
84+
}))
85+
86+
return {
87+
events,
88+
latestEvent,
89+
isConnected,
90+
error,
91+
percentComplete,
92+
blockingProbHistory,
93+
clearEvents,
94+
}
95+
}

0 commit comments

Comments
 (0)