caddforge-gui 0.6.0__py3-none-any.whl

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.
@@ -0,0 +1,3 @@
1
+ """CADDForge GUI server package."""
2
+
3
+ __all__ = ["app"]
caddforge_gui/app.py ADDED
@@ -0,0 +1,644 @@
1
+ """FastAPI app for the CADDForge GUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from datetime import datetime, timedelta
8
+ from pathlib import Path
9
+ from typing import Any, AsyncGenerator, Iterable
10
+
11
+ from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
12
+ import typer
13
+ from fastapi.middleware.cors import CORSMiddleware
14
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, StreamingResponse
15
+ from fastapi.staticfiles import StaticFiles
16
+
17
+ from caddforge.cli.main import _restart_recorded_job
18
+ from caddforge.core.job import Job, JobStatus
19
+ from caddforge.core.backends.slurm import SlurmBackend, SlurmJobHandle
20
+ from caddforge.core.job_registry import get_job, initialize_registry, list_jobs, record_job
21
+ from caddforge.core.status_refresh import refresh_job_status
22
+
23
+ try: # Backward-compatible import if core doesn't have delete_job yet.
24
+ from caddforge.core.job_registry import delete_job
25
+ except ImportError: # pragma: no cover - for older core installs
26
+ def delete_job(job_id: str, registry_path: Path | None = None) -> None:
27
+ import sqlite3
28
+
29
+ registry = registry_path or Path("runs") / "job_registry.sqlite"
30
+ with sqlite3.connect(registry) as conn:
31
+ conn.execute("DELETE FROM jobs WHERE job_id = ?", (job_id,))
32
+ conn.commit()
33
+ from caddforge.gui import discover_gui_extensions
34
+
35
+ from .artifacts import collect_artifacts, resolve_artifact_path, stream_log_events
36
+ from .config import GuiConfig, apply_env_overrides, load_config
37
+
38
+ MAX_INLINE_RESULTS_BYTES = 5 * 1024 * 1024
39
+
40
+
41
+ def build_config(
42
+ *,
43
+ config_path: Path | None = None,
44
+ host: str | None = None,
45
+ port: int | None = None,
46
+ base_path: str | None = None,
47
+ cors_origins: list[str] | None = None,
48
+ auth_mode: str | None = None,
49
+ auth_token: str | None = None,
50
+ runs_root: Path | None = None,
51
+ registry_path: Path | None = None,
52
+ ) -> GuiConfig:
53
+ config = load_config(config_path)
54
+ config = apply_env_overrides(config)
55
+ if runs_root and registry_path is None:
56
+ registry_path = Path(runs_root) / "job_registry.sqlite"
57
+ return config.with_overrides(
58
+ host=host,
59
+ port=port,
60
+ base_path=base_path,
61
+ cors_origins=cors_origins if cors_origins else None,
62
+ auth_mode=auth_mode,
63
+ auth_token=auth_token,
64
+ runs_root=runs_root,
65
+ registry_path=registry_path,
66
+ )
67
+
68
+
69
+ def _auth_dependency(config: GuiConfig):
70
+ def _check(request: Request) -> None:
71
+ if config.auth_mode == "none":
72
+ return
73
+ if config.auth_mode == "token":
74
+ token = (request.headers.get("Authorization") or "").replace("Bearer ", "")
75
+ if not token or token != config.auth_token:
76
+ raise HTTPException(status_code=401, detail="Unauthorized")
77
+ return
78
+ raise HTTPException(status_code=500, detail="Unsupported auth mode.")
79
+
80
+ return _check
81
+
82
+
83
+ def _job_to_summary(job: Job) -> dict[str, Any]:
84
+ job_name = (job.metadata or {}).get("job_name") if job.metadata else None
85
+ return {
86
+ "job_id": job.id,
87
+ "job_name": job_name,
88
+ "tool_name": job.tool_name,
89
+ "plugin_section": job.metadata.get("section") if job.metadata else None,
90
+ "backend": job.backend,
91
+ "status": job.status.value,
92
+ "created_at": job.created_at.isoformat(),
93
+ "started_at": job.started_at.isoformat() if job.started_at else None,
94
+ "finished_at": job.finished_at.isoformat() if job.finished_at else None,
95
+ "run_directory": str(job.run_directory) if job.run_directory else None,
96
+ "metadata": job.metadata,
97
+ }
98
+
99
+
100
+ def _load_results(run_directory: Path | None) -> dict[str, Any] | None:
101
+ if not run_directory:
102
+ return None
103
+ results_path = run_directory / "results" / "results.json"
104
+ if not results_path.exists():
105
+ return None
106
+ if results_path.stat().st_size > MAX_INLINE_RESULTS_BYTES:
107
+ return None
108
+ try:
109
+ return json.loads(results_path.read_text(encoding="utf-8"))
110
+ except json.JSONDecodeError:
111
+ return None
112
+
113
+
114
+ def _results_metadata(run_directory: Path | None) -> dict[str, Any]:
115
+ if not run_directory:
116
+ return {"available": False, "size_bytes": 0, "inline_loaded": False}
117
+ results_path = run_directory / "results" / "results.json"
118
+ if not results_path.exists():
119
+ return {"available": False, "size_bytes": 0, "inline_loaded": False}
120
+ size_bytes = results_path.stat().st_size
121
+ return {
122
+ "available": True,
123
+ "size_bytes": size_bytes,
124
+ "inline_loaded": size_bytes <= MAX_INLINE_RESULTS_BYTES,
125
+ }
126
+
127
+
128
+ def _build_artifact_payload(job_id: str, run_directory: Path | None) -> list[dict[str, Any]]:
129
+ return _build_artifact_payload_limited(job_id, run_directory, include_tool_specific=True)
130
+
131
+
132
+ def _build_artifact_payload_limited(
133
+ job_id: str,
134
+ run_directory: Path | None,
135
+ *,
136
+ include_tool_specific: bool,
137
+ max_tool_specific_files: int = 200,
138
+ ) -> list[dict[str, Any]]:
139
+ if not run_directory:
140
+ return []
141
+ artifacts = collect_artifacts(
142
+ run_directory,
143
+ include_tool_specific=include_tool_specific,
144
+ max_tool_specific_files=max_tool_specific_files,
145
+ )
146
+ return [artifact.to_dict(job_id) for artifact in artifacts]
147
+
148
+
149
+ def _resolve_run_directory(job: Job, runs_root: Path) -> Path | None:
150
+ if job.run_directory:
151
+ return Path(job.run_directory)
152
+ if not runs_root.exists():
153
+ return None
154
+ matches = list(runs_root.glob(f"*/{job.id}"))
155
+ if matches:
156
+ return matches[0]
157
+ return None
158
+
159
+
160
+ def _delete_run_directory(run_directory: Path | None) -> None:
161
+ if not run_directory or not run_directory.exists():
162
+ return
163
+ import shutil
164
+
165
+ shutil.rmtree(run_directory)
166
+
167
+
168
+ def _submission_cwd_from_job(job: Job) -> Path | None:
169
+ if not job.metadata:
170
+ return None
171
+ submission_cwd = job.metadata.get("submission_cwd")
172
+ if not isinstance(submission_cwd, str) or not submission_cwd.strip():
173
+ return None
174
+ return Path(submission_cwd).expanduser().resolve()
175
+
176
+
177
+ def _list_jobs(registry_path: Path, statuses: list[JobStatus] | None) -> list[Job]:
178
+ initialize_registry(registry_path)
179
+ return list(list_jobs(registry_path=registry_path, statuses=statuses))
180
+
181
+
182
+ def _filter_jobs(
183
+ jobs: Iterable[Job], *, search: str | None, status: list[str] | None
184
+ ) -> list[Job]:
185
+ filtered = []
186
+ for job in jobs:
187
+ if status and job.status.value not in status:
188
+ continue
189
+ if search:
190
+ metadata = job.metadata or {}
191
+ haystack = " ".join(
192
+ str(value or "")
193
+ for value in (
194
+ job.id,
195
+ metadata.get("job_name"),
196
+ job.tool_name,
197
+ metadata.get("section"),
198
+ job.backend,
199
+ )
200
+ ).lower()
201
+ if search.lower() not in haystack:
202
+ continue
203
+ filtered.append(job)
204
+ return filtered
205
+
206
+
207
+ def _jobs_response(
208
+ registry_path: Path,
209
+ *,
210
+ status: list[str] | None,
211
+ search: str | None,
212
+ limit: int,
213
+ offset: int,
214
+ ) -> dict[str, Any]:
215
+ items = _filter_jobs(_list_jobs(registry_path, None), search=search, status=status)
216
+ total = len(items)
217
+ sliced = items[offset : offset + limit]
218
+ return {
219
+ "items": [_job_to_summary(job) for job in sliced],
220
+ "total": total,
221
+ "limit": limit,
222
+ "offset": offset,
223
+ }
224
+
225
+
226
+ def _dashboard_totals(jobs: list[Job]) -> dict[str, int]:
227
+ now = datetime.now().astimezone()
228
+ today = now.date()
229
+ last_24h = now - timedelta(hours=24)
230
+
231
+ active_statuses = {JobStatus.PENDING, JobStatus.RUNNING}
232
+ return {
233
+ "total_jobs": len(jobs),
234
+ "active_jobs": sum(1 for job in jobs if job.status in active_statuses),
235
+ "running_jobs": sum(1 for job in jobs if job.status == JobStatus.RUNNING),
236
+ "pending_jobs": sum(1 for job in jobs if job.status == JobStatus.PENDING),
237
+ "completed_today": sum(
238
+ 1
239
+ for job in jobs
240
+ if job.status == JobStatus.COMPLETED
241
+ and job.finished_at
242
+ and job.finished_at.astimezone().date() == today
243
+ ),
244
+ "failed_today": sum(
245
+ 1
246
+ for job in jobs
247
+ if job.status == JobStatus.FAILED
248
+ and (job.finished_at or job.created_at).astimezone().date() == today
249
+ ),
250
+ "created_last_24h": sum(1 for job in jobs if job.created_at >= last_24h),
251
+ "tools_seen": len({job.tool_name for job in jobs}),
252
+ "backends_seen": len({job.backend for job in jobs}),
253
+ }
254
+
255
+
256
+ def _dashboard_summary(jobs: list[Job]) -> dict[str, Any]:
257
+ by_status: dict[str, int] = {}
258
+ by_backend: dict[str, int] = {}
259
+ by_tool: dict[str, int] = {}
260
+ for job in jobs:
261
+ by_status[job.status.value] = by_status.get(job.status.value, 0) + 1
262
+ by_backend[job.backend] = by_backend.get(job.backend, 0) + 1
263
+ by_tool[job.tool_name] = by_tool.get(job.tool_name, 0) + 1
264
+
265
+ active_statuses = {JobStatus.PENDING, JobStatus.RUNNING}
266
+ active_jobs = [job for job in jobs if job.status in active_statuses][:6]
267
+ recent_failures = [job for job in jobs if job.status == JobStatus.FAILED][:5]
268
+
269
+ return {
270
+ "updated_at": datetime.now().astimezone().isoformat(),
271
+ "totals": _dashboard_totals(jobs),
272
+ "status": by_status,
273
+ "backend": by_backend,
274
+ "tool": by_tool,
275
+ "active_jobs": [_job_to_summary(job) for job in active_jobs],
276
+ "recent_jobs": [_job_to_summary(job) for job in jobs[:8]],
277
+ "recent_failures": [_job_to_summary(job) for job in recent_failures],
278
+ }
279
+
280
+
281
+ async def _status_stream(registry_path: Path, interval: float) -> AsyncGenerator[str, None]:
282
+ last_payload: dict[str, str] = {}
283
+ while True:
284
+ jobs = list_jobs(registry_path=registry_path)
285
+ payload = {job.id: job.status.value for job in jobs}
286
+ if payload != last_payload:
287
+ last_payload = payload
288
+ message = {"updated_at": datetime.now().astimezone().isoformat(), "jobs": payload}
289
+ yield f"data: {json.dumps(message)}\n\n"
290
+ await asyncio.sleep(interval)
291
+
292
+
293
+ def create_app(config: GuiConfig) -> FastAPI:
294
+ app = FastAPI(title="CADDForge GUI", root_path=config.base_path)
295
+
296
+ if config.cors_origins:
297
+ app.add_middleware(
298
+ CORSMiddleware,
299
+ allow_origins=config.cors_origins,
300
+ allow_credentials=True,
301
+ allow_methods=["*"],
302
+ allow_headers=["*"],
303
+ )
304
+
305
+ auth_dependency = Depends(_auth_dependency(config))
306
+
307
+ extensions = discover_gui_extensions()
308
+ for ext in extensions:
309
+ if ext.router is not None:
310
+ app.include_router(ext.router, prefix=f"/api/v1/ext/{ext.plugin_id}")
311
+ if ext.frontend_dir:
312
+ app.mount(
313
+ f"/static/ext/{ext.plugin_id}",
314
+ StaticFiles(directory=ext.frontend_dir),
315
+ name=f"ext-{ext.plugin_id}",
316
+ )
317
+
318
+ static_root = Path(__file__).parent / "static"
319
+ app.mount("/static", StaticFiles(directory=static_root), name="static")
320
+
321
+ @app.get("/api/v1/health", dependencies=[auth_dependency])
322
+ def health() -> dict[str, str]:
323
+ return {"status": "ok"}
324
+
325
+ @app.get("/api/v1/jobs", dependencies=[auth_dependency])
326
+ def jobs(
327
+ status: list[str] | None = Query(None),
328
+ search: str | None = None,
329
+ limit: int = Query(200, ge=1, le=500),
330
+ offset: int = Query(0, ge=0),
331
+ ) -> dict[str, Any]:
332
+ return _jobs_response(
333
+ config.registry_path,
334
+ status=status,
335
+ search=search,
336
+ limit=limit,
337
+ offset=offset,
338
+ )
339
+
340
+ @app.get("/api/v1/jobs-stream", dependencies=[auth_dependency])
341
+ async def job_stream(interval: float = 2.0) -> StreamingResponse:
342
+ return StreamingResponse(_status_stream(config.registry_path, interval), media_type="text/event-stream")
343
+
344
+ @app.get("/api/v1/jobs/{job_id}", dependencies=[auth_dependency])
345
+ def job_detail(
346
+ job_id: str,
347
+ include_tool_specific_artifacts: bool = False,
348
+ ) -> dict[str, Any]:
349
+ job = get_job(job_id, registry_path=config.registry_path)
350
+ if not job:
351
+ raise HTTPException(status_code=404, detail="Job not found")
352
+ run_directory = _resolve_run_directory(job, config.runs_root)
353
+ payload = _job_to_summary(job)
354
+ payload["run_directory"] = str(run_directory) if run_directory else None
355
+ payload["artifacts"] = _build_artifact_payload_limited(
356
+ job_id,
357
+ run_directory,
358
+ include_tool_specific=include_tool_specific_artifacts,
359
+ )
360
+ payload["results"] = _load_results(run_directory)
361
+ payload["results_meta"] = _results_metadata(run_directory)
362
+ return payload
363
+
364
+ @app.post("/api/v1/jobs/{job_id}/refresh-status", dependencies=[auth_dependency])
365
+ def job_refresh_status(job_id: str) -> dict[str, Any]:
366
+ job = get_job(job_id, registry_path=config.registry_path)
367
+ if not job:
368
+ raise HTTPException(status_code=404, detail="Job not found")
369
+ previous_status = job.status
370
+ refresh_job_status(job, registry_path=config.registry_path)
371
+ refreshed = get_job(job_id, registry_path=config.registry_path) or job
372
+ return {
373
+ "item": _job_to_summary(refreshed),
374
+ "changed": previous_status != refreshed.status,
375
+ }
376
+
377
+ @app.post("/api/v1/jobs/refresh-status", dependencies=[auth_dependency])
378
+ def jobs_refresh_status(
379
+ status: list[str] | None = None,
380
+ only_active: bool = True,
381
+ limit: int = 500,
382
+ ) -> dict[str, Any]:
383
+ jobs = _list_jobs(config.registry_path, None)
384
+ if status:
385
+ allowed = {value.upper() for value in status}
386
+ jobs = [job for job in jobs if job.status.value in allowed]
387
+ elif only_active:
388
+ jobs = [job for job in jobs if job.status in {JobStatus.PENDING, JobStatus.RUNNING}]
389
+
390
+ if limit > 0:
391
+ jobs = jobs[:limit]
392
+
393
+ changed = 0
394
+ errors: list[dict[str, str]] = []
395
+ refreshed_items: list[dict[str, Any]] = []
396
+ for job in jobs:
397
+ before = job.status
398
+ try:
399
+ refresh_job_status(job, registry_path=config.registry_path)
400
+ refreshed = get_job(job.id, registry_path=config.registry_path) or job
401
+ if refreshed.status != before:
402
+ changed += 1
403
+ refreshed_items.append(_job_to_summary(refreshed))
404
+ except Exception as exc: # pragma: no cover - defensive
405
+ errors.append({"job_id": job.id, "error": str(exc)})
406
+
407
+ return {
408
+ "total": len(jobs),
409
+ "changed": changed,
410
+ "errors": errors,
411
+ "items": refreshed_items,
412
+ }
413
+
414
+ @app.get("/api/v1/jobs/{job_id}/artifacts", dependencies=[auth_dependency])
415
+ def job_artifacts(job_id: str, include_tool_specific: bool = True) -> dict[str, Any]:
416
+ job = get_job(job_id, registry_path=config.registry_path)
417
+ if not job:
418
+ raise HTTPException(status_code=404, detail="Job not found")
419
+ run_directory = _resolve_run_directory(job, config.runs_root)
420
+ if not run_directory:
421
+ raise HTTPException(status_code=404, detail="Run directory not found")
422
+ return {
423
+ "items": _build_artifact_payload_limited(
424
+ job_id,
425
+ run_directory,
426
+ include_tool_specific=include_tool_specific,
427
+ )
428
+ }
429
+
430
+ @app.get("/api/v1/jobs/{job_id}/artifacts/{artifact_id}", dependencies=[auth_dependency])
431
+ def job_artifact_download(job_id: str, artifact_id: str) -> Response:
432
+ job = get_job(job_id, registry_path=config.registry_path)
433
+ if not job:
434
+ raise HTTPException(status_code=404, detail="Job not found")
435
+ run_directory = _resolve_run_directory(job, config.runs_root)
436
+ if not run_directory:
437
+ raise HTTPException(status_code=404, detail="Run directory not found")
438
+ try:
439
+ path = resolve_artifact_path(run_directory, artifact_id)
440
+ except ValueError as exc:
441
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
442
+ if not path.exists():
443
+ raise HTTPException(status_code=404, detail="Artifact not found")
444
+ return FileResponse(path)
445
+
446
+ @app.get("/api/v1/jobs/{job_id}/results", dependencies=[auth_dependency])
447
+ def job_results(job_id: str) -> Response:
448
+ job = get_job(job_id, registry_path=config.registry_path)
449
+ if not job:
450
+ raise HTTPException(status_code=404, detail="Job not found")
451
+ run_directory = _resolve_run_directory(job, config.runs_root)
452
+ if not run_directory:
453
+ raise HTTPException(status_code=404, detail="Run directory not found")
454
+ results = _load_results(run_directory)
455
+ if results is None:
456
+ raise HTTPException(status_code=404, detail="Results not available")
457
+ return JSONResponse(results)
458
+
459
+ @app.post("/api/v1/jobs/{job_id}/restart", dependencies=[auth_dependency])
460
+ def job_restart(job_id: str) -> dict[str, Any]:
461
+ job = get_job(job_id, registry_path=config.registry_path)
462
+ if not job:
463
+ raise HTTPException(status_code=404, detail="Job not found")
464
+ if job.status != JobStatus.FAILED:
465
+ raise HTTPException(status_code=400, detail="Only failed jobs can be restarted.")
466
+ try:
467
+ restarted = _restart_recorded_job(
468
+ job,
469
+ registry_path_override=config.registry_path,
470
+ backend_override=job.backend,
471
+ runs_root_override=config.runs_root,
472
+ submission_cwd=_submission_cwd_from_job(job),
473
+ emit=None,
474
+ local_async=job.backend == "local",
475
+ )
476
+ except (LookupError, FileNotFoundError, typer.BadParameter, ValueError) as exc:
477
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
478
+ return {"item": _job_to_summary(restarted)}
479
+
480
+
481
+ @app.post("/api/v1/jobs/{job_id}/cancel", dependencies=[auth_dependency])
482
+ def job_cancel(job_id: str) -> dict[str, Any]:
483
+ job = get_job(job_id, registry_path=config.registry_path)
484
+ if not job:
485
+ raise HTTPException(status_code=404, detail="Job not found")
486
+ if job.status in {JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED}:
487
+ raise HTTPException(status_code=400, detail="Job is not running or pending.")
488
+ if job.backend == "slurm":
489
+ slurm_job_id = None
490
+ if job.metadata:
491
+ slurm_job_id = job.metadata.get("slurm_job_id")
492
+ if not slurm_job_id:
493
+ raise HTTPException(status_code=400, detail="Missing Slurm job id.")
494
+ backend = SlurmBackend(
495
+ runs_root=config.runs_root,
496
+ registry_path=config.registry_path,
497
+ )
498
+ backend.cancel(SlurmJobHandle(job.id, slurm_job_id))
499
+ return {"cancelled": True, "job_id": job.id}
500
+ if job.backend == "local":
501
+ pid = None
502
+ if job.metadata:
503
+ pid = job.metadata.get("local_pid")
504
+ if not pid:
505
+ raise HTTPException(status_code=400, detail="Missing local process id.")
506
+ import os
507
+ import signal
508
+ from datetime import datetime
509
+ from caddforge.core.job_registry import update_status
510
+
511
+ try:
512
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
513
+ except ProcessLookupError:
514
+ pass
515
+ run_directory = _resolve_run_directory(job, config.runs_root)
516
+ if run_directory:
517
+ job.run_directory = Path(run_directory)
518
+ job.status = JobStatus.CANCELLED
519
+ job.finished_at = datetime.now().astimezone()
520
+ if run_directory:
521
+ job.save_metadata(Path(run_directory) / "metadata.json")
522
+ results_dir = Path(run_directory) / "results"
523
+ results_dir.mkdir(parents=True, exist_ok=True)
524
+ from caddforge.core.result import Result
525
+
526
+ Result(
527
+ job_id=job.id,
528
+ tool_name=job.tool_name,
529
+ status=JobStatus.CANCELLED,
530
+ metrics={},
531
+ paths={},
532
+ extra={"cancelled": True},
533
+ ).save(results_dir / "results.json")
534
+ update_status(job.id, JobStatus.CANCELLED, finished_at=job.finished_at, registry_path=config.registry_path)
535
+ record_job(job, registry_path=config.registry_path)
536
+ return {"cancelled": True, "job_id": job.id}
537
+ raise HTTPException(status_code=400, detail="Backend does not support cancellation.")
538
+
539
+ @app.delete("/api/v1/jobs/{job_id}", dependencies=[auth_dependency])
540
+ def job_delete(job_id: str) -> dict[str, Any]:
541
+ job = get_job(job_id, registry_path=config.registry_path)
542
+ if not job:
543
+ raise HTTPException(status_code=404, detail="Job not found")
544
+ run_directory = _resolve_run_directory(job, config.runs_root)
545
+ _delete_run_directory(run_directory)
546
+ delete_job(job_id, registry_path=config.registry_path)
547
+ return {"deleted": True, "job_id": job_id}
548
+
549
+ @app.get("/api/v1/jobs/{job_id}/logs/{stream}", dependencies=[auth_dependency])
550
+ async def job_logs(
551
+ request: Request,
552
+ job_id: str,
553
+ stream: str,
554
+ interval: float = 0.5,
555
+ tail_kb: int = 256,
556
+ ) -> StreamingResponse:
557
+ job = get_job(job_id, registry_path=config.registry_path)
558
+ if not job:
559
+ raise HTTPException(status_code=404, detail="Job not found")
560
+ run_directory = _resolve_run_directory(job, config.runs_root)
561
+ if not run_directory:
562
+ raise HTTPException(status_code=404, detail="Run directory not found")
563
+ if stream not in {"stdout", "stderr"}:
564
+ raise HTTPException(status_code=400, detail="stream must be stdout or stderr")
565
+ path = Path(run_directory) / f"{stream}.log"
566
+ clamped_tail_kb = max(0, min(tail_kb, 4096))
567
+ async def _stream() -> AsyncGenerator[str, None]:
568
+ async for event in stream_log_events(path, interval, initial_tail_bytes=clamped_tail_kb * 1024):
569
+ if await request.is_disconnected():
570
+ break
571
+ yield event
572
+
573
+ return StreamingResponse(_stream(), media_type="text/event-stream")
574
+
575
+ @app.get("/api/v1/dashboard", dependencies=[auth_dependency])
576
+ def dashboard() -> dict[str, Any]:
577
+ jobs = _list_jobs(config.registry_path, None)
578
+ return _dashboard_summary(jobs)
579
+
580
+ @app.get("/api/v1/gui/extensions", dependencies=[auth_dependency])
581
+ def gui_extensions() -> dict[str, Any]:
582
+ manifest = []
583
+ for ext in extensions:
584
+ remote_entry = None
585
+ if ext.frontend_dir and ext.remote_entry:
586
+ remote_entry = f"{config.base_path}/static/ext/{ext.plugin_id}/{ext.remote_entry}"
587
+ manifest.append(
588
+ {
589
+ "plugin_id": ext.plugin_id,
590
+ "display_name": ext.display_name,
591
+ "api_version": ext.api_version,
592
+ "ui_compat_version": ext.ui_compat_version,
593
+ "nav_items": [
594
+ {
595
+ "path": item.path,
596
+ "label": item.label,
597
+ "module": item.module,
598
+ "export": item.export,
599
+ "icon": item.icon,
600
+ "order": item.order,
601
+ }
602
+ for item in ext.nav_items
603
+ ],
604
+ "remote_entry": remote_entry,
605
+ "remote_modules": [
606
+ {"module": module.module, "export": module.export}
607
+ for module in ext.remote_modules
608
+ ],
609
+ }
610
+ )
611
+ return {"items": manifest}
612
+
613
+ def _index_html() -> str:
614
+ index_path = static_root / "index.html"
615
+ html = index_path.read_text(encoding="utf-8")
616
+ if config.base_path:
617
+ html = html.replace('href="/static/', f'href="{config.base_path}/static/')
618
+ html = html.replace('src="/static/', f'src="{config.base_path}/static/')
619
+ html = html.replace('window.__BASE_PATH__ = "";', f'window.__BASE_PATH__ = "{config.base_path}";')
620
+ return html
621
+
622
+ @app.get("/", response_class=HTMLResponse)
623
+ def index() -> HTMLResponse:
624
+ return HTMLResponse(_index_html())
625
+
626
+ @app.get("/{path:path}", response_class=HTMLResponse)
627
+ def spa_fallback(path: str) -> HTMLResponse:
628
+ if path.startswith("api") or path.startswith("static"):
629
+ raise HTTPException(status_code=404, detail="Not found")
630
+ return HTMLResponse(_index_html())
631
+
632
+ return app
633
+
634
+
635
+ def run_gui(config: GuiConfig) -> None:
636
+ import uvicorn
637
+
638
+ app = create_app(config)
639
+ uvicorn.run(app, host=config.host, port=config.port)
640
+
641
+
642
+ def main() -> None:
643
+ config = build_config()
644
+ run_gui(config)