A fork of mtelver's day10 project

feat(web): enhance package detail page for package authors

Add comprehensive package information that package authors need:

- Build status with success/failure badge
- Build timestamp showing when package was last built
- Direct links to build and doc logs for the latest run
- Dependencies list with clickable links to each dependency
- Reverse dependencies showing packages that depend on this one
- New /packages/:name/:version/logs route for combined log view

Implementation details:
- Add symlink creation in bin/main.ml when building layers
Creates packages/{pkg_str} -> ../build-{hash} for O(1) lookup
- Add layer_data module to read layer.json via symlinks
- Add parse_package_str helper for proper name.version parsing
(handles versions like 3.21.0 and v0.17.0 correctly)

This addresses the usability issues identified in package author review:
- Package authors can now see if their package is building
- Easy access to logs when debugging build failures
- Clear dependency information in both directions

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

+309 -9
+5 -1
bin/main.ml
··· 307 307 (* Scan for files installed by this package (the upperdir contains only new files) *) 308 308 let installed_libs = Util.scan_installed_lib_files ~layer_dir:target_dir in 309 309 let installed_docs = Util.scan_installed_doc_files ~layer_dir:target_dir in 310 - Util.save_layer_info ~installed_libs ~installed_docs layer_json pkg ordered_deps ordered_build_hashes r 310 + Util.save_layer_info ~installed_libs ~installed_docs layer_json pkg ordered_deps ordered_build_hashes r; 311 + (* Create symlink from packages/{pkg} -> ../build-{hash} for easy lookup by package name *) 312 + Util.ensure_package_symlink ~cache_dir:config.dir ~os_key:(Config.os_key ~config) ~pkg_str ~layer_name:build_layer_name 311 313 in 312 314 let safe_write_layer target_dir = 313 315 try ··· 319 321 let target_layer_json = Path.(target_dir / "layer.json") in 320 322 if not (Sys.file_exists target_layer_json) then 321 323 Util.save_layer_info target_layer_json pkg ordered_deps ordered_build_hashes 1; 324 + (* Create symlink even for failures so we can look up the failure status *) 325 + Util.ensure_package_symlink ~cache_dir:config.dir ~os_key:(Config.os_key ~config) ~pkg_str ~layer_name:build_layer_name; 322 326 raise exn 323 327 in 324 328 (* Check layer.json exists, not just the directory - directory might exist from interrupted build *)
+20
bin/util.ml
··· 41 41 in 42 42 Yojson.Safe.to_file name (`Assoc fields) 43 43 44 + (** Ensure a symlink exists from packages/{pkg_str} -> ../{layer_name} 45 + This enables O(1) lookup of layer info by package name. *) 46 + let ensure_package_symlink ~cache_dir ~os_key ~pkg_str ~layer_name = 47 + let packages_dir = Path.(cache_dir / os_key / "packages") in 48 + let symlink_path = Path.(packages_dir / pkg_str) in 49 + let target = Path.(".." / layer_name) in 50 + (* Create packages directory if needed *) 51 + if not (Sys.file_exists packages_dir) then 52 + Os.mkdir ~parents:true packages_dir; 53 + (* Create or update symlink *) 54 + (try Unix.unlink symlink_path with Unix.Unix_error (Unix.ENOENT, _, _) -> ()); 55 + Unix.symlink target symlink_path 56 + 44 57 let save_doc_layer_info ?doc_result name pkg ~build_hash ~dep_doc_hashes = 45 58 let fields = 46 59 [ ··· 88 101 match doc |> member "status" |> to_string with 89 102 | "failure" -> true 90 103 | _ -> false 104 + 105 + let load_layer_info_dep_doc_hashes name = 106 + let json = Yojson.Safe.from_file name in 107 + let open Yojson.Safe.Util in 108 + match json |> member "dep_doc_hashes" with 109 + | `Null -> [] 110 + | hashes -> hashes |> to_list |> List.map to_string 91 111 92 112 let solution_to_json pkgs = 93 113 `Assoc
+1 -1
web/data/dune
··· 1 1 (library 2 2 (name day10_web_data) 3 3 (libraries unix yojson day10_lib) 4 - (modules run_data package_data)) 4 + (modules run_data package_data layer_data))
+95
web/data/layer_data.ml
··· 1 + (** Read layer info for packages from day10's cache directory. 2 + Uses the packages/ symlink directory for O(1) lookup by package name, 3 + with fallback to scanning build-* directories. *) 4 + 5 + type layer_info = { 6 + package: string; 7 + deps: string list; 8 + created: float; 9 + exit_status: int; 10 + } 11 + 12 + (** Read layer.json from a directory and parse it *) 13 + let read_layer_json path = 14 + if Sys.file_exists path then 15 + try 16 + let content = In_channel.with_open_text path In_channel.input_all in 17 + let json = Yojson.Safe.from_string content in 18 + let open Yojson.Safe.Util in 19 + Some { 20 + package = json |> member "package" |> to_string; 21 + deps = json |> member "deps" |> to_list |> List.map to_string; 22 + created = json |> member "created" |> to_float; 23 + exit_status = json |> member "exit_status" |> to_int; 24 + } 25 + with _ -> None 26 + else 27 + None 28 + 29 + (** Get layer info for a package via symlink *) 30 + let get_package_layer ~cache_dir ~platform ~package = 31 + let symlink_path = Filename.concat cache_dir 32 + (Filename.concat platform 33 + (Filename.concat "packages" package)) in 34 + if Sys.file_exists symlink_path then 35 + (* Symlink exists - follow it to layer.json *) 36 + let layer_dir = 37 + try 38 + let target = Unix.readlink symlink_path in 39 + (* Target is relative like "../build-abc123" *) 40 + Filename.concat (Filename.dirname symlink_path) target 41 + with Unix.Unix_error _ -> symlink_path 42 + in 43 + let layer_json = Filename.concat layer_dir "layer.json" in 44 + read_layer_json layer_json 45 + else 46 + (* No symlink - fall back to scanning build-* directories *) 47 + let platform_dir = Filename.concat cache_dir platform in 48 + if Sys.file_exists platform_dir && Sys.is_directory platform_dir then 49 + Sys.readdir platform_dir 50 + |> Array.to_list 51 + |> List.filter (fun name -> String.length name > 6 && String.sub name 0 6 = "build-") 52 + |> List.find_map (fun build_dir -> 53 + let layer_json = Filename.concat platform_dir 54 + (Filename.concat build_dir "layer.json") in 55 + match read_layer_json layer_json with 56 + | Some info when info.package = package -> Some info 57 + | _ -> None) 58 + else 59 + None 60 + 61 + (** List all packages with layer info (for computing reverse deps). 62 + Returns list of (package_name, layer_info) pairs. *) 63 + let list_all_packages ~cache_dir ~platform = 64 + let packages_dir = Filename.concat cache_dir 65 + (Filename.concat platform "packages") in 66 + if Sys.file_exists packages_dir && Sys.is_directory packages_dir then 67 + Sys.readdir packages_dir 68 + |> Array.to_list 69 + |> List.filter_map (fun package -> 70 + match get_package_layer ~cache_dir ~platform ~package with 71 + | Some info -> Some (package, info) 72 + | None -> None) 73 + else 74 + (* Fall back to scanning build-* directories *) 75 + let platform_dir = Filename.concat cache_dir platform in 76 + if Sys.file_exists platform_dir && Sys.is_directory platform_dir then 77 + Sys.readdir platform_dir 78 + |> Array.to_list 79 + |> List.filter (fun name -> String.length name > 6 && String.sub name 0 6 = "build-") 80 + |> List.filter_map (fun build_dir -> 81 + let layer_json = Filename.concat platform_dir 82 + (Filename.concat build_dir "layer.json") in 83 + match read_layer_json layer_json with 84 + | Some info -> Some (info.package, info) 85 + | None -> None) 86 + else 87 + [] 88 + 89 + (** Compute reverse dependencies: which packages depend on the given package. 90 + Returns a list of package names that have this package in their deps. *) 91 + let get_reverse_deps ~cache_dir ~platform ~package = 92 + list_all_packages ~cache_dir ~platform 93 + |> List.filter_map (fun (pkg_name, info) -> 94 + if List.mem package info.deps then Some pkg_name else None) 95 + |> List.sort String.compare
+19
web/data/layer_data.mli
··· 1 + (** Read layer info for packages from day10's cache directory *) 2 + 3 + type layer_info = { 4 + package: string; 5 + deps: string list; 6 + created: float; (** Unix timestamp *) 7 + exit_status: int; 8 + } 9 + 10 + (** Get layer info for a specific package. 11 + Uses symlink if available, falls back to scanning build-* directories. *) 12 + val get_package_layer : cache_dir:string -> platform:string -> package:string -> layer_info option 13 + 14 + (** List all packages with their layer info. 15 + Used for computing reverse dependencies. *) 16 + val list_all_packages : cache_dir:string -> platform:string -> (string * layer_info) list 17 + 18 + (** Get reverse dependencies: packages that depend on the given package. *) 19 + val get_reverse_deps : cache_dir:string -> platform:string -> package:string -> string list
+12 -1
web/main.ml
··· 48 48 let name = Dream.param request "name" in 49 49 let version = Dream.param request "version" in 50 50 let html = Day10_web_views.Packages.detail_page 51 - ~html_dir:config.html_dir ~name ~version in 51 + ~html_dir:config.html_dir 52 + ~cache_dir:config.cache_dir 53 + ~platform:config.platform 54 + ~log_dir:(log_dir config) 55 + ~name ~version in 56 + Dream.html html); 57 + 58 + Dream.get "/packages/:name/:version/logs" (fun request -> 59 + let name = Dream.param request "name" in 60 + let version = Dream.param request "version" in 61 + let html = Day10_web_views.Packages.logs_page 62 + ~log_dir:(log_dir config) ~name ~version in 52 63 Dream.html html); 53 64 54 65 Dream.get "/runs" (fun _ ->
+157 -6
web/views/packages.ml
··· 45 45 in 46 46 Layout.page ~title:"Packages" ~content 47 47 48 - let detail_page ~html_dir ~name ~version = 48 + let detail_page ~html_dir ~cache_dir ~platform ~log_dir ~name ~version = 49 + let package = name ^ "." ^ version in 49 50 if not (Day10_web_data.Package_data.package_has_docs ~html_dir ~name ~version) then 50 51 Layout.page ~title:"Package Not Found" ~content:(Printf.sprintf {| 51 52 <h1>Package Not Found</h1> 52 - <p class="card">No documentation found for %s.%s</p> 53 + <p class="card">No documentation found for %s</p> 53 54 <p><a href="/packages">← Back to packages</a></p> 54 - |} name version) 55 + |} package) 55 56 else 56 57 let all_versions = Day10_web_data.Package_data.list_package_versions ~html_dir ~name in 57 58 let versions_list = all_versions |> List.map (fun v -> ··· 61 62 Printf.sprintf {|<li><a href="/packages/%s/%s">%s</a></li>|} name v v 62 63 ) |> String.concat "\n" in 63 64 65 + (* Get layer info for dependencies and build timestamp *) 66 + let layer_info = Day10_web_data.Layer_data.get_package_layer 67 + ~cache_dir ~platform ~package in 68 + 69 + (* Get latest run ID for log links *) 70 + let latest_run = Day10_web_data.Run_data.get_latest_run_id ~log_dir in 71 + 72 + (* Build info section *) 73 + let build_info = match layer_info with 74 + | Some info -> 75 + let timestamp = Unix.gmtime info.created in 76 + let time_str = Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d UTC" 77 + (timestamp.Unix.tm_year + 1900) (timestamp.Unix.tm_mon + 1) 78 + timestamp.Unix.tm_mday timestamp.Unix.tm_hour 79 + timestamp.Unix.tm_min timestamp.Unix.tm_sec in 80 + let status_badge = if info.exit_status = 0 81 + then Layout.badge `Success 82 + else Layout.badge `Failed in 83 + Printf.sprintf {| 84 + <div class="card"> 85 + <h2>Build Status</h2> 86 + <p><strong>Status:</strong> %s</p> 87 + <p><strong>Built:</strong> %s</p> 88 + %s 89 + </div> 90 + |} status_badge time_str 91 + (match latest_run with 92 + | Some run_id -> Printf.sprintf 93 + {|<p><a href="/runs/%s/build/%s">View Build Log →</a> | <a href="/runs/%s/docs/%s">View Doc Log →</a></p>|} 94 + run_id package run_id package 95 + | None -> "") 96 + | None -> 97 + {|<div class="card"><h2>Build Status</h2><p>No build information available</p></div>|} 98 + in 99 + 100 + (* Parse "name.version" format - version starts at first .digit or .v followed by digit *) 101 + let parse_package_str s = 102 + let len = String.length s in 103 + let rec find_version_start i = 104 + if i >= len - 1 then None 105 + else if s.[i] = '.' then 106 + let next = s.[i + 1] in 107 + if next >= '0' && next <= '9' then Some i 108 + else if next = 'v' && i + 2 < len && s.[i + 2] >= '0' && s.[i + 2] <= '9' then Some i 109 + else find_version_start (i + 1) 110 + else find_version_start (i + 1) 111 + in 112 + match find_version_start 0 with 113 + | Some i -> Some (String.sub s 0 i, String.sub s (i + 1) (len - i - 1)) 114 + | None -> None 115 + in 116 + 117 + (* Dependencies section *) 118 + let deps_section = match layer_info with 119 + | Some info when info.deps <> [] -> 120 + let deps_list = info.deps 121 + |> List.map (fun dep -> 122 + match parse_package_str dep with 123 + | Some (dep_name, dep_version) -> 124 + Printf.sprintf {|<li><a href="/packages/%s/%s">%s</a></li>|} dep_name dep_version dep 125 + | None -> 126 + Printf.sprintf "<li>%s</li>" dep) 127 + |> String.concat "\n" in 128 + Printf.sprintf {| 129 + <div class="card"> 130 + <h2>Dependencies (%d)</h2> 131 + <ul>%s</ul> 132 + </div> 133 + |} (List.length info.deps) deps_list 134 + | _ -> "" 135 + in 136 + 137 + (* Reverse dependencies section *) 138 + let reverse_deps = Day10_web_data.Layer_data.get_reverse_deps 139 + ~cache_dir ~platform ~package in 140 + let rev_deps_section = if reverse_deps <> [] then 141 + let rev_deps_list = reverse_deps 142 + |> List.map (fun dep -> 143 + match parse_package_str dep with 144 + | Some (dep_name, dep_version) -> 145 + Printf.sprintf {|<li><a href="/packages/%s/%s">%s</a></li>|} dep_name dep_version dep 146 + | None -> 147 + Printf.sprintf "<li>%s</li>" dep) 148 + |> String.concat "\n" in 149 + Printf.sprintf {| 150 + <div class="card"> 151 + <h2>Reverse Dependencies (%d)</h2> 152 + <p>Packages that depend on this one:</p> 153 + <ul>%s</ul> 154 + </div> 155 + |} (List.length reverse_deps) rev_deps_list 156 + else "" 157 + in 158 + 64 159 let content = Printf.sprintf {| 65 - <h1>%s.%s</h1> 160 + <h1>%s</h1> 66 161 <p><a href="/packages">← Back to packages</a></p> 67 162 68 163 <div class="card"> ··· 71 166 <p><a href="/docs/p/%s/%s/">View Documentation →</a></p> 72 167 </div> 73 168 169 + %s 170 + %s 171 + %s 172 + 74 173 <div class="card"> 75 174 <h2>Other Versions</h2> 76 175 <ul>%s</ul> 77 176 </div> 78 - |} name version (Layout.badge `Success) name version versions_list 177 + |} package (Layout.badge `Success) name version build_info deps_section rev_deps_section versions_list 79 178 in 80 - Layout.page ~title:(Printf.sprintf "%s.%s" name version) ~content 179 + Layout.page ~title:package ~content 180 + 181 + (** Combined build and doc logs page for a package *) 182 + let logs_page ~log_dir ~name ~version = 183 + let package = name ^ "." ^ version in 184 + let latest_run = Day10_web_data.Run_data.get_latest_run_id ~log_dir in 185 + match latest_run with 186 + | None -> 187 + Layout.page ~title:(package ^ " Logs") ~content:(Printf.sprintf {| 188 + <h1>%s Logs</h1> 189 + <p><a href="/packages/%s/%s">← Back to package</a></p> 190 + <div class="card"> 191 + <p>No run data available.</p> 192 + </div> 193 + |} package name version) 194 + | Some run_id -> 195 + let build_log = Day10_web_data.Run_data.read_build_log ~log_dir ~run_id ~package in 196 + let doc_log = Day10_web_data.Run_data.read_doc_log ~log_dir ~run_id ~package in 197 + 198 + let build_section = match build_log with 199 + | Some log -> 200 + Printf.sprintf {| 201 + <div class="card"> 202 + <h2>Build Log</h2> 203 + <p><em>From run %s</em></p> 204 + <pre>%s</pre> 205 + </div> 206 + |} run_id log 207 + | None -> 208 + {|<div class="card"><h2>Build Log</h2><p>No build log available for this package.</p></div>|} 209 + in 210 + 211 + let doc_section = match doc_log with 212 + | Some log -> 213 + Printf.sprintf {| 214 + <div class="card"> 215 + <h2>Documentation Log</h2> 216 + <p><em>From run %s</em></p> 217 + <pre>%s</pre> 218 + </div> 219 + |} run_id log 220 + | None -> 221 + {|<div class="card"><h2>Documentation Log</h2><p>No doc log available for this package.</p></div>|} 222 + in 223 + 224 + let content = Printf.sprintf {| 225 + <h1>%s Logs</h1> 226 + <p><a href="/packages/%s/%s">← Back to package</a></p> 227 + %s 228 + %s 229 + |} package name version build_section doc_section 230 + in 231 + Layout.page ~title:(package ^ " Logs") ~content