···22222323jobs:
2424 # ---------------------------------------------------------------------------
2525- # Linux x86_64 — runs on a stock ubuntu-latest runner and drives the full
2626- # multi-distro docker compose project generated by `oi docker --all`.
2727- # Produces os_keys like alpine~3.23~x86_64, debian~13~x86_64,
2828- # ubuntu~{22.04,24.04,25.10}~x86_64, fedora~43~x86_64, plus
2929- # oi-linux-x86_64.
2525+ # Linux aarch64 — drives the full multi-distro docker compose project
2626+ # generated by `oi docker --all`. Produces os_keys like alpine~3.23~arm64,
2727+ # debian~13~arm64, ubuntu~{22.04,24.04,25.10}~arm64, fedora~43~arm64.
2828+ #
2929+ # Note: x86_64 Linux is intentionally *not* built here — registry consumers
3030+ # on Intel Linux fall back to source builds. Re-enable the [linux-amd64]
3131+ # job below if the x86_64 binary cache becomes worth the runner minutes.
3032 # ---------------------------------------------------------------------------
3131- linux-amd64:
3232- name: Linux (x86_64)
3333- runs-on: ubuntu-latest
3434- steps:
3535- - uses: actions/checkout@v5
3636-3737- - name: Restore oi/dune cache
3838- uses: actions/cache@v4
3939- with:
4040- path: |
4141- ${{ github.workspace }}/.cache
4242- ~/.cache/oi
4343- ~/.local/share/oi
4444- key: oi-registry-linux-amd64-${{ github.sha }}
4545- restore-keys: |
4646- oi-registry-linux-amd64-
4747-4848- - name: Install oi from latest release
4949- run: |
5050- mkdir -p "$HOME/.local/bin"
5151- curl -fsSL -o "$HOME/.local/bin/oi" \
5252- "https://github.com/${{ github.repository }}/releases/latest/download/oi-linux-$(uname -m)"
5353- chmod +x "$HOME/.local/bin/oi"
5454- echo "$HOME/.local/bin" >> "$GITHUB_PATH"
5555-5656- - name: Bootstrap reporepo
5757- # Touching any `oi repo` subcommand auto-clones the reporepo into
5858- # ~/.local/share/oi/reporepo from the default URL. Override via
5959- # OI_REPOREPO_URL secret/var if this repo's reporepo lives elsewhere.
6060- run: oi repo list
6161-6262- - name: Generate Dockerfiles + compose
6363- # --refresh forces the depext computation to pull a fresh
6464- # reporepo + base overlays before solving each overlay's
6565- # x-root-packages — silent solve failures here would otherwise
6666- # leave the generated install line short of the depexts the
6767- # in-container build needs.
6868- run: |
6969- oi docker --all --refresh -o "$GITHUB_WORKSPACE"
7070-7171- - name: docker compose up --build
7272- working-directory: ${{ github.workspace }}
7373- run: |
7474- mkdir -p "$REGISTRY_LOCAL"
7575- docker compose up --build --abort-on-container-exit
7676- find "$REGISTRY_LOCAL" -type f
7777-7878- - name: Rsync to ${{ vars.REGISTRY_RSYNC_DEST }}
7979- if: vars.REGISTRY_RSYNC_DEST != ''
8080- env:
8181- REGISTRY_SSH_KEY: ${{ secrets.REGISTRY_SSH_KEY }}
8282- REGISTRY_RSYNC_DEST: ${{ vars.REGISTRY_RSYNC_DEST }}
8383- run: |
8484- bash ./.github/scripts/rsync-registry.sh
8585-8633 linux-arm64:
8734 name: Linux (aarch64)
8835 runs-on: ubuntu-24.04-arm
+3-1
lib/cmd/build.ml
···392392 else begin
393393 let layer_hashes =
394394 let on_phase msg = Oi.Say.step "%s" msg in
395395+ let on_progress = Oi.Say.progress in
395396 Oi.Pipeline.build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key
396397 ~extra_repos:all_extras ~pins:url_project.pins ~refresh
397398 ~constraints:extra_constraints ?remote ?jobs ?toolchain
398398- ?local_packages_dir:url_project.packages_dir ~on_phase names
399399+ ?local_packages_dir:url_project.packages_dir ~on_phase ~on_progress
400400+ names
399401 in
400402 match
401403 find_target_layer ~fs ~cache ~os_key ~pkg_name:target layer_hashes
+23-15
lib/cmd/sync.ml
···190190 if quiet then Fmt.kstr (fun s -> Logs.info (fun m -> m "%s" s)) fmt
191191 else Fmt.kstr (fun s -> Oi.Say.step "%s" s) fmt
192192 in
193193- let say_field label fmt =
194194- if quiet then
195195- Fmt.kstr (fun s -> Logs.info (fun m -> m "%s: %s" label s)) fmt
196196- else Fmt.kstr (fun s -> Oi.Say.field label "%s" s) fmt
197197- in
198193 let say_info fmt =
199194 if quiet then Fmt.kstr (fun s -> Logs.info (fun m -> m "%s" s)) fmt
200195 else Fmt.kstr (fun s -> Oi.Say.info "%s" s) fmt
···211206 if deps = [] && extra_cli = [] && url_project.roots = [] then
212207 Oi.Error.config_error "No .opam files found in %s." cwd;
213208 say_step "Sync %s" cwd;
214214- if deps <> [] then say_field "deps" "%s" (String.concat ", " deps);
209209+ if deps <> [] then
210210+ if quiet then Logs.info (fun m -> m "deps: %s" (String.concat ", " deps))
211211+ else Oi.Say.field_list "deps" deps;
215212 if url_project.roots <> [] then
216216- say_field "with-deps" "%s" (String.concat ", " url_project.roots);
213213+ if quiet then
214214+ Logs.info (fun m ->
215215+ m "with-deps: %s" (String.concat ", " url_project.roots))
216216+ else Oi.Say.field_list "with-deps" url_project.roots;
217217 let conf =
218218 Oi.Pipeline.make_conf ~platform ~ocaml_version:Workspace.ocaml_version
219219 in
···235235 ~reporepo_path:(Terms.reporepo_path ()) ~toolchain candidate_overlays
236236 in
237237 if project_overlays <> [] then
238238- say_field "overlays" "%s" (String.concat ", " project_overlays);
238238+ if quiet then
239239+ Logs.info (fun m ->
240240+ m "overlays: %s" (String.concat ", " project_overlays))
241241+ else Oi.Say.field_list "overlays" project_overlays;
239242 let with_repos = project_overlays @ with_repos in
240243 let all_extras =
241244 Target.merge_extras
242245 ~cli:(Target.cli_extra_repos ~fs ~sys ?toolchain with_repos)
243246 ~project:(project.extra_repos @ url_project.extra_repos)
244247 in
245245- if all_extras <> [] then
246246- say_field "extra-repos" "%s"
247247- (String.concat ", "
248248- (List.map
249249- (fun (e : Oi.Project.extra_repo) -> Fmt.str "%s (%s)" e.name e.url)
250250- all_extras));
248248+ if all_extras <> [] then begin
249249+ let labels =
250250+ List.map
251251+ (fun (e : Oi.Project.extra_repo) -> Fmt.str "%s (%s)" e.name e.url)
252252+ all_extras
253253+ in
254254+ if quiet then
255255+ Logs.info (fun m -> m "extra-repos: %s" (String.concat ", " labels))
256256+ else Oi.Say.field_list "extra-repos" labels
257257+ end;
251258 let remote = Terms.remote_of_registry registry in
252259 let extra_constraints = Oi.Project.Script.constraints extra_cli in
253260 let extra_names =
···272279 let on_phase msg =
273280 if quiet then Logs.info (fun m -> m "%s" msg) else Oi.Say.step "%s" msg
274281 in
282282+ let on_progress msg = if not quiet then Oi.Say.progress msg in
275283 Oi.Pipeline.build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key
276284 ~extra_repos:all_extras
277285 ~pins:(project.pins @ url_project.pins)
278286 ~refresh ~constraints:extra_constraints ~project_root:cwd ?remote ?jobs
279279- ?toolchain ?local_packages_dir ~on_phase names
287287+ ?toolchain ?local_packages_dir ~on_phase ~on_progress names
280288 in
281289 let oi_dir = cwd / "_oi" in
282290 let prefix = oi_dir / "prefix" in
+17-10
lib/oi/execute.ml
···423423(* -- Reporter ------------------------------------------------------------- *)
424424425425type pkg_event =
426426- | Started of { pkg : string; stage : int; total_stages : int }
426426+ | Started of { pkg : string; phase : string; stage : int; total_stages : int }
427427 | Cached of { pkg : string }
428428 | Built of { pkg : string }
429429 | Build_failed of { pkg : string; log : string }
···448448 pkg_event =
449449 (fun e ->
450450 match e with
451451- | Started { pkg; stage; total_stages = _ } ->
452452- Ui.with_msg ui (Fmt.str "[%s] %s" (stage_s stage) pkg)
451451+ | Started { pkg; phase; stage; total_stages = _ } ->
452452+ Ui.with_msg ui (Fmt.str "[%s] %s %s" (stage_s stage) phase pkg)
453453 | Cached _ | Built _ -> Ui.tick ui
454454 | Dep_failed _ -> ()
455455 | Build_failed { pkg; log } ->
···735735 (Started
736736 {
737737 pkg = p.pkg;
738738+ phase = "restore";
738739 stage = group.stage;
739740 total_stages = n_stages;
740741 });
···791792 Eio.Fiber.List.iter ~max_fibers:build_parallelism
792793 (fun (p : Plan.package_plan) ->
793794 active := !active + 1;
794794- reporter.pkg_event
795795- (Started
796796- {
797797- pkg = p.pkg;
798798- stage = group.stage;
799799- total_stages = n_stages;
800800- });
795795+ let started phase =
796796+ reporter.pkg_event
797797+ (Started
798798+ {
799799+ pkg = p.pkg;
800800+ phase;
801801+ stage = group.stage;
802802+ total_stages = n_stages;
803803+ })
804804+ in
805805+ started "fetch";
801806 let t = trace_for p in
802807 (try
803808 Eio.Path.rmtree ~missing_ok:true Eio.Path.(fs / p.build_dir);
804809 let t0 = now () in
805810 fetch_phase ~cache_urls ~fs ~cache_root:plan.cache_root p;
806811 t.fetch_dur <- Some (now () -. t0);
812812+ started "build";
807813 let t1 = now () in
808814 build_phase ~proc_mgr ~fs p;
809815 t.build_dur <- Some (now () -. t1)
···833839 (Started
834840 {
835841 pkg = p.pkg;
842842+ phase = "install";
836843 stage = group.stage;
837844 total_stages = n_stages;
838845 });
+12-1
lib/oi/execute.mli
···1313 future reuse. *)
14141515type pkg_event =
1616- | Started of { pkg : string; stage : int; total_stages : int }
1616+ | Started of {
1717+ pkg : string;
1818+ phase : string;
1919+ (** "restore" / "fetch" / "build" / "install" — the lifecycle
2020+ sub-phase the package is entering. Multiple [Started] events fire
2121+ per source package as it transitions fetch → build → install;
2222+ binary packages get a single [Started] with phase ["restore"].
2323+ Reporters use this to drive a phase indicator in the progress bar.
2424+ *)
2525+ stage : int;
2626+ total_stages : int;
2727+ }
1728 | Cached of { pkg : string }
1829 | Built of { pkg : string }
1930 | Build_failed of { pkg : string; log : string }
+41-22
lib/oi/pipeline.ml
···175175 Fmt.str "%.0fKB" (Int64.to_float n /. 1024.)
176176 else Fmt.str "%LdB" n
177177178178-let fetch_remote_layers ?on_phase ?jobs ~remote ~d10 ~packages_dirs ~ctx ~pkgs
179179- build_plan =
178178+let fetch_remote_layers ?on_phase ?on_progress ?jobs ~remote ~d10 ~packages_dirs
179179+ ~ctx ~pkgs build_plan =
180180 match remote with
181181 | None -> build_plan
182182 | Some r ->
···211211 in-flight fibers. We're under [Eio.Fiber.List.iter] which
212212 on the default [Eio_posix] backend uses fibers (cooperative,
213213 no preemption between yield points), so a plain ref is
214214- safe — no mutex needed. *)
214214+ safe — no mutex needed.
215215+216216+ Routing: per-tick byte updates go to [on_progress] (a
217217+ high-frequency in-place sink), while one-shot milestone
218218+ messages — start of phase, final summary — go to [on_phase]
219219+ (typically a [Say.step] line). When [on_progress] isn't
220220+ supplied, byte updates fall through to [on_phase] so
221221+ callers like [oi run]'s spinner still see live activity. *)
215222 let done_count = ref 0 in
216223 let bytes_total = ref 0L in
217224 let last_emit = ref 0.0 in
218225 let throttle_s = 0.05 in
219219- let emit () =
220220- match on_phase with
221221- | None -> ()
222222- | Some f ->
223223- let now = Unix.gettimeofday () in
224224- if now -. !last_emit >= throttle_s then begin
225225- last_emit := now;
226226- f
227227- (Fmt.str "Fetching layers from registry (%d/%d, %s)"
228228- !done_count n_total (fmt_mb !bytes_total))
229229- end
226226+ (* Byte updates prefer [on_progress] (in-place sink). Fall back
227227+ to [on_phase] so callers that only supplied a milestone sink
228228+ — typically [oi run]'s spinner — still see live activity. *)
229229+ let progress_sink =
230230+ match (on_progress, on_phase) with
231231+ | Some f, _ | None, Some f -> f
232232+ | None, None -> fun _ -> ()
233233+ in
234234+ let emit_progress () =
235235+ let now = Unix.gettimeofday () in
236236+ if now -. !last_emit >= throttle_s then begin
237237+ last_emit := now;
238238+ progress_sink
239239+ (Fmt.str "Fetching layers from registry (%d/%d, %s)" !done_count
240240+ n_total (fmt_mb !bytes_total))
241241+ end
230242 in
231243 (* Per-fiber received counter so we don't double-count when
232244 retries / chunked downloads call [on_progress] cumulatively
···235247 let prev = !hash_ref in
236248 hash_ref := received;
237249 bytes_total := Int64.add !bytes_total (Int64.sub received prev);
238238- emit ()
250250+ emit_progress ()
239251 in
240252 Eio.Fiber.List.iter
241253 ~max_fibers:(fetch_parallelism ?jobs ())
···252264 ()
253265 then begin
254266 incr done_count;
255255- emit ();
267267+ emit_progress ();
256268 Logs.info (fun m -> m "Fetched %s from registry" hash)
257269 end)
258270 available;
259259- (* Final line — guaranteed past the throttle window. *)
271271+ (* Wipe the in-place progress line (if any) and emit the final
272272+ summary as a milestone — [on_phase] is a "fresh line" sink,
273273+ so the summary lands on its own row rather than overwriting
274274+ the last progress update. *)
275275+ (match on_progress with
276276+ | Some _ -> Say.progress_clear ()
277277+ | None -> ());
260278 (match on_phase with
261261- | None -> ()
262279 | Some f ->
263280 f
264281 (Fmt.str "Fetched %d/%d layers from registry (%s)" !done_count
265265- n_total (fmt_mb !bytes_total)));
282282+ n_total (fmt_mb !bytes_total))
283283+ | None -> ());
266284 Plan.build ctx ~d10 ~packages_dirs pkgs
267285 end
268286 end
···272290let build ~sys ~proc_mgr ~fs ~clock ~cache ~data_dir ~conf ~os_key
273291 ?(dry_run = false) ?(extra_repos = []) ?(pins = []) ?(refresh = false)
274292 ?remote ?jobs ?toolchain ?(constraints = OpamPackage.Name.Map.empty)
275275- ?project_root ?local_packages_dir ?on_phase ?preflight_done names =
293293+ ?project_root ?local_packages_dir ?on_phase ?on_progress ?preflight_done
294294+ names =
276295 let _ = preflight_done in
277296 let on_phase =
278297 match on_phase with
···389408 | None -> build_plan
390409 | Some _ ->
391410 on_phase "Checking registry for prebuilt layers";
392392- fetch_remote_layers ~on_phase ?jobs ~remote ~d10 ~packages_dirs ~ctx
393393- ~pkgs build_plan
411411+ fetch_remote_layers ~on_phase ?on_progress ?jobs ~remote ~d10
412412+ ~packages_dirs ~ctx ~pkgs build_plan
394413 in
395414 let hashes = Plan.layer_hashes build_plan in
396415 (* Every layer in the plan must be cached (Binary method) to skip
+10-3
lib/oi/pipeline.mli
···112112113113val fetch_remote_layers :
114114 ?on_phase:(string -> unit) ->
115115+ ?on_progress:(string -> unit) ->
115116 ?jobs:int ->
116117 remote:D10.Layer.remote option ->
117118 d10:D10.Config.t ->
···124125 graph with downloaded layers promoted to [Binary]. No-op when
125126 [remote = None] or every layer is already cached.
126127127127- [on_phase] receives aggregated status across the parallel fiber pool — both
128128- completed-layer counts and cumulative bytes received. Throttled to ~20Hz
129129- internally; safe to wire into a [Tty.Progress] sink. *)
128128+ Progress reporting is split:
129129+ - [on_phase] receives one-shot milestones (e.g. the final "Fetched N/M
130130+ layers" summary).
131131+ - [on_progress] receives the high-frequency byte/count updates, throttled to
132132+ ~20Hz. Typically wired to an in-place line sink like {!Say.progress}.
133133+134134+ When only [on_phase] is supplied, byte updates fall through to it so
135135+ spinner-style callers ([oi run]) keep showing live activity. *)
130136131137val build :
132138 sys:D10.Sysops.t ->
···148154 ?project_root:string ->
149155 ?local_packages_dir:string ->
150156 ?on_phase:(string -> unit) ->
157157+ ?on_progress:(string -> unit) ->
151158 ?preflight_done:(unit -> unit) ->
152159 OpamPackage.Name.t list ->
153160 string list
+28
lib/oi/say.ml
···3535 flush_out ())
3636 fmt
37373838+(* Continuation indent for [field_list] wrapping: 2 leading spaces + the
3939+ label gutter + 1 separator space = 15 columns. Has to match [field]'s
4040+ prefix exactly so wrapped lines line up under the first item. *)
4141+let field_continuation_indent = 2 + label_width + 1
4242+4343+let field_list ?(sep = ", ") label items =
4444+ match items with
4545+ | [] -> ()
4646+ | _ ->
4747+ let term_w = Tty.Width.terminal_width () in
4848+ let body_w = max 20 (term_w - field_continuation_indent) in
4949+ let joined = String.concat sep items in
5050+ let wrapped = Tty.Width.wrap body_w joined in
5151+ let pad = String.make field_continuation_indent ' ' in
5252+ (match String.split_on_char '\n' wrapped with
5353+ | [] -> ()
5454+ | first :: rest ->
5555+ Fmt.pr " %a %s@." Style.dim_string
5656+ (Fmt.str "%-*s" label_width (label ^ ":"))
5757+ first;
5858+ List.iter (fun line -> Fmt.pr "%s%s@." pad line) rest);
5959+ flush_out ()
6060+6161+let progress msg =
6262+ if Tty.is_tty () then Fmt.pr "\r\027[K%a%!" Style.dim_string msg
6363+6464+let progress_clear () = if Tty.is_tty () then Fmt.pr "\r\027[K%!"
6565+3866let header fmt =
3967 Fmt.kstr
4068 (fun s ->
+19
lib/oi/say.mli
···2323(** [field "deps" "%s" v] prints [" deps: v"] with the label dim and a fixed
2424 alignment column for the value. *)
25252626+val field_list : ?sep:string -> string -> string list -> unit
2727+(** [field_list ?sep "deps" items] prints a field whose value is a list of
2828+ items, joined with [sep] (default [", "]) and word-wrapped to the terminal
2929+ width. Continuation lines are indented to line up with the value column.
3030+ No-op when [items = []]. *)
3131+3232+val progress : string -> unit
3333+(** [progress msg] writes [msg] in dim style on the current line, replacing
3434+ whatever was there (via [\r\033[K]) without emitting a newline. Used for
3535+ in-place high-frequency status updates (e.g. "Fetching layers (12/47,
3636+ 34MB)" during a registry pull). The next non-progress write — typically
3737+ a {!step} or {!progress_clear} — replaces or overwrites the line.
3838+ No-op on non-TTY. *)
3939+4040+val progress_clear : unit -> unit
4141+(** Erase the current in-place [progress] line without emitting a newline. Use
4242+ before a final {!step} so the summary lands cleanly on the same row. No-op
4343+ on non-TTY. *)
4444+2645val header : ('a, Format.formatter, unit, unit) format4 -> 'a
2746(** [header "%s" h] prints ["h"] in bold — used for section headers in
2847 multi-block reports ([oi show], [oi config]). *)
+198-17
registry/index.html
···287287 border-radius: 6px;
288288 overflow: hidden;
289289}
290290-.pkg-list-header {
290290+/* Column track widths kept identical between header and rows so the
291291+ columns line up. The package column is bounded with [minmax] so it
292292+ doesn't run away on wide windows; surplus width goes to the distros
293293+ column instead, where each new distro chip can use it. */
294294+.pkg-list-header,
295295+.pkg-row {
291296 display: grid;
292292- grid-template-columns: 110px 1fr 110px 110px 80px 70px;
297297+ grid-template-columns:
298298+ 110px /* status badge */
299299+ minmax(180px, 320px) /* package name+version, capped */
300300+ minmax(100px, 1fr) /* per-distro chip strip — flexes */
301301+ 100px /* overlay tags */
302302+ 96px /* short layer hash */
303303+ 70px /* method */
304304+ 64px; /* duration */
293305 gap: 0.6rem;
294294- padding: 0.35rem 0.85rem;
306306+ padding: 0.3rem 0.85rem;
307307+ align-items: center;
308308+}
309309+.pkg-list-header {
310310+ padding-top: 0.35rem; padding-bottom: 0.35rem;
295311 background: var(--bg-sunken);
296312 border-bottom: 1px solid var(--border);
297313 font-family: ui-monospace, monospace;
···302318 font-weight: 600;
303319}
304320.pkg-row {
305305- display: grid;
306306- grid-template-columns: 110px 1fr 110px 110px 80px 70px;
307307- gap: 0.6rem;
308308- padding: 0.3rem 0.85rem;
309321 border-bottom: 1px solid var(--border);
310322 cursor: pointer;
311311- align-items: center;
312323 font-family: ui-monospace, SF Mono, Monaco, monospace;
313324 font-size: 0.78rem;
314325 position: relative;
···351362.outcome-cached { background: var(--cached); color: white; }
352363.outcome-warn { background: var(--warn); color: white; }
353364.outcome-fail { background: var(--fail); color: white; }
365365+.outcome-missing { background: var(--bg-sunken); color: var(--fg-faint); border: 1px dashed var(--border); }
366366+367367+/* Per-distro mini chips: one per probed os_key, colored by that
368368+ distro's outcome for this package. Missing data shows as a dim
369369+ dashed dot so the user can spot "every distro succeeded except
370370+ this one" at a glance. */
371371+.distro-chips {
372372+ display: flex; flex-wrap: wrap; gap: 0.2rem;
373373+}
374374+.distro-chip {
375375+ display: inline-block;
376376+ padding: 0.05rem 0.35rem;
377377+ border-radius: 3px;
378378+ font-size: 0.6rem;
379379+ font-weight: 600;
380380+ letter-spacing: 0.02em;
381381+ text-align: center;
382382+ white-space: nowrap;
383383+ cursor: pointer;
384384+ font-family: ui-monospace, monospace;
385385+ border: 1px solid transparent;
386386+}
387387+.distro-chip:hover { border-color: rgba(255, 255, 255, 0.5); }
388388+.distro-chip.outcome-missing {
389389+ font-weight: 400;
390390+ cursor: default;
391391+}
392392+.distro-chip.outcome-missing:hover { border-color: transparent; }
393393+.distro-chip.active {
394394+ outline: 2px solid var(--accent);
395395+ outline-offset: 1px;
396396+}
354397.pkg-name { font-weight: 600; }
355398.pkg-version { color: var(--fg-dim); }
356399.pkg-meta { color: var(--fg-dim); font-size: 0.72rem; text-align: right; }
···541584.pkg-detail .dim { color: var(--fg-dim); }
542585543586.empty { text-align: center; color: var(--fg-dim); padding: 2.5rem; }
587587+.loading { max-width: 480px; margin: 4rem auto; }
588588+.loading-bar {
589589+ width: 100%; height: 6px; border-radius: 3px;
590590+ background: var(--bg-sunken); overflow: hidden;
591591+ margin-bottom: 0.75rem;
592592+}
593593+.loading-bar-fill {
594594+ height: 100%; width: 0%; background: var(--accent);
595595+ transition: width 0.15s ease-out;
596596+}
597597+.loading-status { font-size: 0.85rem; color: var(--fg); margin-bottom: 0.4rem; }
598598+.loading-detail {
599599+ font-size: 0.7rem; color: var(--fg-dim); font-family: ui-monospace, monospace;
600600+ display: grid; grid-template-columns: auto 1fr auto;
601601+ gap: 0.2rem 0.6rem; max-width: 100%;
602602+ text-align: left; margin: 0 auto; padding: 0 1rem;
603603+}
604604+.loading-detail .row { display: contents; }
605605+.loading-detail .row .status-ok { color: var(--ok); }
606606+.loading-detail .row .status-fail { color: var(--fail); }
607607+.loading-detail .row .status-pending { color: var(--fg-dim); }
544608.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; }
545609@keyframes spin { to { transform: rotate(360deg); } }
546610547611@media (max-width: 760px) {
548612 .filters { grid-template-columns: 1fr; }
549613 .pkg-list-header, .pkg-row {
550550- grid-template-columns: 90px 1fr 80px 70px;
614614+ /* On narrow screens drop the per-row layer/method/duration trio
615615+ and the overlay column — they'd squeeze the package name to
616616+ almost nothing. Status / package / distros stay visible. */
617617+ grid-template-columns: 90px 1fr minmax(80px, 1fr);
551618 }
552552- .pkg-list-header .col-method, .pkg-list-header .col-overlay,
553553- .pkg-row > :nth-child(3), .pkg-row > :nth-child(5) { display: none; }
619619+ .pkg-list-header > :nth-child(n + 4),
620620+ .pkg-row > :nth-child(n + 4) { display: none; }
554621}
555622</style>
556623</head>
···562629 <select class="os-select" id="os-select" title="Select OS"></select>
563630</header>
564631<main>
565565- <div id="content"><div class="empty"><span class="spinner"></span>Loading manifests…</div></div>
632632+ <div id="content">
633633+ <div class="empty loading">
634634+ <div class="loading-bar">
635635+ <div class="loading-bar-fill" id="loading-fill"></div>
636636+ </div>
637637+ <div class="loading-status" id="loading-status">
638638+ <span class="spinner"></span>Loading manifests…
639639+ </div>
640640+ <div class="loading-detail" id="loading-detail"></div>
641641+ </div>
642642+ </div>
566643</main>
567644568645<script>
···669746 return [...set].sort();
670747}
671748749749+// "alpine~3.23~x86_64" → "alpine 3.23". Used for the small distro
750750+// chips in the row strip so the chip is short but unambiguous.
751751+function shortDistro(osKey) {
752752+ const p = parseOsKey(osKey);
753753+ return p.short || p.name;
754754+}
755755+756756+// Cross-distro outcome for a given (pkg.name, pkg.version). Returns
757757+// [{ os_key, outcome, layer_hash }, ...] over every loaded manifest
758758+// — including a sentinel { outcome: "missing" } for distros where
759759+// the package has no entry, so the strip lines up across rows. *)
760760+function distroStripFor(pkg) {
761761+ const out = [];
762762+ for (const k of PROBE_OS_KEYS) {
763763+ const m = state.manifests.get(k);
764764+ if (!m) continue;
765765+ const hit = m.results.find(
766766+ r => r.pkg.name === pkg.name && r.pkg.version === pkg.version);
767767+ if (hit) {
768768+ out.push({ os_key: k, outcome: hit.headline_outcome,
769769+ layer_hash: hit.layer_hash });
770770+ } else {
771771+ out.push({ os_key: k, outcome: "missing", layer_hash: null });
772772+ }
773773+ }
774774+ return out;
775775+}
776776+777777+function renderDistroChips(pkg) {
778778+ const cells = distroStripFor(pkg);
779779+ if (cells.length === 0) return "";
780780+ return `<span class="distro-chips">${cells.map(c => {
781781+ const grp = c.outcome === "missing" ? "missing" : (OUTCOME_GROUPS[c.outcome] || "ok");
782782+ const label = shortDistro(c.os_key);
783783+ const isActive = c.os_key === state.active;
784784+ const cls = "distro-chip outcome-" + grp + (isActive ? " active" : "");
785785+ const tip = c.outcome === "missing"
786786+ ? `${c.os_key}: not built (click disabled)`
787787+ : `${c.os_key}: ${c.outcome.replace(/_/g, " ")} — click to drill in`;
788788+ // Carry the os_key + layer_hash on the chip so the click handler
789789+ // can switch [state.active] and expand the same package's row in
790790+ // the target distro's manifest. Missing chips have no layer_hash
791791+ // and stay non-interactive.
792792+ const hashAttr = c.layer_hash
793793+ ? ` data-hash="${escapeHtml(c.layer_hash)}"` : "";
794794+ const oskAttr = c.outcome === "missing"
795795+ ? "" : ` data-os-key="${escapeHtml(c.os_key)}"`;
796796+ return `<span class="${cls}"${oskAttr}${hashAttr} title="${escapeHtml(tip)}">${escapeHtml(label)}</span>`;
797797+ }).join("")}</span>`;
798798+}
799799+672800// "alpine~3.23~x86_64" -> { name, version, arch, short, full }
673801function parseOsKey(key) {
674802 const parts = key.split("~");
···11191247 const headerRow = `<div class="pkg-list-header">
11201248 <span>status</span>
11211249 <span>package</span>
12501250+ <span>distros</span>
11221251 <span class="col-overlay">overlay</span>
11231252 <span style="text-align:right">layer</span>
11241253 <span class="col-method" style="text-align:right">method</span>
···11451274 const rowHtml = `<div class="pkg-row${expanded}${upstream}" data-hash="${escapeHtml(r.layer_hash)}">
11461275 <span class="outcome-badge outcome-${grp}">${r.headline_outcome.replace(/_/g, " ")}</span>
11471276 <span><span class="pkg-name">${escapeHtml(r.pkg.name)}</span> <span class="pkg-version">${escapeHtml(r.pkg.version)}</span></span>
12771277+ ${renderDistroChips(r.pkg)}
11481278 ${chips}
11491149- <span class="pkg-meta">${escapeHtml(r.layer_hash.slice(0,12))}</span>
11501150- <span class="pkg-meta">${escapeHtml(r.method)}</span>
11511151- <span class="pkg-meta">${r.duration_s != null ? fmtDuration(r.duration_s) : "—"}</span>
12791279+ <span class="pkg-meta" style="text-align:right">${escapeHtml(r.layer_hash.slice(0,12))}</span>
12801280+ <span class="pkg-meta" style="text-align:right">${escapeHtml(r.method)}</span>
12811281+ <span class="pkg-meta" style="text-align:right">${r.duration_s != null ? fmtDuration(r.duration_s) : "—"}</span>
11521282 </div>`;
11531283 return rowHtml + (state.expanded === r.layer_hash ? renderDetail(r, osKey) : "");
11541284 }).join("")}</div>`;
···12411371 row.onclick = (e) => {
12421372 // Clicks on links inside an expanded panel must not collapse the row.
12431373 if (e.target.closest("a[data-jump]")) return;
13741374+ // Clicking a distro chip jumps to that distro's view of the same
13751375+ // package. The active os_key changes, the OS dropdown updates,
13761376+ // and the row stays expanded so the user can read the failure
13771377+ // detail without an extra click. Missing chips (no data) are
13781378+ // skipped because they have no layer_hash to expand.
13791379+ const chip = e.target.closest(".distro-chip[data-os-key]");
13801380+ if (chip) {
13811381+ e.stopPropagation();
13821382+ const targetOs = chip.dataset.osKey;
13831383+ const targetHash = chip.dataset.hash;
13841384+ if (!targetOs || !targetHash) return;
13851385+ if (!state.manifests.has(targetOs)) return;
13861386+ state.active = targetOs;
13871387+ expand(targetHash);
13881388+ writeHash(targetOs, targetHash);
13891389+ render();
13901390+ const t = document.querySelector(`.pkg-row[data-hash="${targetHash}"]`);
13911391+ if (t) t.scrollIntoView({ block: "nearest", behavior: "smooth" });
13921392+ return;
13931393+ }
12441394 const hash = row.dataset.hash;
12451395 if (state.expanded === hash) {
12461396 state.expanded = null;
···13081458}
1309145913101460(async function init() {
13111311- // Fetch manifest + audit slice for every probed os_key in parallel. Audit
13121312- // is best-effort: a registry that ships only manifest.json still works,
14611461+ // Fetch manifest + audit slice for every probed os_key in parallel,
14621462+ // updating a per-distro loading bar as each completes. Audit is
14631463+ // best-effort: a registry that ships only manifest.json still works,
13131464 // it just loses the per-event drill-down in the detail panel.
14651465+ const fill = document.getElementById("loading-fill");
14661466+ const statusEl = document.getElementById("loading-status");
14671467+ const detailEl = document.getElementById("loading-detail");
14681468+ // Pre-populate the detail rows so the user sees the full set of
14691469+ // probes immediately, then watches each one tick from "pending" to
14701470+ // "ok" / "fail" / "missing" as it lands.
14711471+ detailEl.innerHTML = PROBE_OS_KEYS
14721472+ .map(k => `<div class="row" data-osk="${escapeHtml(k)}">
14731473+ <span class="status-pending">·</span>
14741474+ <span>${escapeHtml(k)}</span>
14751475+ <span class="status-pending">pending</span>
14761476+ </div>`)
14771477+ .join("");
14781478+ let done = 0;
14791479+ const total = PROBE_OS_KEYS.length;
14801480+ const tick = (k, label, cls) => {
14811481+ done++;
14821482+ fill.style.width = `${Math.round((done / total) * 100)}%`;
14831483+ statusEl.textContent = `Loaded ${done}/${total} manifests…`;
14841484+ const row = detailEl.querySelector(`[data-osk="${CSS.escape(k)}"]`);
14851485+ if (row) {
14861486+ const [icon, _name, status] = row.children;
14871487+ icon.textContent = cls === "fail" ? "✗" : "✓";
14881488+ icon.className = cls === "fail" ? "status-fail" : "status-ok";
14891489+ status.textContent = label;
14901490+ status.className = cls === "fail" ? "status-fail" : "status-ok";
14911491+ }
14921492+ };
13141493 const results = await Promise.all(
13151494 PROBE_OS_KEYS.map(async k => {
13161495 const [m, a] = await Promise.all([fetchManifest(k), fetchAudit(k)]);
14961496+ if (m) tick(k, `${m.n_packages} entries`, "ok");
14971497+ else tick(k, "missing", "fail");
13171498 return [k, m, a];
13181499 }));
13191500 for (const [k, m, a] of results) {