My own OCaml monorepo using monopam
0
fork

Configure Feed

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

agent reports on export

+957 -97
+13 -1
lib/cmd/registry_export.ml
··· 105 105 Oi.Say.field "audit" "%d event(s) at %s/audit.jsonl" (List.length events) 106 106 (output / os_key) 107 107 end 108 - end 108 + end; 109 + (* Emit per-overlay-handle markdown reports aggregated across every distro 110 + currently staged under [output]. Re-run from each export so a freshly 111 + synced sibling distro picks up the latest view. *) 112 + let handles_written = 113 + Oi.Handle_report.write_all ~fs ~output_dir:output 114 + ~generated_at:(Unix.gettimeofday ()) 115 + in 116 + match handles_written with 117 + | [] -> () 118 + | hs -> 119 + Oi.Say.field "handles" "%d failure report(s) at %s/handles/" 120 + (List.length hs) output
+347
lib/oi/handle_report.ml
··· 1 + let ( / ) = Filename.concat 2 + let log_src = Logs.Src.create "oi.handle_report" 3 + 4 + module Log = (val Logs.src_log log_src : Logs.LOG) 5 + 6 + type slice = { 7 + os_key : string; 8 + manifest : Manifest.t; 9 + events : Audit.event list; 10 + } 11 + 12 + (* -- Reading slices ------------------------------------------------------ *) 13 + 14 + let load_text fs path = 15 + try Some (Eio.Path.load Eio.Path.(fs / path)) with Eio.Exn.Io _ -> None 16 + 17 + let split_lines s = 18 + String.split_on_char '\n' s |> List.filter (fun l -> l <> "") 19 + 20 + let load_manifest ~fs ~output_dir ~os_key = 21 + let path = output_dir / os_key / "logs" / "manifest.json" in 22 + match load_text fs path with 23 + | None -> None 24 + | Some content -> ( 25 + match 26 + Jsont_bytesrw.decode_string ~locs:false ~file:path Manifest.codec 27 + content 28 + with 29 + | Ok m -> Some m 30 + | Error msg -> 31 + Log.debug (fun m -> m "manifest decode %s: %s" path msg); 32 + None) 33 + 34 + let load_audit_events ~fs ~output_dir ~os_key = 35 + let path = Audit.per_os_path ~output_dir ~os_key in 36 + match load_text fs path with 37 + | None -> [] 38 + | Some content -> 39 + split_lines content 40 + |> List.filter_map (fun line -> 41 + match 42 + Jsont_bytesrw.decode_string ~locs:false ~file:path Audit.event_codec 43 + line 44 + with 45 + | Ok e -> Some e 46 + | Error msg -> 47 + Log.debug (fun m -> m "audit bad line %s: %s" path msg); 48 + None) 49 + 50 + let read_slice ~fs ~output_dir ~os_key = 51 + match load_manifest ~fs ~output_dir ~os_key with 52 + | None -> None 53 + | Some manifest -> 54 + let events = load_audit_events ~fs ~output_dir ~os_key in 55 + Some { os_key; manifest; events } 56 + 57 + let list_subdirs ~fs path = 58 + try 59 + Eio.Path.read_dir Eio.Path.(fs / path) 60 + |> List.filter (fun name -> 61 + name <> "" 62 + && name.[0] <> '.' 63 + && Eio.Path.is_directory Eio.Path.(fs / path / name)) 64 + with Eio.Exn.Io _ -> [] 65 + 66 + let read_all_slices ~fs ~output_dir = 67 + list_subdirs ~fs output_dir 68 + |> List.filter_map (fun os_key -> read_slice ~fs ~output_dir ~os_key) 69 + |> List.sort (fun a b -> String.compare a.os_key b.os_key) 70 + 71 + (* -- Handle enumeration -------------------------------------------------- *) 72 + 73 + module String_set = Set.Make (String) 74 + 75 + let handles slices = 76 + List.fold_left 77 + (fun acc s -> 78 + List.fold_left 79 + (fun acc (e : Audit.event) -> 80 + match e.context.overlay with 81 + | Some o -> String_set.add o.handle acc 82 + | None -> acc) 83 + acc s.events) 84 + String_set.empty slices 85 + |> String_set.elements 86 + 87 + (* -- Markdown helpers ---------------------------------------------------- *) 88 + 89 + let is_failure_kind = function 90 + | Outcome.K_ok | K_cached | K_restored | K_skipped -> false 91 + | K_build_failed | K_install_failed | K_dep_failed | K_fetch_failed 92 + | K_depext_missing | K_solve_failed -> 93 + true 94 + 95 + let outcome_to_kind_string o = Outcome.kind_to_string (Outcome.kind_of o) 96 + 97 + let pp_outcome_detail buf (o : Outcome.t) = 98 + let p fmt = Fmt.kstr (Buffer.add_string buf) fmt in 99 + match o with 100 + | Build_failed { command; exit_code } -> 101 + p "- Command: `%s`\n" command; 102 + Stdlib.Option.iter (fun c -> p "- Exit code: `%d`\n" c) exit_code 103 + | Install_failed { command; exit_code } -> 104 + p "- Command: `%s`\n" command; 105 + Stdlib.Option.iter (fun c -> p "- Exit code: `%d`\n" c) exit_code 106 + | Fetch_failed { url; kind } -> 107 + p "- URL: `%s`\n" url; 108 + let k = 109 + match kind with 110 + | Http_status n -> Fmt.str "HTTP %d" n 111 + | Checksum_mismatch -> "checksum mismatch" 112 + | Network_timeout -> "network timeout" 113 + | Git_failed -> "git failed" 114 + | Other s -> s 115 + in 116 + p "- Kind: %s\n" k 117 + | Dep_failed { upstream } -> 118 + p "- Failing upstream dep: `%s`\n" (Identity.to_string upstream.id); 119 + p "- Upstream layer: `%s`\n" upstream.hash 120 + | Depext_missing { missing; not_found } -> 121 + if missing <> [] then 122 + p "- System packages missing: %s\n" 123 + (String.concat ", " (List.map (Fmt.str "`%s`") missing)); 124 + if not_found <> [] then 125 + p "- System packages with no manager mapping: %s\n" 126 + (String.concat ", " (List.map (Fmt.str "`%s`") not_found)) 127 + | Solve_failed { reason } -> p "- Reason: %s\n" reason 128 + | Skipped { reason } -> p "- Reason: %s\n" reason 129 + | Ok | Cached | Restored -> () 130 + 131 + let format_iso_utc ts = 132 + let tm = Unix.gmtime ts in 133 + Fmt.str "%04d-%02d-%02dT%02d:%02d:%02dZ" (tm.tm_year + 1900) (tm.tm_mon + 1) 134 + tm.tm_mday tm.tm_hour tm.tm_min tm.tm_sec 135 + 136 + let log_relative_path ~os_key (lp : Audit.log_pointer) = 137 + (* The audit log_pointer's text_path is the original local cache path, e.g. 138 + [<cache>/build/logs/build-foo.1.0-abc.log]. The registry only ships the 139 + filename's logs/ trailing component conceptually, so we emit a hint 140 + pointing at the conventional "../<os_key>/logs/<basename>" location an 141 + agent can join with the registry root. The file may or may not be 142 + published — the embedded tail is the authoritative copy. *) 143 + let basename = Filename.basename lp.text_path in 144 + "../" ^ os_key ^ "/logs/" ^ basename 145 + 146 + let trim_tail ?(max_lines = 60) text = 147 + let lines = String.split_on_char '\n' text in 148 + let n = List.length lines in 149 + if n <= max_lines then text 150 + else 151 + let drop = n - max_lines in 152 + let rec skip k = function 153 + | [] -> [] 154 + | _ :: rest when k > 0 -> skip (k - 1) rest 155 + | xs -> xs 156 + in 157 + String.concat "\n" (skip drop lines) 158 + 159 + (* -- Filtering & grouping ------------------------------------------------ *) 160 + 161 + let event_handle (e : Audit.event) = 162 + Stdlib.Option.map (fun (o : D10.Overlay.t) -> o.handle) e.context.overlay 163 + 164 + let failures_for_handle ~handle slices = 165 + List.concat_map 166 + (fun s -> 167 + List.filter_map 168 + (fun (e : Audit.event) -> 169 + if event_handle e <> Some handle then None 170 + else if not (is_failure_kind (Outcome.kind_of e.outcome)) then None 171 + else Some (s.os_key, e)) 172 + s.events) 173 + slices 174 + 175 + (* Group failures by Identity.t (preserving the kind histogram). *) 176 + module Pkg_map = Map.Make (struct 177 + type t = Identity.t 178 + 179 + let compare (a : Identity.t) (b : Identity.t) = 180 + match String.compare a.Identity.name b.Identity.name with 181 + | 0 -> String.compare a.Identity.version b.Identity.version 182 + | n -> n 183 + end) 184 + 185 + let group_by_pkg pairs = 186 + List.fold_left 187 + (fun acc (os_key, ev) -> 188 + let pkg = (ev : Audit.event).pkg in 189 + let prev = try Pkg_map.find pkg acc with Not_found -> [] in 190 + Pkg_map.add pkg ((os_key, ev) :: prev) acc) 191 + Pkg_map.empty pairs 192 + |> Pkg_map.bindings 193 + |> List.map (fun (pkg, evs) -> 194 + let evs = 195 + List.sort (fun (a_os, _) (b_os, _) -> String.compare a_os b_os) evs 196 + in 197 + (pkg, evs)) 198 + 199 + (* Find a per-package source URL across slices, falling back across [os_key]s 200 + so the agent gets some upstream pointer even if the failing distro's 201 + manifest doesn't have provenance for that package (typical: build never 202 + committed). *) 203 + let source_for_pkg ~pkg slices = 204 + List.find_map 205 + (fun s -> 206 + List.find_map 207 + (fun (e : Manifest.entry) -> 208 + if e.pkg = pkg then 209 + match e.source with 210 + | Some src when src.url <> "" -> Some src.url 211 + | _ -> None 212 + else None) 213 + s.manifest.results) 214 + slices 215 + 216 + (* -- Markdown rendering -------------------------------------------------- *) 217 + 218 + let buf_add buf s = Buffer.add_string buf s 219 + let buf_addf buf fmt = Fmt.kstr (fun s -> Buffer.add_string buf s) fmt 220 + 221 + let summarize_kinds events = 222 + List.fold_left 223 + (fun acc (_os_key, (e : Audit.event)) -> 224 + Outcome.bump (Outcome.kind_of e.outcome) acc) 225 + [] events 226 + |> Outcome.sort_histogram 227 + 228 + let markdown ~handle ~generated_at slices = 229 + let failures = failures_for_handle ~handle slices in 230 + if failures = [] then "" 231 + else 232 + let buf = Buffer.create 4096 in 233 + let pkg_groups = group_by_pkg failures in 234 + let n_pkgs = List.length pkg_groups in 235 + let n_events = List.length failures in 236 + let kinds = summarize_kinds failures in 237 + let distros = 238 + List.fold_left 239 + (fun acc (os_key, _) -> String_set.add os_key acc) 240 + String_set.empty failures 241 + |> String_set.elements 242 + in 243 + buf_addf buf "# Failure report: @%s\n\n" handle; 244 + buf_addf buf "_Generated %s — for LLM-agent consumption._\n\n" 245 + (format_iso_utc generated_at); 246 + buf_add buf 247 + "This file lists every package that failed to build under the `@"; 248 + buf_add buf handle; 249 + buf_add buf 250 + "` overlay handle, joined across every distro currently published to \ 251 + this registry. Each section gives the failing outcome, an embedded tail \ 252 + of the build log, and a one-liner you can paste to reproduce locally.\n\n"; 253 + buf_add buf "## Summary\n\n"; 254 + buf_addf buf "- %d failing package(s), %d failure event(s)\n" n_pkgs 255 + n_events; 256 + buf_addf buf "- Distros with at least one failure: %s\n" 257 + (String.concat ", " (List.map (fun s -> "`" ^ s ^ "`") distros)); 258 + buf_add buf "- Outcome mix: "; 259 + (match kinds with 260 + | [] -> buf_add buf "_(none)_" 261 + | _ -> 262 + buf_add buf 263 + (String.concat ", " 264 + (List.map 265 + (fun (k, n) -> Fmt.str "%d %s" n (Outcome.kind_to_string k)) 266 + kinds))); 267 + buf_add buf "\n\n"; 268 + buf_add buf "## Reproduction\n\n"; 269 + buf_add buf 270 + "Build every package that participates in this overlay (host distro):\n\n"; 271 + buf_addf buf "```sh\noi build @%s\n```\n\n" handle; 272 + buf_add buf "Build a single failing package:\n\n"; 273 + buf_add buf "```sh\n"; 274 + buf_addf buf "oi build @%s/<pkg>\n" handle; 275 + buf_add buf "```\n\n"; 276 + buf_add buf 277 + "To rebuild on a specific distro, run inside the matching container \ 278 + image (e.g. `docker run --rm -it oi:fedora-43`).\n\n"; 279 + buf_add buf "## Failures\n\n"; 280 + List.iter 281 + (fun (pkg, evs) -> 282 + let pkg_label = Identity.to_string pkg in 283 + let kinds_for_pkg = 284 + List.map (fun (_, e) -> Outcome.kind_of (e : Audit.event).outcome) evs 285 + |> List.sort_uniq compare 286 + |> List.map Outcome.kind_to_string 287 + |> String.concat ", " 288 + in 289 + buf_addf buf "### `%s` — %s\n\n" pkg_label kinds_for_pkg; 290 + buf_addf buf "Reproduce: `oi build @%s/%s`\n\n" handle pkg_label; 291 + (match source_for_pkg ~pkg slices with 292 + | Some url -> buf_addf buf "Source: %s\n\n" url 293 + | None -> ()); 294 + let by_os = 295 + List.sort 296 + (fun (a, _) (b, _) -> String.compare a b) 297 + (List.map (fun (os_key, ev) -> (os_key, ev)) evs) 298 + in 299 + List.iter 300 + (fun (os_key, (e : Audit.event)) -> 301 + buf_addf buf "#### %s — %s\n\n" os_key 302 + (outcome_to_kind_string e.outcome); 303 + buf_addf buf "- When: %s\n" (format_iso_utc e.ts); 304 + (match e.target with 305 + | Layer h -> buf_addf buf "- Layer hash: `%s`\n" h 306 + | Solve_key h -> buf_addf buf "- Solve key: `%s`\n" h); 307 + pp_outcome_detail buf e.outcome; 308 + (match e.log with 309 + | Some lp -> 310 + buf_addf buf "- Log (registry-relative): `%s`\n" 311 + (log_relative_path ~os_key lp) 312 + | None -> ()); 313 + buf_add buf "\n"; 314 + match Stdlib.Option.bind e.log (fun lp -> lp.tail) with 315 + | Some tail when String.trim tail <> "" -> 316 + buf_add buf "<details><summary>Log tail</summary>\n\n"; 317 + buf_add buf "```\n"; 318 + buf_add buf (trim_tail tail); 319 + if 320 + not 321 + (String.length tail > 0 322 + && tail.[String.length tail - 1] = '\n') 323 + then buf_add buf "\n"; 324 + buf_add buf "```\n\n"; 325 + buf_add buf "</details>\n\n" 326 + | _ -> ()) 327 + by_os; 328 + buf_add buf "---\n\n") 329 + pkg_groups; 330 + Buffer.contents buf 331 + 332 + (* -- write_all ----------------------------------------------------------- *) 333 + 334 + let write_all ~fs ~output_dir ~generated_at = 335 + let slices = read_all_slices ~fs ~output_dir in 336 + let hs = handles slices in 337 + let dir = output_dir / "handles" in 338 + Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 Eio.Path.(fs / dir); 339 + List.filter_map 340 + (fun handle -> 341 + let body = markdown ~handle ~generated_at slices in 342 + if body = "" then None 343 + else 344 + let path = dir / (handle ^ ".md") in 345 + Eio.Path.save ~create:(`Or_truncate 0o644) Eio.Path.(fs / path) body; 346 + Some handle) 347 + hs
+53
lib/oi/handle_report.mli
··· 1 + (** Per-overlay-handle failure report in markdown. 2 + 3 + Joins {!Manifest} (one per [os_key]) with the {!Audit} event slice for the 4 + same [os_key] to surface, for each overlay handle that built under 5 + [@<handle>], the packages that failed and how to reproduce them. 6 + 7 + The generated files are written to [<output_dir>/handles/<handle>.md] — a 8 + stable, well-known location an LLM agent can fetch from a registry to pick 9 + up bug reports without having to parse the JSON manifests. *) 10 + 11 + type slice = { 12 + os_key : string; 13 + manifest : Manifest.t; 14 + events : Audit.event list; 15 + } 16 + (** A registry-side data slice for one OS key. *) 17 + 18 + val read_slice : 19 + fs:Eio.Fs.dir_ty Eio.Path.t -> 20 + output_dir:string -> 21 + os_key:string -> 22 + slice option 23 + (** Load [<output_dir>/<os_key>/{logs/manifest.json,audit.jsonl}] into a slice. 24 + Returns [None] when the manifest is missing or fails to decode. Missing 25 + audit files become an empty event list (which is normal for an all-success 26 + build). *) 27 + 28 + val read_all_slices : 29 + fs:Eio.Fs.dir_ty Eio.Path.t -> output_dir:string -> slice list 30 + (** Walk every direct subdirectory of [output_dir] and try [read_slice] for 31 + each. Subdirectories without a manifest are skipped silently. *) 32 + 33 + val handles : slice list -> string list 34 + (** Distinct overlay handles seen across every event's [context.overlay] in 35 + [slices]. Sorted alphabetically. *) 36 + 37 + val markdown : handle:string -> generated_at:float -> slice list -> string 38 + (** Render the per-handle markdown report. Includes a summary, reproduction 39 + commands, and one section per failing package with outcome detail and the 40 + audit event's log tail. 41 + 42 + Only events whose [context.overlay.handle] matches [handle] are considered. 43 + Returns [""] when no failures touch [handle]. *) 44 + 45 + val write_all : 46 + fs:Eio.Fs.dir_ty Eio.Path.t -> 47 + output_dir:string -> 48 + generated_at:float -> 49 + string list 50 + (** Convenience: read every slice under [output_dir], compute the union of 51 + handles, and write [<output_dir>/handles/<handle>.md] for each handle that 52 + has at least one failure. Returns the list of handles for which a file was 53 + written. *)
+544 -96
registry/index.html
··· 52 52 53 53 /* ---- header ---- */ 54 54 header { 55 - display: flex; align-items: center; gap: 1rem; 55 + display: flex; align-items: center; gap: 0.6rem; 56 56 padding: 0.5rem 1rem; 57 57 border-bottom: 1px solid var(--border); 58 58 background: var(--bg-elev); 59 59 position: sticky; top: 0; z-index: 10; 60 60 } 61 + header .left { 62 + display: flex; align-items: baseline; gap: 0.6rem; 63 + min-width: 0; 64 + } 61 65 header .brand { 62 66 font-family: ui-monospace, SF Mono, Monaco, monospace; 63 67 font-size: 0.95rem; ··· 65 69 color: var(--fg); 66 70 } 67 71 header .brand .sub { color: var(--fg-dim); font-weight: normal; font-size: 0.75rem; margin-left: 0.4rem; } 68 - header .spacer { flex: 1; } 69 - header .exported-info { 72 + header .updated { 70 73 font-family: ui-monospace, monospace; 71 - font-size: 0.7rem; 74 + font-size: 0.65rem; 72 75 color: var(--fg-faint); 73 76 white-space: nowrap; 74 77 } 75 - header .exported-info .schema { margin-left: 0.5rem; opacity: 0.7; } 76 - header .os-select { 78 + /* Selection chips pushed to the right edge — [margin-left:auto] is 79 + the standard flex idiom that beats a [<spacer>] element. The chips 80 + themselves stay flex-wrap so that on narrow windows they wrap to a 81 + second header row rather than overlapping the title. */ 82 + header .selections { 83 + display: flex; 84 + flex-wrap: wrap; 85 + justify-content: flex-end; 86 + gap: 0.3rem; 87 + margin-left: auto; 88 + min-width: 0; 89 + } 90 + /* Selection chips up here mirror what's in the URL hash. Each carries 91 + an × button that drops just that selection — the header stays a 92 + live readout of "what filters narrow the view right now". 93 + The OS chip is special: instead of an × it opens a tiny popover 94 + to switch distros (since per-row chips also navigate but the user 95 + needs a way to flip distro before any row is in view). */ 96 + header .sel-chip { 97 + display: inline-flex; 98 + align-items: center; 99 + gap: 0.25rem; 100 + padding: 0.1rem 0.4rem; 101 + border-radius: 4px; 102 + font-size: 0.7rem; 77 103 font-family: ui-monospace, monospace; 78 - font-size: 0.78rem; 79 - padding: 0.3rem 0.6rem; 80 104 border: 1px solid var(--border-strong); 81 - border-radius: 5px; 82 105 background: var(--bg); 83 106 color: var(--fg); 107 + white-space: nowrap; 108 + line-height: 1.4; 109 + } 110 + header .sel-chip .sel-key { 111 + color: var(--fg-faint); 112 + text-transform: uppercase; 113 + letter-spacing: 0.04em; 114 + font-size: 0.6rem; 115 + } 116 + header .sel-chip .sel-x { 84 117 cursor: pointer; 85 - min-width: 200px; 118 + color: var(--fg-dim); 119 + padding: 0 0.15rem; 120 + border-radius: 2px; 121 + user-select: none; 86 122 } 87 - header .os-select:hover { border-color: var(--fg-dim); } 123 + header .sel-chip .sel-x:hover { color: var(--fg); background: var(--bg-sunken); } 124 + header .sel-chip.os { 125 + background: var(--bg-elev); 126 + border-color: var(--accent); 127 + color: var(--accent); 128 + } 129 + header .sel-chip.os summary { 130 + cursor: pointer; 131 + list-style: none; 132 + display: inline-flex; 133 + align-items: center; 134 + gap: 0.25rem; 135 + } 136 + header .sel-chip.os summary::-webkit-details-marker { display: none; } 137 + header .sel-chip.os[open] { 138 + position: relative; 139 + } 140 + header .sel-chip.os .os-popover { 141 + position: absolute; 142 + top: calc(100% + 0.3rem); 143 + right: 0; 144 + min-width: 220px; 145 + background: var(--bg-elev); 146 + border: 1px solid var(--border-strong); 147 + border-radius: 6px; 148 + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18); 149 + padding: 0.3rem; 150 + display: flex; 151 + flex-direction: column; 152 + gap: 0.15rem; 153 + z-index: 20; 154 + } 155 + header .sel-chip.os .os-popover button { 156 + appearance: none; 157 + text-align: left; 158 + font-family: inherit; 159 + font-size: 0.72rem; 160 + padding: 0.3rem 0.5rem; 161 + background: transparent; 162 + border: 1px solid transparent; 163 + border-radius: 4px; 164 + color: var(--fg); 165 + cursor: pointer; 166 + } 167 + header .sel-chip.os .os-popover button.current { 168 + background: var(--bg-sunken); 169 + border-color: var(--accent); 170 + color: var(--accent); 171 + } 172 + header .sel-chip.os .os-popover button:hover { background: var(--bg-sunken); } 173 + header .sel-chip.os .os-popover .fail-count { 174 + color: var(--fail); 175 + font-size: 0.6rem; 176 + margin-left: 0.4rem; 177 + } 88 178 header .fail-pill { 89 179 display: inline-block; 90 180 padding: 0.05rem 0.4rem; ··· 295 385 .pkg-row { 296 386 display: grid; 297 387 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 */ 388 + 110px /* status badge */ 389 + minmax(180px, 1fr) /* package name+version, flexes */ 390 + minmax(180px, 240px)/* per-distro chip strip — bounded */ 391 + minmax(80px, 120px) /* overlay tags */ 392 + 96px /* short layer hash */ 393 + 70px /* method */ 394 + 64px; /* duration */ 305 395 gap: 0.6rem; 306 396 padding: 0.3rem 0.85rem; 307 397 align-items: center; ··· 339 429 .group-header .group-count { color: var(--fg-dim); margin-left: 0.4rem; font-weight: normal; } 340 430 .overlay-tag { color: var(--accent); font-size: 0.72rem; } 341 431 .overlay-tag.none { color: var(--fg-faint); } 432 + /* Dropdown for the multi-handle case: when an entry's [callers[]] 433 + names more than one overlay, the row would otherwise overflow the 434 + ~120px overlay column. Show the first chip plus a "+N" counter that 435 + doubles as a [details] toggle; the full handle list is absolutely 436 + positioned below so opening the dropdown doesn't push neighboring 437 + rows down. */ 438 + .handle-dd { 439 + position: relative; 440 + display: inline-flex; 441 + align-items: center; 442 + gap: 0.25rem; 443 + min-width: 0; 444 + } 445 + .handle-dd > summary { 446 + list-style: none; 447 + cursor: pointer; 448 + display: inline-flex; 449 + align-items: center; 450 + gap: 0.25rem; 451 + user-select: none; 452 + } 453 + .handle-dd > summary::-webkit-details-marker { display: none; } 454 + .handle-dd > summary::marker { content: ""; } 455 + .handle-dd > summary .more { 456 + font-size: 0.62rem; 457 + font-weight: 600; 458 + color: var(--fg-dim); 459 + background: var(--bg-sunken); 460 + border: 1px solid var(--border); 461 + border-radius: 999px; 462 + padding: 0.02rem 0.32rem; 463 + font-family: ui-monospace, monospace; 464 + } 465 + .handle-dd[open] > summary .more { 466 + background: var(--accent); 467 + color: white; 468 + border-color: var(--accent); 469 + } 470 + .handle-dd-list { 471 + position: absolute; 472 + top: calc(100% + 4px); 473 + left: 0; 474 + z-index: 60; 475 + display: flex; 476 + flex-direction: column; 477 + gap: 0.25rem; 478 + background: var(--bg); 479 + border: 1px solid var(--border-strong); 480 + border-radius: 4px; 481 + padding: 0.35rem 0.5rem; 482 + white-space: nowrap; 483 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18); 484 + min-width: 8rem; 485 + } 486 + @media (prefers-color-scheme: dark) { 487 + .handle-dd-list { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); } 488 + } 342 489 .pkg-row:hover { background: var(--bg-sunken); } 343 490 .pkg-row.expanded { background: var(--bg-sunken); } 344 491 .pkg-row.expanded::before { content: ""; width: 3px; background: var(--accent); position: absolute; left: 0; top: 0; bottom: 0; } ··· 373 520 } 374 521 .distro-chip { 375 522 display: inline-block; 376 - padding: 0.05rem 0.35rem; 523 + /* Min-width keeps the 2-char "A3" chip the same width as the 3-char 524 + "U25" chip so columns stay vertically aligned across rows. */ 525 + min-width: 1.6rem; 526 + padding: 0.05rem 0.3rem; 377 527 border-radius: 3px; 378 528 font-size: 0.6rem; 379 529 font-weight: 600; ··· 383 533 cursor: pointer; 384 534 font-family: ui-monospace, monospace; 385 535 border: 1px solid transparent; 536 + box-sizing: border-box; 386 537 } 387 538 .distro-chip:hover { border-color: rgba(255, 255, 255, 0.5); } 388 539 .distro-chip.outcome-missing { ··· 545 696 margin-bottom: 0.2rem; 546 697 } 547 698 .pkg-detail .event-head .event-ts { margin-left: auto; } 699 + .pkg-detail .event-count { 700 + display: inline-block; 701 + padding: 0.05rem 0.35rem; 702 + border-radius: 999px; 703 + background: var(--bg); 704 + border: 1px solid var(--border-strong); 705 + color: var(--fg-dim); 706 + font-size: 0.62rem; 707 + font-weight: 600; 708 + font-family: ui-monospace, monospace; 709 + } 548 710 .pkg-detail .event-trigger { 549 711 font-family: ui-monospace, monospace; 550 712 font-size: 0.7rem; ··· 623 785 </head> 624 786 <body> 625 787 <header> 626 - <span class="brand">oi <span class="sub">registry</span></span> 627 - <span class="spacer"></span> 628 - <span class="exported-info" id="exported-info"></span> 629 - <select class="os-select" id="os-select" title="Select OS"></select> 788 + <span class="left"> 789 + <span class="brand">oi <span class="sub">registry</span></span> 790 + <span class="updated" id="updated" title=""></span> 791 + </span> 792 + <span class="selections" id="selections"></span> 630 793 </header> 631 794 <main> 632 795 <div id="content"> ··· 746 909 return [...set].sort(); 747 910 } 748 911 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. 912 + // "alpine~3.23~x86_64" → "alpine 3.23". Used for tooltips and 913 + // dropdowns where the full distro name+version is wanted. 751 914 function shortDistro(osKey) { 752 915 const p = parseOsKey(osKey); 753 916 return p.short || p.name; 754 917 } 755 918 919 + // Compact 2–3 char chip label: first letter of the distro name 920 + // uppercase, followed by the version's major segment. Designed to 921 + // stay within ~3 chars so a 5-distro chip strip fits in a narrow 922 + // column without spilling. 923 + // alpine~3.23~* → "A3" 924 + // debian~13~* → "D13" 925 + // ubuntu~24.04~* → "U24" 926 + // ubuntu~25.10~* → "U25" 927 + // fedora~43~* → "F43" 928 + // macos~26~* → "M26" 929 + // Disambiguates the common case (different ubuntu versions); the 930 + // hover tooltip still shows the full os_key so any near-collision 931 + // (e.g. two alpine 3.x rolls) is recoverable. 932 + function chipLabel(osKey) { 933 + const p = parseOsKey(osKey); 934 + const letter = (p.name || "?").charAt(0).toUpperCase(); 935 + const major = (p.version || "").split(".")[0]; 936 + return major ? letter + major : letter; 937 + } 938 + 756 939 // Cross-distro outcome for a given (pkg.name, pkg.version). Returns 757 940 // [{ os_key, outcome, layer_hash }, ...] over every loaded manifest 758 941 // — including a sentinel { outcome: "missing" } for distros where ··· 779 962 if (cells.length === 0) return ""; 780 963 return `<span class="distro-chips">${cells.map(c => { 781 964 const grp = c.outcome === "missing" ? "missing" : (OUTCOME_GROUPS[c.outcome] || "ok"); 782 - const label = shortDistro(c.os_key); 965 + const label = chipLabel(c.os_key); 783 966 const isActive = c.os_key === state.active; 784 967 const cls = "distro-chip outcome-" + grp + (isActive ? " active" : ""); 968 + // Tooltip carries the full os_key + outcome since the chip itself 969 + // shows only a 2–3 char acronym. 970 + const full = shortDistro(c.os_key); 785 971 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`; 972 + ? `${full} — not built` 973 + : `${full} — ${c.outcome.replace(/_/g, " ")} (click to drill in)`; 788 974 // Carry the os_key + layer_hash on the chip so the click handler 789 975 // can switch [state.active] and expand the same package's row in 790 976 // the target distro's manifest. Missing chips have no layer_hash ··· 859 1045 return String(s).replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c])); 860 1046 } 861 1047 862 - // URL hash format: #<os_key>/<layer_hash>. Tilde, slash and hex are all 863 - // fragment-safe so no encoding needed for our values. 1048 + // URL hash format: #<os_key>[/<layer_hash>][?overlay=<a>,<b>&...]. 1049 + // Tilde, slash, hex and comma are all fragment-safe; only the "(none)" 1050 + // sentinel for "no overlay" needs URL-encoding because of the parens. 1051 + // 1052 + // Examples: 1053 + // #ubuntu~25.10~x86_64 1054 + // #ubuntu~25.10~x86_64/abc123def456 1055 + // #ubuntu~25.10~x86_64?overlay=avsm,samoht 1056 + // #ubuntu~25.10~x86_64/abc123?overlay=avsm 864 1057 function readHash() { 865 - const h = window.location.hash.replace(/^#/, ""); 866 - if (!h) return { osKey: null, layer: null }; 867 - const slash = h.indexOf("/"); 868 - if (slash < 0) return { osKey: h, layer: null }; 869 - return { osKey: h.slice(0, slash), layer: h.slice(slash + 1) }; 1058 + const raw = window.location.hash.replace(/^#/, ""); 1059 + if (!raw) return { osKey: null, layer: null, overlays: null }; 1060 + const q = raw.indexOf("?"); 1061 + const path = q < 0 ? raw : raw.slice(0, q); 1062 + const query = q < 0 ? "" : raw.slice(q + 1); 1063 + const slash = path.indexOf("/"); 1064 + const osKey = slash < 0 ? path : path.slice(0, slash); 1065 + const layer = slash < 0 ? null : path.slice(slash + 1); 1066 + let overlays = null; 1067 + if (query) { 1068 + const params = new URLSearchParams(query); 1069 + const ov = params.get("overlay"); 1070 + if (ov !== null) { 1071 + overlays = ov === "" ? [] : ov.split(",").map(decodeURIComponent); 1072 + } 1073 + } 1074 + return { osKey, layer, overlays }; 870 1075 } 871 1076 872 - function writeHash(osKey, layer) { 873 - const want = layer ? `#${osKey}/${layer}` : `#${osKey}`; 1077 + // Serialise the current state-relevant bits into the hash. Pass null 1078 + // for [layer] to clear the layer-expansion deep-link; pass null for 1079 + // [overlays] to omit the overlay query (i.e. "no filter set"). Pass 1080 + // an empty array for [overlays] to encode "all overlays explicitly 1081 + // excluded" — that survives a reload as a remembered preference, 1082 + // even if the visible result is "no rows". 1083 + function writeHash(osKey, layer, overlays) { 1084 + let want = `#${osKey}`; 1085 + if (layer) want += `/${layer}`; 1086 + if (overlays && overlays.length > 0) { 1087 + const enc = overlays.map(encodeURIComponent).join(","); 1088 + want += `?overlay=${enc}`; 1089 + } 874 1090 if (window.location.hash !== want) { 875 1091 history.replaceState(null, "", want); 876 1092 } 877 1093 } 878 1094 1095 + // Convenience: capture the current overlay filter as the array form 1096 + // [writeHash] expects, or null if the user hasn't set any filter yet 1097 + // (so the default URL stays clean). 1098 + function overlaysFromState() { 1099 + return state.overlayFilter.size === 0 1100 + ? null 1101 + : [...state.overlayFilter]; 1102 + } 1103 + 1104 + // Relative-time formatter for the header's "updated" readout. Falls 1105 + // back to the absolute date once the gap exceeds a week — at that 1106 + // point "12d ago" becomes less useful than the actual day. 1107 + function fmtRelative(unixSec) { 1108 + if (!unixSec) return ""; 1109 + const now = Date.now() / 1000; 1110 + const ago = Math.max(0, now - unixSec); 1111 + if (ago < 60) return "just now"; 1112 + if (ago < 3600) return `${Math.floor(ago / 60)}m ago`; 1113 + if (ago < 86400) return `${Math.floor(ago / 3600)}h ago`; 1114 + if (ago < 7 * 86400) return `${Math.floor(ago / 86400)}d ago`; 1115 + return new Date(unixSec * 1000).toISOString().slice(0, 10); 1116 + } 1117 + 879 1118 function renderHeader() { 880 1119 const m = state.manifests.get(state.active); 881 - const sel = document.getElementById("os-select"); 882 - sel.innerHTML = ""; 883 - for (const [key, mm] of state.manifests) { 884 - const failures = mm.summary.build_failed + mm.summary.install_failed 885 - + mm.summary.fetch_failed + mm.summary.solve_failed; 886 - const p = parseOsKey(key); 887 - const opt = document.createElement("option"); 888 - opt.value = key; 889 - opt.textContent = `${p.full} (${mm.n_packages})${failures > 0 ? ` — ${failures} failed` : ""}`; 890 - if (key === state.active) opt.selected = true; 891 - sel.appendChild(opt); 1120 + const root = document.getElementById("selections"); 1121 + root.innerHTML = ""; 1122 + 1123 + // [chip] helper: build a sel-chip with key/value + optional ×. 1124 + // [onDismiss] (when non-null) is invoked synchronously, then the 1125 + // URL-+-render cycle runs. 1126 + const chip = (cls, body, onDismiss) => { 1127 + const el = document.createElement("span"); 1128 + el.className = "sel-chip" + (cls ? " " + cls : ""); 1129 + el.innerHTML = body; 1130 + if (onDismiss) { 1131 + const x = document.createElement("span"); 1132 + x.className = "sel-x"; 1133 + x.title = "dismiss"; 1134 + x.textContent = "×"; 1135 + x.onclick = (e) => { 1136 + e.stopPropagation(); 1137 + onDismiss(); 1138 + writeHash(state.active, state.expanded, overlaysFromState()); 1139 + render(); 1140 + }; 1141 + el.appendChild(x); 1142 + } 1143 + root.appendChild(el); 1144 + return el; 1145 + }; 1146 + 1147 + // OS chip — always present, no ×, but acts as a popover to switch 1148 + // distros. Uses <details> so the open/close state is browser-native 1149 + // and the click-outside-to-close behaviour we get for free. 1150 + if (state.manifests.size > 0) { 1151 + const osChip = document.createElement("details"); 1152 + osChip.className = "sel-chip os"; 1153 + const failures = m 1154 + ? (m.summary.build_failed + m.summary.install_failed 1155 + + m.summary.fetch_failed + m.summary.solve_failed) 1156 + : 0; 1157 + const cur = parseOsKey(state.active); 1158 + osChip.innerHTML = 1159 + `<summary>` + 1160 + `<span class="sel-key">os</span>` + 1161 + `<span>${escapeHtml(cur.short)}</span>` + 1162 + (failures > 0 ? `<span class="fail-pill">${failures}</span>` : "") + 1163 + `<span style="opacity:0.7">▾</span>` + 1164 + `</summary>`; 1165 + const pop = document.createElement("div"); 1166 + pop.className = "os-popover"; 1167 + for (const [key, mm] of state.manifests) { 1168 + const f = mm.summary.build_failed + mm.summary.install_failed 1169 + + mm.summary.fetch_failed + mm.summary.solve_failed; 1170 + const p = parseOsKey(key); 1171 + const btn = document.createElement("button"); 1172 + if (key === state.active) btn.className = "current"; 1173 + btn.innerHTML = 1174 + `${escapeHtml(p.full)} <span style="color:var(--fg-faint)">(${mm.n_packages})</span>` + 1175 + (f > 0 ? `<span class="fail-count">${f} failed</span>` : ""); 1176 + btn.onclick = (e) => { 1177 + e.preventDefault(); 1178 + state.active = key; 1179 + state.expanded = null; 1180 + state.highlightUpstream = null; 1181 + writeHash(state.active, null, overlaysFromState()); 1182 + render(); 1183 + }; 1184 + pop.appendChild(btn); 1185 + } 1186 + osChip.appendChild(pop); 1187 + root.appendChild(osChip); 892 1188 } 893 - sel.onchange = (e) => { 894 - state.active = e.target.value; 895 - state.expanded = null; 896 - state.highlightUpstream = null; 897 - writeHash(state.active, null); 898 - render(); 899 - }; 900 - const info = document.getElementById("exported-info"); 1189 + 1190 + // Expanded layer chip — present iff a row is open. 1191 + if (state.expanded) { 1192 + chip("", 1193 + `<span class="sel-key">layer</span><span>${escapeHtml(state.expanded.slice(0, 12))}</span>`, 1194 + () => { state.expanded = null; state.highlightUpstream = null; }); 1195 + } 1196 + 1197 + // Overlay filter — one chip per included handle so individual ones 1198 + // can be dismissed without nuking the rest. 1199 + for (const handle of state.overlayFilter) { 1200 + const label = handle === "(none)" ? "(no overlay)" : "@" + handle; 1201 + chip("", 1202 + `<span class="sel-key">overlay</span><span>${escapeHtml(label)}</span>`, 1203 + () => { state.overlayFilter.delete(handle); }); 1204 + } 1205 + 1206 + // Status-group filter — only show chips when the user has narrowed 1207 + // away from "all four groups". A chip per *enabled* group then 1208 + // reads naturally as "showing: ok, fail" with × removing each. 1209 + const allStatuses = STATUS_GROUPS.map(g => g.key); 1210 + const statusOff = state.statusFilter.size !== allStatuses.length; 1211 + if (statusOff) { 1212 + for (const k of allStatuses) { 1213 + if (!state.statusFilter.has(k)) continue; 1214 + chip("", 1215 + `<span class="sel-key">status</span><span>${escapeHtml(k)}</span>`, 1216 + () => { state.statusFilter.delete(k); }); 1217 + } 1218 + } 1219 + 1220 + // Search-text filter. 1221 + if (state.filter) { 1222 + chip("", 1223 + `<span class="sel-key">search</span><span>${escapeHtml(state.filter)}</span>`, 1224 + () => { state.filter = ""; }); 1225 + } 1226 + 1227 + // Compact "updated Xm ago" readout. Tooltip carries the absolute 1228 + // timestamp + schema for the rare case someone wants the detail. 1229 + const upd = document.getElementById("updated"); 901 1230 if (m) { 902 - info.innerHTML = 903 - `<span title="last export">exported ${escapeHtml(fmtTimeShort(m.exported_at))} UTC</span>` + 904 - `<span class="schema">schema v${escapeHtml(String(m.schema))}</span>`; 1231 + upd.textContent = `updated ${fmtRelative(m.exported_at)}`; 1232 + upd.title = `${fmtTimeShort(m.exported_at)} UTC · schema v${m.schema}`; 905 1233 } else { 906 - info.textContent = ""; 1234 + upd.textContent = ""; 1235 + upd.title = ""; 907 1236 } 908 1237 } 909 1238 ··· 1117 1446 : `<span class="overlay-tag none">(no overlay)</span>`; 1118 1447 } 1119 1448 1449 + // Equivalence key for grouping identical events. Two events collapse 1450 + // into one row iff they share their outcome (kind + payload) AND 1451 + // their caller context (overlay, toolchain, trigger, project, host). 1452 + // Volatile fields — [event_id], [invocation_id], [ts], [duration_s], 1453 + // [log] — are deliberately excluded so a CI run that hits the same 1454 + // build failure 50 times collapses cleanly. 1455 + function eventKey(ev) { 1456 + const o = ev.outcome || {}; 1457 + const c = ev.context || {}; 1458 + const ov = c.overlay || {}; 1459 + const u = o.upstream || {}; 1460 + const payload = JSON.stringify({ 1461 + kind: o.kind, 1462 + command: o.command || "", 1463 + exit_code: o.exit_code ?? null, 1464 + url: o.url || "", 1465 + fetch_kind: o.fetch_kind || null, 1466 + upstream: o.kind === "dep_failed" 1467 + ? { n: u.name || "", v: u.version || "", h: u.hash || "" } 1468 + : null, 1469 + reason: o.reason || "", 1470 + missing: (o.missing || []).join(","), 1471 + not_found: (o.not_found || []).join(","), 1472 + }); 1473 + return [ 1474 + payload, 1475 + ov.handle || "", 1476 + ov.version || "", 1477 + c.toolchain || "", 1478 + c.trigger || "", 1479 + c.host || "", 1480 + c.project || "", 1481 + ].join(""); 1482 + } 1483 + 1484 + // Group identical events. Returns one record per unique key with the 1485 + // underlying events sorted oldest→newest (matching [fetchAudit]'s 1486 + // per-layer ordering). Group order is most-recent-last-seen first 1487 + // — failures the user just hit float to the top. 1488 + function groupEvents(events) { 1489 + const map = new Map(); 1490 + for (const ev of events) { 1491 + const k = eventKey(ev); 1492 + let g = map.get(k); 1493 + if (!g) { 1494 + g = { key: k, events: [] }; 1495 + map.set(k, g); 1496 + } 1497 + g.events.push(ev); 1498 + } 1499 + const groups = [...map.values()].map(g => ({ 1500 + ...g, 1501 + first: g.events[0], 1502 + last: g.events[g.events.length - 1], 1503 + })); 1504 + groups.sort((a, b) => (b.last.ts || 0) - (a.last.ts || 0)); 1505 + return groups; 1506 + } 1507 + 1120 1508 // Per-event detail surfaced under the aggregated callers table. The 1121 - // manifest's [callers] is a histogram; the audit log preserves the full 1122 - // event stream so we can render the actual failure command, fetch URL, 1123 - // log tail, etc. for each invocation. 1509 + // manifest's [callers] is a histogram across (overlay, toolchain, 1510 + // trigger) tuples; the audit log preserves individual invocations. 1511 + // We collapse runs that are pairwise identical so a build that 1512 + // fails repeatedly shows once with a count, not N times in a row. 1124 1513 function renderEvents(events) { 1125 1514 if (!events || events.length === 0) return ""; 1126 - const rows = events.map(ev => { 1515 + const groups = groupEvents(events); 1516 + const rows = groups.map(g => { 1517 + // Use the most recent event as the representative — its log tail 1518 + // is the most likely to still be on disk, and its detail reflects 1519 + // the latest failure mode in the rare case the payload's 1520 + // equivalence-key fields drift mid-grouping (shouldn't happen 1521 + // because they're the key, but belt-and-braces). 1522 + const ev = g.last; 1127 1523 const grp = OUTCOME_GROUPS[ev.outcome.kind] || "ok"; 1128 - const ts = fmtTimeShort(ev.ts); 1129 1524 const ctx = ev.context || {}; 1130 1525 const tc = ctx.toolchain ? `<span class="dim"> · ${escapeHtml(ctx.toolchain)}</span>` : ""; 1131 1526 const host = ctx.host ? `<span class="dim"> · ${escapeHtml(ctx.host)}</span>` : ""; 1527 + // Time display: a single tick if the group has one event OR all 1528 + // events share an instant; otherwise [first → last] so the user 1529 + // sees how long the same outcome has been recurring. 1530 + const sameInstant = g.first.ts === g.last.ts; 1531 + const tsLabel = g.events.length === 1 || sameInstant 1532 + ? fmtTimeShort(g.last.ts) 1533 + : `${fmtTimeShort(g.first.ts)} → ${fmtTimeShort(g.last.ts)}`; 1534 + const countChip = g.events.length > 1 1535 + ? `<span class="event-count" title="${g.events.length} identical occurrences">×${g.events.length}</span>` 1536 + : ""; 1132 1537 const detail = renderOutcomeDetail(ev.outcome); 1133 1538 const log = ev.log && ev.log.tail 1134 1539 ? `<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>` ··· 1136 1541 return `<div class="event-row"> 1137 1542 <div class="event-head"> 1138 1543 <span class="outcome-badge outcome-${grp}">${ev.outcome.kind.replace(/_/g, " ")}</span> 1544 + ${countChip} 1139 1545 ${eventOverlayLabel(ev)}${tc}${host} 1140 - <span class="dim event-ts">${escapeHtml(ts)}</span> 1546 + <span class="dim event-ts">${escapeHtml(tsLabel)}</span> 1141 1547 </div> 1142 1548 <div class="event-trigger"><code>${escapeHtml(ctx.trigger || "")}</code></div> 1143 1549 ${detail} 1144 1550 ${log} 1145 1551 </div>`; 1146 1552 }).join(""); 1147 - return `<h3>Events (${events.length})</h3> 1553 + const heading = groups.length === events.length 1554 + ? `Events (${events.length})` 1555 + : `Events (${events.length} total, ${groups.length} unique)`; 1556 + return `<h3>${heading}</h3> 1148 1557 <div class="event-list">${rows}</div>`; 1149 1558 } 1150 1559 ··· 1262 1671 const expanded = state.expanded === r.layer_hash ? " expanded" : ""; 1263 1672 const upstream = state.highlightUpstream === r.layer_hash ? " upstream" : ""; 1264 1673 const handles = handlesOf(r); 1265 - // Up to 3 chips; if more, append a "+N" indicator. 1266 - const chips = 1267 - handles.length === 0 1268 - ? `<span class="overlay-tag none">—</span>` 1269 - : handles.slice(0, 3).map(h => 1270 - h === "(none)" 1271 - ? `<span class="overlay-tag none">∅</span>` 1272 - : `<span class="overlay-tag">@${escapeHtml(h)}</span>` 1273 - ).join(" ") + (handles.length > 3 ? ` <span class="overlay-tag none">+${handles.length - 3}</span>` : ""); 1674 + // Single handle → flat chip. 2+ handles → a [details] dropdown 1675 + // anchored on the first chip plus a "+N" counter. Avoids overflowing 1676 + // the bounded overlay column on entries that fan out across many 1677 + // overlays. 1678 + const renderHandleChip = (h) => 1679 + h === "(none)" 1680 + ? `<span class="overlay-tag none">∅</span>` 1681 + : `<span class="overlay-tag">@${escapeHtml(h)}</span>`; 1682 + let chips; 1683 + if (handles.length === 0) { 1684 + chips = `<span class="overlay-tag none">—</span>`; 1685 + } else if (handles.length === 1) { 1686 + chips = renderHandleChip(handles[0]); 1687 + } else { 1688 + const head = renderHandleChip(handles[0]); 1689 + const more = handles.length - 1; 1690 + const list = handles.map(renderHandleChip).join(""); 1691 + chips = `<details class="handle-dd"> 1692 + <summary>${head}<span class="more">+${more}</span></summary> 1693 + <div class="handle-dd-list">${list}</div> 1694 + </details>`; 1695 + } 1274 1696 const rowHtml = `<div class="pkg-row${expanded}${upstream}" data-hash="${escapeHtml(r.layer_hash)}"> 1275 1697 <span class="outcome-badge outcome-${grp}">${r.headline_outcome.replace(/_/g, " ")}</span> 1276 1698 <span><span class="pkg-name">${escapeHtml(r.pkg.name)}</span> <span class="pkg-version">${escapeHtml(r.pkg.version)}</span></span> ··· 1336 1758 // overlay multi-select handlers 1337 1759 const overlay = document.getElementById("overlay-multi"); 1338 1760 if (overlay) { 1761 + // Both checkbox toggles and the [Clear] / [All] action buttons 1762 + // mutate [state.overlayFilter] then re-render. Centralise the 1763 + // post-mutation work — URL persistence + re-render-while-keeping- 1764 + // -the-dropdown-open — so the URL stays in sync with whatever the 1765 + // user just clicked. 1766 + const persistAndRender = () => { 1767 + writeHash(state.active, state.expanded, overlaysFromState()); 1768 + const wasOpen = overlay.open; 1769 + render(); 1770 + if (wasOpen) { 1771 + const next = document.getElementById("overlay-multi"); 1772 + if (next) next.open = true; 1773 + } 1774 + }; 1339 1775 for (const cb of overlay.querySelectorAll('input[type="checkbox"]')) { 1340 1776 cb.onchange = () => { 1341 1777 const h = cb.dataset.handle; 1342 1778 if (cb.checked) state.overlayFilter.add(h); 1343 1779 else state.overlayFilter.delete(h); 1344 - // Re-render but keep dropdown open. 1345 - const wasOpen = overlay.open; 1346 - render(); 1347 - if (wasOpen) { 1348 - const next = document.getElementById("overlay-multi"); 1349 - if (next) next.open = true; 1350 - } 1780 + persistAndRender(); 1351 1781 }; 1352 1782 } 1353 1783 for (const btn of overlay.querySelectorAll("[data-overlay-action]")) { 1354 1784 btn.onclick = (e) => { 1355 1785 e.preventDefault(); 1356 1786 if (btn.dataset.overlayAction === "clear") state.overlayFilter.clear(); 1357 - const wasOpen = overlay.open; 1358 - render(); 1359 - if (wasOpen) { 1360 - const next = document.getElementById("overlay-multi"); 1361 - if (next) next.open = true; 1362 - } 1787 + persistAndRender(); 1363 1788 }; 1364 1789 } 1365 1790 } ··· 1371 1796 row.onclick = (e) => { 1372 1797 // Clicks on links inside an expanded panel must not collapse the row. 1373 1798 if (e.target.closest("a[data-jump]")) return; 1799 + // The multi-handle dropdown lives inside the row but its toggling 1800 + // is handled by the native [details] element; intercept here so 1801 + // opening/closing it doesn't also expand the row. 1802 + if (e.target.closest(".handle-dd")) { 1803 + e.stopPropagation(); 1804 + return; 1805 + } 1374 1806 // Clicking a distro chip jumps to that distro's view of the same 1375 1807 // package. The active os_key changes, the OS dropdown updates, 1376 1808 // and the row stays expanded so the user can read the failure ··· 1385 1817 if (!state.manifests.has(targetOs)) return; 1386 1818 state.active = targetOs; 1387 1819 expand(targetHash); 1388 - writeHash(targetOs, targetHash); 1820 + writeHash(targetOs, targetHash, overlaysFromState()); 1389 1821 render(); 1390 1822 const t = document.querySelector(`.pkg-row[data-hash="${targetHash}"]`); 1391 1823 if (t) t.scrollIntoView({ block: "nearest", behavior: "smooth" }); ··· 1395 1827 if (state.expanded === hash) { 1396 1828 state.expanded = null; 1397 1829 state.highlightUpstream = null; 1398 - writeHash(state.active, null); 1830 + writeHash(state.active, null, overlaysFromState()); 1399 1831 } else { 1400 1832 expand(hash); 1401 - writeHash(state.active, hash); 1833 + writeHash(state.active, hash, overlaysFromState()); 1402 1834 } 1403 1835 render(); 1404 1836 const newRow = document.querySelector(`.pkg-row[data-hash="${hash}"]`); ··· 1414 1846 const hash = a.dataset.jump; 1415 1847 if (!m.results.find(x => x.layer_hash === hash)) return; 1416 1848 expand(hash); 1417 - writeHash(state.active, hash); 1849 + writeHash(state.active, hash, overlaysFromState()); 1418 1850 render(); 1419 1851 const target = document.querySelector(`.pkg-row[data-hash="${hash}"]`); 1420 1852 if (target) target.scrollIntoView({ block: "nearest", behavior: "smooth" }); 1421 1853 }; 1422 1854 } 1423 1855 1424 - // "Copy link" button: write the deep-link to the clipboard. 1856 + // "Copy link" button: write the deep-link to the clipboard. The 1857 + // [data-share] attribute carries the [<os_key>/<layer>] path; we 1858 + // append the current overlay filter at click time so the copied 1859 + // URL reproduces what the recipient actually sees in the table. 1425 1860 for (const btn of document.querySelectorAll(".pkg-detail .share")) { 1426 1861 btn.onclick = async (e) => { 1427 1862 e.stopPropagation(); 1428 - const frag = btn.dataset.share; 1863 + let frag = btn.dataset.share; 1864 + const ov = overlaysFromState(); 1865 + if (ov && ov.length > 0) { 1866 + const enc = ov.map(encodeURIComponent).join(","); 1867 + frag += `?overlay=${enc}`; 1868 + } 1429 1869 const url = `${window.location.origin}${window.location.pathname}#${frag}`; 1430 1870 try { await navigator.clipboard.writeText(url); } catch { /* ignore */ } 1431 1871 btn.classList.add("copied"); ··· 1448 1888 } 1449 1889 1450 1890 function applyHash() { 1451 - const { osKey, layer } = readHash(); 1891 + const { osKey, layer, overlays } = readHash(); 1452 1892 if (osKey && state.manifests.has(osKey)) { 1453 1893 state.active = osKey; 1454 1894 if (layer) expand(layer); else { state.expanded = null; state.highlightUpstream = null; } 1455 1895 } else if (!state.active) { 1456 1896 state.active = state.manifests.keys().next().value; 1897 + } 1898 + // Overlay filter follows the URL. [overlays = null] means the 1899 + // hash didn't carry an overlay query at all, so we leave the 1900 + // current filter alone (preserves explicit user toggles across 1901 + // hashchange events that only mutated [osKey] / [layer]). 1902 + // [overlays = []] is "URL says explicitly empty"; respect that too. 1903 + if (overlays !== null) { 1904 + state.overlayFilter = new Set(overlays); 1457 1905 } 1458 1906 } 1459 1907