This repository has no description
0

Configure Feed

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

1<!doctype html> 2<html> 3 <head> 4 <meta charset="utf-8" /> 5 <title>Unmaintained packages</title> 6 <style> 7 :root { 8 --bg: #0d1117; 9 --panel: #161b22; 10 --fg: #c9d1d9; 11 --dim: #8b949e; 12 --line: #30363d; 13 --red: #f85149; 14 --blue: #58a6ff; 15 --green: #3fb950; 16 --yellow: #d29922; 17 } 18 * { 19 box-sizing: border-box; 20 } 21 body { 22 margin: 0; 23 font: 24 13px/1.4 ui-monospace, 25 SFMono-Regular, 26 Menlo, 27 monospace; 28 background: var(--bg); 29 color: var(--fg); 30 display: grid; 31 grid-template-columns: 340px 1fr 380px; 32 grid-template-rows: auto 1fr; 33 height: 100vh; 34 } 35 header { 36 grid-column: 1 / -1; 37 display: flex; 38 gap: 12px; 39 align-items: center; 40 padding: 8px 12px; 41 background: var(--panel); 42 border-bottom: 1px solid var(--line); 43 } 44 header h1 { 45 font-size: 14px; 46 margin: 0 12px 0 0; 47 } 48 input, 49 select, 50 button { 51 font: inherit; 52 background: var(--bg); 53 color: var(--fg); 54 border: 1px solid var(--line); 55 border-radius: 4px; 56 padding: 3px 6px; 57 } 58 button { 59 cursor: pointer; 60 } 61 button:hover { 62 border-color: var(--blue); 63 } 64 #list { 65 overflow: auto; 66 border-right: 1px solid var(--line); 67 } 68 #list .row { 69 display: flex; 70 justify-content: space-between; 71 gap: 8px; 72 padding: 3px 10px; 73 cursor: pointer; 74 white-space: nowrap; 75 } 76 #list .row:hover, 77 #list .row.sel { 78 background: #1f2630; 79 } 80 #list .row .n { 81 overflow: hidden; 82 text-overflow: ellipsis; 83 } 84 #list .row .c { 85 color: var(--dim); 86 } 87 #list .meta { 88 padding: 6px 10px; 89 color: var(--dim); 90 border-bottom: 1px solid var(--line); 91 } 92 .inst { 93 color: var(--yellow); 94 } 95 #graph { 96 position: relative; 97 overflow: hidden; 98 } 99 #graph svg { 100 width: 100%; 101 height: 100%; 102 display: block; 103 } 104 #toolbar { 105 position: absolute; 106 top: 8px; 107 left: 8px; 108 display: flex; 109 gap: 6px; 110 flex-wrap: wrap; 111 } 112 #legend { 113 position: absolute; 114 bottom: 8px; 115 left: 8px; 116 color: var(--dim); 117 background: #161b22cc; 118 padding: 6px 8px; 119 border-radius: 4px; 120 } 121 #legend span { 122 margin-right: 10px; 123 } 124 .dot { 125 display: inline-block; 126 width: 9px; 127 height: 9px; 128 border-radius: 50%; 129 margin-right: 4px; 130 vertical-align: middle; 131 } 132 #details { 133 overflow: auto; 134 border-left: 1px solid var(--line); 135 padding: 10px 12px; 136 } 137 #details .desc { 138 margin: 4px 0 8px; 139 font-size: 13px; 140 } 141 #details h2 { 142 font-size: 14px; 143 margin: 0 0 6px; 144 word-break: break-all; 145 } 146 #details .k { 147 color: var(--dim); 148 } 149 #details ul { 150 margin: 4px 0 10px; 151 padding-left: 16px; 152 } 153 #details li { 154 cursor: pointer; 155 word-break: break-all; 156 } 157 #details li:hover { 158 color: var(--blue); 159 } 160 a { 161 color: var(--blue); 162 } 163 .u { 164 color: var(--red); 165 } 166 text { 167 fill: var(--fg); 168 font-size: 10px; 169 pointer-events: none; 170 paint-order: stroke; 171 stroke: var(--bg); 172 stroke-width: 3px; 173 } 174 .link { 175 stroke: #3d444d; 176 stroke-width: 1.2; 177 } 178 .link.via { 179 stroke: var(--red); 180 stroke-dasharray: 4 3; 181 opacity: 0.7; 182 } 183 .node circle { 184 stroke: var(--bg); 185 stroke-width: 1.5; 186 cursor: pointer; 187 } 188 .node.focus circle { 189 stroke: #fff; 190 stroke-width: 2.5; 191 } 192 .node.inst circle { 193 stroke: var(--yellow); 194 stroke-width: 2.5; 195 } 196 </style> 197 </head> 198 <body> 199 <header> 200 <h1 id="title">Unmaintained packages</h1> 201 <label 202 >machine 203 <select id="machine"></select 204 ></label> 205 <input id="search" placeholder="filter name or description…" size="28" /> 206 <label 207 ><input type="checkbox" id="onlyInst" /> directly installed only</label 208 > 209 <span id="summary" class="k"></span> 210 </header> 211 <div id="list"></div> 212 <div id="graph"> 213 <div id="toolbar"> 214 <button 215 id="bExpand" 216 title="add packages that use the selected node (or double-click a node)" 217 > 218 expand users 219 </button> 220 <button 221 id="bPath" 222 title="all shortest chains from the system toplevel down to the selected node" 223 > 224 chains to system 225 </button> 226 <button 227 id="bUnm" 228 title="nearest unmaintained packages that (transitively) use the selected node; adds the 30 biggest, the side panel lists all" 229 > 230 unmaintained users 231 </button> 232 <button id="bReset">reset</button> 233 </div> 234 <div id="legend"> 235 <span 236 ><i class="dot" style="background: var(--red)"></i>unmaintained</span 237 > 238 <span><i class="dot" style="background: var(--dim)"></i>other</span> 239 <span 240 ><i class="dot" style="background: var(--green)"></i>system-path</span 241 > 242 <span 243 ><i class="dot" style="background: var(--blue)"></i>system 244 toplevel</span 245 > 246 <span 247 ><i class="dot" style="border: 2px solid var(--yellow)"></i>directly 248 installed</span 249 > 250 <span 251 >arrow: user → used · dashed: via maintained packages · node size: 252 #dependents</span 253 > 254 </div> 255 <svg></svg> 256 </div> 257 <div id="details"> 258 <p class="k"> 259 Pick a package on the left. The graph shows every shortest chain through 260 which the system (blue, top) pulls it in. Click nodes to inspect, 261 double-click to expand who uses them. 262 </p> 263 </div> 264 265 <script> 266 /*D3*/ 267 </script> 268 <script> 269 const D = /*DATA*/; 270 const N = D.names.length, M = D.machines.length; 271 const isU = new Uint8Array(N); for (const k in D.udep) isU[+k] = 1; 272 const rootSet = new Set(D.roots); 273 const children = Array.from({ length: N }, () => []); 274 D.parents.forEach((ps, c) => { for (const p of ps) children[p].push(c); }); 275 const direct = new Map(Object.entries(D.direct).map(([k, v]) => [+k, v])); 276 const nameCount = new Map(); for (const n of D.names) nameCount.set(n, (nameCount.get(n) || 0) + 1); 277 const label = i => nameCount.get(D.names[i]) > 1 ? `${D.names[i]} (${D.hash[i]})` : D.names[i]; 278 279 const $ = s => document.querySelector(s); 280 const msel = $("#machine"); 281 msel.innerHTML = `<option value="-1">all (${M})</option>` + D.machines.map((m, i) => `<option value="${i}">${m}</option>`).join(""); 282 let mi = -1; 283 const bitOf = i => 1 << i; 284 const inMachine = i => mi < 0 || (D.mask[i] & bitOf(mi)); 285 const depOf = i => mi < 0 ? D.dep[i] : (isU[i] ? D.udep[i][mi] : D.dep[i]); 286 const instOn = i => direct.get(i) || 0; 287 const isInst = i => mi < 0 ? instOn(i) !== 0 : (instOn(i) & bitOf(mi)) !== 0; 288 const machinesOf = bits => D.machines.filter((_, k) => bits & bitOf(k)); 289 const parentsOf = i => D.parents[i].filter(inMachine); 290 // D.file[i] = [path shown, link or null]; the generator links nixpkgs files to GitHub when it knows the revision 291 const fileLink = i => { 292 const f = D.file[i]; if (!f) return ""; 293 return f[1] ? `<a target="_blank" href="${f[1]}">${f[0]}</a>` : f[0]; 294 }; 295 document.title = $("#title").textContent = D.title; 296 297 // package meta (description, homepage, ...) by package name; strings come from nixpkgs, so escape them 298 const metaOf = n => (D.meta || {})[n]; 299 const esc = s => String(s).replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]); 300 const urlHtml = s => /^https?:\/\//.test(s) ? `<a target="_blank" rel="noreferrer" href="${esc(s)}">${esc(s)}</a>` : esc(s); 301 function metaHtml(n) { 302 const m = metaOf(n); if (!m) return ""; 303 return [ 304 m.description && `<div class="desc">${esc(m.description)}</div>`, 305 m.homepage && `<div><span class="k">homepage</span> ${urlHtml(m.homepage)}</div>`, 306 m.license?.length && `<div><span class="k">license</span> ${esc(m.license.join(", "))}</div>`, 307 m.changelog && `<div><span class="k">changelog</span> ${urlHtml(m.changelog)}</div>`, 308 m.attr && `<div><span class="k">attribute</span> pkgs.${esc(m.attr)}</div>`, 309 m.vulnerabilities?.length && `<div class="u">known vulnerabilities:<ul>${m.vulnerabilities.map(v => `<li>${esc(v)}</li>`).join("")}</ul></div>`, 310 ].filter(Boolean).join(""); 311 } 312 313 // ---------- list ---------- 314 let selected = -1; 315 $("#list").addEventListener("click", e => { const r = e.target.closest(".row"); if (r) focusNode(+r.dataset.i); }); 316 $("#search").addEventListener("input", renderList); 317 $("#onlyInst").addEventListener("change", renderList); 318 msel.addEventListener("change", () => { mi = +msel.value; renderList(); if (selected >= 0) focusNode(selected); }); 319 320 // one list row per package name; variants = all drvs with that name (different deps/stages) 321 const variants = new Map(); 322 for (const k in D.udep) { const i = +k, n = D.names[i]; if (!variants.has(n)) variants.set(n, []); variants.get(n).push(i); } 323 function renderList() { 324 const q = $("#search").value.toLowerCase(), oi = $("#onlyInst").checked; 325 const rows = []; 326 for (const [n, vs] of variants) { 327 if (q && !n.toLowerCase().includes(q) && !(metaOf(n)?.description || "").toLowerCase().includes(q)) continue; 328 const v = vs.filter(inMachine); if (!v.length) continue; 329 if (oi && !v.some(isInst)) continue; 330 v.sort((a, b) => depOf(b) - depOf(a)); 331 rows.push({ n, best: v[0], count: v.length, dep: depOf(v[0]), inst: v.some(isInst) }); 332 } 333 rows.sort((a, b) => b.dep - a.dep); 334 $("#summary").textContent = `${rows.length} unmaintained packages` + (mi < 0 ? " across all machines" : ` on ${D.machines[mi]}`); 335 const shown = rows.slice(0, 800); 336 const sel = selected >= 0 ? D.names[selected] : null; 337 $("#list").innerHTML = `<div class="meta">sorted by #dependents (best variant)${rows.length > shown.length ? ` · showing ${shown.length} of ${rows.length}, filter to narrow` : ""}</div>` + 338 shown.map(r => `<div class="row${r.n === sel ? " sel" : ""}" data-i="${r.best}" title="${esc(metaOf(r.n)?.description || "")}"><span class="n">${r.inst ? '<span class="inst">●</span> ' : ""}${metaOf(r.n)?.vulnerabilities?.length ? '<span class="u" title="known vulnerabilities">⚠</span> ' : ""}${r.n}${r.count > 1 ? ` <span class="k">×${r.count}</span>` : ""}</span><span class="c">${r.dep}</span></div>`).join(""); 339 } 340 const svg = d3.select("#graph svg"), g = svg.append("g"); 341 svg.append("defs").append("marker").attr("id", "arrow").attr("viewBox", "0 -4 8 8").attr("refX", 8).attr("refY", 0) 342 .attr("markerWidth", 7).attr("markerHeight", 7).attr("orient", "auto").append("path").attr("d", "M0,-4L8,0L0,4").attr("fill", "#3d444d"); 343 const zoom = d3.zoom().scaleExtent([0.05, 4]).on("zoom", e => g.attr("transform", e.transform)); 344 svg.call(zoom).on("dblclick.zoom", null); 345 const gl = g.append("g"), gn = g.append("g"); 346 let vis = new Map(); // node id -> {id, level, x, y} 347 let via = []; // [{source, target, steps}] dashed shortcuts 348 let focusId = -1; 349 const sim = d3.forceSimulation() 350 .force("link", d3.forceLink().id(d => d.id).distance(90).strength(0.3)) 351 .force("charge", d3.forceManyBody().strength(-450)) 352 .force("collide", d3.forceCollide(d => radius(d.id) + 10)) 353 .force("y", d3.forceY(d => -d.level * 140).strength(0.6)) 354 .force("x", d3.forceX(0).strength(0.01)) 355 .on("tick", tick); 356 357 const radius = i => 4 + 2.2 * Math.log10(Math.max(0, depOf(i)) + 1); 358 const color = i => rootSet.has(i) ? "var(--blue)" : D.names[i] === "system-path" ? "var(--green)" : isU[i] ? "var(--red)" : "var(--dim)"; 359 360 function add(i, level) { 361 if (!vis.has(i)) { const near = [...vis.values()].find(n => n.level === level - 1); vis.set(i, { id: i, level, x: (near?.x || 0) + (Math.random() - .5) * 80, y: -level * 110 }); } 362 } 363 function expand(i, limit = 40) { 364 const lv = vis.get(i).level; 365 const ps = parentsOf(i).sort((a, b) => (isU[b] - isU[a]) || (depOf(b) - depOf(a))); 366 ps.slice(0, limit).forEach(p => add(p, lv + 1)); 367 return ps.length; 368 } 369 function bfsUp(start, stop) { // breadth-first over users until stop(i) holds on each branch 370 const prev = new Map([[start, -1]]), q = [start], hits = []; 371 for (let h = 0; h < q.length; h++) { 372 const i = q[h]; 373 if (i !== start && stop(i)) { hits.push(i); continue; } 374 for (const p of parentsOf(i)) if (!prev.has(p)) { prev.set(p, i); q.push(p); } 375 } 376 return { prev, hits }; 377 } 378 // distance (in "uses" steps) from i up to every node that transitively uses it 379 function distUp(i) { 380 const d = new Map([[i, 0]]), q = [i]; 381 for (let h = 0; h < q.length; h++) { const v = q[h]; for (const p of parentsOf(v)) if (!d.has(p)) { d.set(p, d.get(v) + 1); q.push(p); } } 382 return d; 383 } 384 // how far the selected machine's system (or the nearest one, for "all") is above i 385 function systemDistance(i, d = distUp(i)) { 386 const reach = (mi < 0 ? D.roots : [D.roots[mi]]).filter(r => d.has(r)); 387 if (!reach.length) return null; 388 const L = Math.min(...reach.map(r => d.get(r))); 389 return { L, roots: reach.filter(r => d.get(r) === L) }; 390 } 391 // add every node on a shortest chain system -> ... -> i (walk down from the roots along edges that get one step closer to i) 392 function chainsToSystem(i, cap = 150) { 393 const d = distUp(i), s = systemDistance(i, d); 394 if (!s) return null; 395 const on = new Set(s.roots), q = [...s.roots]; 396 for (let h = 0; h < q.length && on.size < cap; h++) { 397 const v = q[h]; 398 for (const c of children[v]) if (inMachine(c) && d.get(c) === d.get(v) - 1 && !on.has(c)) { on.add(c); q.push(c); } 399 } 400 const base = vis.get(i)?.level ?? 0; 401 for (const v of on) add(v, base + d.get(v)); 402 return { ...s, nodes: on.size, capped: on.size >= cap }; 403 } 404 function unmaintainedUsers(i, limit = 30) { // adds the `limit` biggest; the details panel lists all 405 const { prev, hits } = bfsUp(i, j => isU[j]); 406 const lv = vis.get(i).level; 407 hits.sort((a, b) => depOf(b) - depOf(a)); 408 for (const h of hits.slice(0, limit)) { 409 let steps = 0; for (let j = h; j !== i; j = prev.get(j)) steps++; 410 add(h, lv + 1); 411 if (steps > 1) via.push({ source: h, target: i, steps }); 412 } 413 return hits; 414 } 415 416 function draw() { 417 const nodes = [...vis.values()]; 418 const links = []; 419 for (const n of nodes) for (const p of D.parents[n.id]) if (vis.has(p) && inMachine(p)) links.push({ source: p, target: n.id }); 420 const vlinks = via.filter(v => vis.has(v.source) && vis.has(v.target)).map(v => ({ ...v, via: true })); 421 const all = links.concat(vlinks); 422 const ls = gl.selectAll("line").data(all, d => `${d.source.id ?? d.source}-${d.target.id ?? d.target}-${!!d.via}`); 423 ls.exit().remove(); 424 ls.enter().append("line").attr("class", d => d.via ? "link via" : "link").attr("marker-end", "url(#arrow)") 425 .append("title").text(d => d.via ? `uses via ${d.steps - 1} maintained package(s)` : "uses"); 426 const ns = gn.selectAll("g.node").data(nodes, d => d.id); 427 ns.exit().remove(); 428 const ne = ns.enter().append("g").attr("class", "node") 429 .call(d3.drag().on("start", (e, d) => { if (!e.active) sim.alphaTarget(.3).restart(); d.fx = d.x; d.fy = d.y; }) 430 .on("drag", (e, d) => { d.fx = e.x; d.fy = e.y; }) 431 .on("end", (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = d.fy = null; })) 432 .on("click", (e, d) => { e.stopPropagation(); showDetails(d.id); }) 433 .on("dblclick", (e, d) => { e.stopPropagation(); expand(d.id); draw(); showDetails(d.id); }); 434 ne.append("circle"); 435 ne.append("text").attr("dy", -9).attr("text-anchor", "middle"); 436 ne.append("title"); 437 const nm = ne.merge(ns); 438 nm.attr("class", d => `node${d.id === focusId ? " focus" : ""}${isInst(d.id) ? " inst" : ""}`); 439 nm.select("circle").attr("r", d => radius(d.id)).attr("fill", d => color(d.id)); 440 nm.select("text").text(d => label(d.id)); 441 nm.select("title").text(d => `${label(d.id)}${metaOf(D.names[d.id])?.description ? "\n" + metaOf(D.names[d.id]).description : ""}\n${depOf(d.id)} dependents · ${parentsOf(d.id).length} direct users`); 442 sim.nodes(nodes); 443 sim.force("link").links(all); 444 sim.alpha(0.8).restart(); 445 fitSoon(); 446 } 447 function tick() { 448 gl.selectAll("line").each(function (d) { 449 const dx = d.target.x - d.source.x, dy = d.target.y - d.source.y, len = Math.hypot(dx, dy) || 1, r = radius(d.target.id) + 2; 450 this.setAttribute("x1", d.source.x); this.setAttribute("y1", d.source.y); 451 this.setAttribute("x2", d.target.x - dx / len * r); this.setAttribute("y2", d.target.y - dy / len * r); 452 }); 453 gn.selectAll("g.node").attr("transform", d => `translate(${d.x},${d.y})`); 454 } 455 function fit() { 456 const ns = sim.nodes().filter(n => isFinite(n.x)); if (!ns.length) return; 457 const { width, height } = svg.node().getBoundingClientRect(), pad = 60; 458 const [x0, x1] = d3.extent(ns, n => n.x), [y0, y1] = d3.extent(ns, n => n.y); 459 const k = Math.min(1.5, (width - 2 * pad) / Math.max(1, x1 - x0), (height - 2 * pad) / Math.max(1, y1 - y0)); 460 svg.transition().duration(400).call(zoom.transform, d3.zoomIdentity.translate(width / 2 - k * (x0 + x1) / 2, height / 2 - k * (y0 + y1) / 2).scale(k)); 461 } 462 let fitTimer; const fitSoon = () => { clearTimeout(fitTimer); fitTimer = setTimeout(fit, 700); }; 463 464 function focusNode(i) { 465 selected = focusId = i; 466 vis = new Map(); via = []; 467 gl.selectAll("*").remove(); gn.selectAll("*").remove(); 468 add(i, 0); 469 if (!chainsToSystem(i)) expand(i); 470 draw(); showDetails(i); renderList(); 471 history.replaceState(null, "", "#" + D.hash[i]); 472 } 473 474 let detailId = -1; 475 function showDetails(i) { 476 detailId = i; 477 const ps = parentsOf(i).sort((a, b) => (isU[b] - isU[a]) || (depOf(b) - depOf(a))); 478 const ms = machinesOf(D.mask[i]), inst = machinesOf(instOn(i)); 479 const li = j => `<li data-i="${j}" class="${isU[j] ? "u" : ""}">${label(j)} <span class="k">(${depOf(j)})</span></li>`; 480 $("#details").innerHTML = ` 481 <h2 class="${isU[i] ? "u" : ""}">${label(i)}</h2> 482 <div>${isU[i] ? '<span class="u">no maintainer</span>' : '<span class="k">not flagged</span>'}</div> 483 ${metaHtml(D.names[i])} 484 ${D.file[i] ? `<div><span class="k">file</span> ${fileLink(i)}</div>` : ""} 485 <div><span class="k">drv</span> ${D.hash[i]}…-${D.names[i]}.drv</div> 486 <div><span class="k">dependents</span> ${depOf(i)} ${mi < 0 ? "(max over machines)" : "on " + D.machines[mi]}</div> 487 <div><span class="k">distance to system</span> ${(s => s ? `${s.L} step(s)${mi < 0 ? ` (nearest: ${s.roots.map(r => D.machines[D.roots.indexOf(r)]).join(" ")})` : ""}` : "not reachable")(systemDistance(i))}</div> 488 ${isU[i] ? `<div><span class="k">per machine</span> ${D.machines.map((m, k) => D.udep[i][k] >= 0 ? `${m}:${D.udep[i][k]}` : "").filter(Boolean).join(" ")}</div>` : ""} 489 <div><span class="k">in ${ms.length} machine(s)</span> ${ms.join(" ")}</div> 490 <div><span class="k">directly installed on</span> ${inst.length ? `<span class="inst">${inst.join(" ")}</span>` : "—"}</div> 491 <p><button id="dFocus">focus here</button> <button id="dExpand">expand users</button></p> 492 <div class="k">used directly by ${ps.length} (click to add to graph, red = unmaintained):</div> 493 <ul id="dParents">${ps.slice(0, 300).map(li).join("")}${ps.length > 300 ? `<li class="k">… ${ps.length - 300} more</li>` : ""}</ul> 494 ${(variants.get(D.names[i]) || []).length > 1 ? `<div class="k">other variants of ${D.names[i]} (separate derivations, e.g. bootstrap stages):</div><ul id="dVar">${variants.get(D.names[i]).filter(j => j !== i && inMachine(j)).sort((a, b) => depOf(b) - depOf(a)).map(li).join("")}</ul>` : ""} 495 <div id="dUnm"></div>`; 496 $("#dFocus").onclick = () => focusNode(i); 497 $("#dExpand").onclick = () => { if (!vis.has(i)) add(i, 0); expand(i, 1e9); draw(); }; 498 $("#dParents").onclick = e => { const l = e.target.closest("li[data-i]"); if (!l) return; const j = +l.dataset.i; if (!vis.has(i)) add(i, 0); add(j, vis.get(i).level + 1); draw(); }; 499 if ($("#dVar")) $("#dVar").onclick = e => { const l = e.target.closest("li[data-i]"); if (l) focusNode(+l.dataset.i); }; 500 if (isU[i]) { 501 const { hits } = bfsUp(i, j => isU[j]); 502 hits.sort((a, b) => depOf(b) - depOf(a)); 503 $("#dUnm").innerHTML = `<div class="k">nearest unmaintained users (${hits.length}):</div><ul>${hits.slice(0, 200).map(li).join("")}</ul>`; 504 $("#dUnm").onclick = e => { const l = e.target.closest("li[data-i]"); if (l) focusNode(+l.dataset.i); }; 505 } 506 } 507 const needSel = f => () => { if (detailId < 0) return; if (!vis.has(detailId)) add(detailId, 0); f(detailId); draw(); }; 508 $("#bExpand").onclick = needSel(i => expand(i)); 509 $("#bPath").onclick = needSel(i => chainsToSystem(i)); 510 $("#bUnm").onclick = needSel(unmaintainedUsers); 511 $("#bReset").onclick = () => { if (focusId >= 0) focusNode(focusId); }; 512 513 renderList(); 514 const h = location.hash.slice(1); const hi = h ? D.hash.indexOf(h) : -1; 515 if (hi >= 0) focusNode(hi); 516 </script> 517 </body> 518</html>