···105105 Oi.Say.field "audit" "%d event(s) at %s/audit.jsonl" (List.length events)
106106 (output / os_key)
107107 end
108108- end
108108+ end;
109109+ (* Emit per-overlay-handle markdown reports aggregated across every distro
110110+ currently staged under [output]. Re-run from each export so a freshly
111111+ synced sibling distro picks up the latest view. *)
112112+ let handles_written =
113113+ Oi.Handle_report.write_all ~fs ~output_dir:output
114114+ ~generated_at:(Unix.gettimeofday ())
115115+ in
116116+ match handles_written with
117117+ | [] -> ()
118118+ | hs ->
119119+ Oi.Say.field "handles" "%d failure report(s) at %s/handles/"
120120+ (List.length hs) output
+347
lib/oi/handle_report.ml
···11+let ( / ) = Filename.concat
22+let log_src = Logs.Src.create "oi.handle_report"
33+44+module Log = (val Logs.src_log log_src : Logs.LOG)
55+66+type slice = {
77+ os_key : string;
88+ manifest : Manifest.t;
99+ events : Audit.event list;
1010+}
1111+1212+(* -- Reading slices ------------------------------------------------------ *)
1313+1414+let load_text fs path =
1515+ try Some (Eio.Path.load Eio.Path.(fs / path)) with Eio.Exn.Io _ -> None
1616+1717+let split_lines s =
1818+ String.split_on_char '\n' s |> List.filter (fun l -> l <> "")
1919+2020+let load_manifest ~fs ~output_dir ~os_key =
2121+ let path = output_dir / os_key / "logs" / "manifest.json" in
2222+ match load_text fs path with
2323+ | None -> None
2424+ | Some content -> (
2525+ match
2626+ Jsont_bytesrw.decode_string ~locs:false ~file:path Manifest.codec
2727+ content
2828+ with
2929+ | Ok m -> Some m
3030+ | Error msg ->
3131+ Log.debug (fun m -> m "manifest decode %s: %s" path msg);
3232+ None)
3333+3434+let load_audit_events ~fs ~output_dir ~os_key =
3535+ let path = Audit.per_os_path ~output_dir ~os_key in
3636+ match load_text fs path with
3737+ | None -> []
3838+ | Some content ->
3939+ split_lines content
4040+ |> List.filter_map (fun line ->
4141+ match
4242+ Jsont_bytesrw.decode_string ~locs:false ~file:path Audit.event_codec
4343+ line
4444+ with
4545+ | Ok e -> Some e
4646+ | Error msg ->
4747+ Log.debug (fun m -> m "audit bad line %s: %s" path msg);
4848+ None)
4949+5050+let read_slice ~fs ~output_dir ~os_key =
5151+ match load_manifest ~fs ~output_dir ~os_key with
5252+ | None -> None
5353+ | Some manifest ->
5454+ let events = load_audit_events ~fs ~output_dir ~os_key in
5555+ Some { os_key; manifest; events }
5656+5757+let list_subdirs ~fs path =
5858+ try
5959+ Eio.Path.read_dir Eio.Path.(fs / path)
6060+ |> List.filter (fun name ->
6161+ name <> ""
6262+ && name.[0] <> '.'
6363+ && Eio.Path.is_directory Eio.Path.(fs / path / name))
6464+ with Eio.Exn.Io _ -> []
6565+6666+let read_all_slices ~fs ~output_dir =
6767+ list_subdirs ~fs output_dir
6868+ |> List.filter_map (fun os_key -> read_slice ~fs ~output_dir ~os_key)
6969+ |> List.sort (fun a b -> String.compare a.os_key b.os_key)
7070+7171+(* -- Handle enumeration -------------------------------------------------- *)
7272+7373+module String_set = Set.Make (String)
7474+7575+let handles slices =
7676+ List.fold_left
7777+ (fun acc s ->
7878+ List.fold_left
7979+ (fun acc (e : Audit.event) ->
8080+ match e.context.overlay with
8181+ | Some o -> String_set.add o.handle acc
8282+ | None -> acc)
8383+ acc s.events)
8484+ String_set.empty slices
8585+ |> String_set.elements
8686+8787+(* -- Markdown helpers ---------------------------------------------------- *)
8888+8989+let is_failure_kind = function
9090+ | Outcome.K_ok | K_cached | K_restored | K_skipped -> false
9191+ | K_build_failed | K_install_failed | K_dep_failed | K_fetch_failed
9292+ | K_depext_missing | K_solve_failed ->
9393+ true
9494+9595+let outcome_to_kind_string o = Outcome.kind_to_string (Outcome.kind_of o)
9696+9797+let pp_outcome_detail buf (o : Outcome.t) =
9898+ let p fmt = Fmt.kstr (Buffer.add_string buf) fmt in
9999+ match o with
100100+ | Build_failed { command; exit_code } ->
101101+ p "- Command: `%s`\n" command;
102102+ Stdlib.Option.iter (fun c -> p "- Exit code: `%d`\n" c) exit_code
103103+ | Install_failed { command; exit_code } ->
104104+ p "- Command: `%s`\n" command;
105105+ Stdlib.Option.iter (fun c -> p "- Exit code: `%d`\n" c) exit_code
106106+ | Fetch_failed { url; kind } ->
107107+ p "- URL: `%s`\n" url;
108108+ let k =
109109+ match kind with
110110+ | Http_status n -> Fmt.str "HTTP %d" n
111111+ | Checksum_mismatch -> "checksum mismatch"
112112+ | Network_timeout -> "network timeout"
113113+ | Git_failed -> "git failed"
114114+ | Other s -> s
115115+ in
116116+ p "- Kind: %s\n" k
117117+ | Dep_failed { upstream } ->
118118+ p "- Failing upstream dep: `%s`\n" (Identity.to_string upstream.id);
119119+ p "- Upstream layer: `%s`\n" upstream.hash
120120+ | Depext_missing { missing; not_found } ->
121121+ if missing <> [] then
122122+ p "- System packages missing: %s\n"
123123+ (String.concat ", " (List.map (Fmt.str "`%s`") missing));
124124+ if not_found <> [] then
125125+ p "- System packages with no manager mapping: %s\n"
126126+ (String.concat ", " (List.map (Fmt.str "`%s`") not_found))
127127+ | Solve_failed { reason } -> p "- Reason: %s\n" reason
128128+ | Skipped { reason } -> p "- Reason: %s\n" reason
129129+ | Ok | Cached | Restored -> ()
130130+131131+let format_iso_utc ts =
132132+ let tm = Unix.gmtime ts in
133133+ Fmt.str "%04d-%02d-%02dT%02d:%02d:%02dZ" (tm.tm_year + 1900) (tm.tm_mon + 1)
134134+ tm.tm_mday tm.tm_hour tm.tm_min tm.tm_sec
135135+136136+let log_relative_path ~os_key (lp : Audit.log_pointer) =
137137+ (* The audit log_pointer's text_path is the original local cache path, e.g.
138138+ [<cache>/build/logs/build-foo.1.0-abc.log]. The registry only ships the
139139+ filename's logs/ trailing component conceptually, so we emit a hint
140140+ pointing at the conventional "../<os_key>/logs/<basename>" location an
141141+ agent can join with the registry root. The file may or may not be
142142+ published — the embedded tail is the authoritative copy. *)
143143+ let basename = Filename.basename lp.text_path in
144144+ "../" ^ os_key ^ "/logs/" ^ basename
145145+146146+let trim_tail ?(max_lines = 60) text =
147147+ let lines = String.split_on_char '\n' text in
148148+ let n = List.length lines in
149149+ if n <= max_lines then text
150150+ else
151151+ let drop = n - max_lines in
152152+ let rec skip k = function
153153+ | [] -> []
154154+ | _ :: rest when k > 0 -> skip (k - 1) rest
155155+ | xs -> xs
156156+ in
157157+ String.concat "\n" (skip drop lines)
158158+159159+(* -- Filtering & grouping ------------------------------------------------ *)
160160+161161+let event_handle (e : Audit.event) =
162162+ Stdlib.Option.map (fun (o : D10.Overlay.t) -> o.handle) e.context.overlay
163163+164164+let failures_for_handle ~handle slices =
165165+ List.concat_map
166166+ (fun s ->
167167+ List.filter_map
168168+ (fun (e : Audit.event) ->
169169+ if event_handle e <> Some handle then None
170170+ else if not (is_failure_kind (Outcome.kind_of e.outcome)) then None
171171+ else Some (s.os_key, e))
172172+ s.events)
173173+ slices
174174+175175+(* Group failures by Identity.t (preserving the kind histogram). *)
176176+module Pkg_map = Map.Make (struct
177177+ type t = Identity.t
178178+179179+ let compare (a : Identity.t) (b : Identity.t) =
180180+ match String.compare a.Identity.name b.Identity.name with
181181+ | 0 -> String.compare a.Identity.version b.Identity.version
182182+ | n -> n
183183+end)
184184+185185+let group_by_pkg pairs =
186186+ List.fold_left
187187+ (fun acc (os_key, ev) ->
188188+ let pkg = (ev : Audit.event).pkg in
189189+ let prev = try Pkg_map.find pkg acc with Not_found -> [] in
190190+ Pkg_map.add pkg ((os_key, ev) :: prev) acc)
191191+ Pkg_map.empty pairs
192192+ |> Pkg_map.bindings
193193+ |> List.map (fun (pkg, evs) ->
194194+ let evs =
195195+ List.sort (fun (a_os, _) (b_os, _) -> String.compare a_os b_os) evs
196196+ in
197197+ (pkg, evs))
198198+199199+(* Find a per-package source URL across slices, falling back across [os_key]s
200200+ so the agent gets some upstream pointer even if the failing distro's
201201+ manifest doesn't have provenance for that package (typical: build never
202202+ committed). *)
203203+let source_for_pkg ~pkg slices =
204204+ List.find_map
205205+ (fun s ->
206206+ List.find_map
207207+ (fun (e : Manifest.entry) ->
208208+ if e.pkg = pkg then
209209+ match e.source with
210210+ | Some src when src.url <> "" -> Some src.url
211211+ | _ -> None
212212+ else None)
213213+ s.manifest.results)
214214+ slices
215215+216216+(* -- Markdown rendering -------------------------------------------------- *)
217217+218218+let buf_add buf s = Buffer.add_string buf s
219219+let buf_addf buf fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt
220220+221221+let summarize_kinds events =
222222+ List.fold_left
223223+ (fun acc (_os_key, (e : Audit.event)) ->
224224+ Outcome.bump (Outcome.kind_of e.outcome) acc)
225225+ [] events
226226+ |> Outcome.sort_histogram
227227+228228+let markdown ~handle ~generated_at slices =
229229+ let failures = failures_for_handle ~handle slices in
230230+ if failures = [] then ""
231231+ else
232232+ let buf = Buffer.create 4096 in
233233+ let pkg_groups = group_by_pkg failures in
234234+ let n_pkgs = List.length pkg_groups in
235235+ let n_events = List.length failures in
236236+ let kinds = summarize_kinds failures in
237237+ let distros =
238238+ List.fold_left
239239+ (fun acc (os_key, _) -> String_set.add os_key acc)
240240+ String_set.empty failures
241241+ |> String_set.elements
242242+ in
243243+ buf_addf buf "# Failure report: @%s\n\n" handle;
244244+ buf_addf buf "_Generated %s — for LLM-agent consumption._\n\n"
245245+ (format_iso_utc generated_at);
246246+ buf_add buf
247247+ "This file lists every package that failed to build under the `@";
248248+ buf_add buf handle;
249249+ buf_add buf
250250+ "` overlay handle, joined across every distro currently published to \
251251+ this registry. Each section gives the failing outcome, an embedded tail \
252252+ of the build log, and a one-liner you can paste to reproduce locally.\n\n";
253253+ buf_add buf "## Summary\n\n";
254254+ buf_addf buf "- %d failing package(s), %d failure event(s)\n" n_pkgs
255255+ n_events;
256256+ buf_addf buf "- Distros with at least one failure: %s\n"
257257+ (String.concat ", " (List.map (fun s -> "`" ^ s ^ "`") distros));
258258+ buf_add buf "- Outcome mix: ";
259259+ (match kinds with
260260+ | [] -> buf_add buf "_(none)_"
261261+ | _ ->
262262+ buf_add buf
263263+ (String.concat ", "
264264+ (List.map
265265+ (fun (k, n) -> Fmt.str "%d %s" n (Outcome.kind_to_string k))
266266+ kinds)));
267267+ buf_add buf "\n\n";
268268+ buf_add buf "## Reproduction\n\n";
269269+ buf_add buf
270270+ "Build every package that participates in this overlay (host distro):\n\n";
271271+ buf_addf buf "```sh\noi build @%s\n```\n\n" handle;
272272+ buf_add buf "Build a single failing package:\n\n";
273273+ buf_add buf "```sh\n";
274274+ buf_addf buf "oi build @%s/<pkg>\n" handle;
275275+ buf_add buf "```\n\n";
276276+ buf_add buf
277277+ "To rebuild on a specific distro, run inside the matching container \
278278+ image (e.g. `docker run --rm -it oi:fedora-43`).\n\n";
279279+ buf_add buf "## Failures\n\n";
280280+ List.iter
281281+ (fun (pkg, evs) ->
282282+ let pkg_label = Identity.to_string pkg in
283283+ let kinds_for_pkg =
284284+ List.map (fun (_, e) -> Outcome.kind_of (e : Audit.event).outcome) evs
285285+ |> List.sort_uniq compare
286286+ |> List.map Outcome.kind_to_string
287287+ |> String.concat ", "
288288+ in
289289+ buf_addf buf "### `%s` — %s\n\n" pkg_label kinds_for_pkg;
290290+ buf_addf buf "Reproduce: `oi build @%s/%s`\n\n" handle pkg_label;
291291+ (match source_for_pkg ~pkg slices with
292292+ | Some url -> buf_addf buf "Source: %s\n\n" url
293293+ | None -> ());
294294+ let by_os =
295295+ List.sort
296296+ (fun (a, _) (b, _) -> String.compare a b)
297297+ (List.map (fun (os_key, ev) -> (os_key, ev)) evs)
298298+ in
299299+ List.iter
300300+ (fun (os_key, (e : Audit.event)) ->
301301+ buf_addf buf "#### %s — %s\n\n" os_key
302302+ (outcome_to_kind_string e.outcome);
303303+ buf_addf buf "- When: %s\n" (format_iso_utc e.ts);
304304+ (match e.target with
305305+ | Layer h -> buf_addf buf "- Layer hash: `%s`\n" h
306306+ | Solve_key h -> buf_addf buf "- Solve key: `%s`\n" h);
307307+ pp_outcome_detail buf e.outcome;
308308+ (match e.log with
309309+ | Some lp ->
310310+ buf_addf buf "- Log (registry-relative): `%s`\n"
311311+ (log_relative_path ~os_key lp)
312312+ | None -> ());
313313+ buf_add buf "\n";
314314+ match Stdlib.Option.bind e.log (fun lp -> lp.tail) with
315315+ | Some tail when String.trim tail <> "" ->
316316+ buf_add buf "<details><summary>Log tail</summary>\n\n";
317317+ buf_add buf "```\n";
318318+ buf_add buf (trim_tail tail);
319319+ if
320320+ not
321321+ (String.length tail > 0
322322+ && tail.[String.length tail - 1] = '\n')
323323+ then buf_add buf "\n";
324324+ buf_add buf "```\n\n";
325325+ buf_add buf "</details>\n\n"
326326+ | _ -> ())
327327+ by_os;
328328+ buf_add buf "---\n\n")
329329+ pkg_groups;
330330+ Buffer.contents buf
331331+332332+(* -- write_all ----------------------------------------------------------- *)
333333+334334+let write_all ~fs ~output_dir ~generated_at =
335335+ let slices = read_all_slices ~fs ~output_dir in
336336+ let hs = handles slices in
337337+ let dir = output_dir / "handles" in
338338+ Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 Eio.Path.(fs / dir);
339339+ List.filter_map
340340+ (fun handle ->
341341+ let body = markdown ~handle ~generated_at slices in
342342+ if body = "" then None
343343+ else
344344+ let path = dir / (handle ^ ".md") in
345345+ Eio.Path.save ~create:(`Or_truncate 0o644) Eio.Path.(fs / path) body;
346346+ Some handle)
347347+ hs
+53
lib/oi/handle_report.mli
···11+(** Per-overlay-handle failure report in markdown.
22+33+ Joins {!Manifest} (one per [os_key]) with the {!Audit} event slice for the
44+ same [os_key] to surface, for each overlay handle that built under
55+ [@<handle>], the packages that failed and how to reproduce them.
66+77+ The generated files are written to [<output_dir>/handles/<handle>.md] — a
88+ stable, well-known location an LLM agent can fetch from a registry to pick
99+ up bug reports without having to parse the JSON manifests. *)
1010+1111+type slice = {
1212+ os_key : string;
1313+ manifest : Manifest.t;
1414+ events : Audit.event list;
1515+}
1616+(** A registry-side data slice for one OS key. *)
1717+1818+val read_slice :
1919+ fs:Eio.Fs.dir_ty Eio.Path.t ->
2020+ output_dir:string ->
2121+ os_key:string ->
2222+ slice option
2323+(** Load [<output_dir>/<os_key>/{logs/manifest.json,audit.jsonl}] into a slice.
2424+ Returns [None] when the manifest is missing or fails to decode. Missing
2525+ audit files become an empty event list (which is normal for an all-success
2626+ build). *)
2727+2828+val read_all_slices :
2929+ fs:Eio.Fs.dir_ty Eio.Path.t -> output_dir:string -> slice list
3030+(** Walk every direct subdirectory of [output_dir] and try [read_slice] for
3131+ each. Subdirectories without a manifest are skipped silently. *)
3232+3333+val handles : slice list -> string list
3434+(** Distinct overlay handles seen across every event's [context.overlay] in
3535+ [slices]. Sorted alphabetically. *)
3636+3737+val markdown : handle:string -> generated_at:float -> slice list -> string
3838+(** Render the per-handle markdown report. Includes a summary, reproduction
3939+ commands, and one section per failing package with outcome detail and the
4040+ audit event's log tail.
4141+4242+ Only events whose [context.overlay.handle] matches [handle] are considered.
4343+ Returns [""] when no failures touch [handle]. *)
4444+4545+val write_all :
4646+ fs:Eio.Fs.dir_ty Eio.Path.t ->
4747+ output_dir:string ->
4848+ generated_at:float ->
4949+ string list
5050+(** Convenience: read every slice under [output_dir], compute the union of
5151+ handles, and write [<output_dir>/handles/<handle>.md] for each handle that
5252+ has at least one failure. Returns the list of handles for which a file was
5353+ written. *)
+544-96
registry/index.html
···52525353/* ---- header ---- */
5454header {
5555- display: flex; align-items: center; gap: 1rem;
5555+ display: flex; align-items: center; gap: 0.6rem;
5656 padding: 0.5rem 1rem;
5757 border-bottom: 1px solid var(--border);
5858 background: var(--bg-elev);
5959 position: sticky; top: 0; z-index: 10;
6060}
6161+header .left {
6262+ display: flex; align-items: baseline; gap: 0.6rem;
6363+ min-width: 0;
6464+}
6165header .brand {
6266 font-family: ui-monospace, SF Mono, Monaco, monospace;
6367 font-size: 0.95rem;
···6569 color: var(--fg);
6670}
6771header .brand .sub { color: var(--fg-dim); font-weight: normal; font-size: 0.75rem; margin-left: 0.4rem; }
6868-header .spacer { flex: 1; }
6969-header .exported-info {
7272+header .updated {
7073 font-family: ui-monospace, monospace;
7171- font-size: 0.7rem;
7474+ font-size: 0.65rem;
7275 color: var(--fg-faint);
7376 white-space: nowrap;
7477}
7575-header .exported-info .schema { margin-left: 0.5rem; opacity: 0.7; }
7676-header .os-select {
7878+/* Selection chips pushed to the right edge — [margin-left:auto] is
7979+ the standard flex idiom that beats a [<spacer>] element. The chips
8080+ themselves stay flex-wrap so that on narrow windows they wrap to a
8181+ second header row rather than overlapping the title. */
8282+header .selections {
8383+ display: flex;
8484+ flex-wrap: wrap;
8585+ justify-content: flex-end;
8686+ gap: 0.3rem;
8787+ margin-left: auto;
8888+ min-width: 0;
8989+}
9090+/* Selection chips up here mirror what's in the URL hash. Each carries
9191+ an × button that drops just that selection — the header stays a
9292+ live readout of "what filters narrow the view right now".
9393+ The OS chip is special: instead of an × it opens a tiny popover
9494+ to switch distros (since per-row chips also navigate but the user
9595+ needs a way to flip distro before any row is in view). */
9696+header .sel-chip {
9797+ display: inline-flex;
9898+ align-items: center;
9999+ gap: 0.25rem;
100100+ padding: 0.1rem 0.4rem;
101101+ border-radius: 4px;
102102+ font-size: 0.7rem;
77103 font-family: ui-monospace, monospace;
7878- font-size: 0.78rem;
7979- padding: 0.3rem 0.6rem;
80104 border: 1px solid var(--border-strong);
8181- border-radius: 5px;
82105 background: var(--bg);
83106 color: var(--fg);
107107+ white-space: nowrap;
108108+ line-height: 1.4;
109109+}
110110+header .sel-chip .sel-key {
111111+ color: var(--fg-faint);
112112+ text-transform: uppercase;
113113+ letter-spacing: 0.04em;
114114+ font-size: 0.6rem;
115115+}
116116+header .sel-chip .sel-x {
84117 cursor: pointer;
8585- min-width: 200px;
118118+ color: var(--fg-dim);
119119+ padding: 0 0.15rem;
120120+ border-radius: 2px;
121121+ user-select: none;
86122}
8787-header .os-select:hover { border-color: var(--fg-dim); }
123123+header .sel-chip .sel-x:hover { color: var(--fg); background: var(--bg-sunken); }
124124+header .sel-chip.os {
125125+ background: var(--bg-elev);
126126+ border-color: var(--accent);
127127+ color: var(--accent);
128128+}
129129+header .sel-chip.os summary {
130130+ cursor: pointer;
131131+ list-style: none;
132132+ display: inline-flex;
133133+ align-items: center;
134134+ gap: 0.25rem;
135135+}
136136+header .sel-chip.os summary::-webkit-details-marker { display: none; }
137137+header .sel-chip.os[open] {
138138+ position: relative;
139139+}
140140+header .sel-chip.os .os-popover {
141141+ position: absolute;
142142+ top: calc(100% + 0.3rem);
143143+ right: 0;
144144+ min-width: 220px;
145145+ background: var(--bg-elev);
146146+ border: 1px solid var(--border-strong);
147147+ border-radius: 6px;
148148+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
149149+ padding: 0.3rem;
150150+ display: flex;
151151+ flex-direction: column;
152152+ gap: 0.15rem;
153153+ z-index: 20;
154154+}
155155+header .sel-chip.os .os-popover button {
156156+ appearance: none;
157157+ text-align: left;
158158+ font-family: inherit;
159159+ font-size: 0.72rem;
160160+ padding: 0.3rem 0.5rem;
161161+ background: transparent;
162162+ border: 1px solid transparent;
163163+ border-radius: 4px;
164164+ color: var(--fg);
165165+ cursor: pointer;
166166+}
167167+header .sel-chip.os .os-popover button.current {
168168+ background: var(--bg-sunken);
169169+ border-color: var(--accent);
170170+ color: var(--accent);
171171+}
172172+header .sel-chip.os .os-popover button:hover { background: var(--bg-sunken); }
173173+header .sel-chip.os .os-popover .fail-count {
174174+ color: var(--fail);
175175+ font-size: 0.6rem;
176176+ margin-left: 0.4rem;
177177+}
88178header .fail-pill {
89179 display: inline-block;
90180 padding: 0.05rem 0.4rem;
···295385.pkg-row {
296386 display: grid;
297387 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 */
388388+ 110px /* status badge */
389389+ minmax(180px, 1fr) /* package name+version, flexes */
390390+ minmax(180px, 240px)/* per-distro chip strip — bounded */
391391+ minmax(80px, 120px) /* overlay tags */
392392+ 96px /* short layer hash */
393393+ 70px /* method */
394394+ 64px; /* duration */
305395 gap: 0.6rem;
306396 padding: 0.3rem 0.85rem;
307397 align-items: center;
···339429.group-header .group-count { color: var(--fg-dim); margin-left: 0.4rem; font-weight: normal; }
340430.overlay-tag { color: var(--accent); font-size: 0.72rem; }
341431.overlay-tag.none { color: var(--fg-faint); }
432432+/* Dropdown for the multi-handle case: when an entry's [callers[]]
433433+ names more than one overlay, the row would otherwise overflow the
434434+ ~120px overlay column. Show the first chip plus a "+N" counter that
435435+ doubles as a [details] toggle; the full handle list is absolutely
436436+ positioned below so opening the dropdown doesn't push neighboring
437437+ rows down. */
438438+.handle-dd {
439439+ position: relative;
440440+ display: inline-flex;
441441+ align-items: center;
442442+ gap: 0.25rem;
443443+ min-width: 0;
444444+}
445445+.handle-dd > summary {
446446+ list-style: none;
447447+ cursor: pointer;
448448+ display: inline-flex;
449449+ align-items: center;
450450+ gap: 0.25rem;
451451+ user-select: none;
452452+}
453453+.handle-dd > summary::-webkit-details-marker { display: none; }
454454+.handle-dd > summary::marker { content: ""; }
455455+.handle-dd > summary .more {
456456+ font-size: 0.62rem;
457457+ font-weight: 600;
458458+ color: var(--fg-dim);
459459+ background: var(--bg-sunken);
460460+ border: 1px solid var(--border);
461461+ border-radius: 999px;
462462+ padding: 0.02rem 0.32rem;
463463+ font-family: ui-monospace, monospace;
464464+}
465465+.handle-dd[open] > summary .more {
466466+ background: var(--accent);
467467+ color: white;
468468+ border-color: var(--accent);
469469+}
470470+.handle-dd-list {
471471+ position: absolute;
472472+ top: calc(100% + 4px);
473473+ left: 0;
474474+ z-index: 60;
475475+ display: flex;
476476+ flex-direction: column;
477477+ gap: 0.25rem;
478478+ background: var(--bg);
479479+ border: 1px solid var(--border-strong);
480480+ border-radius: 4px;
481481+ padding: 0.35rem 0.5rem;
482482+ white-space: nowrap;
483483+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
484484+ min-width: 8rem;
485485+}
486486+@media (prefers-color-scheme: dark) {
487487+ .handle-dd-list { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); }
488488+}
342489.pkg-row:hover { background: var(--bg-sunken); }
343490.pkg-row.expanded { background: var(--bg-sunken); }
344491.pkg-row.expanded::before { content: ""; width: 3px; background: var(--accent); position: absolute; left: 0; top: 0; bottom: 0; }
···373520}
374521.distro-chip {
375522 display: inline-block;
376376- padding: 0.05rem 0.35rem;
523523+ /* Min-width keeps the 2-char "A3" chip the same width as the 3-char
524524+ "U25" chip so columns stay vertically aligned across rows. */
525525+ min-width: 1.6rem;
526526+ padding: 0.05rem 0.3rem;
377527 border-radius: 3px;
378528 font-size: 0.6rem;
379529 font-weight: 600;
···383533 cursor: pointer;
384534 font-family: ui-monospace, monospace;
385535 border: 1px solid transparent;
536536+ box-sizing: border-box;
386537}
387538.distro-chip:hover { border-color: rgba(255, 255, 255, 0.5); }
388539.distro-chip.outcome-missing {
···545696 margin-bottom: 0.2rem;
546697}
547698.pkg-detail .event-head .event-ts { margin-left: auto; }
699699+.pkg-detail .event-count {
700700+ display: inline-block;
701701+ padding: 0.05rem 0.35rem;
702702+ border-radius: 999px;
703703+ background: var(--bg);
704704+ border: 1px solid var(--border-strong);
705705+ color: var(--fg-dim);
706706+ font-size: 0.62rem;
707707+ font-weight: 600;
708708+ font-family: ui-monospace, monospace;
709709+}
548710.pkg-detail .event-trigger {
549711 font-family: ui-monospace, monospace;
550712 font-size: 0.7rem;
···623785</head>
624786<body>
625787<header>
626626- <span class="brand">oi <span class="sub">registry</span></span>
627627- <span class="spacer"></span>
628628- <span class="exported-info" id="exported-info"></span>
629629- <select class="os-select" id="os-select" title="Select OS"></select>
788788+ <span class="left">
789789+ <span class="brand">oi <span class="sub">registry</span></span>
790790+ <span class="updated" id="updated" title=""></span>
791791+ </span>
792792+ <span class="selections" id="selections"></span>
630793</header>
631794<main>
632795 <div id="content">
···746909 return [...set].sort();
747910}
748911749749-// "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.
912912+// "alpine~3.23~x86_64" → "alpine 3.23". Used for tooltips and
913913+// dropdowns where the full distro name+version is wanted.
751914function shortDistro(osKey) {
752915 const p = parseOsKey(osKey);
753916 return p.short || p.name;
754917}
755918919919+// Compact 2–3 char chip label: first letter of the distro name
920920+// uppercase, followed by the version's major segment. Designed to
921921+// stay within ~3 chars so a 5-distro chip strip fits in a narrow
922922+// column without spilling.
923923+// alpine~3.23~* → "A3"
924924+// debian~13~* → "D13"
925925+// ubuntu~24.04~* → "U24"
926926+// ubuntu~25.10~* → "U25"
927927+// fedora~43~* → "F43"
928928+// macos~26~* → "M26"
929929+// Disambiguates the common case (different ubuntu versions); the
930930+// hover tooltip still shows the full os_key so any near-collision
931931+// (e.g. two alpine 3.x rolls) is recoverable.
932932+function chipLabel(osKey) {
933933+ const p = parseOsKey(osKey);
934934+ const letter = (p.name || "?").charAt(0).toUpperCase();
935935+ const major = (p.version || "").split(".")[0];
936936+ return major ? letter + major : letter;
937937+}
938938+756939// Cross-distro outcome for a given (pkg.name, pkg.version). Returns
757940// [{ os_key, outcome, layer_hash }, ...] over every loaded manifest
758941// — including a sentinel { outcome: "missing" } for distros where
···779962 if (cells.length === 0) return "";
780963 return `<span class="distro-chips">${cells.map(c => {
781964 const grp = c.outcome === "missing" ? "missing" : (OUTCOME_GROUPS[c.outcome] || "ok");
782782- const label = shortDistro(c.os_key);
965965+ const label = chipLabel(c.os_key);
783966 const isActive = c.os_key === state.active;
784967 const cls = "distro-chip outcome-" + grp + (isActive ? " active" : "");
968968+ // Tooltip carries the full os_key + outcome since the chip itself
969969+ // shows only a 2–3 char acronym.
970970+ const full = shortDistro(c.os_key);
785971 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`;
972972+ ? `${full} — not built`
973973+ : `${full} — ${c.outcome.replace(/_/g, " ")} (click to drill in)`;
788974 // Carry the os_key + layer_hash on the chip so the click handler
789975 // can switch [state.active] and expand the same package's row in
790976 // the target distro's manifest. Missing chips have no layer_hash
···8591045 return String(s).replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">","\"":""","'":"'"}[c]));
8601046}
8611047862862-// URL hash format: #<os_key>/<layer_hash>. Tilde, slash and hex are all
863863-// fragment-safe so no encoding needed for our values.
10481048+// URL hash format: #<os_key>[/<layer_hash>][?overlay=<a>,<b>&...].
10491049+// Tilde, slash, hex and comma are all fragment-safe; only the "(none)"
10501050+// sentinel for "no overlay" needs URL-encoding because of the parens.
10511051+//
10521052+// Examples:
10531053+// #ubuntu~25.10~x86_64
10541054+// #ubuntu~25.10~x86_64/abc123def456
10551055+// #ubuntu~25.10~x86_64?overlay=avsm,samoht
10561056+// #ubuntu~25.10~x86_64/abc123?overlay=avsm
8641057function readHash() {
865865- const h = window.location.hash.replace(/^#/, "");
866866- if (!h) return { osKey: null, layer: null };
867867- const slash = h.indexOf("/");
868868- if (slash < 0) return { osKey: h, layer: null };
869869- return { osKey: h.slice(0, slash), layer: h.slice(slash + 1) };
10581058+ const raw = window.location.hash.replace(/^#/, "");
10591059+ if (!raw) return { osKey: null, layer: null, overlays: null };
10601060+ const q = raw.indexOf("?");
10611061+ const path = q < 0 ? raw : raw.slice(0, q);
10621062+ const query = q < 0 ? "" : raw.slice(q + 1);
10631063+ const slash = path.indexOf("/");
10641064+ const osKey = slash < 0 ? path : path.slice(0, slash);
10651065+ const layer = slash < 0 ? null : path.slice(slash + 1);
10661066+ let overlays = null;
10671067+ if (query) {
10681068+ const params = new URLSearchParams(query);
10691069+ const ov = params.get("overlay");
10701070+ if (ov !== null) {
10711071+ overlays = ov === "" ? [] : ov.split(",").map(decodeURIComponent);
10721072+ }
10731073+ }
10741074+ return { osKey, layer, overlays };
8701075}
8711076872872-function writeHash(osKey, layer) {
873873- const want = layer ? `#${osKey}/${layer}` : `#${osKey}`;
10771077+// Serialise the current state-relevant bits into the hash. Pass null
10781078+// for [layer] to clear the layer-expansion deep-link; pass null for
10791079+// [overlays] to omit the overlay query (i.e. "no filter set"). Pass
10801080+// an empty array for [overlays] to encode "all overlays explicitly
10811081+// excluded" — that survives a reload as a remembered preference,
10821082+// even if the visible result is "no rows".
10831083+function writeHash(osKey, layer, overlays) {
10841084+ let want = `#${osKey}`;
10851085+ if (layer) want += `/${layer}`;
10861086+ if (overlays && overlays.length > 0) {
10871087+ const enc = overlays.map(encodeURIComponent).join(",");
10881088+ want += `?overlay=${enc}`;
10891089+ }
8741090 if (window.location.hash !== want) {
8751091 history.replaceState(null, "", want);
8761092 }
8771093}
878109410951095+// Convenience: capture the current overlay filter as the array form
10961096+// [writeHash] expects, or null if the user hasn't set any filter yet
10971097+// (so the default URL stays clean).
10981098+function overlaysFromState() {
10991099+ return state.overlayFilter.size === 0
11001100+ ? null
11011101+ : [...state.overlayFilter];
11021102+}
11031103+11041104+// Relative-time formatter for the header's "updated" readout. Falls
11051105+// back to the absolute date once the gap exceeds a week — at that
11061106+// point "12d ago" becomes less useful than the actual day.
11071107+function fmtRelative(unixSec) {
11081108+ if (!unixSec) return "";
11091109+ const now = Date.now() / 1000;
11101110+ const ago = Math.max(0, now - unixSec);
11111111+ if (ago < 60) return "just now";
11121112+ if (ago < 3600) return `${Math.floor(ago / 60)}m ago`;
11131113+ if (ago < 86400) return `${Math.floor(ago / 3600)}h ago`;
11141114+ if (ago < 7 * 86400) return `${Math.floor(ago / 86400)}d ago`;
11151115+ return new Date(unixSec * 1000).toISOString().slice(0, 10);
11161116+}
11171117+8791118function renderHeader() {
8801119 const m = state.manifests.get(state.active);
881881- const sel = document.getElementById("os-select");
882882- sel.innerHTML = "";
883883- for (const [key, mm] of state.manifests) {
884884- const failures = mm.summary.build_failed + mm.summary.install_failed
885885- + mm.summary.fetch_failed + mm.summary.solve_failed;
886886- const p = parseOsKey(key);
887887- const opt = document.createElement("option");
888888- opt.value = key;
889889- opt.textContent = `${p.full} (${mm.n_packages})${failures > 0 ? ` — ${failures} failed` : ""}`;
890890- if (key === state.active) opt.selected = true;
891891- sel.appendChild(opt);
11201120+ const root = document.getElementById("selections");
11211121+ root.innerHTML = "";
11221122+11231123+ // [chip] helper: build a sel-chip with key/value + optional ×.
11241124+ // [onDismiss] (when non-null) is invoked synchronously, then the
11251125+ // URL-+-render cycle runs.
11261126+ const chip = (cls, body, onDismiss) => {
11271127+ const el = document.createElement("span");
11281128+ el.className = "sel-chip" + (cls ? " " + cls : "");
11291129+ el.innerHTML = body;
11301130+ if (onDismiss) {
11311131+ const x = document.createElement("span");
11321132+ x.className = "sel-x";
11331133+ x.title = "dismiss";
11341134+ x.textContent = "×";
11351135+ x.onclick = (e) => {
11361136+ e.stopPropagation();
11371137+ onDismiss();
11381138+ writeHash(state.active, state.expanded, overlaysFromState());
11391139+ render();
11401140+ };
11411141+ el.appendChild(x);
11421142+ }
11431143+ root.appendChild(el);
11441144+ return el;
11451145+ };
11461146+11471147+ // OS chip — always present, no ×, but acts as a popover to switch
11481148+ // distros. Uses <details> so the open/close state is browser-native
11491149+ // and the click-outside-to-close behaviour we get for free.
11501150+ if (state.manifests.size > 0) {
11511151+ const osChip = document.createElement("details");
11521152+ osChip.className = "sel-chip os";
11531153+ const failures = m
11541154+ ? (m.summary.build_failed + m.summary.install_failed
11551155+ + m.summary.fetch_failed + m.summary.solve_failed)
11561156+ : 0;
11571157+ const cur = parseOsKey(state.active);
11581158+ osChip.innerHTML =
11591159+ `<summary>` +
11601160+ `<span class="sel-key">os</span>` +
11611161+ `<span>${escapeHtml(cur.short)}</span>` +
11621162+ (failures > 0 ? `<span class="fail-pill">${failures}</span>` : "") +
11631163+ `<span style="opacity:0.7">▾</span>` +
11641164+ `</summary>`;
11651165+ const pop = document.createElement("div");
11661166+ pop.className = "os-popover";
11671167+ for (const [key, mm] of state.manifests) {
11681168+ const f = mm.summary.build_failed + mm.summary.install_failed
11691169+ + mm.summary.fetch_failed + mm.summary.solve_failed;
11701170+ const p = parseOsKey(key);
11711171+ const btn = document.createElement("button");
11721172+ if (key === state.active) btn.className = "current";
11731173+ btn.innerHTML =
11741174+ `${escapeHtml(p.full)} <span style="color:var(--fg-faint)">(${mm.n_packages})</span>` +
11751175+ (f > 0 ? `<span class="fail-count">${f} failed</span>` : "");
11761176+ btn.onclick = (e) => {
11771177+ e.preventDefault();
11781178+ state.active = key;
11791179+ state.expanded = null;
11801180+ state.highlightUpstream = null;
11811181+ writeHash(state.active, null, overlaysFromState());
11821182+ render();
11831183+ };
11841184+ pop.appendChild(btn);
11851185+ }
11861186+ osChip.appendChild(pop);
11871187+ root.appendChild(osChip);
8921188 }
893893- sel.onchange = (e) => {
894894- state.active = e.target.value;
895895- state.expanded = null;
896896- state.highlightUpstream = null;
897897- writeHash(state.active, null);
898898- render();
899899- };
900900- const info = document.getElementById("exported-info");
11891189+11901190+ // Expanded layer chip — present iff a row is open.
11911191+ if (state.expanded) {
11921192+ chip("",
11931193+ `<span class="sel-key">layer</span><span>${escapeHtml(state.expanded.slice(0, 12))}</span>`,
11941194+ () => { state.expanded = null; state.highlightUpstream = null; });
11951195+ }
11961196+11971197+ // Overlay filter — one chip per included handle so individual ones
11981198+ // can be dismissed without nuking the rest.
11991199+ for (const handle of state.overlayFilter) {
12001200+ const label = handle === "(none)" ? "(no overlay)" : "@" + handle;
12011201+ chip("",
12021202+ `<span class="sel-key">overlay</span><span>${escapeHtml(label)}</span>`,
12031203+ () => { state.overlayFilter.delete(handle); });
12041204+ }
12051205+12061206+ // Status-group filter — only show chips when the user has narrowed
12071207+ // away from "all four groups". A chip per *enabled* group then
12081208+ // reads naturally as "showing: ok, fail" with × removing each.
12091209+ const allStatuses = STATUS_GROUPS.map(g => g.key);
12101210+ const statusOff = state.statusFilter.size !== allStatuses.length;
12111211+ if (statusOff) {
12121212+ for (const k of allStatuses) {
12131213+ if (!state.statusFilter.has(k)) continue;
12141214+ chip("",
12151215+ `<span class="sel-key">status</span><span>${escapeHtml(k)}</span>`,
12161216+ () => { state.statusFilter.delete(k); });
12171217+ }
12181218+ }
12191219+12201220+ // Search-text filter.
12211221+ if (state.filter) {
12221222+ chip("",
12231223+ `<span class="sel-key">search</span><span>${escapeHtml(state.filter)}</span>`,
12241224+ () => { state.filter = ""; });
12251225+ }
12261226+12271227+ // Compact "updated Xm ago" readout. Tooltip carries the absolute
12281228+ // timestamp + schema for the rare case someone wants the detail.
12291229+ const upd = document.getElementById("updated");
9011230 if (m) {
902902- info.innerHTML =
903903- `<span title="last export">exported ${escapeHtml(fmtTimeShort(m.exported_at))} UTC</span>` +
904904- `<span class="schema">schema v${escapeHtml(String(m.schema))}</span>`;
12311231+ upd.textContent = `updated ${fmtRelative(m.exported_at)}`;
12321232+ upd.title = `${fmtTimeShort(m.exported_at)} UTC · schema v${m.schema}`;
9051233 } else {
906906- info.textContent = "";
12341234+ upd.textContent = "";
12351235+ upd.title = "";
9071236 }
9081237}
9091238···11171446 : `<span class="overlay-tag none">(no overlay)</span>`;
11181447}
1119144814491449+// Equivalence key for grouping identical events. Two events collapse
14501450+// into one row iff they share their outcome (kind + payload) AND
14511451+// their caller context (overlay, toolchain, trigger, project, host).
14521452+// Volatile fields — [event_id], [invocation_id], [ts], [duration_s],
14531453+// [log] — are deliberately excluded so a CI run that hits the same
14541454+// build failure 50 times collapses cleanly.
14551455+function eventKey(ev) {
14561456+ const o = ev.outcome || {};
14571457+ const c = ev.context || {};
14581458+ const ov = c.overlay || {};
14591459+ const u = o.upstream || {};
14601460+ const payload = JSON.stringify({
14611461+ kind: o.kind,
14621462+ command: o.command || "",
14631463+ exit_code: o.exit_code ?? null,
14641464+ url: o.url || "",
14651465+ fetch_kind: o.fetch_kind || null,
14661466+ upstream: o.kind === "dep_failed"
14671467+ ? { n: u.name || "", v: u.version || "", h: u.hash || "" }
14681468+ : null,
14691469+ reason: o.reason || "",
14701470+ missing: (o.missing || []).join(","),
14711471+ not_found: (o.not_found || []).join(","),
14721472+ });
14731473+ return [
14741474+ payload,
14751475+ ov.handle || "",
14761476+ ov.version || "",
14771477+ c.toolchain || "",
14781478+ c.trigger || "",
14791479+ c.host || "",
14801480+ c.project || "",
14811481+ ].join("");
14821482+}
14831483+14841484+// Group identical events. Returns one record per unique key with the
14851485+// underlying events sorted oldest→newest (matching [fetchAudit]'s
14861486+// per-layer ordering). Group order is most-recent-last-seen first
14871487+// — failures the user just hit float to the top.
14881488+function groupEvents(events) {
14891489+ const map = new Map();
14901490+ for (const ev of events) {
14911491+ const k = eventKey(ev);
14921492+ let g = map.get(k);
14931493+ if (!g) {
14941494+ g = { key: k, events: [] };
14951495+ map.set(k, g);
14961496+ }
14971497+ g.events.push(ev);
14981498+ }
14991499+ const groups = [...map.values()].map(g => ({
15001500+ ...g,
15011501+ first: g.events[0],
15021502+ last: g.events[g.events.length - 1],
15031503+ }));
15041504+ groups.sort((a, b) => (b.last.ts || 0) - (a.last.ts || 0));
15051505+ return groups;
15061506+}
15071507+11201508// Per-event detail surfaced under the aggregated callers table. The
11211121-// manifest's [callers] is a histogram; the audit log preserves the full
11221122-// event stream so we can render the actual failure command, fetch URL,
11231123-// log tail, etc. for each invocation.
15091509+// manifest's [callers] is a histogram across (overlay, toolchain,
15101510+// trigger) tuples; the audit log preserves individual invocations.
15111511+// We collapse runs that are pairwise identical so a build that
15121512+// fails repeatedly shows once with a count, not N times in a row.
11241513function renderEvents(events) {
11251514 if (!events || events.length === 0) return "";
11261126- const rows = events.map(ev => {
15151515+ const groups = groupEvents(events);
15161516+ const rows = groups.map(g => {
15171517+ // Use the most recent event as the representative — its log tail
15181518+ // is the most likely to still be on disk, and its detail reflects
15191519+ // the latest failure mode in the rare case the payload's
15201520+ // equivalence-key fields drift mid-grouping (shouldn't happen
15211521+ // because they're the key, but belt-and-braces).
15221522+ const ev = g.last;
11271523 const grp = OUTCOME_GROUPS[ev.outcome.kind] || "ok";
11281128- const ts = fmtTimeShort(ev.ts);
11291524 const ctx = ev.context || {};
11301525 const tc = ctx.toolchain ? `<span class="dim"> · ${escapeHtml(ctx.toolchain)}</span>` : "";
11311526 const host = ctx.host ? `<span class="dim"> · ${escapeHtml(ctx.host)}</span>` : "";
15271527+ // Time display: a single tick if the group has one event OR all
15281528+ // events share an instant; otherwise [first → last] so the user
15291529+ // sees how long the same outcome has been recurring.
15301530+ const sameInstant = g.first.ts === g.last.ts;
15311531+ const tsLabel = g.events.length === 1 || sameInstant
15321532+ ? fmtTimeShort(g.last.ts)
15331533+ : `${fmtTimeShort(g.first.ts)} → ${fmtTimeShort(g.last.ts)}`;
15341534+ const countChip = g.events.length > 1
15351535+ ? `<span class="event-count" title="${g.events.length} identical occurrences">×${g.events.length}</span>`
15361536+ : "";
11321537 const detail = renderOutcomeDetail(ev.outcome);
11331538 const log = ev.log && ev.log.tail
11341539 ? `<details class="ev-log"><summary>log tail (${ev.log.text_path ? escapeHtml(ev.log.text_path) : "inline"})</summary><pre class="log-tail">${escapeHtml(ev.log.tail)}</pre></details>`
···11361541 return `<div class="event-row">
11371542 <div class="event-head">
11381543 <span class="outcome-badge outcome-${grp}">${ev.outcome.kind.replace(/_/g, " ")}</span>
15441544+ ${countChip}
11391545 ${eventOverlayLabel(ev)}${tc}${host}
11401140- <span class="dim event-ts">${escapeHtml(ts)}</span>
15461546+ <span class="dim event-ts">${escapeHtml(tsLabel)}</span>
11411547 </div>
11421548 <div class="event-trigger"><code>${escapeHtml(ctx.trigger || "")}</code></div>
11431549 ${detail}
11441550 ${log}
11451551 </div>`;
11461552 }).join("");
11471147- return `<h3>Events (${events.length})</h3>
15531553+ const heading = groups.length === events.length
15541554+ ? `Events (${events.length})`
15551555+ : `Events (${events.length} total, ${groups.length} unique)`;
15561556+ return `<h3>${heading}</h3>
11481557 <div class="event-list">${rows}</div>`;
11491558}
11501559···12621671 const expanded = state.expanded === r.layer_hash ? " expanded" : "";
12631672 const upstream = state.highlightUpstream === r.layer_hash ? " upstream" : "";
12641673 const handles = handlesOf(r);
12651265- // Up to 3 chips; if more, append a "+N" indicator.
12661266- const chips =
12671267- handles.length === 0
12681268- ? `<span class="overlay-tag none">—</span>`
12691269- : handles.slice(0, 3).map(h =>
12701270- h === "(none)"
12711271- ? `<span class="overlay-tag none">∅</span>`
12721272- : `<span class="overlay-tag">@${escapeHtml(h)}</span>`
12731273- ).join(" ") + (handles.length > 3 ? ` <span class="overlay-tag none">+${handles.length - 3}</span>` : "");
16741674+ // Single handle → flat chip. 2+ handles → a [details] dropdown
16751675+ // anchored on the first chip plus a "+N" counter. Avoids overflowing
16761676+ // the bounded overlay column on entries that fan out across many
16771677+ // overlays.
16781678+ const renderHandleChip = (h) =>
16791679+ h === "(none)"
16801680+ ? `<span class="overlay-tag none">∅</span>`
16811681+ : `<span class="overlay-tag">@${escapeHtml(h)}</span>`;
16821682+ let chips;
16831683+ if (handles.length === 0) {
16841684+ chips = `<span class="overlay-tag none">—</span>`;
16851685+ } else if (handles.length === 1) {
16861686+ chips = renderHandleChip(handles[0]);
16871687+ } else {
16881688+ const head = renderHandleChip(handles[0]);
16891689+ const more = handles.length - 1;
16901690+ const list = handles.map(renderHandleChip).join("");
16911691+ chips = `<details class="handle-dd">
16921692+ <summary>${head}<span class="more">+${more}</span></summary>
16931693+ <div class="handle-dd-list">${list}</div>
16941694+ </details>`;
16951695+ }
12741696 const rowHtml = `<div class="pkg-row${expanded}${upstream}" data-hash="${escapeHtml(r.layer_hash)}">
12751697 <span class="outcome-badge outcome-${grp}">${r.headline_outcome.replace(/_/g, " ")}</span>
12761698 <span><span class="pkg-name">${escapeHtml(r.pkg.name)}</span> <span class="pkg-version">${escapeHtml(r.pkg.version)}</span></span>
···13361758 // overlay multi-select handlers
13371759 const overlay = document.getElementById("overlay-multi");
13381760 if (overlay) {
17611761+ // Both checkbox toggles and the [Clear] / [All] action buttons
17621762+ // mutate [state.overlayFilter] then re-render. Centralise the
17631763+ // post-mutation work — URL persistence + re-render-while-keeping-
17641764+ // -the-dropdown-open — so the URL stays in sync with whatever the
17651765+ // user just clicked.
17661766+ const persistAndRender = () => {
17671767+ writeHash(state.active, state.expanded, overlaysFromState());
17681768+ const wasOpen = overlay.open;
17691769+ render();
17701770+ if (wasOpen) {
17711771+ const next = document.getElementById("overlay-multi");
17721772+ if (next) next.open = true;
17731773+ }
17741774+ };
13391775 for (const cb of overlay.querySelectorAll('input[type="checkbox"]')) {
13401776 cb.onchange = () => {
13411777 const h = cb.dataset.handle;
13421778 if (cb.checked) state.overlayFilter.add(h);
13431779 else state.overlayFilter.delete(h);
13441344- // Re-render but keep dropdown open.
13451345- const wasOpen = overlay.open;
13461346- render();
13471347- if (wasOpen) {
13481348- const next = document.getElementById("overlay-multi");
13491349- if (next) next.open = true;
13501350- }
17801780+ persistAndRender();
13511781 };
13521782 }
13531783 for (const btn of overlay.querySelectorAll("[data-overlay-action]")) {
13541784 btn.onclick = (e) => {
13551785 e.preventDefault();
13561786 if (btn.dataset.overlayAction === "clear") state.overlayFilter.clear();
13571357- const wasOpen = overlay.open;
13581358- render();
13591359- if (wasOpen) {
13601360- const next = document.getElementById("overlay-multi");
13611361- if (next) next.open = true;
13621362- }
17871787+ persistAndRender();
13631788 };
13641789 }
13651790 }
···13711796 row.onclick = (e) => {
13721797 // Clicks on links inside an expanded panel must not collapse the row.
13731798 if (e.target.closest("a[data-jump]")) return;
17991799+ // The multi-handle dropdown lives inside the row but its toggling
18001800+ // is handled by the native [details] element; intercept here so
18011801+ // opening/closing it doesn't also expand the row.
18021802+ if (e.target.closest(".handle-dd")) {
18031803+ e.stopPropagation();
18041804+ return;
18051805+ }
13741806 // Clicking a distro chip jumps to that distro's view of the same
13751807 // package. The active os_key changes, the OS dropdown updates,
13761808 // and the row stays expanded so the user can read the failure
···13851817 if (!state.manifests.has(targetOs)) return;
13861818 state.active = targetOs;
13871819 expand(targetHash);
13881388- writeHash(targetOs, targetHash);
18201820+ writeHash(targetOs, targetHash, overlaysFromState());
13891821 render();
13901822 const t = document.querySelector(`.pkg-row[data-hash="${targetHash}"]`);
13911823 if (t) t.scrollIntoView({ block: "nearest", behavior: "smooth" });
···13951827 if (state.expanded === hash) {
13961828 state.expanded = null;
13971829 state.highlightUpstream = null;
13981398- writeHash(state.active, null);
18301830+ writeHash(state.active, null, overlaysFromState());
13991831 } else {
14001832 expand(hash);
14011401- writeHash(state.active, hash);
18331833+ writeHash(state.active, hash, overlaysFromState());
14021834 }
14031835 render();
14041836 const newRow = document.querySelector(`.pkg-row[data-hash="${hash}"]`);
···14141846 const hash = a.dataset.jump;
14151847 if (!m.results.find(x => x.layer_hash === hash)) return;
14161848 expand(hash);
14171417- writeHash(state.active, hash);
18491849+ writeHash(state.active, hash, overlaysFromState());
14181850 render();
14191851 const target = document.querySelector(`.pkg-row[data-hash="${hash}"]`);
14201852 if (target) target.scrollIntoView({ block: "nearest", behavior: "smooth" });
14211853 };
14221854 }
1423185514241424- // "Copy link" button: write the deep-link to the clipboard.
18561856+ // "Copy link" button: write the deep-link to the clipboard. The
18571857+ // [data-share] attribute carries the [<os_key>/<layer>] path; we
18581858+ // append the current overlay filter at click time so the copied
18591859+ // URL reproduces what the recipient actually sees in the table.
14251860 for (const btn of document.querySelectorAll(".pkg-detail .share")) {
14261861 btn.onclick = async (e) => {
14271862 e.stopPropagation();
14281428- const frag = btn.dataset.share;
18631863+ let frag = btn.dataset.share;
18641864+ const ov = overlaysFromState();
18651865+ if (ov && ov.length > 0) {
18661866+ const enc = ov.map(encodeURIComponent).join(",");
18671867+ frag += `?overlay=${enc}`;
18681868+ }
14291869 const url = `${window.location.origin}${window.location.pathname}#${frag}`;
14301870 try { await navigator.clipboard.writeText(url); } catch { /* ignore */ }
14311871 btn.classList.add("copied");
···14481888}
1449188914501890function applyHash() {
14511451- const { osKey, layer } = readHash();
18911891+ const { osKey, layer, overlays } = readHash();
14521892 if (osKey && state.manifests.has(osKey)) {
14531893 state.active = osKey;
14541894 if (layer) expand(layer); else { state.expanded = null; state.highlightUpstream = null; }
14551895 } else if (!state.active) {
14561896 state.active = state.manifests.keys().next().value;
18971897+ }
18981898+ // Overlay filter follows the URL. [overlays = null] means the
18991899+ // hash didn't carry an overlay query at all, so we leave the
19001900+ // current filter alone (preserves explicit user toggles across
19011901+ // hashchange events that only mutated [osKey] / [layer]).
19021902+ // [overlays = []] is "URL says explicitly empty"; respect that too.
19031903+ if (overlays !== null) {
19041904+ state.overlayFilter = new Set(overlays);
14571905 }
14581906}
14591907