My own OCaml monorepo using monopam
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

sync

+358 -129
+7 -60
.github/workflows/registry.yml
··· 22 22 23 23 jobs: 24 24 # --------------------------------------------------------------------------- 25 - # Linux x86_64 — runs on a stock ubuntu-latest runner and drives the full 26 - # multi-distro docker compose project generated by `oi docker --all`. 27 - # Produces os_keys like alpine~3.23~x86_64, debian~13~x86_64, 28 - # ubuntu~{22.04,24.04,25.10}~x86_64, fedora~43~x86_64, plus 29 - # oi-linux-x86_64. 25 + # Linux aarch64 — drives the full multi-distro docker compose project 26 + # generated by `oi docker --all`. Produces os_keys like alpine~3.23~arm64, 27 + # debian~13~arm64, ubuntu~{22.04,24.04,25.10}~arm64, fedora~43~arm64. 28 + # 29 + # Note: x86_64 Linux is intentionally *not* built here — registry consumers 30 + # on Intel Linux fall back to source builds. Re-enable the [linux-amd64] 31 + # job below if the x86_64 binary cache becomes worth the runner minutes. 30 32 # --------------------------------------------------------------------------- 31 - linux-amd64: 32 - name: Linux (x86_64) 33 - runs-on: ubuntu-latest 34 - steps: 35 - - uses: actions/checkout@v5 36 - 37 - - name: Restore oi/dune cache 38 - uses: actions/cache@v4 39 - with: 40 - path: | 41 - ${{ github.workspace }}/.cache 42 - ~/.cache/oi 43 - ~/.local/share/oi 44 - key: oi-registry-linux-amd64-${{ github.sha }} 45 - restore-keys: | 46 - oi-registry-linux-amd64- 47 - 48 - - name: Install oi from latest release 49 - run: | 50 - mkdir -p "$HOME/.local/bin" 51 - curl -fsSL -o "$HOME/.local/bin/oi" \ 52 - "https://github.com/${{ github.repository }}/releases/latest/download/oi-linux-$(uname -m)" 53 - chmod +x "$HOME/.local/bin/oi" 54 - echo "$HOME/.local/bin" >> "$GITHUB_PATH" 55 - 56 - - name: Bootstrap reporepo 57 - # Touching any `oi repo` subcommand auto-clones the reporepo into 58 - # ~/.local/share/oi/reporepo from the default URL. Override via 59 - # OI_REPOREPO_URL secret/var if this repo's reporepo lives elsewhere. 60 - run: oi repo list 61 - 62 - - name: Generate Dockerfiles + compose 63 - # --refresh forces the depext computation to pull a fresh 64 - # reporepo + base overlays before solving each overlay's 65 - # x-root-packages — silent solve failures here would otherwise 66 - # leave the generated install line short of the depexts the 67 - # in-container build needs. 68 - run: | 69 - oi docker --all --refresh -o "$GITHUB_WORKSPACE" 70 - 71 - - name: docker compose up --build 72 - working-directory: ${{ github.workspace }} 73 - run: | 74 - mkdir -p "$REGISTRY_LOCAL" 75 - docker compose up --build --abort-on-container-exit 76 - find "$REGISTRY_LOCAL" -type f 77 - 78 - - name: Rsync to ${{ vars.REGISTRY_RSYNC_DEST }} 79 - if: vars.REGISTRY_RSYNC_DEST != '' 80 - env: 81 - REGISTRY_SSH_KEY: ${{ secrets.REGISTRY_SSH_KEY }} 82 - REGISTRY_RSYNC_DEST: ${{ vars.REGISTRY_RSYNC_DEST }} 83 - run: | 84 - bash ./.github/scripts/rsync-registry.sh 85 - 86 33 linux-arm64: 87 34 name: Linux (aarch64) 88 35 runs-on: ubuntu-24.04-arm
+3 -1
lib/cmd/build.ml
··· 392 392 else begin 393 393 let layer_hashes = 394 394 let on_phase msg = Oi.Say.step "%s" msg in 395 + let on_progress = Oi.Say.progress in 395 396 Oi.Pipeline.build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key 396 397 ~extra_repos:all_extras ~pins:url_project.pins ~refresh 397 398 ~constraints:extra_constraints ?remote ?jobs ?toolchain 398 - ?local_packages_dir:url_project.packages_dir ~on_phase names 399 + ?local_packages_dir:url_project.packages_dir ~on_phase ~on_progress 400 + names 399 401 in 400 402 match 401 403 find_target_layer ~fs ~cache ~os_key ~pkg_name:target layer_hashes
+23 -15
lib/cmd/sync.ml
··· 190 190 if quiet then Fmt.kstr (fun s -> Logs.info (fun m -> m "%s" s)) fmt 191 191 else Fmt.kstr (fun s -> Oi.Say.step "%s" s) fmt 192 192 in 193 - let say_field label fmt = 194 - if quiet then 195 - Fmt.kstr (fun s -> Logs.info (fun m -> m "%s: %s" label s)) fmt 196 - else Fmt.kstr (fun s -> Oi.Say.field label "%s" s) fmt 197 - in 198 193 let say_info fmt = 199 194 if quiet then Fmt.kstr (fun s -> Logs.info (fun m -> m "%s" s)) fmt 200 195 else Fmt.kstr (fun s -> Oi.Say.info "%s" s) fmt ··· 211 206 if deps = [] && extra_cli = [] && url_project.roots = [] then 212 207 Oi.Error.config_error "No .opam files found in %s." cwd; 213 208 say_step "Sync %s" cwd; 214 - if deps <> [] then say_field "deps" "%s" (String.concat ", " deps); 209 + if deps <> [] then 210 + if quiet then Logs.info (fun m -> m "deps: %s" (String.concat ", " deps)) 211 + else Oi.Say.field_list "deps" deps; 215 212 if url_project.roots <> [] then 216 - say_field "with-deps" "%s" (String.concat ", " url_project.roots); 213 + if quiet then 214 + Logs.info (fun m -> 215 + m "with-deps: %s" (String.concat ", " url_project.roots)) 216 + else Oi.Say.field_list "with-deps" url_project.roots; 217 217 let conf = 218 218 Oi.Pipeline.make_conf ~platform ~ocaml_version:Workspace.ocaml_version 219 219 in ··· 235 235 ~reporepo_path:(Terms.reporepo_path ()) ~toolchain candidate_overlays 236 236 in 237 237 if project_overlays <> [] then 238 - say_field "overlays" "%s" (String.concat ", " project_overlays); 238 + if quiet then 239 + Logs.info (fun m -> 240 + m "overlays: %s" (String.concat ", " project_overlays)) 241 + else Oi.Say.field_list "overlays" project_overlays; 239 242 let with_repos = project_overlays @ with_repos in 240 243 let all_extras = 241 244 Target.merge_extras 242 245 ~cli:(Target.cli_extra_repos ~fs ~sys ?toolchain with_repos) 243 246 ~project:(project.extra_repos @ url_project.extra_repos) 244 247 in 245 - if all_extras <> [] then 246 - say_field "extra-repos" "%s" 247 - (String.concat ", " 248 - (List.map 249 - (fun (e : Oi.Project.extra_repo) -> Fmt.str "%s (%s)" e.name e.url) 250 - all_extras)); 248 + if all_extras <> [] then begin 249 + let labels = 250 + List.map 251 + (fun (e : Oi.Project.extra_repo) -> Fmt.str "%s (%s)" e.name e.url) 252 + all_extras 253 + in 254 + if quiet then 255 + Logs.info (fun m -> m "extra-repos: %s" (String.concat ", " labels)) 256 + else Oi.Say.field_list "extra-repos" labels 257 + end; 251 258 let remote = Terms.remote_of_registry registry in 252 259 let extra_constraints = Oi.Project.Script.constraints extra_cli in 253 260 let extra_names = ··· 272 279 let on_phase msg = 273 280 if quiet then Logs.info (fun m -> m "%s" msg) else Oi.Say.step "%s" msg 274 281 in 282 + let on_progress msg = if not quiet then Oi.Say.progress msg in 275 283 Oi.Pipeline.build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key 276 284 ~extra_repos:all_extras 277 285 ~pins:(project.pins @ url_project.pins) 278 286 ~refresh ~constraints:extra_constraints ~project_root:cwd ?remote ?jobs 279 - ?toolchain ?local_packages_dir ~on_phase names 287 + ?toolchain ?local_packages_dir ~on_phase ~on_progress names 280 288 in 281 289 let oi_dir = cwd / "_oi" in 282 290 let prefix = oi_dir / "prefix" in
+17 -10
lib/oi/execute.ml
··· 423 423 (* -- Reporter ------------------------------------------------------------- *) 424 424 425 425 type pkg_event = 426 - | Started of { pkg : string; stage : int; total_stages : int } 426 + | Started of { pkg : string; phase : string; stage : int; total_stages : int } 427 427 | Cached of { pkg : string } 428 428 | Built of { pkg : string } 429 429 | Build_failed of { pkg : string; log : string } ··· 448 448 pkg_event = 449 449 (fun e -> 450 450 match e with 451 - | Started { pkg; stage; total_stages = _ } -> 452 - Ui.with_msg ui (Fmt.str "[%s] %s" (stage_s stage) pkg) 451 + | Started { pkg; phase; stage; total_stages = _ } -> 452 + Ui.with_msg ui (Fmt.str "[%s] %s %s" (stage_s stage) phase pkg) 453 453 | Cached _ | Built _ -> Ui.tick ui 454 454 | Dep_failed _ -> () 455 455 | Build_failed { pkg; log } -> ··· 735 735 (Started 736 736 { 737 737 pkg = p.pkg; 738 + phase = "restore"; 738 739 stage = group.stage; 739 740 total_stages = n_stages; 740 741 }); ··· 791 792 Eio.Fiber.List.iter ~max_fibers:build_parallelism 792 793 (fun (p : Plan.package_plan) -> 793 794 active := !active + 1; 794 - reporter.pkg_event 795 - (Started 796 - { 797 - pkg = p.pkg; 798 - stage = group.stage; 799 - total_stages = n_stages; 800 - }); 795 + let started phase = 796 + reporter.pkg_event 797 + (Started 798 + { 799 + pkg = p.pkg; 800 + phase; 801 + stage = group.stage; 802 + total_stages = n_stages; 803 + }) 804 + in 805 + started "fetch"; 801 806 let t = trace_for p in 802 807 (try 803 808 Eio.Path.rmtree ~missing_ok:true Eio.Path.(fs / p.build_dir); 804 809 let t0 = now () in 805 810 fetch_phase ~cache_urls ~fs ~cache_root:plan.cache_root p; 806 811 t.fetch_dur <- Some (now () -. t0); 812 + started "build"; 807 813 let t1 = now () in 808 814 build_phase ~proc_mgr ~fs p; 809 815 t.build_dur <- Some (now () -. t1) ··· 833 839 (Started 834 840 { 835 841 pkg = p.pkg; 842 + phase = "install"; 836 843 stage = group.stage; 837 844 total_stages = n_stages; 838 845 });
+12 -1
lib/oi/execute.mli
··· 13 13 future reuse. *) 14 14 15 15 type pkg_event = 16 - | Started of { pkg : string; stage : int; total_stages : int } 16 + | Started of { 17 + pkg : string; 18 + phase : string; 19 + (** "restore" / "fetch" / "build" / "install" — the lifecycle 20 + sub-phase the package is entering. Multiple [Started] events fire 21 + per source package as it transitions fetch → build → install; 22 + binary packages get a single [Started] with phase ["restore"]. 23 + Reporters use this to drive a phase indicator in the progress bar. 24 + *) 25 + stage : int; 26 + total_stages : int; 27 + } 17 28 | Cached of { pkg : string } 18 29 | Built of { pkg : string } 19 30 | Build_failed of { pkg : string; log : string }
+41 -22
lib/oi/pipeline.ml
··· 175 175 Fmt.str "%.0fKB" (Int64.to_float n /. 1024.) 176 176 else Fmt.str "%LdB" n 177 177 178 - let fetch_remote_layers ?on_phase ?jobs ~remote ~d10 ~packages_dirs ~ctx ~pkgs 179 - build_plan = 178 + let fetch_remote_layers ?on_phase ?on_progress ?jobs ~remote ~d10 ~packages_dirs 179 + ~ctx ~pkgs build_plan = 180 180 match remote with 181 181 | None -> build_plan 182 182 | Some r -> ··· 211 211 in-flight fibers. We're under [Eio.Fiber.List.iter] which 212 212 on the default [Eio_posix] backend uses fibers (cooperative, 213 213 no preemption between yield points), so a plain ref is 214 - safe — no mutex needed. *) 214 + safe — no mutex needed. 215 + 216 + Routing: per-tick byte updates go to [on_progress] (a 217 + high-frequency in-place sink), while one-shot milestone 218 + messages — start of phase, final summary — go to [on_phase] 219 + (typically a [Say.step] line). When [on_progress] isn't 220 + supplied, byte updates fall through to [on_phase] so 221 + callers like [oi run]'s spinner still see live activity. *) 215 222 let done_count = ref 0 in 216 223 let bytes_total = ref 0L in 217 224 let last_emit = ref 0.0 in 218 225 let throttle_s = 0.05 in 219 - let emit () = 220 - match on_phase with 221 - | None -> () 222 - | Some f -> 223 - let now = Unix.gettimeofday () in 224 - if now -. !last_emit >= throttle_s then begin 225 - last_emit := now; 226 - f 227 - (Fmt.str "Fetching layers from registry (%d/%d, %s)" 228 - !done_count n_total (fmt_mb !bytes_total)) 229 - end 226 + (* Byte updates prefer [on_progress] (in-place sink). Fall back 227 + to [on_phase] so callers that only supplied a milestone sink 228 + — typically [oi run]'s spinner — still see live activity. *) 229 + let progress_sink = 230 + match (on_progress, on_phase) with 231 + | Some f, _ | None, Some f -> f 232 + | None, None -> fun _ -> () 233 + in 234 + let emit_progress () = 235 + let now = Unix.gettimeofday () in 236 + if now -. !last_emit >= throttle_s then begin 237 + last_emit := now; 238 + progress_sink 239 + (Fmt.str "Fetching layers from registry (%d/%d, %s)" !done_count 240 + n_total (fmt_mb !bytes_total)) 241 + end 230 242 in 231 243 (* Per-fiber received counter so we don't double-count when 232 244 retries / chunked downloads call [on_progress] cumulatively ··· 235 247 let prev = !hash_ref in 236 248 hash_ref := received; 237 249 bytes_total := Int64.add !bytes_total (Int64.sub received prev); 238 - emit () 250 + emit_progress () 239 251 in 240 252 Eio.Fiber.List.iter 241 253 ~max_fibers:(fetch_parallelism ?jobs ()) ··· 252 264 () 253 265 then begin 254 266 incr done_count; 255 - emit (); 267 + emit_progress (); 256 268 Logs.info (fun m -> m "Fetched %s from registry" hash) 257 269 end) 258 270 available; 259 - (* Final line — guaranteed past the throttle window. *) 271 + (* Wipe the in-place progress line (if any) and emit the final 272 + summary as a milestone — [on_phase] is a "fresh line" sink, 273 + so the summary lands on its own row rather than overwriting 274 + the last progress update. *) 275 + (match on_progress with 276 + | Some _ -> Say.progress_clear () 277 + | None -> ()); 260 278 (match on_phase with 261 - | None -> () 262 279 | Some f -> 263 280 f 264 281 (Fmt.str "Fetched %d/%d layers from registry (%s)" !done_count 265 - n_total (fmt_mb !bytes_total))); 282 + n_total (fmt_mb !bytes_total)) 283 + | None -> ()); 266 284 Plan.build ctx ~d10 ~packages_dirs pkgs 267 285 end 268 286 end ··· 272 290 let build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key 273 291 ?(dry_run = false) ?(extra_repos = []) ?(pins = []) ?(refresh = false) 274 292 ?remote ?jobs ?toolchain ?(constraints = OpamPackage.Name.Map.empty) 275 - ?project_root ?local_packages_dir ?on_phase ?preflight_done names = 293 + ?project_root ?local_packages_dir ?on_phase ?on_progress ?preflight_done 294 + names = 276 295 let _ = preflight_done in 277 296 let on_phase = 278 297 match on_phase with ··· 389 408 | None -> build_plan 390 409 | Some _ -> 391 410 on_phase "Checking registry for prebuilt layers"; 392 - fetch_remote_layers ~on_phase ?jobs ~remote ~d10 ~packages_dirs ~ctx 393 - ~pkgs build_plan 411 + fetch_remote_layers ~on_phase ?on_progress ?jobs ~remote ~d10 412 + ~packages_dirs ~ctx ~pkgs build_plan 394 413 in 395 414 let hashes = Plan.layer_hashes build_plan in 396 415 (* Every layer in the plan must be cached (Binary method) to skip
+10 -3
lib/oi/pipeline.mli
··· 112 112 113 113 val fetch_remote_layers : 114 114 ?on_phase:(string -> unit) -> 115 + ?on_progress:(string -> unit) -> 115 116 ?jobs:int -> 116 117 remote:D10.Layer.remote option -> 117 118 d10:D10.Config.t -> ··· 124 125 graph with downloaded layers promoted to [Binary]. No-op when 125 126 [remote = None] or every layer is already cached. 126 127 127 - [on_phase] receives aggregated status across the parallel fiber pool — both 128 - completed-layer counts and cumulative bytes received. Throttled to ~20Hz 129 - internally; safe to wire into a [Tty.Progress] sink. *) 128 + Progress reporting is split: 129 + - [on_phase] receives one-shot milestones (e.g. the final "Fetched N/M 130 + layers" summary). 131 + - [on_progress] receives the high-frequency byte/count updates, throttled to 132 + ~20Hz. Typically wired to an in-place line sink like {!Say.progress}. 133 + 134 + When only [on_phase] is supplied, byte updates fall through to it so 135 + spinner-style callers ([oi run]) keep showing live activity. *) 130 136 131 137 val build : 132 138 sys:D10.Sysops.t -> ··· 148 154 ?project_root:string -> 149 155 ?local_packages_dir:string -> 150 156 ?on_phase:(string -> unit) -> 157 + ?on_progress:(string -> unit) -> 151 158 ?preflight_done:(unit -> unit) -> 152 159 OpamPackage.Name.t list -> 153 160 string list
+28
lib/oi/say.ml
··· 35 35 flush_out ()) 36 36 fmt 37 37 38 + (* Continuation indent for [field_list] wrapping: 2 leading spaces + the 39 + label gutter + 1 separator space = 15 columns. Has to match [field]'s 40 + prefix exactly so wrapped lines line up under the first item. *) 41 + let field_continuation_indent = 2 + label_width + 1 42 + 43 + let field_list ?(sep = ", ") label items = 44 + match items with 45 + | [] -> () 46 + | _ -> 47 + let term_w = Tty.Width.terminal_width () in 48 + let body_w = max 20 (term_w - field_continuation_indent) in 49 + let joined = String.concat sep items in 50 + let wrapped = Tty.Width.wrap body_w joined in 51 + let pad = String.make field_continuation_indent ' ' in 52 + (match String.split_on_char '\n' wrapped with 53 + | [] -> () 54 + | first :: rest -> 55 + Fmt.pr " %a %s@." Style.dim_string 56 + (Fmt.str "%-*s" label_width (label ^ ":")) 57 + first; 58 + List.iter (fun line -> Fmt.pr "%s%s@." pad line) rest); 59 + flush_out () 60 + 61 + let progress msg = 62 + if Tty.is_tty () then Fmt.pr "\r\027[K%a%!" Style.dim_string msg 63 + 64 + let progress_clear () = if Tty.is_tty () then Fmt.pr "\r\027[K%!" 65 + 38 66 let header fmt = 39 67 Fmt.kstr 40 68 (fun s ->
+19
lib/oi/say.mli
··· 23 23 (** [field "deps" "%s" v] prints [" deps: v"] with the label dim and a fixed 24 24 alignment column for the value. *) 25 25 26 + val field_list : ?sep:string -> string -> string list -> unit 27 + (** [field_list ?sep "deps" items] prints a field whose value is a list of 28 + items, joined with [sep] (default [", "]) and word-wrapped to the terminal 29 + width. Continuation lines are indented to line up with the value column. 30 + No-op when [items = []]. *) 31 + 32 + val progress : string -> unit 33 + (** [progress msg] writes [msg] in dim style on the current line, replacing 34 + whatever was there (via [\r\033[K]) without emitting a newline. Used for 35 + in-place high-frequency status updates (e.g. "Fetching layers (12/47, 36 + 34MB)" during a registry pull). The next non-progress write — typically 37 + a {!step} or {!progress_clear} — replaces or overwrites the line. 38 + No-op on non-TTY. *) 39 + 40 + val progress_clear : unit -> unit 41 + (** Erase the current in-place [progress] line without emitting a newline. Use 42 + before a final {!step} so the summary lands cleanly on the same row. No-op 43 + on non-TTY. *) 44 + 26 45 val header : ('a, Format.formatter, unit, unit) format4 -> 'a 27 46 (** [header "%s" h] prints ["h"] in bold — used for section headers in 28 47 multi-block reports ([oi show], [oi config]). *)
+198 -17
registry/index.html
··· 287 287 border-radius: 6px; 288 288 overflow: hidden; 289 289 } 290 - .pkg-list-header { 290 + /* Column track widths kept identical between header and rows so the 291 + columns line up. The package column is bounded with [minmax] so it 292 + doesn't run away on wide windows; surplus width goes to the distros 293 + column instead, where each new distro chip can use it. */ 294 + .pkg-list-header, 295 + .pkg-row { 291 296 display: grid; 292 - grid-template-columns: 110px 1fr 110px 110px 80px 70px; 297 + grid-template-columns: 298 + 110px /* status badge */ 299 + minmax(180px, 320px) /* package name+version, capped */ 300 + minmax(100px, 1fr) /* per-distro chip strip — flexes */ 301 + 100px /* overlay tags */ 302 + 96px /* short layer hash */ 303 + 70px /* method */ 304 + 64px; /* duration */ 293 305 gap: 0.6rem; 294 - padding: 0.35rem 0.85rem; 306 + padding: 0.3rem 0.85rem; 307 + align-items: center; 308 + } 309 + .pkg-list-header { 310 + padding-top: 0.35rem; padding-bottom: 0.35rem; 295 311 background: var(--bg-sunken); 296 312 border-bottom: 1px solid var(--border); 297 313 font-family: ui-monospace, monospace; ··· 302 318 font-weight: 600; 303 319 } 304 320 .pkg-row { 305 - display: grid; 306 - grid-template-columns: 110px 1fr 110px 110px 80px 70px; 307 - gap: 0.6rem; 308 - padding: 0.3rem 0.85rem; 309 321 border-bottom: 1px solid var(--border); 310 322 cursor: pointer; 311 - align-items: center; 312 323 font-family: ui-monospace, SF Mono, Monaco, monospace; 313 324 font-size: 0.78rem; 314 325 position: relative; ··· 351 362 .outcome-cached { background: var(--cached); color: white; } 352 363 .outcome-warn { background: var(--warn); color: white; } 353 364 .outcome-fail { background: var(--fail); color: white; } 365 + .outcome-missing { background: var(--bg-sunken); color: var(--fg-faint); border: 1px dashed var(--border); } 366 + 367 + /* Per-distro mini chips: one per probed os_key, colored by that 368 + distro's outcome for this package. Missing data shows as a dim 369 + dashed dot so the user can spot "every distro succeeded except 370 + this one" at a glance. */ 371 + .distro-chips { 372 + display: flex; flex-wrap: wrap; gap: 0.2rem; 373 + } 374 + .distro-chip { 375 + display: inline-block; 376 + padding: 0.05rem 0.35rem; 377 + border-radius: 3px; 378 + font-size: 0.6rem; 379 + font-weight: 600; 380 + letter-spacing: 0.02em; 381 + text-align: center; 382 + white-space: nowrap; 383 + cursor: pointer; 384 + font-family: ui-monospace, monospace; 385 + border: 1px solid transparent; 386 + } 387 + .distro-chip:hover { border-color: rgba(255, 255, 255, 0.5); } 388 + .distro-chip.outcome-missing { 389 + font-weight: 400; 390 + cursor: default; 391 + } 392 + .distro-chip.outcome-missing:hover { border-color: transparent; } 393 + .distro-chip.active { 394 + outline: 2px solid var(--accent); 395 + outline-offset: 1px; 396 + } 354 397 .pkg-name { font-weight: 600; } 355 398 .pkg-version { color: var(--fg-dim); } 356 399 .pkg-meta { color: var(--fg-dim); font-size: 0.72rem; text-align: right; } ··· 541 584 .pkg-detail .dim { color: var(--fg-dim); } 542 585 543 586 .empty { text-align: center; color: var(--fg-dim); padding: 2.5rem; } 587 + .loading { max-width: 480px; margin: 4rem auto; } 588 + .loading-bar { 589 + width: 100%; height: 6px; border-radius: 3px; 590 + background: var(--bg-sunken); overflow: hidden; 591 + margin-bottom: 0.75rem; 592 + } 593 + .loading-bar-fill { 594 + height: 100%; width: 0%; background: var(--accent); 595 + transition: width 0.15s ease-out; 596 + } 597 + .loading-status { font-size: 0.85rem; color: var(--fg); margin-bottom: 0.4rem; } 598 + .loading-detail { 599 + font-size: 0.7rem; color: var(--fg-dim); font-family: ui-monospace, monospace; 600 + display: grid; grid-template-columns: auto 1fr auto; 601 + gap: 0.2rem 0.6rem; max-width: 100%; 602 + text-align: left; margin: 0 auto; padding: 0 1rem; 603 + } 604 + .loading-detail .row { display: contents; } 605 + .loading-detail .row .status-ok { color: var(--ok); } 606 + .loading-detail .row .status-fail { color: var(--fail); } 607 + .loading-detail .row .status-pending { color: var(--fg-dim); } 544 608 .spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; vertical-align: middle; margin-right: 0.5rem; } 545 609 @keyframes spin { to { transform: rotate(360deg); } } 546 610 547 611 @media (max-width: 760px) { 548 612 .filters { grid-template-columns: 1fr; } 549 613 .pkg-list-header, .pkg-row { 550 - grid-template-columns: 90px 1fr 80px 70px; 614 + /* On narrow screens drop the per-row layer/method/duration trio 615 + and the overlay column — they'd squeeze the package name to 616 + almost nothing. Status / package / distros stay visible. */ 617 + grid-template-columns: 90px 1fr minmax(80px, 1fr); 551 618 } 552 - .pkg-list-header .col-method, .pkg-list-header .col-overlay, 553 - .pkg-row > :nth-child(3), .pkg-row > :nth-child(5) { display: none; } 619 + .pkg-list-header > :nth-child(n + 4), 620 + .pkg-row > :nth-child(n + 4) { display: none; } 554 621 } 555 622 </style> 556 623 </head> ··· 562 629 <select class="os-select" id="os-select" title="Select OS"></select> 563 630 </header> 564 631 <main> 565 - <div id="content"><div class="empty"><span class="spinner"></span>Loading manifests…</div></div> 632 + <div id="content"> 633 + <div class="empty loading"> 634 + <div class="loading-bar"> 635 + <div class="loading-bar-fill" id="loading-fill"></div> 636 + </div> 637 + <div class="loading-status" id="loading-status"> 638 + <span class="spinner"></span>Loading manifests… 639 + </div> 640 + <div class="loading-detail" id="loading-detail"></div> 641 + </div> 642 + </div> 566 643 </main> 567 644 568 645 <script> ··· 669 746 return [...set].sort(); 670 747 } 671 748 749 + // "alpine~3.23~x86_64" → "alpine 3.23". Used for the small distro 750 + // chips in the row strip so the chip is short but unambiguous. 751 + function shortDistro(osKey) { 752 + const p = parseOsKey(osKey); 753 + return p.short || p.name; 754 + } 755 + 756 + // Cross-distro outcome for a given (pkg.name, pkg.version). Returns 757 + // [{ os_key, outcome, layer_hash }, ...] over every loaded manifest 758 + // — including a sentinel { outcome: "missing" } for distros where 759 + // the package has no entry, so the strip lines up across rows. *) 760 + function distroStripFor(pkg) { 761 + const out = []; 762 + for (const k of PROBE_OS_KEYS) { 763 + const m = state.manifests.get(k); 764 + if (!m) continue; 765 + const hit = m.results.find( 766 + r => r.pkg.name === pkg.name && r.pkg.version === pkg.version); 767 + if (hit) { 768 + out.push({ os_key: k, outcome: hit.headline_outcome, 769 + layer_hash: hit.layer_hash }); 770 + } else { 771 + out.push({ os_key: k, outcome: "missing", layer_hash: null }); 772 + } 773 + } 774 + return out; 775 + } 776 + 777 + function renderDistroChips(pkg) { 778 + const cells = distroStripFor(pkg); 779 + if (cells.length === 0) return ""; 780 + return `<span class="distro-chips">${cells.map(c => { 781 + const grp = c.outcome === "missing" ? "missing" : (OUTCOME_GROUPS[c.outcome] || "ok"); 782 + const label = shortDistro(c.os_key); 783 + const isActive = c.os_key === state.active; 784 + const cls = "distro-chip outcome-" + grp + (isActive ? " active" : ""); 785 + const tip = c.outcome === "missing" 786 + ? `${c.os_key}: not built (click disabled)` 787 + : `${c.os_key}: ${c.outcome.replace(/_/g, " ")} — click to drill in`; 788 + // Carry the os_key + layer_hash on the chip so the click handler 789 + // can switch [state.active] and expand the same package's row in 790 + // the target distro's manifest. Missing chips have no layer_hash 791 + // and stay non-interactive. 792 + const hashAttr = c.layer_hash 793 + ? ` data-hash="${escapeHtml(c.layer_hash)}"` : ""; 794 + const oskAttr = c.outcome === "missing" 795 + ? "" : ` data-os-key="${escapeHtml(c.os_key)}"`; 796 + return `<span class="${cls}"${oskAttr}${hashAttr} title="${escapeHtml(tip)}">${escapeHtml(label)}</span>`; 797 + }).join("")}</span>`; 798 + } 799 + 672 800 // "alpine~3.23~x86_64" -> { name, version, arch, short, full } 673 801 function parseOsKey(key) { 674 802 const parts = key.split("~"); ··· 1119 1247 const headerRow = `<div class="pkg-list-header"> 1120 1248 <span>status</span> 1121 1249 <span>package</span> 1250 + <span>distros</span> 1122 1251 <span class="col-overlay">overlay</span> 1123 1252 <span style="text-align:right">layer</span> 1124 1253 <span class="col-method" style="text-align:right">method</span> ··· 1145 1274 const rowHtml = `<div class="pkg-row${expanded}${upstream}" data-hash="${escapeHtml(r.layer_hash)}"> 1146 1275 <span class="outcome-badge outcome-${grp}">${r.headline_outcome.replace(/_/g, " ")}</span> 1147 1276 <span><span class="pkg-name">${escapeHtml(r.pkg.name)}</span> <span class="pkg-version">${escapeHtml(r.pkg.version)}</span></span> 1277 + ${renderDistroChips(r.pkg)} 1148 1278 ${chips} 1149 - <span class="pkg-meta">${escapeHtml(r.layer_hash.slice(0,12))}</span> 1150 - <span class="pkg-meta">${escapeHtml(r.method)}</span> 1151 - <span class="pkg-meta">${r.duration_s != null ? fmtDuration(r.duration_s) : "—"}</span> 1279 + <span class="pkg-meta" style="text-align:right">${escapeHtml(r.layer_hash.slice(0,12))}</span> 1280 + <span class="pkg-meta" style="text-align:right">${escapeHtml(r.method)}</span> 1281 + <span class="pkg-meta" style="text-align:right">${r.duration_s != null ? fmtDuration(r.duration_s) : "—"}</span> 1152 1282 </div>`; 1153 1283 return rowHtml + (state.expanded === r.layer_hash ? renderDetail(r, osKey) : ""); 1154 1284 }).join("")}</div>`; ··· 1241 1371 row.onclick = (e) => { 1242 1372 // Clicks on links inside an expanded panel must not collapse the row. 1243 1373 if (e.target.closest("a[data-jump]")) return; 1374 + // Clicking a distro chip jumps to that distro's view of the same 1375 + // package. The active os_key changes, the OS dropdown updates, 1376 + // and the row stays expanded so the user can read the failure 1377 + // detail without an extra click. Missing chips (no data) are 1378 + // skipped because they have no layer_hash to expand. 1379 + const chip = e.target.closest(".distro-chip[data-os-key]"); 1380 + if (chip) { 1381 + e.stopPropagation(); 1382 + const targetOs = chip.dataset.osKey; 1383 + const targetHash = chip.dataset.hash; 1384 + if (!targetOs || !targetHash) return; 1385 + if (!state.manifests.has(targetOs)) return; 1386 + state.active = targetOs; 1387 + expand(targetHash); 1388 + writeHash(targetOs, targetHash); 1389 + render(); 1390 + const t = document.querySelector(`.pkg-row[data-hash="${targetHash}"]`); 1391 + if (t) t.scrollIntoView({ block: "nearest", behavior: "smooth" }); 1392 + return; 1393 + } 1244 1394 const hash = row.dataset.hash; 1245 1395 if (state.expanded === hash) { 1246 1396 state.expanded = null; ··· 1308 1458 } 1309 1459 1310 1460 (async function init() { 1311 - // Fetch manifest + audit slice for every probed os_key in parallel. Audit 1312 - // is best-effort: a registry that ships only manifest.json still works, 1461 + // Fetch manifest + audit slice for every probed os_key in parallel, 1462 + // updating a per-distro loading bar as each completes. Audit is 1463 + // best-effort: a registry that ships only manifest.json still works, 1313 1464 // it just loses the per-event drill-down in the detail panel. 1465 + const fill = document.getElementById("loading-fill"); 1466 + const statusEl = document.getElementById("loading-status"); 1467 + const detailEl = document.getElementById("loading-detail"); 1468 + // Pre-populate the detail rows so the user sees the full set of 1469 + // probes immediately, then watches each one tick from "pending" to 1470 + // "ok" / "fail" / "missing" as it lands. 1471 + detailEl.innerHTML = PROBE_OS_KEYS 1472 + .map(k => `<div class="row" data-osk="${escapeHtml(k)}"> 1473 + <span class="status-pending">·</span> 1474 + <span>${escapeHtml(k)}</span> 1475 + <span class="status-pending">pending</span> 1476 + </div>`) 1477 + .join(""); 1478 + let done = 0; 1479 + const total = PROBE_OS_KEYS.length; 1480 + const tick = (k, label, cls) => { 1481 + done++; 1482 + fill.style.width = `${Math.round((done / total) * 100)}%`; 1483 + statusEl.textContent = `Loaded ${done}/${total} manifests…`; 1484 + const row = detailEl.querySelector(`[data-osk="${CSS.escape(k)}"]`); 1485 + if (row) { 1486 + const [icon, _name, status] = row.children; 1487 + icon.textContent = cls === "fail" ? "✗" : "✓"; 1488 + icon.className = cls === "fail" ? "status-fail" : "status-ok"; 1489 + status.textContent = label; 1490 + status.className = cls === "fail" ? "status-fail" : "status-ok"; 1491 + } 1492 + }; 1314 1493 const results = await Promise.all( 1315 1494 PROBE_OS_KEYS.map(async k => { 1316 1495 const [m, a] = await Promise.all([fetchManifest(k), fetchAudit(k)]); 1496 + if (m) tick(k, `${m.n_packages} entries`, "ok"); 1497 + else tick(k, "missing", "fail"); 1317 1498 return [k, m, a]; 1318 1499 })); 1319 1500 for (const [k, m, a] of results) {