A fork of mtelver's day10 project

feat(web): add run history and detail pages

Add runs.ml view with three pages:
- list_page: Shows all runs with summary stats (builds, failures, docs)
- detail_page: Individual run with full metrics and failure table
- log_page: Build and doc log viewer with HTML escaping

Wire up routes in main.ml:
- /runs - Run history list
- /runs/:run_id - Run detail page
- /runs/:run_id/build/:package - Build log viewer
- /runs/:run_id/docs/:package - Doc log viewer

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

+182 -1
+23
web/main.ml
··· 39 39 ~log_dir:(log_dir config) 40 40 ~html_dir:config.html_dir in 41 41 Dream.html html); 42 + 43 + Dream.get "/runs" (fun _ -> 44 + let html = Day10_web_views.Runs.list_page ~log_dir:(log_dir config) in 45 + Dream.html html); 46 + 47 + Dream.get "/runs/:run_id" (fun request -> 48 + let run_id = Dream.param request "run_id" in 49 + let html = Day10_web_views.Runs.detail_page ~log_dir:(log_dir config) ~run_id in 50 + Dream.html html); 51 + 52 + Dream.get "/runs/:run_id/build/:package" (fun request -> 53 + let run_id = Dream.param request "run_id" in 54 + let package = Dream.param request "package" in 55 + let html = Day10_web_views.Runs.log_page 56 + ~log_dir:(log_dir config) ~run_id ~log_type:`Build ~package in 57 + Dream.html html); 58 + 59 + Dream.get "/runs/:run_id/docs/:package" (fun request -> 60 + let run_id = Dream.param request "run_id" in 61 + let package = Dream.param request "package" in 62 + let html = Day10_web_views.Runs.log_page 63 + ~log_dir:(log_dir config) ~run_id ~log_type:`Docs ~package in 64 + Dream.html html); 42 65 ] 43 66 44 67 let main cache_dir html_dir port host platform =
+1 -1
web/views/dune
··· 1 1 (library 2 2 (name day10_web_views) 3 3 (libraries dream day10_web_data) 4 - (modules layout dashboard)) 4 + (modules layout dashboard runs))
+158
web/views/runs.ml
··· 1 + (** Run history and detail pages *) 2 + 3 + let list_page ~log_dir = 4 + let runs = Day10_web_data.Run_data.list_runs ~log_dir in 5 + let rows = runs |> List.map (fun run_id -> 6 + let summary = Day10_web_data.Run_data.read_summary ~log_dir ~run_id in 7 + match summary with 8 + | Some s -> 9 + Printf.sprintf {| 10 + <tr> 11 + <td><a href="/runs/%s">%s</a></td> 12 + <td>%s</td> 13 + <td>%.0fs</td> 14 + <td>%d %s</td> 15 + <td>%d %s</td> 16 + <td>%d %s</td> 17 + </tr> 18 + |} run_id run_id 19 + s.start_time 20 + s.duration_seconds 21 + s.build_success (if s.build_success > 0 then Layout.badge `Success else "") 22 + s.build_failed (if s.build_failed > 0 then Layout.badge `Failed else "") 23 + s.doc_success (if s.doc_success > 0 then Layout.badge `Success else "") 24 + | None -> 25 + Printf.sprintf {|<tr><td><a href="/runs/%s">%s</a></td><td colspan="5">Summary not available</td></tr>|} run_id run_id 26 + ) |> String.concat "\n" in 27 + 28 + let content = if List.length runs = 0 then 29 + {|<h1>Run History</h1><p class="card">No runs recorded yet.</p>|} 30 + else 31 + Printf.sprintf {| 32 + <h1>Run History</h1> 33 + <div class="card"> 34 + <table> 35 + <tr> 36 + <th>Run ID</th> 37 + <th>Started</th> 38 + <th>Duration</th> 39 + <th>Builds</th> 40 + <th>Failed</th> 41 + <th>Docs</th> 42 + </tr> 43 + %s 44 + </table> 45 + </div> 46 + |} rows 47 + in 48 + Layout.page ~title:"Run History" ~content 49 + 50 + let detail_page ~log_dir ~run_id = 51 + match Day10_web_data.Run_data.read_summary ~log_dir ~run_id with 52 + | None -> 53 + Layout.page ~title:"Run Not Found" ~content:{| 54 + <h1>Run Not Found</h1> 55 + <p class="card">The requested run could not be found.</p> 56 + <p><a href="/runs">← Back to run history</a></p> 57 + |} 58 + | Some s -> 59 + let failures_table = if List.length s.failures > 0 then 60 + Printf.sprintf {| 61 + <h2>Failures (%d)</h2> 62 + <div class="card"> 63 + <table> 64 + <tr><th>Package</th><th>Error</th><th>Logs</th></tr> 65 + %s 66 + </table> 67 + </div> 68 + |} (List.length s.failures) 69 + (s.failures |> List.map (fun (pkg, err) -> 70 + Printf.sprintf {|<tr> 71 + <td>%s</td> 72 + <td>%s</td> 73 + <td> 74 + <a href="/runs/%s/build/%s">build</a> | 75 + <a href="/runs/%s/docs/%s">docs</a> 76 + </td> 77 + </tr>|} pkg err run_id pkg run_id pkg 78 + ) |> String.concat "\n") 79 + else "" 80 + in 81 + 82 + let build_logs = Day10_web_data.Run_data.list_build_logs ~log_dir ~run_id in 83 + let logs_section = if List.length build_logs > 0 then 84 + Printf.sprintf {| 85 + <h2>Build Logs (%d)</h2> 86 + <div class="card"> 87 + <ul>%s</ul> 88 + </div> 89 + |} (List.length build_logs) 90 + (build_logs |> List.map (fun pkg -> 91 + Printf.sprintf {|<li><a href="/runs/%s/build/%s">%s</a></li>|} run_id pkg pkg 92 + ) |> String.concat "\n") 93 + else "" 94 + in 95 + 96 + let content = Printf.sprintf {| 97 + <h1>Run %s</h1> 98 + <p><a href="/runs">← Back to run history</a></p> 99 + 100 + <div class="card"> 101 + <h2>Summary</h2> 102 + <table> 103 + <tr><td>Started</td><td>%s</td></tr> 104 + <tr><td>Ended</td><td>%s</td></tr> 105 + <tr><td>Duration</td><td>%.0f seconds</td></tr> 106 + </table> 107 + </div> 108 + 109 + <div class="card"> 110 + <h2>Results</h2> 111 + <div class="grid"> 112 + %s %s %s %s %s %s %s 113 + </div> 114 + </div> 115 + 116 + %s 117 + %s 118 + |} 119 + run_id 120 + s.start_time s.end_time s.duration_seconds 121 + (Layout.stat ~value:(string_of_int s.targets_requested) ~label:"Targets") 122 + (Layout.stat ~value:(string_of_int s.solutions_found) ~label:"Solved") 123 + (Layout.stat ~value:(string_of_int s.build_success) ~label:"Build OK") 124 + (Layout.stat ~value:(string_of_int s.build_failed) ~label:"Build Failed") 125 + (Layout.stat ~value:(string_of_int s.doc_success) ~label:"Docs OK") 126 + (Layout.stat ~value:(string_of_int s.doc_failed) ~label:"Docs Failed") 127 + (Layout.stat ~value:(string_of_int s.doc_skipped) ~label:"Docs Skipped") 128 + failures_table 129 + logs_section 130 + in 131 + Layout.page ~title:(Printf.sprintf "Run %s" run_id) ~content 132 + 133 + let log_page ~log_dir ~run_id ~log_type ~package = 134 + let content_opt = match log_type with 135 + | `Build -> Day10_web_data.Run_data.read_build_log ~log_dir ~run_id ~package 136 + | `Docs -> Day10_web_data.Run_data.read_doc_log ~log_dir ~run_id ~package 137 + in 138 + let type_str = match log_type with `Build -> "Build" | `Docs -> "Doc" in 139 + match content_opt with 140 + | None -> 141 + Layout.page ~title:"Log Not Found" ~content:(Printf.sprintf {| 142 + <h1>Log Not Found</h1> 143 + <p class="card">The requested log could not be found. It may have been garbage collected.</p> 144 + <p><a href="/runs/%s">← Back to run %s</a></p> 145 + |} run_id run_id) 146 + | Some content -> 147 + let escaped = content 148 + |> String.split_on_char '&' |> String.concat "&amp;" 149 + |> String.split_on_char '<' |> String.concat "&lt;" 150 + |> String.split_on_char '>' |> String.concat "&gt;" 151 + in 152 + Layout.page ~title:(Printf.sprintf "%s Log: %s" type_str package) ~content:(Printf.sprintf {| 153 + <h1>%s Log: %s</h1> 154 + <p><a href="/runs/%s">← Back to run %s</a></p> 155 + <div class="card"> 156 + <pre>%s</pre> 157 + </div> 158 + |} type_str package run_id run_id escaped)