A fork of mtelver's day10 project

Add day10-web implementation plan

Detailed step-by-step plan for building the web frontend:
- 9 tasks with TDD approach
- Project setup with Dream
- Data layer (run_data, package_data) with 9 unit tests
- HTML layout and view modules
- Dashboard, Packages, and Runs pages
- Admin guide updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1669
+1669
docs/plans/2026-02-04-web-frontend-impl.md
··· 1 + # day10-web Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build a status dashboard web frontend for day10 using OCaml and Dream. 6 + 7 + **Architecture:** Separate service (`day10-web`) that reads day10's cache/html directories and serves HTML pages. Server-side rendering with Dream, no database, minimal JavaScript. 8 + 9 + **Tech Stack:** OCaml 5.3+, Dream (web framework), Tyxml (HTML generation), cmdliner (CLI) 10 + 11 + --- 12 + 13 + ## Task 1: Project Setup 14 + 15 + **Files:** 16 + - Modify: `/workspace/dune-project` 17 + - Create: `/workspace/web/dune` 18 + - Create: `/workspace/web/main.ml` 19 + 20 + **Step 1: Add day10-web package to dune-project** 21 + 22 + Edit `/workspace/dune-project` to add after the existing `(package ...)` stanza: 23 + 24 + ```dune 25 + (package 26 + (name day10-web) 27 + (synopsis "Web dashboard for day10 documentation status") 28 + (description "Status dashboard for package maintainers and operators") 29 + (depends 30 + (ocaml (>= 5.3.0)) 31 + dune 32 + dream 33 + day10 34 + cmdliner)) 35 + ``` 36 + 37 + **Step 2: Create web/dune file** 38 + 39 + Create `/workspace/web/dune`: 40 + 41 + ```dune 42 + (executable 43 + (name main) 44 + (public_name day10-web) 45 + (package day10-web) 46 + (libraries dream day10_lib cmdliner unix yojson)) 47 + ``` 48 + 49 + **Step 3: Create minimal web/main.ml** 50 + 51 + Create `/workspace/web/main.ml`: 52 + 53 + ```ocaml 54 + let () = 55 + Dream.run 56 + @@ Dream.logger 57 + @@ Dream.router [ 58 + Dream.get "/" (fun _ -> Dream.html "<h1>day10-web</h1>"); 59 + ] 60 + ``` 61 + 62 + **Step 4: Build to verify setup** 63 + 64 + Run: `dune build` 65 + Expected: Builds successfully with no errors 66 + 67 + **Step 5: Test the server starts** 68 + 69 + Run: `dune exec web/main.exe &; sleep 2; curl http://localhost:8080; kill %1` 70 + Expected: Returns `<h1>day10-web</h1>` 71 + 72 + **Step 6: Commit** 73 + 74 + ```bash 75 + git add dune-project web/ 76 + git commit -m "feat(web): initial project setup with Dream" 77 + ``` 78 + 79 + --- 80 + 81 + ## Task 2: CLI with cmdliner 82 + 83 + **Files:** 84 + - Modify: `/workspace/web/main.ml` 85 + 86 + **Step 1: Add cmdliner CLI** 87 + 88 + Replace `/workspace/web/main.ml` with: 89 + 90 + ```ocaml 91 + open Cmdliner 92 + 93 + let cache_dir = 94 + let doc = "Path to day10's cache directory" in 95 + Arg.(required & opt (some dir) None & info ["cache-dir"] ~docv:"DIR" ~doc) 96 + 97 + let html_dir = 98 + let doc = "Path to generated documentation directory" in 99 + Arg.(required & opt (some dir) None & info ["html-dir"] ~docv:"DIR" ~doc) 100 + 101 + let port = 102 + let doc = "HTTP port to listen on" in 103 + Arg.(value & opt int 8080 & info ["port"; "p"] ~docv:"PORT" ~doc) 104 + 105 + let host = 106 + let doc = "Host address to bind to" in 107 + Arg.(value & opt string "127.0.0.1" & info ["host"] ~docv:"HOST" ~doc) 108 + 109 + let platform = 110 + let doc = "Platform subdirectory in cache" in 111 + Arg.(value & opt string "debian-12-x86_64" & info ["platform"] ~docv:"PLATFORM" ~doc) 112 + 113 + type config = { 114 + cache_dir : string; 115 + html_dir : string; 116 + port : int; 117 + host : string; 118 + platform : string; 119 + } 120 + 121 + let run_server config = 122 + Dream.run ~port:config.port ~interface:config.host 123 + @@ Dream.logger 124 + @@ Dream.router [ 125 + Dream.get "/" (fun _ -> Dream.html "<h1>day10-web</h1>"); 126 + ] 127 + 128 + let main cache_dir html_dir port host platform = 129 + let config = { cache_dir; html_dir; port; host; platform } in 130 + run_server config 131 + 132 + let cmd = 133 + let doc = "Web dashboard for day10 documentation status" in 134 + let info = Cmd.info "day10-web" ~version:"0.1.0" ~doc in 135 + Cmd.v info Term.(const main $ cache_dir $ html_dir $ port $ host $ platform) 136 + 137 + let () = exit (Cmd.eval cmd) 138 + ``` 139 + 140 + **Step 2: Build and test help** 141 + 142 + Run: `dune build && dune exec -- day10-web --help` 143 + Expected: Shows help with --cache-dir, --html-dir, --port, --host, --platform options 144 + 145 + **Step 3: Commit** 146 + 147 + ```bash 148 + git add web/main.ml 149 + git commit -m "feat(web): add cmdliner CLI" 150 + ``` 151 + 152 + --- 153 + 154 + ## Task 3: Data Layer - Run Data 155 + 156 + **Files:** 157 + - Create: `/workspace/web/data/dune` 158 + - Create: `/workspace/web/data/run_data.ml` 159 + - Create: `/workspace/web/data/run_data.mli` 160 + - Create: `/workspace/tests/unit/test_run_data.ml` 161 + - Modify: `/workspace/tests/unit/dune` 162 + 163 + **Step 1: Create web/data/dune** 164 + 165 + Create `/workspace/web/data/dune`: 166 + 167 + ```dune 168 + (library 169 + (name day10_web_data) 170 + (libraries unix yojson day10_lib)) 171 + ``` 172 + 173 + **Step 2: Write the failing test** 174 + 175 + Create `/workspace/tests/unit/test_run_data.ml`: 176 + 177 + ```ocaml 178 + (** Unit tests for run data reading *) 179 + 180 + let test_dir = ref "" 181 + 182 + let setup () = 183 + let dir = Filename.temp_dir "test-run-data-" "" in 184 + test_dir := dir; 185 + dir 186 + 187 + let teardown () = 188 + if !test_dir <> "" then begin 189 + ignore (Sys.command (Printf.sprintf "rm -rf %s" !test_dir)); 190 + test_dir := "" 191 + end 192 + 193 + let mkdir_p path = 194 + let rec create dir = 195 + if not (Sys.file_exists dir) then begin 196 + create (Filename.dirname dir); 197 + try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> () 198 + end 199 + in 200 + create path 201 + 202 + let write_file path content = 203 + let dir = Filename.dirname path in 204 + mkdir_p dir; 205 + Out_channel.with_open_text path (fun oc -> Out_channel.output_string oc content) 206 + 207 + (** Test: list_runs returns runs sorted by most recent first *) 208 + let test_list_runs () = 209 + let base_dir = setup () in 210 + let runs_dir = Filename.concat base_dir "runs" in 211 + mkdir_p (Filename.concat runs_dir "2026-02-01-120000"); 212 + mkdir_p (Filename.concat runs_dir "2026-02-03-120000"); 213 + mkdir_p (Filename.concat runs_dir "2026-02-02-120000"); 214 + 215 + let runs = Day10_web_data.Run_data.list_runs ~log_dir:base_dir in 216 + assert (List.length runs = 3); 217 + assert (List.hd runs = "2026-02-03-120000"); 218 + 219 + teardown (); 220 + Printf.printf "PASS: test_list_runs\n%!" 221 + 222 + (** Test: read_summary parses summary.json *) 223 + let test_read_summary () = 224 + let base_dir = setup () in 225 + let run_dir = Filename.concat (Filename.concat base_dir "runs") "2026-02-04-120000" in 226 + mkdir_p run_dir; 227 + write_file (Filename.concat run_dir "summary.json") {|{ 228 + "run_id": "2026-02-04-120000", 229 + "start_time": "2026-02-04T12:00:00", 230 + "end_time": "2026-02-04T12:30:00", 231 + "duration_seconds": 1800.0, 232 + "targets_requested": 100, 233 + "solutions_found": 95, 234 + "build_success": 90, 235 + "build_failed": 5, 236 + "doc_success": 80, 237 + "doc_failed": 5, 238 + "doc_skipped": 5, 239 + "failures": [{"package": "bad.1.0", "error": "build failed"}] 240 + }|}; 241 + 242 + let summary = Day10_web_data.Run_data.read_summary ~log_dir:base_dir ~run_id:"2026-02-04-120000" in 243 + assert (Option.is_some summary); 244 + let s = Option.get summary in 245 + assert (s.run_id = "2026-02-04-120000"); 246 + assert (s.build_success = 90); 247 + assert (List.length s.failures = 1); 248 + 249 + teardown (); 250 + Printf.printf "PASS: test_read_summary\n%!" 251 + 252 + (** Test: read_summary returns None for missing run *) 253 + let test_read_summary_missing () = 254 + let base_dir = setup () in 255 + let summary = Day10_web_data.Run_data.read_summary ~log_dir:base_dir ~run_id:"nonexistent" in 256 + assert (Option.is_none summary); 257 + teardown (); 258 + Printf.printf "PASS: test_read_summary_missing\n%!" 259 + 260 + (** Test: get_latest_run_id follows symlink *) 261 + let test_get_latest_run_id () = 262 + let base_dir = setup () in 263 + let runs_dir = Filename.concat base_dir "runs" in 264 + mkdir_p (Filename.concat runs_dir "2026-02-04-120000"); 265 + let latest = Filename.concat base_dir "latest" in 266 + Unix.symlink "runs/2026-02-04-120000" latest; 267 + 268 + let latest_id = Day10_web_data.Run_data.get_latest_run_id ~log_dir:base_dir in 269 + assert (Option.is_some latest_id); 270 + assert (Option.get latest_id = "2026-02-04-120000"); 271 + 272 + teardown (); 273 + Printf.printf "PASS: test_get_latest_run_id\n%!" 274 + 275 + (** Test: read_log returns log content *) 276 + let test_read_log () = 277 + let base_dir = setup () in 278 + let run_dir = Filename.concat (Filename.concat base_dir "runs") "2026-02-04-120000" in 279 + write_file (Filename.concat (Filename.concat run_dir "build") "test-pkg.1.0.log") 280 + "Build output here\n"; 281 + 282 + let content = Day10_web_data.Run_data.read_build_log 283 + ~log_dir:base_dir ~run_id:"2026-02-04-120000" ~package:"test-pkg.1.0" in 284 + assert (Option.is_some content); 285 + assert (String.trim (Option.get content) = "Build output here"); 286 + 287 + teardown (); 288 + Printf.printf "PASS: test_read_log\n%!" 289 + 290 + let () = 291 + Printf.printf "Running Run_data tests...\n%!"; 292 + test_list_runs (); 293 + test_read_summary (); 294 + test_read_summary_missing (); 295 + test_get_latest_run_id (); 296 + test_read_log (); 297 + Printf.printf "\nAll Run_data tests passed!\n%!" 298 + ``` 299 + 300 + **Step 3: Add test to tests/unit/dune** 301 + 302 + Add to `/workspace/tests/unit/dune`: 303 + 304 + ```dune 305 + (executable 306 + (name test_run_data) 307 + (libraries day10_web_data unix yojson)) 308 + ``` 309 + 310 + **Step 4: Run test to verify it fails** 311 + 312 + Run: `dune build tests/unit/test_run_data.exe 2>&1` 313 + Expected: FAIL with "Unbound module Day10_web_data" 314 + 315 + **Step 5: Create run_data.mli interface** 316 + 317 + Create `/workspace/web/data/run_data.mli`: 318 + 319 + ```ocaml 320 + (** Read run data from day10's log directory *) 321 + 322 + (** List all run IDs, most recent first *) 323 + val list_runs : log_dir:string -> string list 324 + 325 + (** Get the latest run ID from the 'latest' symlink *) 326 + val get_latest_run_id : log_dir:string -> string option 327 + 328 + (** Read summary.json for a run *) 329 + val read_summary : log_dir:string -> run_id:string -> Day10_lib.Run_log.summary option 330 + 331 + (** Read a build log file *) 332 + val read_build_log : log_dir:string -> run_id:string -> package:string -> string option 333 + 334 + (** Read a doc log file *) 335 + val read_doc_log : log_dir:string -> run_id:string -> package:string -> string option 336 + 337 + (** List all build logs in a run *) 338 + val list_build_logs : log_dir:string -> run_id:string -> string list 339 + 340 + (** List all doc logs in a run *) 341 + val list_doc_logs : log_dir:string -> run_id:string -> string list 342 + ``` 343 + 344 + **Step 6: Implement run_data.ml** 345 + 346 + Create `/workspace/web/data/run_data.ml`: 347 + 348 + ```ocaml 349 + (** Read run data from day10's log directory *) 350 + 351 + let list_runs ~log_dir = 352 + let runs_dir = Filename.concat log_dir "runs" in 353 + if Sys.file_exists runs_dir && Sys.is_directory runs_dir then 354 + Sys.readdir runs_dir 355 + |> Array.to_list 356 + |> List.filter (fun name -> 357 + let path = Filename.concat runs_dir name in 358 + Sys.is_directory path) 359 + |> List.sort (fun a b -> String.compare b a) (* Descending *) 360 + else 361 + [] 362 + 363 + let get_latest_run_id ~log_dir = 364 + let latest = Filename.concat log_dir "latest" in 365 + if Sys.file_exists latest then 366 + try 367 + let target = Unix.readlink latest in 368 + (* Target is like "runs/2026-02-04-120000" *) 369 + Some (Filename.basename target) 370 + with Unix.Unix_error _ -> None 371 + else 372 + None 373 + 374 + let read_summary ~log_dir ~run_id = 375 + let path = Filename.concat log_dir 376 + (Filename.concat "runs" (Filename.concat run_id "summary.json")) in 377 + if Sys.file_exists path then 378 + try 379 + let content = In_channel.with_open_text path In_channel.input_all in 380 + let json = Yojson.Safe.from_string content in 381 + let open Yojson.Safe.Util in 382 + let failures = 383 + json |> member "failures" |> to_list 384 + |> List.map (fun f -> 385 + (f |> member "package" |> to_string, 386 + f |> member "error" |> to_string)) 387 + in 388 + Some { 389 + Day10_lib.Run_log.run_id = json |> member "run_id" |> to_string; 390 + start_time = json |> member "start_time" |> to_string; 391 + end_time = json |> member "end_time" |> to_string; 392 + duration_seconds = json |> member "duration_seconds" |> to_float; 393 + targets_requested = json |> member "targets_requested" |> to_int; 394 + solutions_found = json |> member "solutions_found" |> to_int; 395 + build_success = json |> member "build_success" |> to_int; 396 + build_failed = json |> member "build_failed" |> to_int; 397 + doc_success = json |> member "doc_success" |> to_int; 398 + doc_failed = json |> member "doc_failed" |> to_int; 399 + doc_skipped = json |> member "doc_skipped" |> to_int; 400 + failures; 401 + } 402 + with _ -> None 403 + else 404 + None 405 + 406 + let read_log_file path = 407 + if Sys.file_exists path then 408 + try Some (In_channel.with_open_text path In_channel.input_all) 409 + with _ -> None 410 + else 411 + None 412 + 413 + let read_build_log ~log_dir ~run_id ~package = 414 + let path = Filename.concat log_dir 415 + (Filename.concat "runs" 416 + (Filename.concat run_id 417 + (Filename.concat "build" (package ^ ".log")))) in 418 + read_log_file path 419 + 420 + let read_doc_log ~log_dir ~run_id ~package = 421 + let path = Filename.concat log_dir 422 + (Filename.concat "runs" 423 + (Filename.concat run_id 424 + (Filename.concat "docs" (package ^ ".log")))) in 425 + read_log_file path 426 + 427 + let list_logs_in_dir dir = 428 + if Sys.file_exists dir && Sys.is_directory dir then 429 + Sys.readdir dir 430 + |> Array.to_list 431 + |> List.filter (fun name -> Filename.check_suffix name ".log") 432 + |> List.map (fun name -> Filename.chop_suffix name ".log") 433 + |> List.sort String.compare 434 + else 435 + [] 436 + 437 + let list_build_logs ~log_dir ~run_id = 438 + let dir = Filename.concat log_dir 439 + (Filename.concat "runs" (Filename.concat run_id "build")) in 440 + list_logs_in_dir dir 441 + 442 + let list_doc_logs ~log_dir ~run_id = 443 + let dir = Filename.concat log_dir 444 + (Filename.concat "runs" (Filename.concat run_id "docs")) in 445 + list_logs_in_dir dir 446 + ``` 447 + 448 + **Step 7: Run tests to verify they pass** 449 + 450 + Run: `dune exec tests/unit/test_run_data.exe` 451 + Expected: All 5 tests pass 452 + 453 + **Step 8: Commit** 454 + 455 + ```bash 456 + git add web/data/ tests/unit/test_run_data.ml tests/unit/dune 457 + git commit -m "feat(web): add run data layer with tests" 458 + ``` 459 + 460 + --- 461 + 462 + ## Task 4: Data Layer - Package Data 463 + 464 + **Files:** 465 + - Create: `/workspace/web/data/package_data.ml` 466 + - Create: `/workspace/web/data/package_data.mli` 467 + - Modify: `/workspace/web/data/dune` 468 + - Create: `/workspace/tests/unit/test_package_data.ml` 469 + - Modify: `/workspace/tests/unit/dune` 470 + 471 + **Step 1: Write the failing test** 472 + 473 + Create `/workspace/tests/unit/test_package_data.ml`: 474 + 475 + ```ocaml 476 + (** Unit tests for package data reading *) 477 + 478 + let test_dir = ref "" 479 + 480 + let setup () = 481 + let dir = Filename.temp_dir "test-pkg-data-" "" in 482 + test_dir := dir; 483 + dir 484 + 485 + let teardown () = 486 + if !test_dir <> "" then begin 487 + ignore (Sys.command (Printf.sprintf "rm -rf %s" !test_dir)); 488 + test_dir := "" 489 + end 490 + 491 + let mkdir_p path = 492 + let rec create dir = 493 + if not (Sys.file_exists dir) then begin 494 + create (Filename.dirname dir); 495 + try Unix.mkdir dir 0o755 with Unix.Unix_error (Unix.EEXIST, _, _) -> () 496 + end 497 + in 498 + create path 499 + 500 + let write_file path content = 501 + let dir = Filename.dirname path in 502 + mkdir_p dir; 503 + Out_channel.with_open_text path (fun oc -> Out_channel.output_string oc content) 504 + 505 + (** Test: list_packages returns packages from html/p directory *) 506 + let test_list_packages () = 507 + let base_dir = setup () in 508 + let html_dir = Filename.concat base_dir "html" in 509 + mkdir_p (Filename.concat html_dir "p/base/0.16.0"); 510 + mkdir_p (Filename.concat html_dir "p/base/0.15.0"); 511 + mkdir_p (Filename.concat html_dir "p/core/0.16.0"); 512 + 513 + let packages = Day10_web_data.Package_data.list_packages ~html_dir in 514 + assert (List.length packages = 3); 515 + assert (List.mem ("base", "0.16.0") packages); 516 + assert (List.mem ("base", "0.15.0") packages); 517 + assert (List.mem ("core", "0.16.0") packages); 518 + 519 + teardown (); 520 + Printf.printf "PASS: test_list_packages\n%!" 521 + 522 + (** Test: list_package_versions returns versions for a package *) 523 + let test_list_package_versions () = 524 + let base_dir = setup () in 525 + let html_dir = Filename.concat base_dir "html" in 526 + mkdir_p (Filename.concat html_dir "p/base/0.16.0"); 527 + mkdir_p (Filename.concat html_dir "p/base/0.15.0"); 528 + mkdir_p (Filename.concat html_dir "p/base/0.14.0"); 529 + 530 + let versions = Day10_web_data.Package_data.list_package_versions ~html_dir ~name:"base" in 531 + assert (List.length versions = 3); 532 + (* Should be sorted descending *) 533 + assert (List.hd versions = "0.16.0"); 534 + 535 + teardown (); 536 + Printf.printf "PASS: test_list_package_versions\n%!" 537 + 538 + (** Test: package_has_docs checks if docs exist *) 539 + let test_package_has_docs () = 540 + let base_dir = setup () in 541 + let html_dir = Filename.concat base_dir "html" in 542 + mkdir_p (Filename.concat html_dir "p/base/0.16.0"); 543 + 544 + assert (Day10_web_data.Package_data.package_has_docs ~html_dir ~name:"base" ~version:"0.16.0"); 545 + assert (not (Day10_web_data.Package_data.package_has_docs ~html_dir ~name:"base" ~version:"0.15.0")); 546 + 547 + teardown (); 548 + Printf.printf "PASS: test_package_has_docs\n%!" 549 + 550 + (** Test: list_package_names returns unique package names *) 551 + let test_list_package_names () = 552 + let base_dir = setup () in 553 + let html_dir = Filename.concat base_dir "html" in 554 + mkdir_p (Filename.concat html_dir "p/base/0.16.0"); 555 + mkdir_p (Filename.concat html_dir "p/base/0.15.0"); 556 + mkdir_p (Filename.concat html_dir "p/core/0.16.0"); 557 + mkdir_p (Filename.concat html_dir "p/async/0.16.0"); 558 + 559 + let names = Day10_web_data.Package_data.list_package_names ~html_dir in 560 + assert (List.length names = 3); 561 + assert (List.mem "base" names); 562 + assert (List.mem "core" names); 563 + assert (List.mem "async" names); 564 + 565 + teardown (); 566 + Printf.printf "PASS: test_list_package_names\n%!" 567 + 568 + let () = 569 + Printf.printf "Running Package_data tests...\n%!"; 570 + test_list_packages (); 571 + test_list_package_versions (); 572 + test_package_has_docs (); 573 + test_list_package_names (); 574 + Printf.printf "\nAll Package_data tests passed!\n%!" 575 + ``` 576 + 577 + **Step 2: Add test to tests/unit/dune** 578 + 579 + Add to `/workspace/tests/unit/dune`: 580 + 581 + ```dune 582 + (executable 583 + (name test_package_data) 584 + (libraries day10_web_data unix)) 585 + ``` 586 + 587 + **Step 3: Run test to verify it fails** 588 + 589 + Run: `dune build tests/unit/test_package_data.exe 2>&1` 590 + Expected: FAIL with "Unbound module Package_data" 591 + 592 + **Step 4: Update web/data/dune to include new module** 593 + 594 + Update `/workspace/web/data/dune`: 595 + 596 + ```dune 597 + (library 598 + (name day10_web_data) 599 + (libraries unix yojson day10_lib) 600 + (modules run_data package_data)) 601 + ``` 602 + 603 + **Step 5: Create package_data.mli interface** 604 + 605 + Create `/workspace/web/data/package_data.mli`: 606 + 607 + ```ocaml 608 + (** Read package data from day10's html directory *) 609 + 610 + (** List all (name, version) pairs with docs *) 611 + val list_packages : html_dir:string -> (string * string) list 612 + 613 + (** List unique package names *) 614 + val list_package_names : html_dir:string -> string list 615 + 616 + (** List all versions for a package name, sorted descending *) 617 + val list_package_versions : html_dir:string -> name:string -> string list 618 + 619 + (** Check if docs exist for a package version *) 620 + val package_has_docs : html_dir:string -> name:string -> version:string -> bool 621 + 622 + (** Get the docs URL path for a package *) 623 + val docs_path : name:string -> version:string -> string 624 + ``` 625 + 626 + **Step 6: Implement package_data.ml** 627 + 628 + Create `/workspace/web/data/package_data.ml`: 629 + 630 + ```ocaml 631 + (** Read package data from day10's html directory *) 632 + 633 + let list_package_names ~html_dir = 634 + let p_dir = Filename.concat html_dir "p" in 635 + if Sys.file_exists p_dir && Sys.is_directory p_dir then 636 + Sys.readdir p_dir 637 + |> Array.to_list 638 + |> List.filter (fun name -> 639 + let path = Filename.concat p_dir name in 640 + Sys.is_directory path) 641 + |> List.sort String.compare 642 + else 643 + [] 644 + 645 + let compare_versions v1 v2 = 646 + (* Simple version comparison - compare segments numerically where possible *) 647 + let parse v = 648 + String.split_on_char '.' v 649 + |> List.map (fun s -> try `Int (int_of_string s) with _ -> `Str s) 650 + in 651 + let rec cmp l1 l2 = match l1, l2 with 652 + | [], [] -> 0 653 + | [], _ -> -1 654 + | _, [] -> 1 655 + | `Int a :: t1, `Int b :: t2 -> 656 + let c = Int.compare a b in if c <> 0 then c else cmp t1 t2 657 + | `Str a :: t1, `Str b :: t2 -> 658 + let c = String.compare a b in if c <> 0 then c else cmp t1 t2 659 + | `Int _ :: _, `Str _ :: _ -> -1 660 + | `Str _ :: _, `Int _ :: _ -> 1 661 + in 662 + cmp (parse v2) (parse v1) (* Descending order *) 663 + 664 + let list_package_versions ~html_dir ~name = 665 + let pkg_dir = Filename.concat (Filename.concat html_dir "p") name in 666 + if Sys.file_exists pkg_dir && Sys.is_directory pkg_dir then 667 + Sys.readdir pkg_dir 668 + |> Array.to_list 669 + |> List.filter (fun version -> 670 + let path = Filename.concat pkg_dir version in 671 + Sys.is_directory path) 672 + |> List.sort compare_versions 673 + else 674 + [] 675 + 676 + let list_packages ~html_dir = 677 + list_package_names ~html_dir 678 + |> List.concat_map (fun name -> 679 + list_package_versions ~html_dir ~name 680 + |> List.map (fun version -> (name, version))) 681 + 682 + let package_has_docs ~html_dir ~name ~version = 683 + let path = Filename.concat html_dir 684 + (Filename.concat "p" (Filename.concat name version)) in 685 + Sys.file_exists path && Sys.is_directory path 686 + 687 + let docs_path ~name ~version = 688 + Printf.sprintf "/docs/p/%s/%s/" name version 689 + ``` 690 + 691 + **Step 7: Run tests to verify they pass** 692 + 693 + Run: `dune exec tests/unit/test_package_data.exe` 694 + Expected: All 4 tests pass 695 + 696 + **Step 8: Commit** 697 + 698 + ```bash 699 + git add web/data/ tests/unit/test_package_data.ml tests/unit/dune 700 + git commit -m "feat(web): add package data layer with tests" 701 + ``` 702 + 703 + --- 704 + 705 + ## Task 5: HTML Layout Module 706 + 707 + **Files:** 708 + - Create: `/workspace/web/views/dune` 709 + - Create: `/workspace/web/views/layout.ml` 710 + - Modify: `/workspace/web/dune` 711 + 712 + **Step 1: Create web/views/dune** 713 + 714 + Create `/workspace/web/views/dune`: 715 + 716 + ```dune 717 + (library 718 + (name day10_web_views) 719 + (libraries dream day10_web_data)) 720 + ``` 721 + 722 + **Step 2: Create layout.ml with base HTML structure** 723 + 724 + Create `/workspace/web/views/layout.ml`: 725 + 726 + ```ocaml 727 + (** Common HTML layout components *) 728 + 729 + let head ~title = 730 + Printf.sprintf {|<!DOCTYPE html> 731 + <html lang="en"> 732 + <head> 733 + <meta charset="UTF-8"> 734 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 735 + <title>%s - day10</title> 736 + <style> 737 + :root { 738 + --bg: #1a1a2e; 739 + --bg-card: #16213e; 740 + --text: #eee; 741 + --text-muted: #888; 742 + --accent: #0f3460; 743 + --success: #2ecc71; 744 + --error: #e74c3c; 745 + --warning: #f39c12; 746 + --border: #333; 747 + } 748 + * { box-sizing: border-box; margin: 0; padding: 0; } 749 + body { 750 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 751 + background: var(--bg); 752 + color: var(--text); 753 + line-height: 1.6; 754 + } 755 + .container { max-width: 1200px; margin: 0 auto; padding: 1rem; } 756 + nav { 757 + background: var(--bg-card); 758 + border-bottom: 1px solid var(--border); 759 + padding: 1rem; 760 + } 761 + nav a { color: var(--text); text-decoration: none; margin-right: 1.5rem; } 762 + nav a:hover { text-decoration: underline; } 763 + nav .brand { font-weight: bold; font-size: 1.2rem; } 764 + h1, h2, h3 { margin-bottom: 1rem; } 765 + .card { 766 + background: var(--bg-card); 767 + border-radius: 8px; 768 + padding: 1.5rem; 769 + margin-bottom: 1rem; 770 + } 771 + .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; } 772 + .stat { text-align: center; } 773 + .stat-value { font-size: 2rem; font-weight: bold; } 774 + .stat-label { color: var(--text-muted); font-size: 0.9rem; } 775 + .badge { 776 + display: inline-block; 777 + padding: 0.25rem 0.5rem; 778 + border-radius: 4px; 779 + font-size: 0.85rem; 780 + font-weight: 500; 781 + } 782 + .badge-success { background: var(--success); color: #fff; } 783 + .badge-error { background: var(--error); color: #fff; } 784 + .badge-warning { background: var(--warning); color: #000; } 785 + table { width: 100%%; border-collapse: collapse; } 786 + th, td { padding: 0.75rem; text-align: left; border-bottom: 1px solid var(--border); } 787 + th { color: var(--text-muted); font-weight: 500; } 788 + a { color: #5dade2; } 789 + pre { 790 + background: #0d1117; 791 + padding: 1rem; 792 + border-radius: 4px; 793 + overflow-x: auto; 794 + font-size: 0.9rem; 795 + } 796 + input[type="search"] { 797 + width: 100%%; 798 + padding: 0.75rem; 799 + background: var(--accent); 800 + border: 1px solid var(--border); 801 + border-radius: 4px; 802 + color: var(--text); 803 + margin-bottom: 1rem; 804 + } 805 + input[type="search"]:focus { outline: 2px solid #5dade2; } 806 + </style> 807 + </head> 808 + <body> 809 + |} title 810 + 811 + let nav () = {| 812 + <nav> 813 + <div class="container"> 814 + <a href="/" class="brand">day10</a> 815 + <a href="/packages">Packages</a> 816 + <a href="/runs">Runs</a> 817 + </div> 818 + </nav> 819 + |} 820 + 821 + let footer () = {| 822 + </body> 823 + </html> 824 + |} 825 + 826 + let page ~title ~content = 827 + head ~title ^ nav () ^ 828 + {|<main class="container">|} ^ content ^ {|</main>|} ^ 829 + footer () 830 + 831 + let badge status = 832 + match status with 833 + | `Success -> {|<span class="badge badge-success">success</span>|} 834 + | `Failed -> {|<span class="badge badge-error">failed</span>|} 835 + | `Skipped -> {|<span class="badge badge-warning">skipped</span>|} 836 + 837 + let stat ~value ~label = 838 + Printf.sprintf {|<div class="stat"><div class="stat-value">%s</div><div class="stat-label">%s</div></div>|} value label 839 + ``` 840 + 841 + **Step 3: Update web/dune to include views** 842 + 843 + Update `/workspace/web/dune`: 844 + 845 + ```dune 846 + (executable 847 + (name main) 848 + (public_name day10-web) 849 + (package day10-web) 850 + (libraries dream day10_lib day10_web_data day10_web_views cmdliner unix yojson)) 851 + ``` 852 + 853 + **Step 4: Build to verify it compiles** 854 + 855 + Run: `dune build` 856 + Expected: Builds successfully 857 + 858 + **Step 5: Commit** 859 + 860 + ```bash 861 + git add web/views/ web/dune 862 + git commit -m "feat(web): add HTML layout module" 863 + ``` 864 + 865 + --- 866 + 867 + ## Task 6: Dashboard Page 868 + 869 + **Files:** 870 + - Create: `/workspace/web/views/dashboard.ml` 871 + - Modify: `/workspace/web/views/dune` 872 + - Modify: `/workspace/web/main.ml` 873 + 874 + **Step 1: Create dashboard.ml** 875 + 876 + Create `/workspace/web/views/dashboard.ml`: 877 + 878 + ```ocaml 879 + (** Dashboard page view *) 880 + 881 + let render ~log_dir ~html_dir = 882 + let latest_run_id = Day10_web_data.Run_data.get_latest_run_id ~log_dir in 883 + let latest_summary = match latest_run_id with 884 + | Some run_id -> Day10_web_data.Run_data.read_summary ~log_dir ~run_id 885 + | None -> None 886 + in 887 + let packages = Day10_web_data.Package_data.list_packages ~html_dir in 888 + let total_packages = List.length packages in 889 + 890 + let stats_content = match latest_summary with 891 + | Some s -> 892 + let build_rate = if s.targets_requested > 0 893 + then float_of_int s.build_success /. float_of_int s.targets_requested *. 100.0 894 + else 0.0 in 895 + let doc_rate = if s.build_success > 0 896 + then float_of_int s.doc_success /. float_of_int s.build_success *. 100.0 897 + else 0.0 in 898 + Printf.sprintf {| 899 + <div class="grid"> 900 + %s 901 + %s 902 + %s 903 + %s 904 + </div> 905 + |} 906 + (Layout.stat ~value:(string_of_int total_packages) ~label:"Packages with Docs") 907 + (Layout.stat ~value:(Printf.sprintf "%.0f%%" build_rate) ~label:"Build Success Rate") 908 + (Layout.stat ~value:(Printf.sprintf "%.0f%%" doc_rate) ~label:"Doc Success Rate") 909 + (Layout.stat ~value:(Printf.sprintf "%.0fs" s.duration_seconds) ~label:"Last Run Duration") 910 + | None -> 911 + Printf.sprintf {| 912 + <div class="grid"> 913 + %s 914 + %s 915 + </div> 916 + <p style="color: var(--text-muted); margin-top: 1rem;">No runs recorded yet.</p> 917 + |} 918 + (Layout.stat ~value:(string_of_int total_packages) ~label:"Packages with Docs") 919 + (Layout.stat ~value:"—" ~label:"No Runs Yet") 920 + in 921 + 922 + let latest_run_content = match latest_summary with 923 + | Some s -> 924 + Printf.sprintf {| 925 + <h2>Latest Run</h2> 926 + <div class="card"> 927 + <p><strong>Run ID:</strong> <a href="/runs/%s">%s</a></p> 928 + <p><strong>Started:</strong> %s</p> 929 + <p><strong>Duration:</strong> %.0f seconds</p> 930 + <table> 931 + <tr><th>Metric</th><th>Count</th></tr> 932 + <tr><td>Targets Requested</td><td>%d</td></tr> 933 + <tr><td>Solutions Found</td><td>%d</td></tr> 934 + <tr><td>Build Success</td><td>%d %s</td></tr> 935 + <tr><td>Build Failed</td><td>%d %s</td></tr> 936 + <tr><td>Doc Success</td><td>%d %s</td></tr> 937 + <tr><td>Doc Failed</td><td>%d %s</td></tr> 938 + <tr><td>Doc Skipped</td><td>%d %s</td></tr> 939 + </table> 940 + %s 941 + </div> 942 + |} 943 + s.run_id s.run_id 944 + s.start_time 945 + s.duration_seconds 946 + s.targets_requested 947 + s.solutions_found 948 + s.build_success (if s.build_success > 0 then Layout.badge `Success else "") 949 + s.build_failed (if s.build_failed > 0 then Layout.badge `Failed else "") 950 + s.doc_success (if s.doc_success > 0 then Layout.badge `Success else "") 951 + s.doc_failed (if s.doc_failed > 0 then Layout.badge `Failed else "") 952 + s.doc_skipped (if s.doc_skipped > 0 then Layout.badge `Skipped else "") 953 + (if List.length s.failures > 0 then 954 + Printf.sprintf {| 955 + <h3 style="margin-top: 1rem;">Failures (%d)</h3> 956 + <table> 957 + <tr><th>Package</th><th>Error</th></tr> 958 + %s 959 + </table> 960 + |} (List.length s.failures) 961 + (s.failures |> List.map (fun (pkg, err) -> 962 + Printf.sprintf "<tr><td><a href=\"/packages/%s\">%s</a></td><td>%s</td></tr>" 963 + (String.concat "/" (String.split_on_char '.' pkg)) pkg err 964 + ) |> String.concat "\n") 965 + else "") 966 + | None -> "" 967 + in 968 + 969 + let content = Printf.sprintf {| 970 + <h1>Dashboard</h1> 971 + <div class="card"> 972 + %s 973 + </div> 974 + %s 975 + |} stats_content latest_run_content 976 + in 977 + Layout.page ~title:"Dashboard" ~content 978 + ``` 979 + 980 + **Step 2: Update web/views/dune** 981 + 982 + Update `/workspace/web/views/dune`: 983 + 984 + ```dune 985 + (library 986 + (name day10_web_views) 987 + (libraries dream day10_web_data) 988 + (modules layout dashboard)) 989 + ``` 990 + 991 + **Step 3: Update main.ml to use dashboard** 992 + 993 + Update the router in `/workspace/web/main.ml`: 994 + 995 + ```ocaml 996 + open Cmdliner 997 + 998 + let cache_dir = 999 + let doc = "Path to day10's cache directory" in 1000 + Arg.(required & opt (some dir) None & info ["cache-dir"] ~docv:"DIR" ~doc) 1001 + 1002 + let html_dir = 1003 + let doc = "Path to generated documentation directory" in 1004 + Arg.(required & opt (some dir) None & info ["html-dir"] ~docv:"DIR" ~doc) 1005 + 1006 + let port = 1007 + let doc = "HTTP port to listen on" in 1008 + Arg.(value & opt int 8080 & info ["port"; "p"] ~docv:"PORT" ~doc) 1009 + 1010 + let host = 1011 + let doc = "Host address to bind to" in 1012 + Arg.(value & opt string "127.0.0.1" & info ["host"] ~docv:"HOST" ~doc) 1013 + 1014 + let platform = 1015 + let doc = "Platform subdirectory in cache" in 1016 + Arg.(value & opt string "debian-12-x86_64" & info ["platform"] ~docv:"PLATFORM" ~doc) 1017 + 1018 + type config = { 1019 + cache_dir : string; 1020 + html_dir : string; 1021 + port : int; 1022 + host : string; 1023 + platform : string; 1024 + } 1025 + 1026 + let log_dir config = Filename.concat config.cache_dir "logs" 1027 + 1028 + let run_server config = 1029 + Dream.run ~port:config.port ~interface:config.host 1030 + @@ Dream.logger 1031 + @@ Dream.router [ 1032 + Dream.get "/" (fun _ -> 1033 + let html = Day10_web_views.Dashboard.render 1034 + ~log_dir:(log_dir config) 1035 + ~html_dir:config.html_dir in 1036 + Dream.html html); 1037 + ] 1038 + 1039 + let main cache_dir html_dir port host platform = 1040 + let config = { cache_dir; html_dir; port; host; platform } in 1041 + run_server config 1042 + 1043 + let cmd = 1044 + let doc = "Web dashboard for day10 documentation status" in 1045 + let info = Cmd.info "day10-web" ~version:"0.1.0" ~doc in 1046 + Cmd.v info Term.(const main $ cache_dir $ html_dir $ port $ host $ platform) 1047 + 1048 + let () = exit (Cmd.eval cmd) 1049 + ``` 1050 + 1051 + **Step 4: Build and verify** 1052 + 1053 + Run: `dune build` 1054 + Expected: Builds successfully 1055 + 1056 + **Step 5: Commit** 1057 + 1058 + ```bash 1059 + git add web/views/ web/main.ml 1060 + git commit -m "feat(web): add dashboard page" 1061 + ``` 1062 + 1063 + --- 1064 + 1065 + ## Task 7: Runs Pages 1066 + 1067 + **Files:** 1068 + - Create: `/workspace/web/views/runs.ml` 1069 + - Modify: `/workspace/web/views/dune` 1070 + - Modify: `/workspace/web/main.ml` 1071 + 1072 + **Step 1: Create runs.ml** 1073 + 1074 + Create `/workspace/web/views/runs.ml`: 1075 + 1076 + ```ocaml 1077 + (** Run history and detail pages *) 1078 + 1079 + let list_page ~log_dir = 1080 + let runs = Day10_web_data.Run_data.list_runs ~log_dir in 1081 + let rows = runs |> List.map (fun run_id -> 1082 + let summary = Day10_web_data.Run_data.read_summary ~log_dir ~run_id in 1083 + match summary with 1084 + | Some s -> 1085 + Printf.sprintf {| 1086 + <tr> 1087 + <td><a href="/runs/%s">%s</a></td> 1088 + <td>%s</td> 1089 + <td>%.0fs</td> 1090 + <td>%d %s</td> 1091 + <td>%d %s</td> 1092 + <td>%d %s</td> 1093 + </tr> 1094 + |} run_id run_id 1095 + s.start_time 1096 + s.duration_seconds 1097 + s.build_success (if s.build_success > 0 then Layout.badge `Success else "") 1098 + s.build_failed (if s.build_failed > 0 then Layout.badge `Failed else "") 1099 + s.doc_success (if s.doc_success > 0 then Layout.badge `Success else "") 1100 + | None -> 1101 + Printf.sprintf {|<tr><td><a href="/runs/%s">%s</a></td><td colspan="5">Summary not available</td></tr>|} run_id run_id 1102 + ) |> String.concat "\n" in 1103 + 1104 + let content = if List.length runs = 0 then 1105 + {|<h1>Run History</h1><p class="card">No runs recorded yet.</p>|} 1106 + else 1107 + Printf.sprintf {| 1108 + <h1>Run History</h1> 1109 + <div class="card"> 1110 + <table> 1111 + <tr> 1112 + <th>Run ID</th> 1113 + <th>Started</th> 1114 + <th>Duration</th> 1115 + <th>Builds</th> 1116 + <th>Failed</th> 1117 + <th>Docs</th> 1118 + </tr> 1119 + %s 1120 + </table> 1121 + </div> 1122 + |} rows 1123 + in 1124 + Layout.page ~title:"Run History" ~content 1125 + 1126 + let detail_page ~log_dir ~run_id = 1127 + match Day10_web_data.Run_data.read_summary ~log_dir ~run_id with 1128 + | None -> 1129 + Layout.page ~title:"Run Not Found" ~content:{| 1130 + <h1>Run Not Found</h1> 1131 + <p class="card">The requested run could not be found.</p> 1132 + <p><a href="/runs">← Back to run history</a></p> 1133 + |} 1134 + | Some s -> 1135 + let failures_table = if List.length s.failures > 0 then 1136 + Printf.sprintf {| 1137 + <h2>Failures (%d)</h2> 1138 + <div class="card"> 1139 + <table> 1140 + <tr><th>Package</th><th>Error</th><th>Logs</th></tr> 1141 + %s 1142 + </table> 1143 + </div> 1144 + |} (List.length s.failures) 1145 + (s.failures |> List.map (fun (pkg, err) -> 1146 + Printf.sprintf {|<tr> 1147 + <td>%s</td> 1148 + <td>%s</td> 1149 + <td> 1150 + <a href="/runs/%s/build/%s">build</a> | 1151 + <a href="/runs/%s/docs/%s">docs</a> 1152 + </td> 1153 + </tr>|} pkg err run_id pkg run_id pkg 1154 + ) |> String.concat "\n") 1155 + else "" 1156 + in 1157 + 1158 + let build_logs = Day10_web_data.Run_data.list_build_logs ~log_dir ~run_id in 1159 + let logs_section = if List.length build_logs > 0 then 1160 + Printf.sprintf {| 1161 + <h2>Build Logs (%d)</h2> 1162 + <div class="card"> 1163 + <ul>%s</ul> 1164 + </div> 1165 + |} (List.length build_logs) 1166 + (build_logs |> List.map (fun pkg -> 1167 + Printf.sprintf {|<li><a href="/runs/%s/build/%s">%s</a></li>|} run_id pkg pkg 1168 + ) |> String.concat "\n") 1169 + else "" 1170 + in 1171 + 1172 + let content = Printf.sprintf {| 1173 + <h1>Run %s</h1> 1174 + <p><a href="/runs">← Back to run history</a></p> 1175 + 1176 + <div class="card"> 1177 + <h2>Summary</h2> 1178 + <table> 1179 + <tr><td>Started</td><td>%s</td></tr> 1180 + <tr><td>Ended</td><td>%s</td></tr> 1181 + <tr><td>Duration</td><td>%.0f seconds</td></tr> 1182 + </table> 1183 + </div> 1184 + 1185 + <div class="card"> 1186 + <h2>Results</h2> 1187 + <div class="grid"> 1188 + %s %s %s %s %s %s %s 1189 + </div> 1190 + </div> 1191 + 1192 + %s 1193 + %s 1194 + |} 1195 + run_id 1196 + s.start_time s.end_time s.duration_seconds 1197 + (Layout.stat ~value:(string_of_int s.targets_requested) ~label:"Targets") 1198 + (Layout.stat ~value:(string_of_int s.solutions_found) ~label:"Solved") 1199 + (Layout.stat ~value:(string_of_int s.build_success) ~label:"Build OK") 1200 + (Layout.stat ~value:(string_of_int s.build_failed) ~label:"Build Failed") 1201 + (Layout.stat ~value:(string_of_int s.doc_success) ~label:"Docs OK") 1202 + (Layout.stat ~value:(string_of_int s.doc_failed) ~label:"Docs Failed") 1203 + (Layout.stat ~value:(string_of_int s.doc_skipped) ~label:"Docs Skipped") 1204 + failures_table 1205 + logs_section 1206 + in 1207 + Layout.page ~title:(Printf.sprintf "Run %s" run_id) ~content 1208 + 1209 + let log_page ~log_dir ~run_id ~log_type ~package = 1210 + let content_opt = match log_type with 1211 + | `Build -> Day10_web_data.Run_data.read_build_log ~log_dir ~run_id ~package 1212 + | `Docs -> Day10_web_data.Run_data.read_doc_log ~log_dir ~run_id ~package 1213 + in 1214 + let type_str = match log_type with `Build -> "Build" | `Docs -> "Doc" in 1215 + match content_opt with 1216 + | None -> 1217 + Layout.page ~title:"Log Not Found" ~content:(Printf.sprintf {| 1218 + <h1>Log Not Found</h1> 1219 + <p class="card">The requested log could not be found. It may have been garbage collected.</p> 1220 + <p><a href="/runs/%s">← Back to run %s</a></p> 1221 + |} run_id run_id) 1222 + | Some content -> 1223 + let escaped = content 1224 + |> String.split_on_char '&' |> String.concat "&amp;" 1225 + |> String.split_on_char '<' |> String.concat "&lt;" 1226 + |> String.split_on_char '>' |> String.concat "&gt;" 1227 + in 1228 + Layout.page ~title:(Printf.sprintf "%s Log: %s" type_str package) ~content:(Printf.sprintf {| 1229 + <h1>%s Log: %s</h1> 1230 + <p><a href="/runs/%s">← Back to run %s</a></p> 1231 + <div class="card"> 1232 + <pre>%s</pre> 1233 + </div> 1234 + |} type_str package run_id run_id escaped) 1235 + ``` 1236 + 1237 + **Step 2: Update web/views/dune** 1238 + 1239 + Update `/workspace/web/views/dune`: 1240 + 1241 + ```dune 1242 + (library 1243 + (name day10_web_views) 1244 + (libraries dream day10_web_data) 1245 + (modules layout dashboard runs)) 1246 + ``` 1247 + 1248 + **Step 3: Update main.ml with run routes** 1249 + 1250 + Add routes to `/workspace/web/main.ml`: 1251 + 1252 + ```ocaml 1253 + open Cmdliner 1254 + 1255 + let cache_dir = 1256 + let doc = "Path to day10's cache directory" in 1257 + Arg.(required & opt (some dir) None & info ["cache-dir"] ~docv:"DIR" ~doc) 1258 + 1259 + let html_dir = 1260 + let doc = "Path to generated documentation directory" in 1261 + Arg.(required & opt (some dir) None & info ["html-dir"] ~docv:"DIR" ~doc) 1262 + 1263 + let port = 1264 + let doc = "HTTP port to listen on" in 1265 + Arg.(value & opt int 8080 & info ["port"; "p"] ~docv:"PORT" ~doc) 1266 + 1267 + let host = 1268 + let doc = "Host address to bind to" in 1269 + Arg.(value & opt string "127.0.0.1" & info ["host"] ~docv:"HOST" ~doc) 1270 + 1271 + let platform = 1272 + let doc = "Platform subdirectory in cache" in 1273 + Arg.(value & opt string "debian-12-x86_64" & info ["platform"] ~docv:"PLATFORM" ~doc) 1274 + 1275 + type config = { 1276 + cache_dir : string; 1277 + html_dir : string; 1278 + port : int; 1279 + host : string; 1280 + platform : string; 1281 + } 1282 + 1283 + let log_dir config = Filename.concat config.cache_dir "logs" 1284 + 1285 + let run_server config = 1286 + Dream.run ~port:config.port ~interface:config.host 1287 + @@ Dream.logger 1288 + @@ Dream.router [ 1289 + Dream.get "/" (fun _ -> 1290 + let html = Day10_web_views.Dashboard.render 1291 + ~log_dir:(log_dir config) 1292 + ~html_dir:config.html_dir in 1293 + Dream.html html); 1294 + 1295 + Dream.get "/runs" (fun _ -> 1296 + let html = Day10_web_views.Runs.list_page ~log_dir:(log_dir config) in 1297 + Dream.html html); 1298 + 1299 + Dream.get "/runs/:run_id" (fun request -> 1300 + let run_id = Dream.param request "run_id" in 1301 + let html = Day10_web_views.Runs.detail_page ~log_dir:(log_dir config) ~run_id in 1302 + Dream.html html); 1303 + 1304 + Dream.get "/runs/:run_id/build/:package" (fun request -> 1305 + let run_id = Dream.param request "run_id" in 1306 + let package = Dream.param request "package" in 1307 + let html = Day10_web_views.Runs.log_page 1308 + ~log_dir:(log_dir config) ~run_id ~log_type:`Build ~package in 1309 + Dream.html html); 1310 + 1311 + Dream.get "/runs/:run_id/docs/:package" (fun request -> 1312 + let run_id = Dream.param request "run_id" in 1313 + let package = Dream.param request "package" in 1314 + let html = Day10_web_views.Runs.log_page 1315 + ~log_dir:(log_dir config) ~run_id ~log_type:`Docs ~package in 1316 + Dream.html html); 1317 + ] 1318 + 1319 + let main cache_dir html_dir port host platform = 1320 + let config = { cache_dir; html_dir; port; host; platform } in 1321 + run_server config 1322 + 1323 + let cmd = 1324 + let doc = "Web dashboard for day10 documentation status" in 1325 + let info = Cmd.info "day10-web" ~version:"0.1.0" ~doc in 1326 + Cmd.v info Term.(const main $ cache_dir $ html_dir $ port $ host $ platform) 1327 + 1328 + let () = exit (Cmd.eval cmd) 1329 + ``` 1330 + 1331 + **Step 4: Build and verify** 1332 + 1333 + Run: `dune build` 1334 + Expected: Builds successfully 1335 + 1336 + **Step 5: Commit** 1337 + 1338 + ```bash 1339 + git add web/views/ web/main.ml 1340 + git commit -m "feat(web): add run history and detail pages" 1341 + ``` 1342 + 1343 + --- 1344 + 1345 + ## Task 8: Packages Pages 1346 + 1347 + **Files:** 1348 + - Create: `/workspace/web/views/packages.ml` 1349 + - Modify: `/workspace/web/views/dune` 1350 + - Modify: `/workspace/web/main.ml` 1351 + 1352 + **Step 1: Create packages.ml** 1353 + 1354 + Create `/workspace/web/views/packages.ml`: 1355 + 1356 + ```ocaml 1357 + (** Package list and detail pages *) 1358 + 1359 + let list_page ~html_dir = 1360 + let packages = Day10_web_data.Package_data.list_packages ~html_dir in 1361 + let rows = packages |> List.map (fun (name, version) -> 1362 + Printf.sprintf {| 1363 + <tr> 1364 + <td><a href="/packages/%s/%s">%s</a></td> 1365 + <td>%s</td> 1366 + <td>%s</td> 1367 + <td><a href="/docs/p/%s/%s/">View Docs</a></td> 1368 + </tr> 1369 + |} name version name version (Layout.badge `Success) name version 1370 + ) |> String.concat "\n" in 1371 + 1372 + let content = Printf.sprintf {| 1373 + <h1>Packages</h1> 1374 + <div class="card"> 1375 + <input type="search" id="pkg-search" placeholder="Search packages..." onkeyup="filterTable()"> 1376 + <table id="pkg-table"> 1377 + <thead> 1378 + <tr> 1379 + <th>Package</th> 1380 + <th>Version</th> 1381 + <th>Docs Status</th> 1382 + <th>Links</th> 1383 + </tr> 1384 + </thead> 1385 + <tbody> 1386 + %s 1387 + </tbody> 1388 + </table> 1389 + </div> 1390 + <script> 1391 + function filterTable() { 1392 + const filter = document.getElementById('pkg-search').value.toLowerCase(); 1393 + const rows = document.querySelectorAll('#pkg-table tbody tr'); 1394 + rows.forEach(row => { 1395 + const text = row.textContent.toLowerCase(); 1396 + row.style.display = text.includes(filter) ? '' : 'none'; 1397 + }); 1398 + } 1399 + </script> 1400 + |} rows 1401 + in 1402 + Layout.page ~title:"Packages" ~content 1403 + 1404 + let detail_page ~html_dir ~name ~version = 1405 + if not (Day10_web_data.Package_data.package_has_docs ~html_dir ~name ~version) then 1406 + Layout.page ~title:"Package Not Found" ~content:(Printf.sprintf {| 1407 + <h1>Package Not Found</h1> 1408 + <p class="card">No documentation found for %s.%s</p> 1409 + <p><a href="/packages">← Back to packages</a></p> 1410 + |} name version) 1411 + else 1412 + let all_versions = Day10_web_data.Package_data.list_package_versions ~html_dir ~name in 1413 + let versions_list = all_versions |> List.map (fun v -> 1414 + if v = version then 1415 + Printf.sprintf "<li><strong>%s</strong> (current)</li>" v 1416 + else 1417 + Printf.sprintf {|<li><a href="/packages/%s/%s">%s</a></li>|} name v v 1418 + ) |> String.concat "\n" in 1419 + 1420 + let content = Printf.sprintf {| 1421 + <h1>%s.%s</h1> 1422 + <p><a href="/packages">← Back to packages</a></p> 1423 + 1424 + <div class="card"> 1425 + <h2>Documentation</h2> 1426 + <p>%s</p> 1427 + <p><a href="/docs/p/%s/%s/">View Documentation →</a></p> 1428 + </div> 1429 + 1430 + <div class="card"> 1431 + <h2>Other Versions</h2> 1432 + <ul>%s</ul> 1433 + </div> 1434 + |} name version (Layout.badge `Success) name version versions_list 1435 + in 1436 + Layout.page ~title:(Printf.sprintf "%s.%s" name version) ~content 1437 + ``` 1438 + 1439 + **Step 2: Update web/views/dune** 1440 + 1441 + Update `/workspace/web/views/dune`: 1442 + 1443 + ```dune 1444 + (library 1445 + (name day10_web_views) 1446 + (libraries dream day10_web_data) 1447 + (modules layout dashboard runs packages)) 1448 + ``` 1449 + 1450 + **Step 3: Update main.ml with package routes** 1451 + 1452 + Add routes to `/workspace/web/main.ml` (full file): 1453 + 1454 + ```ocaml 1455 + open Cmdliner 1456 + 1457 + let cache_dir = 1458 + let doc = "Path to day10's cache directory" in 1459 + Arg.(required & opt (some dir) None & info ["cache-dir"] ~docv:"DIR" ~doc) 1460 + 1461 + let html_dir = 1462 + let doc = "Path to generated documentation directory" in 1463 + Arg.(required & opt (some dir) None & info ["html-dir"] ~docv:"DIR" ~doc) 1464 + 1465 + let port = 1466 + let doc = "HTTP port to listen on" in 1467 + Arg.(value & opt int 8080 & info ["port"; "p"] ~docv:"PORT" ~doc) 1468 + 1469 + let host = 1470 + let doc = "Host address to bind to" in 1471 + Arg.(value & opt string "127.0.0.1" & info ["host"] ~docv:"HOST" ~doc) 1472 + 1473 + let platform = 1474 + let doc = "Platform subdirectory in cache" in 1475 + Arg.(value & opt string "debian-12-x86_64" & info ["platform"] ~docv:"PLATFORM" ~doc) 1476 + 1477 + type config = { 1478 + cache_dir : string; 1479 + html_dir : string; 1480 + port : int; 1481 + host : string; 1482 + platform : string; 1483 + } 1484 + 1485 + let log_dir config = Filename.concat config.cache_dir "logs" 1486 + 1487 + let run_server config = 1488 + Dream.run ~port:config.port ~interface:config.host 1489 + @@ Dream.logger 1490 + @@ Dream.router [ 1491 + Dream.get "/" (fun _ -> 1492 + let html = Day10_web_views.Dashboard.render 1493 + ~log_dir:(log_dir config) 1494 + ~html_dir:config.html_dir in 1495 + Dream.html html); 1496 + 1497 + Dream.get "/packages" (fun _ -> 1498 + let html = Day10_web_views.Packages.list_page ~html_dir:config.html_dir in 1499 + Dream.html html); 1500 + 1501 + Dream.get "/packages/:name/:version" (fun request -> 1502 + let name = Dream.param request "name" in 1503 + let version = Dream.param request "version" in 1504 + let html = Day10_web_views.Packages.detail_page 1505 + ~html_dir:config.html_dir ~name ~version in 1506 + Dream.html html); 1507 + 1508 + Dream.get "/runs" (fun _ -> 1509 + let html = Day10_web_views.Runs.list_page ~log_dir:(log_dir config) in 1510 + Dream.html html); 1511 + 1512 + Dream.get "/runs/:run_id" (fun request -> 1513 + let run_id = Dream.param request "run_id" in 1514 + let html = Day10_web_views.Runs.detail_page ~log_dir:(log_dir config) ~run_id in 1515 + Dream.html html); 1516 + 1517 + Dream.get "/runs/:run_id/build/:package" (fun request -> 1518 + let run_id = Dream.param request "run_id" in 1519 + let package = Dream.param request "package" in 1520 + let html = Day10_web_views.Runs.log_page 1521 + ~log_dir:(log_dir config) ~run_id ~log_type:`Build ~package in 1522 + Dream.html html); 1523 + 1524 + Dream.get "/runs/:run_id/docs/:package" (fun request -> 1525 + let run_id = Dream.param request "run_id" in 1526 + let package = Dream.param request "package" in 1527 + let html = Day10_web_views.Runs.log_page 1528 + ~log_dir:(log_dir config) ~run_id ~log_type:`Docs ~package in 1529 + Dream.html html); 1530 + ] 1531 + 1532 + let main cache_dir html_dir port host platform = 1533 + let config = { cache_dir; html_dir; port; host; platform } in 1534 + run_server config 1535 + 1536 + let cmd = 1537 + let doc = "Web dashboard for day10 documentation status" in 1538 + let info = Cmd.info "day10-web" ~version:"0.1.0" ~doc in 1539 + Cmd.v info Term.(const main $ cache_dir $ html_dir $ port $ host $ platform) 1540 + 1541 + let () = exit (Cmd.eval cmd) 1542 + ``` 1543 + 1544 + **Step 4: Build and verify** 1545 + 1546 + Run: `dune build` 1547 + Expected: Builds successfully 1548 + 1549 + **Step 5: Commit** 1550 + 1551 + ```bash 1552 + git add web/views/ web/main.ml 1553 + git commit -m "feat(web): add packages list and detail pages" 1554 + ``` 1555 + 1556 + --- 1557 + 1558 + ## Task 9: Update Admin Guide 1559 + 1560 + **Files:** 1561 + - Modify: `/workspace/docs/ADMIN_GUIDE.md` 1562 + 1563 + **Step 1: Add day10-web section to admin guide** 1564 + 1565 + Add a new section after "Serving Documentation" in `/workspace/docs/ADMIN_GUIDE.md`: 1566 + 1567 + ```markdown 1568 + ### Status Dashboard (day10-web) 1569 + 1570 + day10-web provides a web interface for monitoring package build status: 1571 + 1572 + ```bash 1573 + # Install day10-web 1574 + opam install day10-web 1575 + 1576 + # Run the dashboard 1577 + day10-web --cache-dir /data/cache --html-dir /data/html --port 8080 1578 + ``` 1579 + 1580 + #### Systemd Service for day10-web 1581 + 1582 + Create `/etc/systemd/system/day10-web.service`: 1583 + 1584 + ```ini 1585 + [Unit] 1586 + Description=day10 status dashboard 1587 + After=network.target 1588 + 1589 + [Service] 1590 + Type=simple 1591 + User=www-data 1592 + ExecStart=/usr/local/bin/day10-web \ 1593 + --cache-dir /data/cache \ 1594 + --html-dir /data/html \ 1595 + --host 0.0.0.0 \ 1596 + --port 8080 1597 + Restart=always 1598 + 1599 + [Install] 1600 + WantedBy=multi-user.target 1601 + ``` 1602 + 1603 + Enable and start: 1604 + 1605 + ```bash 1606 + sudo systemctl enable day10-web 1607 + sudo systemctl start day10-web 1608 + ``` 1609 + 1610 + #### Combined nginx Configuration 1611 + 1612 + Serve both the dashboard and documentation: 1613 + 1614 + ```nginx 1615 + server { 1616 + listen 80; 1617 + server_name docs.example.com; 1618 + 1619 + # Status dashboard 1620 + location / { 1621 + proxy_pass http://127.0.0.1:8080; 1622 + proxy_set_header Host $host; 1623 + proxy_set_header X-Real-IP $remote_addr; 1624 + } 1625 + 1626 + # Generated documentation 1627 + location /docs/ { 1628 + alias /data/html/; 1629 + autoindex on; 1630 + try_files $uri $uri/ =404; 1631 + } 1632 + } 1633 + ``` 1634 + 1635 + #### Dashboard Features 1636 + 1637 + - **Dashboard** (`/`): Overview with build/doc success rates, latest run summary 1638 + - **Packages** (`/packages`): Searchable list of all packages with docs 1639 + - **Package Detail** (`/packages/{name}/{version}`): Version list and doc links 1640 + - **Runs** (`/runs`): History of all batch runs 1641 + - **Run Detail** (`/runs/{id}`): Statistics, failures, and log links 1642 + - **Logs** (`/runs/{id}/build/{pkg}`, `/runs/{id}/docs/{pkg}`): View build and doc logs 1643 + ``` 1644 + 1645 + **Step 2: Commit** 1646 + 1647 + ```bash 1648 + git add docs/ADMIN_GUIDE.md 1649 + git commit -m "docs: add day10-web to admin guide" 1650 + ``` 1651 + 1652 + --- 1653 + 1654 + ## Summary 1655 + 1656 + | Task | Description | Tests | 1657 + |------|-------------|-------| 1658 + | 1 | Project setup (dune-project, web/dune, minimal main.ml) | Build check | 1659 + | 2 | CLI with cmdliner | Help output | 1660 + | 3 | Data layer: run_data | 5 unit tests | 1661 + | 4 | Data layer: package_data | 4 unit tests | 1662 + | 5 | HTML layout module | Build check | 1663 + | 6 | Dashboard page | Manual verification | 1664 + | 7 | Runs pages (list, detail, logs) | Manual verification | 1665 + | 8 | Packages pages (list, detail) | Manual verification | 1666 + | 9 | Update admin guide | Documentation | 1667 + 1668 + **Total new tests:** 9 unit tests 1669 + **Total commits:** 9 commits