system-tracker 3.0.17 → 3.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +8 -2
  2. package/SKILL.md +8 -7
  3. package/dist/auto/capture.js +1 -1
  4. package/dist/auto/classify.js +1 -1
  5. package/dist/auto/http.js +1 -1
  6. package/dist/auto/naming.js +1 -1
  7. package/dist/auto/outbound.js +1 -1
  8. package/dist/auto/patch.js +1 -1
  9. package/dist/auto/record.js +1 -1
  10. package/dist/auto/samples.d.ts +1 -0
  11. package/dist/auto/samples.js +1 -1
  12. package/dist/auto/sites.js +1 -1
  13. package/dist/auto/views.js +1 -1
  14. package/dist/auto/ws.js +1 -1
  15. package/dist/buckets.js +1 -1
  16. package/dist/dashboard/announce.js +1 -1
  17. package/dist/dashboard/auth.d.ts +3 -3
  18. package/dist/dashboard/auth.js +1 -1
  19. package/dist/dashboard/boards.js +1 -1
  20. package/dist/dashboard/config.js +1 -1
  21. package/dist/dashboard/data.d.ts +21 -0
  22. package/dist/dashboard/data.js +1 -1
  23. package/dist/dashboard/index.js +1 -1
  24. package/dist/dashboard/insights.js +1 -1
  25. package/dist/dashboard/lockout.d.ts +6 -0
  26. package/dist/dashboard/lockout.js +1 -0
  27. package/dist/dashboard/page-html.js +1 -1
  28. package/dist/dashboard/page.js +1 -1
  29. package/dist/dashboard/periods.js +1 -1
  30. package/dist/dashboard/query.js +1 -1
  31. package/dist/dashboard/report.js +1 -1
  32. package/dist/dashboard/reset.js +1 -1
  33. package/dist/dashboard/storage.js +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/init.js +1 -1
  36. package/dist/logger.js +1 -1
  37. package/dist/lua.d.ts +1 -0
  38. package/dist/lua.js +1 -1
  39. package/dist/redis.js +1 -1
  40. package/dist/search.js +1 -1
  41. package/dist/state.js +1 -1
  42. package/dist/types.d.ts +8 -2
  43. package/dist/types.js +1 -1
  44. package/package.json +1 -1
@@ -1,2 +1,2 @@
1
1
  "use strict";
2
- module.exports = "<!doctype html>\n<html lang=\"en\" data-theme=\"dark\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title>system-tracker dashboard</title>\n<script nonce=\"__NONCE__\">\n try { if (localStorage.getItem(\"system-tracker-theme\") === \"light\") document.documentElement.dataset.theme = \"light\"; } catch (e) {}\n</script>\n<style>\n:root {\n --bg: #0d1117; --surface: #151b23; --surface2: #1b222c; --line: #262f3a; --line2: #1e2630;\n --ink: #e6edf3; --ink2: #9aa7b4; --ink3: #6b7684; --hover: rgba(110, 168, 254, 0.08); --track: #222a34;\n --grid: #1f2731; --tip: #1f2937; --tipink: #e6edf3; --tipline: #374151; --bad: #f0706a; --ok: #3fb979; --brand: #ef5a4f; --bar: #6ea8fe;\n --warn: #e3b341; --info: #6ea8fe; --warnbg: rgba(210, 153, 34, 0.12); --warnink: #e3b341; --pill: #2d3748;\n --add: rgba(46, 160, 67, 0.5); --del: rgba(248, 81, 73, 0.4); --code: #0b0f14;\n --sel: #3fb979; --selbg: rgba(63, 185, 121, 0.16); --pri: #2f81f7; --pribg: rgba(47, 129, 247, 0.14);\n color-scheme: dark;\n}\n[data-theme=\"light\"] {\n --bg: #f6f8fa; --surface: #ffffff; --surface2: #f3f5f8; --line: #d8dee4; --line2: #e8ecf0;\n --ink: #1f2328; --ink2: #57606a; --ink3: #8c959f; --hover: rgba(9, 105, 218, 0.06); --track: #e6eaef;\n --grid: #eef1f4; --tip: #ffffff; --tipink: #1f2328; --tipline: #d8dee4; --bad: #cf222e; --ok: #1a7f37; --bar: #4c8ef5;\n --warn: #9a6700; --info: #0969da; --warnbg: rgba(210, 153, 34, 0.12); --warnink: #9a6700; --pill: #57606a;\n --add: rgba(46, 160, 67, 0.5); --del: rgba(248, 81, 73, 0.35); --code: #f6f8fa;\n --sel: #1a7f37; --selbg: rgba(26, 127, 55, 0.14); --pri: #0969da; --pribg: rgba(9, 105, 218, 0.1);\n color-scheme: light;\n}\n* { box-sizing: border-box; margin: 0; padding: 0; }\nbody { background: var(--bg); color: var(--ink); font: 14px/1.4 system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif; min-height: 100vh; }\nbutton, input, select, textarea { font: inherit; color: inherit; }\nbutton { cursor: pointer; background: none; border: 0; }\na { color: inherit; }\n.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace; }\n.tnum { font-variant-numeric: tabular-nums; }\n.muted { color: var(--ink3); }\n\n/* top bar and navigation */\n.top { height: 56px; display: flex; align-items: center; gap: 12px; padding: 0 24px; border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 20; }\n.mark { flex: none; width: 22px; height: 22px; border-radius: 6px; background: var(--brand); position: relative; }\n.mark::before, .mark::after { content: \"\"; position: absolute; left: 5px; right: 5px; height: 2.5px; border-radius: 2px; background: #fff; }\n.mark::before { top: 7px; } .mark::after { top: 12.5px; right: 9px; }\n.brand { font-size: 16px; font-weight: 600; }\n.pchip { font-size: 12.5px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--line); color: var(--ink2); }\n.nav { display: flex; gap: 2px; margin-left: 18px; height: 100%; }\n.nav a { display: flex; align-items: center; gap: 7px; padding: 0 12px; text-decoration: none; color: var(--ink2); font-weight: 500; border-bottom: 2px solid transparent; }\n.nav a:hover { color: var(--ink); }\n.nav a.on { color: var(--ink); border-bottom-color: var(--sel); }\n.badge { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; color: #fff; background: var(--ink3); }\n.badge.critical { background: #d93a33; } .badge.warning { background: #b7791f; }\n.grow { flex: 1; }\n.live { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink2); padding: 0 4px 0 10px; height: 32px; border: 1px solid var(--line); border-radius: 8px; }\n.live i { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px rgba(63, 185, 121, 0.18); }\n.live.off i { background: var(--ink3); box-shadow: none; }\n.live select { border: 0; background: transparent; padding: 4px 2px; font-size: 13px; color: var(--ink); cursor: pointer; }\n.live select:focus { outline: none; }\n.live select option, select option { background: var(--surface); color: var(--ink); }\n.ibtn { width: 34px; height: 32px; border: 1px solid var(--line); border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; color: var(--ink2); flex: none; }\n.ibtn:hover { color: var(--ink); border-color: var(--ink3); }\n.ibtn svg { width: 16px; height: 16px; }\n.ibtn.sm { width: 28px; height: 26px; border-radius: 6px; }\n.ibtn.sm svg { width: 14px; height: 14px; }\n.user { font-size: 13px; color: var(--ink2); }\n.link { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }\n.dest { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 5px; border: 1px solid var(--line); color: var(--ink2); margin-left: 8px; vertical-align: 1px; font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; }\n.stchip { font-size: 12px; padding: 4px 9px; border-radius: 8px; border: 1px solid var(--line); color: var(--ink2); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 340px; }\n.stchip:hover { border-color: var(--ink3); color: var(--ink); }\n.stchip.near { border-color: var(--warn); color: var(--warn); }\n.preview-chip { font-size: 12.5px; padding: 5px 10px; border-radius: 8px; border: 1px dashed var(--warn); color: var(--warn); text-decoration: none; white-space: nowrap; }\n.banner { display: block; line-height: 1.6; overflow-wrap: anywhere; padding: 9px 24px; font-size: 13px; background: var(--warnbg); color: var(--warnink); border-bottom: 1px solid rgba(210, 153, 34, 0.35); }\nmain { padding: 18px 24px 48px; max-width: 1600px; margin: 0 auto; }\n\n/* headings and cards */\n.crumbs { display: flex; align-items: center; gap: 8px; font-size: 15px; min-height: 30px; flex-wrap: wrap; }\n.crumbs a { color: var(--ink2); text-decoration: none; }\n.crumbs a:hover { color: var(--ink); text-decoration: underline; }\n.crumbs .sep { color: var(--ink3); }\n.crumbs b { font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }\n.crumbs .meta { margin-left: auto; font-size: 12.5px; color: var(--ink3); }\n.h2 { font-size: 20px; font-weight: 650; display: flex; align-items: center; gap: 10px; }\n.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); gap: 14px; margin-top: 14px; }\n.kpi { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; min-height: 92px; position: relative; overflow: hidden; }\n.kpi .l { font-size: 12.5px; color: var(--ink2); display: flex; align-items: center; gap: 6px; }\n.kpi .v { font-size: 27px; font-weight: 600; margin-top: 4px; letter-spacing: -0.01em; }\n.kpi .s { font-size: 12px; color: var(--ink3); margin-top: 2px; }\n.kpi svg { position: absolute; right: 12px; bottom: 12px; }\n.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); display: inline-block; flex: none; }\n.bad { color: var(--bad); } .good { color: var(--ok); }\n.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; margin-top: 14px; overflow: hidden; }\n.ch { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line2); flex-wrap: wrap; min-height: 49px; }\n.ch b { font-size: 14.5px; font-weight: 600; }\n.ch .k { font-size: 12px; color: var(--ink3); }\n.ch .right { margin-left: auto; display: flex; align-items: center; gap: 8px; }\n.cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }\n.cols > .card { margin-top: 14px; }\n.split { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 14px; }\n@media (max-width: 1100px) { .cols, .split { grid-template-columns: 1fr; } }\n#tabs { display: flex; min-width: 0; max-width: 100%; }\n.seg { display: flex; border: 1px solid var(--line); border-radius: 8px; overflow-x: auto; max-width: 100%; scrollbar-width: none; }\n.seg button { flex: none; white-space: nowrap; font-size: 12.5px; padding: 5px 11px; color: var(--ink2); border-left: 1px solid var(--line); }\n.seg button:first-child { border-left: 0; }\n.seg button.on { background: var(--selbg); color: var(--ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sel); }\n.seg button:disabled { opacity: 0.4; cursor: not-allowed; }\n.empty { padding: 36px 16px; text-align: center; color: var(--ink3); }\n.err { margin-top: 14px; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--bad); color: var(--bad); font-size: 13px; }\n\n/* charts */\n.chart { position: relative; padding: 10px 12px 4px; }\n.chart svg { display: block; }\n.legend { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 8px 16px 0; font-size: 12.5px; }\n.legend button { display: inline-flex; align-items: center; gap: 6px; color: var(--ink2); }\n.legend button i { width: 10px; height: 10px; border-radius: 3px; }\n.legend button.off { opacity: 0.4; text-decoration: line-through; }\n.legend button:hover { color: var(--ink); }\n.xh, .yh { position: absolute; pointer-events: none; display: none; }\n.xh { width: 0; border-left: 1px dashed var(--ink3); }\n.yh { height: 0; border-top: 1px dashed var(--ink3); }\n.ax { position: absolute; pointer-events: none; display: none; background: var(--pill); color: #fff; font-size: 10.5px; padding: 1px 6px; border-radius: 4px; white-space: nowrap; transform: translate(-50%, 0); }\n.ax.y { transform: translate(-100%, -50%); }\n#tt { position: fixed; z-index: 80; pointer-events: none; display: none; min-width: 150px; max-width: 320px; background: var(--tip); color: var(--tipink); border: 1px solid var(--tipline); border-radius: 8px; padding: 8px 10px; font-size: 12.5px; box-shadow: 0 12px 30px -12px rgba(0, 0, 0, 0.45); }\n#tt .t { font-size: 11.5px; opacity: 0.7; margin-bottom: 4px; }\n#tt .r { display: flex; align-items: center; gap: 8px; line-height: 1.6; }\n#tt .r i { width: 8px; height: 8px; border-radius: 2px; flex: none; }\n#tt .r b { margin-left: auto; padding-left: 12px; font-variant-numeric: tabular-nums; }\n#tt .f { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--tipline); opacity: 0.8; font-size: 11.5px; }\nsvg .b { transition: opacity 0.15s; }\n.anim svg .b { transform-box: fill-box; transform-origin: 50% 100%; animation: rise 0.55s cubic-bezier(.2, .7, .2, 1) both; }\n.anim svg .ln { stroke-dasharray: 4000; animation: draw 0.9s ease-out both; }\n@keyframes rise { from { transform: scaleY(0); } to { transform: scaleY(1); } }\n@keyframes draw { from { stroke-dashoffset: 4000; } to { stroke-dashoffset: 0; } }\n\n/* tables */\n.tbl { width: 100%; border-collapse: collapse; }\n.tbl th { font-size: 12px; font-weight: 500; color: var(--ink3); text-align: right; padding: 7px 16px; border-bottom: 1px solid var(--line2); white-space: nowrap; }\n.tbl th:first-child, .tbl td:first-child { text-align: left; }\n.tbl td { font-size: 13.5px; text-align: right; padding: 0 16px; height: 36px; border-bottom: 1px solid var(--line2); white-space: nowrap; }\n.tbl tr:last-child td { border-bottom: 0; }\n/* tables that fit their card: fixed number columns, the first one takes the rest and ends in \"…\" */\n.tbl.fit { table-layout: fixed; }\n.tbl.fit th, .tbl.fit td { padding-left: 10px; padding-right: 10px; }\n.tbl.fit th:first-child, .tbl.fit td:first-child { padding-left: 16px; }\n.tbl.fit th:last-child, .tbl.fit td:last-child { padding-right: 16px; }\n.tbl.fit td { overflow: hidden; text-overflow: ellipsis; } /* a long value never pushes the table wider than its card */\n/* optional columns give way when the card is narrow: .opt first, then .opt2 */\n#table, #top, #p-routes, .wb > .tscroll { container-type: inline-size; }\n@container (max-width: 560px) { .tbl .opt { display: none; } }\n@container (max-width: 420px) { .tbl .opt2 { display: none; } }\n.tbl .bar { width: 100%; margin: 0; }\n.tbl .when { font-size: 13px; }\n.tbl .when small { color: var(--ink3); font-size: 11.5px; margin-left: 6px; }\n.tbl tr[data-go] { cursor: pointer; }\n.tbl tr[data-go]:hover td { background: var(--hover); }\n.tbl .chev { color: var(--ink3); width: 30px; }\n.tbl td.name { max-width: 460px; overflow: hidden; text-overflow: ellipsis; }\n.tp { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; font-size: 13px; }\n.tp i { width: 9px; height: 9px; border-radius: 3px; }\n.meth { display: inline-block; min-width: 54px; text-align: center; font-size: 11.5px; font-weight: 600; color: #fff; border-radius: 5px; padding: 2px 6px; margin-right: 10px; background: #57606b; }\n.share { display: inline-block; width: 90px; height: 6px; border-radius: 3px; background: var(--track); vertical-align: middle; margin-right: 8px; overflow: hidden; }\n.share i { display: block; height: 100%; border-radius: 3px; }\n.search, .inp { border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px; font-size: 13px; background: var(--bg); }\n.search { width: 230px; }\n.search:focus, .inp:focus, select:focus, .in:focus, textarea:focus { outline: none; border-color: var(--bar); box-shadow: 0 0 0 3px rgba(110, 168, 254, 0.18); }\nselect { border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; font-size: 13px; background: var(--bg); }\n.bars-list { padding: 10px 16px 14px; display: grid; gap: 10px; }\n.bars-list .row { display: grid; grid-template-columns: 150px 1fr 90px; align-items: center; gap: 12px; font-size: 13px; }\n.bars-list .row .bar { height: 8px; border-radius: 4px; background: var(--track); overflow: hidden; }\n.bars-list .row .bar i { display: block; height: 100%; border-radius: 4px; }\n.bars-list .row span:last-child { text-align: right; }\n\n/* route view */\n.slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-top: 14px; }\n.slot { text-align: left; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px 44px; position: relative; }\n.slot:hover { border-color: var(--ink3); }\n.slot.on { border-color: var(--sel); box-shadow: 0 0 0 3px var(--selbg); }\n.slot .l { font-size: 12px; color: var(--ink2); }\n.slot .v { font-size: 21px; font-weight: 600; margin-top: 2px; }\n.slot .s { font-size: 11px; color: var(--ink3); }\n.slot svg { position: absolute; left: 12px; bottom: 10px; width: calc(100% - 24px); }\n.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 14px; }\n.stat { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }\n.stat .l { font-size: 12px; color: var(--ink2); }\n.stat .v { font-size: 19px; font-weight: 600; margin-top: 2px; }\n.stat .s { font-size: 11.5px; color: var(--ink3); }\n.keychip { font-size: 12.5px; padding: 4px 10px; border-radius: 6px; background: var(--surface2); border: 1px solid var(--line); color: var(--ink2); word-break: break-all; }\n\n/* insights */\n.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }\n.chips button { display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); color: var(--ink2); font-size: 13px; }\n.chips button.on { border-color: var(--ink); color: var(--ink); background: var(--hover); }\n.chips button b { font-size: 12px; color: var(--ink3); }\n.ins-list { display: grid; gap: 10px; margin-top: 14px; }\n.pgrid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); border-top: 1px solid var(--line2); }\n.pgrid > div + div { border-left: 1px solid var(--line2); }\n@media (max-width: 1000px) { .pgrid { grid-template-columns: 1fr; } .pgrid > div + div { border-left: 0; border-top: 1px solid var(--line2); } }\n.pkpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); }\n.pkpis > div { padding: 12px 16px; border-left: 1px solid var(--line2); }\n.pkpis > div:first-child { border-left: 0; }\n.pkpis .l { font-size: 12px; color: var(--ink2); } .pkpis .v { font-size: 22px; font-weight: 650; margin-top: 2px; } .pkpis .s { font-size: 12px; color: var(--ink3); margin-top: 2px; }\n.ctitle { padding: 12px 16px 0; font-size: 12.5px; color: var(--ink3); }\n.fold { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 10px 16px; }\n.fold:hover { background: var(--hover); }\n.fold .more { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink2); }\n.fold .more svg { width: 14px; height: 14px; transition: transform 0.2s; }\n.fold.open .more svg { transform: rotate(180deg); }\n.pick { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; }\n.pick button { text-align: left; border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; background: var(--bg); }\n.pick button b { display: block; font-size: 13px; } .pick button span { display: block; font-size: 11.5px; color: var(--ink3); margin-top: 2px; }\n.pick button.on { border-color: var(--sel); box-shadow: 0 0 0 3px var(--selbg); }\n.mb h4 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink3); margin: 18px 0 8px; }\n.mb h4:first-child { margin-top: 0; }\n.danger-box { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--bad); background: rgba(240, 112, 106, 0.1); font-size: 13.5px; }\n.danger-box b { color: var(--bad); }\n.hist { display: grid; gap: 6px; max-height: 300px; overflow-y: auto; }\n.hist > div { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--line2); border-radius: 8px; font-size: 13px; }\n.hist .ibtn.sm { flex: none; }\n#print { display: none; }\n@media print {\n @page { margin: 14mm; }\n body { background: #fff !important; min-height: 0; }\n body > *:not(#print) { display: none !important; }\n #print { display: block; color: #111; font: 11.5px/1.45 system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif; }\n #print h1 { font-size: 20px; margin-bottom: 2px; } #print h2 { font-size: 14px; margin: 18px 0 6px; }\n #print .sub { color: #555; }\n #print .kp { display: flex; gap: 28px; margin-top: 14px; } #print .kp b { display: block; font-size: 18px; }\n #print table { width: 100%; border-collapse: collapse; } #print th, #print td { text-align: right; padding: 4px 6px; border-bottom: 1px solid #ddd; }\n #print th:first-child, #print td:first-child, #print td.l, #print th.l { text-align: left; }\n #print tr { break-inside: avoid; }\n}\n.ins-sum { display: grid; grid-template-columns: 300px minmax(0, 0.9fr) minmax(0, 1.4fr); gap: 14px; margin-top: 14px; }\n@media (max-width: 1150px) { .ins-sum { grid-template-columns: 1fr; } }\n.ins-sum .card { margin-top: 0; }\n.ins { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; transition: border-color 0.15s; }\n.ins:hover { border-color: var(--ink3); }\n.ins-h { display: grid; grid-template-columns: 4px minmax(0, 1fr) 180px 96px 92px; align-items: center; gap: 14px; text-align: left; width: 100%; padding: 12px 16px 12px 0; }\n.ins-h:hover { background: var(--hover); }\n.ins .bar { align-self: stretch; margin: -12px 0; }\n.ins.critical .bar { background: var(--bad); } .ins.warning .bar { background: var(--warn); } .ins.info .bar { background: var(--info); }\n.ins .tag { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }\n.ins.critical .tag { color: var(--bad); } .ins.warning .tag { color: var(--warn); } .ins.info .tag { color: var(--info); }\n.ins .t { font-size: 14.5px; font-weight: 600; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.ins .d { font-size: 12.5px; color: var(--ink2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.ins.open .d { white-space: normal; }\n.ins .val { font-size: 20px; font-weight: 650; text-align: right; }\n.ins.critical .val { color: var(--bad); } .ins.warning .val { color: var(--warn); }\n.ins .more { display: inline-flex; align-items: center; justify-content: flex-end; gap: 5px; font-size: 12.5px; color: var(--ink2); white-space: nowrap; }\n.ins .more svg { width: 14px; height: 14px; transition: transform 0.2s; }\n.ins.open .more svg { transform: rotate(180deg); }\n.ins-b { display: none; border-top: 1px solid var(--line2); grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }\n.ins.open .ins-b { display: grid; }\n.ins.opening .ins-b { animation: open 0.2s ease-out; }\n@keyframes open { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }\n@media (max-width: 1000px) { .ins-b { grid-template-columns: 1fr; } }\n.ins-b .side { padding: 14px 16px; border-left: 1px solid var(--line2); display: flex; flex-direction: column; gap: 14px; }\n.ins-b h5 { font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink3); margin-bottom: 6px; }\n.ins-b ul { margin: 0 0 0 18px; font-size: 13px; color: var(--ink2); display: grid; gap: 4px; }\n.ins-b .ctitle { padding: 12px 16px 0; font-size: 12.5px; color: var(--ink3); }\n.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 8px; }\n.fact { background: var(--surface2); border: 1px solid var(--line2); border-radius: 8px; padding: 8px 10px; }\n.fact .l { font-size: 11.5px; color: var(--ink3); }\n.fact .v { font-size: 15px; font-weight: 650; margin-top: 2px; }\n.gbars { padding: 12px 16px 14px; display: grid; gap: 7px; }\n.gbars button { display: grid; grid-template-columns: 96px 1fr 28px; align-items: center; gap: 10px; text-align: left; font-size: 13px; padding: 2px 4px; border-radius: 6px; }\n.gbars button:hover, .gbars button.on { background: var(--hover); }\n.gbars button i { display: block; height: 10px; border-radius: 4px; min-width: 2px; }\n.gbars button b { text-align: right; font-variant-numeric: tabular-nums; }\n.strip { display: grid; grid-template-columns: 260px 1fr; }\n.strip .sum { padding: 14px 16px; border-right: 1px solid var(--line2); display: flex; flex-direction: column; gap: 8px; }\n.strip .sum .n { display: flex; gap: 14px; font-size: 13px; }\n.strip .sum .n b { font-size: 22px; display: block; }\n.strip .rows { display: grid; }\n.strip .rows button { display: grid; grid-template-columns: 8px 1fr 80px; align-items: center; gap: 10px; text-align: left; padding: 9px 16px; border-bottom: 1px solid var(--line2); font-size: 13px; }\n.strip .rows button:last-child { border-bottom: 0; }\n.strip .rows button:hover { background: var(--hover); }\n.strip .rows i { width: 8px; height: 8px; border-radius: 50%; }\n.allclear { display: flex; align-items: center; gap: 10px; color: var(--ok); font-weight: 600; padding: 16px; }\n\n/* settings */\n.notice { margin-top: 14px; display: flex; gap: 12px; align-items: flex-start; padding: 12px 16px; border-radius: 10px; border: 1px solid rgba(210, 153, 34, 0.45); background: var(--warnbg); color: var(--ink); font-size: 13.5px; }\n.notice b { color: var(--warnink); }\n.notice.info { border-color: rgba(110, 168, 254, 0.45); background: rgba(110, 168, 254, 0.08); }\n.notice.info b { color: var(--info); }\n.st-meter { display: flex; height: 10px; border-radius: 5px; background: var(--track); overflow: hidden; margin: 14px 16px 4px; }\n.st-meter i { display: block; height: 100%; }\n.st-meter .tr { background: var(--bar); } .st-meter .ot { background: var(--ink3); opacity: 0.55; }\n.st-sum { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); gap: 4px 18px; padding: 10px 16px 14px; border-bottom: 1px solid var(--line2); }\n.st-sum div { font-size: 12.5px; color: var(--ink2); display: flex; align-items: center; gap: 7px; }\n.st-sum b { font-size: 19px; font-weight: 600; color: var(--ink); margin-left: 2px; }\n.st-sum i { width: 10px; height: 10px; border-radius: 3px; flex: none; }\n.st-sum span { display: block; width: 100%; font-size: 12px; color: var(--ink3); }\n.st-sum > div { flex-wrap: wrap; }\n.st-notes { padding: 10px 16px; font-size: 12.5px; color: var(--warnink); border-top: 1px solid var(--line2); }\n.set-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: 14px; align-items: start; }\n@media (max-width: 1100px) { .set-grid { grid-template-columns: 1fr; } }\n.set-row { display: flex; align-items: center; gap: 14px; padding: 10px 16px; border-bottom: 1px solid var(--line2); }\n.set-row:last-child { border-bottom: 0; }\n.set-row .txt { flex: 1; min-width: 0; }\n.set-row .txt b { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 8px; }\n.set-row .txt span { display: block; font-size: 12px; color: var(--ink3); margin-top: 1px; }\n.set-row.changed { background: rgba(46, 160, 67, 0.08); }\n.set-row.changed .txt b::after { content: \"changed\"; font-size: 10.5px; font-weight: 600; padding: 1px 6px; border-radius: 4px; background: var(--add); color: var(--ink); }\n.tgl { position: relative; width: 40px; height: 22px; flex: none; }\n.tgl input { position: absolute; inset: 0; opacity: 0; cursor: pointer; margin: 0; z-index: 1; }\n.tgl span { position: absolute; inset: 0; border-radius: 11px; background: var(--track); border: 1px solid var(--line); transition: background 0.15s; }\n.tgl span::after { content: \"\"; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); transition: transform 0.15s; }\n.tgl input:checked + span { background: var(--ok); border-color: var(--ok); }\n.tgl input:checked + span::after { transform: translateX(18px); }\n.tgl input:disabled + span { opacity: 0.45; }\n.paths { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 16px 0; }\n.paths span { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 3px 4px 3px 9px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface2); }\n.code { font-size: 12.8px; line-height: 1.65; background: var(--code); padding: 10px 0; overflow-x: auto; }\n.code .ln { display: flex; white-space: pre; padding-right: 16px; }\n.code .ln .g { flex: none; width: 46px; text-align: right; padding-right: 10px; color: var(--ink3); user-select: none; }\n.code .ln .s { flex: none; width: 16px; color: var(--ink3); user-select: none; }\n.code .ln.add { background: var(--add); }\n.code .ln.add .s { color: var(--ink); }\n.reqs .req + .req { border-top: 1px solid var(--line2); }\n.req summary { display: flex; align-items: center; gap: 10px; padding: 9px 16px; cursor: pointer; list-style: none; font-size: 13px; min-width: 0; }\n.req summary::-webkit-details-marker { display: none; }\n.req summary:hover { background: var(--hover); }\n.req summary .st { font-weight: 600; color: var(--ok); } .req summary .st.bad { color: var(--bad); }\n.req summary .rep { flex: none; font-size: 11.5px; font-weight: 600; padding: 1px 7px; border-radius: 10px; background: var(--pribg); color: var(--pri); }\n.req summary .when { margin-left: auto; color: var(--ink2); white-space: nowrap; font-size: 12.5px; }\n.req summary svg { flex: none; width: 14px; height: 14px; color: var(--ink3); transition: transform 0.2s; }\n.req[open] summary svg { transform: rotate(180deg); }\n.req h5 { margin: 2px 16px 6px; font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink2); }\n.req .note { margin: -4px 16px 10px; font-size: 12.5px; color: var(--ink3); }\n.curl { position: relative; margin: 0 16px 12px; }\n.curl pre { margin: 0; font-size: 12.5px; line-height: 1.6; background: var(--code); border: 1px solid var(--line); border-radius: 8px; padding: 10px 84px 10px 12px; overflow-x: auto; white-space: pre; max-height: 360px; }\n.curl .btn { position: absolute; top: 6px; right: 6px; height: 26px; padding: 0 8px; font-size: 12px; }\n.code .ln.del { background: var(--del); text-decoration: line-through; text-decoration-color: rgba(127, 127, 127, 0.6); }\n.k-k { color: #ff7aa2; } .k-s { color: #8fd6a8; } .k-n { color: #ffb37a; } .k-c { color: var(--ink3); font-style: italic; } .k-p { color: #8cb8ff; } .k-m { color: var(--warn); }\n[data-theme=\"light\"] .k-k { color: #cf222e; } [data-theme=\"light\"] .k-s { color: #0a7f3f; } [data-theme=\"light\"] .k-n { color: #953800; } [data-theme=\"light\"] .k-p { color: #0550ae; }\n\n/* buttons, boards, editor */\n.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 32px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 600; border: 1px solid var(--line); background: var(--surface); color: var(--ink); white-space: nowrap; }\n.btn:hover { border-color: var(--ink3); }\n.btn.p { background: var(--pri); border-color: var(--pri); color: #fff; }\n.btn.d { background: var(--bad); border-color: var(--bad); color: #fff; }\n.btn.d:hover { filter: brightness(1.08); }\n.btn.on { color: var(--sel); border-color: var(--sel); background: var(--selbg); }\n.btn.p:hover { filter: brightness(1.08); }\n.btn.ghost { border-color: transparent; background: transparent; color: var(--ink2); }\n.btn.ghost:hover { color: var(--ink); background: var(--hover); }\n.btn.danger { color: var(--bad); }\n.btn svg { width: 14px; height: 14px; }\n.btn:disabled { opacity: 0.5; cursor: not-allowed; }\n/* an unavailable button looks grey and doesn't react to hover */\n.btn:disabled:hover { border-color: var(--line); filter: none; }\n.btn.ghost:disabled, .btn.ghost:disabled:hover { color: var(--ink3); background: transparent; border-color: transparent; }\n.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 14px; margin-top: 14px; }\n.bcard { text-align: left; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 150px; }\n.bcard:hover { border-color: var(--ink3); }\n.bcard .nm { font-size: 15.5px; font-weight: 650; display: flex; align-items: center; gap: 8px; min-width: 0; }\n.bcard .mini { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-top: auto; }\n.bcard .mini i { height: 22px; border-radius: 4px; background: var(--track); }\n.bcard .mini i.m { grid-column: span 2; } .bcard .mini i.l { grid-column: span 4; }\n.bcard.tpl { border-style: dashed; }\n.bcard .role { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sel); }\n.bcard.off { opacity: 0.55; cursor: not-allowed; }\n.star { color: #e3b341; }\n.wgrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: 170px; grid-auto-flow: row dense; gap: 14px; margin-top: 14px; }\n.w { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; min-width: 0; position: relative; }\n.w[data-cw=\"1\"] { grid-column: span 1; } .w[data-cw=\"2\"] { grid-column: span 2; } .w[data-cw=\"3\"] { grid-column: span 3; } .w[data-cw=\"4\"] { grid-column: span 4; }\n.w[data-ch=\"1\"] { grid-row: span 1; } .w[data-ch=\"2\"] { grid-row: span 2; } .w[data-ch=\"3\"] { grid-row: span 3; } .w[data-ch=\"4\"] { grid-row: span 4; }\n.rsz { position: absolute; z-index: 3; width: 16px; height: 16px; }\n.rsz::after { content: \"\"; position: absolute; border: 0 solid var(--pri); }\n.rsz:hover::after { border-color: var(--ink); }\n.rsz[data-dir=\"se\"] { right: 0; bottom: 0; cursor: nwse-resize; } .rsz[data-dir=\"se\"]::after { right: 4px; bottom: 4px; width: 8px; height: 8px; border-right-width: 2px; border-bottom-width: 2px; border-radius: 0 0 3px 0; }\n.rsz[data-dir=\"sw\"] { left: 0; bottom: 0; cursor: nesw-resize; } .rsz[data-dir=\"sw\"]::after { left: 4px; bottom: 4px; width: 8px; height: 8px; border-left-width: 2px; border-bottom-width: 2px; border-radius: 0 0 0 3px; }\n.rsz[data-dir=\"ne\"] { right: 0; top: 0; cursor: nesw-resize; } .rsz[data-dir=\"ne\"]::after { right: 4px; top: 4px; width: 8px; height: 8px; border-right-width: 2px; border-top-width: 2px; border-radius: 0 3px 0 0; }\n.rsz[data-dir=\"nw\"] { left: 0; top: 0; cursor: nwse-resize; } .rsz[data-dir=\"nw\"]::after { left: 4px; top: 4px; width: 8px; height: 8px; border-left-width: 2px; border-top-width: 2px; border-radius: 3px 0 0 0; }\n.rsz[data-dir=\"n\"], .rsz[data-dir=\"s\"] { left: calc(50% - 16px); width: 32px; height: 10px; cursor: ns-resize; }\n.rsz[data-dir=\"n\"] { top: 0; } .rsz[data-dir=\"s\"] { bottom: 0; }\n.rsz[data-dir=\"n\"]::after, .rsz[data-dir=\"s\"]::after { left: 6px; right: 6px; border-top-width: 3px; border-radius: 2px; }\n.rsz[data-dir=\"n\"]::after { top: 2px; } .rsz[data-dir=\"s\"]::after { bottom: 2px; }\n.rsz[data-dir=\"e\"], .rsz[data-dir=\"w\"] { top: calc(50% - 16px); width: 10px; height: 32px; cursor: ew-resize; }\n.rsz[data-dir=\"e\"] { right: 0; } .rsz[data-dir=\"w\"] { left: 0; }\n.rsz[data-dir=\"e\"]::after, .rsz[data-dir=\"w\"]::after { top: 6px; bottom: 6px; border-left-width: 3px; border-radius: 2px; }\n.rsz[data-dir=\"e\"]::after { right: 2px; } .rsz[data-dir=\"w\"]::after { left: 2px; }\n.dims { position: absolute; right: 26px; bottom: 6px; z-index: 2; font-size: 11px; font-weight: 600; padding: 1px 6px; border-radius: 4px; background: var(--pill); color: #fff; }\n.w.resizing { box-shadow: 0 0 0 2px var(--sel); }\n.wins { display: grid; padding: 2px 0 6px; }\n.wins button { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 10px; text-align: left; padding: 9px 14px; font-size: 13px; border-bottom: 1px solid var(--line2); }\n.wins button:hover { background: var(--hover); }\n.wins button i { width: 8px; height: 8px; border-radius: 50%; }\n.trunc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }\n.w .wh { display: flex; align-items: center; gap: 8px; padding: 10px 12px 6px 14px; min-height: 40px; }\n.w .wh b { font-size: 13.5px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.w .wh .tools { display: flex; gap: 4px; }\n.w .wk { font-size: 11px; color: var(--ink3); padding: 0 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.w .wb { flex: 1; min-height: 0; position: relative; overflow: hidden; }\n.editing .w { cursor: grab; border-style: dashed; border-color: var(--pri); user-select: none; }\n.w.dragging { opacity: 0.45; pointer-events: none; box-shadow: 0 0 0 2px var(--pri); }\nbody.moving, body.moving * { cursor: grabbing !important; }\n/* room below the last chart, to drag or stretch charts past the bottom of the window */\n.wgrid.editing { padding-bottom: 360px; }\n.num { padding: 8px 14px 12px; }\n.num .v { font-size: 34px; font-weight: 650; letter-spacing: -0.02em; }\n.num .s { font-size: 12px; color: var(--ink3); display: flex; gap: 8px; }\n.num .rows { display: grid; gap: 4px; margin-top: 4px; }\n.num .rows div { display: flex; justify-content: space-between; font-size: 13px; }\n.donut { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; padding: 8px 16px 14px; }\n.donut .lg { flex: 1; min-width: 180px; display: grid; gap: 4px; font-size: 12.5px; }\n.donut .lg button { display: grid; grid-template-columns: 10px 1fr auto 46px; align-items: center; gap: 8px; text-align: left; padding: 2px 4px; border-radius: 5px; }\n.donut .lg button:hover { background: var(--hover); }\n.donut .lg i { width: 10px; height: 10px; border-radius: 3px; }\n.donut .lg span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.toast { position: fixed; right: 20px; bottom: 20px; z-index: 70; display: grid; gap: 8px; }\n.toast div { background: var(--tip); color: var(--tipink); border: 1px solid var(--tipline); border-radius: 8px; padding: 10px 14px; font-size: 13px; box-shadow: 0 12px 30px -12px rgba(0, 0, 0, 0.5); }\n.modal-back { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.55); display: flex; align-items: flex-start; justify-content: center; padding: 40px 20px; overflow-y: auto; }\n.modal { width: min(1180px, 100%); background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6); }\n.modal.sm { width: min(520px, 100%); }\n.modal .mh { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--line2); }\n.modal .mh b { font-size: 16px; font-weight: 650; flex: 1; }\n.modal .mb { padding: 16px 18px; }\n.modal .mf { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--line2); }\n.ed { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; }\n@media (max-width: 1000px) { .ed { grid-template-columns: 1fr; } }\n.ed h4 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink3); margin: 16px 0 8px; display: flex; align-items: center; gap: 8px; }\n.ed h4:first-child { margin-top: 0; }\n.ed h4 i { font-style: normal; width: 18px; height: 18px; border-radius: 50%; background: var(--pri); color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; }\n.kinds { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }\n.kinds button, .charts-pick button { text-align: left; border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; background: var(--bg); }\n.kinds button b, .charts-pick button b { display: block; font-size: 13px; }\n.kinds button span { display: block; font-size: 11.5px; color: var(--ink3); margin-top: 2px; }\n.kinds button.on, .charts-pick button.on { border-color: var(--sel); box-shadow: 0 0 0 3px var(--selbg); }\n.charts-pick { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; }\n.charts-pick button { text-align: center; padding: 8px 4px; }\n.charts-pick button svg { width: 34px; height: 24px; display: block; margin: 0 auto 4px; color: var(--ink2); }\n.charts-pick button:disabled { opacity: 0.35; cursor: not-allowed; }\n.serieses { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }\n.serieses button { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); max-width: 100%; }\n.serieses button.on { border-color: var(--sel); box-shadow: 0 0 0 2px var(--selbg); }\n.serieses button i { width: 9px; height: 9px; border-radius: 2px; flex: none; }\n.serieses button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.typechips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }\n.typechips button { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; }\n.typechips button.on { border-color: var(--sel); background: var(--selbg); }\n.typechips button:disabled { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }\n.typechips button i { width: 8px; height: 8px; border-radius: 2px; }\n.rlist { max-height: 210px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; margin-top: 8px; }\n.rlist button { display: flex; align-items: center; width: 100%; text-align: left; padding: 6px 10px; font-size: 12.5px; border-bottom: 1px solid var(--line2); }\n.rlist button:last-child { border-bottom: 0; }\n.rlist button:hover, .rlist button.on { background: var(--hover); }\n.rlist button span:last-child { margin-left: auto; color: var(--ink3); }\n.pat { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 8px; margin-top: 10px; }\n.pat label { font-size: 11.5px; color: var(--ink3); display: grid; gap: 4px; min-width: 0; }\n.pat input { width: 100%; min-width: 0; }\n.keyline { margin-top: 8px; font-size: 12px; color: var(--ink2); }\n.keyline code { color: var(--ink); background: var(--surface2); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; }\n.row2 { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }\n.preview { background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 12px; position: sticky; top: 0; }\n.preview .w { border-color: var(--line); }\n.hint { font-size: 12px; color: var(--ink3); margin-top: 6px; }\n.namein { font-size: 20px; font-weight: 650; background: var(--bg); border: 1px dashed var(--line); border-radius: 8px; padding: 2px 8px; min-width: 120px; }\n.namein:hover { border-color: var(--ink3); }\n.namewrap { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }\n.namewrap .ibtn { color: var(--ink3); flex: none; }\n.namewrap .ibtn:hover { color: var(--ink); }\n.bcard .nm .rn { margin-left: auto; color: var(--ink3); flex: none; }\n.bcard .nm .rn:hover { color: var(--ink); }\n.namein:focus { outline: none; border-color: var(--bar); }\n.login { max-width: 400px; margin: 12vh auto 0; width: calc(100% - 24px); background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 30px; }\n.login label { display: block; font-size: 13px; color: var(--ink2); margin: 16px 0 6px; }\n.in { width: 100%; height: 40px; border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; font-size: 14px; background: var(--bg); }\n.login .btn { margin-top: 22px; width: 100%; height: 42px; font-size: 15px; }\n\n/* responsive: large screens keep 4 board columns, tablets 2, phones 1; everything else stacks */\n#table, #top, .wb > div { overflow-x: auto; }\n.tbl { min-width: 480px; }\n.tbl.fit { min-width: 0; } /* sized to its card */\n@media (max-width: 1279px) {\n .wgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n .w[data-cw=\"3\"], .w[data-cw=\"4\"] { grid-column: span 2; }\n .charts-pick { grid-template-columns: repeat(4, 1fr); }\n}\n@media (max-width: 1023px) {\n .top { flex-wrap: wrap; height: auto; padding: 8px 16px; row-gap: 4px; }\n .nav { order: 10; width: 100%; margin-left: 0; height: 40px; overflow-x: auto; }\n .nav a { padding: 0 10px; white-space: nowrap; }\n .strip { grid-template-columns: 1fr; }\n .strip .sum { border-right: 0; border-bottom: 1px solid var(--line2); }\n .ins-h { grid-template-columns: 4px minmax(0, 1fr) 80px 72px; }\n .ins-h > span:nth-child(3) { display: none; }\n .kinds { grid-template-columns: repeat(2, 1fr); }\n}\n@media (max-width: 767px) {\n main { padding: 12px 12px 40px; }\n .pchip, .user, .stchip { display: none; }\n .banner { padding: 8px 16px; }\n .live { font-size: 12px; }\n .wgrid { grid-template-columns: minmax(0, 1fr); grid-auto-rows: 160px; }\n .w[data-cw] { grid-column: span 1; }\n .h2 { font-size: 18px; }\n .kpi .v { font-size: 23px; }\n .search { width: 100%; }\n .ch .right { margin-left: 0; width: 100%; flex-wrap: wrap; }\n .ins-h { grid-template-columns: 4px minmax(0, 1fr) 64px; gap: 10px; }\n .ins-h .more { display: none; }\n .set-row { flex-wrap: wrap; }\n .pat { grid-template-columns: 1fr; }\n .charts-pick { grid-template-columns: repeat(3, 1fr); }\n .modal-back { padding: 12px 8px; }\n .bars-list .row { grid-template-columns: 90px 1fr 80px; }\n}\n@media (max-width: 639px) {\n .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }\n .kpi svg { position: static; display: block; width: 100%; margin-top: 6px; }\n .seg { flex-wrap: nowrap; overflow-x: auto; max-width: 100%; }\n .seg button { flex: none; }\n .live { padding-left: 8px; }\n .live select { max-width: 112px; }\n .crumbs .meta { margin-left: 0; width: 100%; }\n}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<div id=\"tt\"></div>\n<div class=\"toast\" id=\"toast\"></div>\n<script nonce=\"__NONCE__\">\n(function () {\n \"use strict\";\n var BASE = __BASE__;\n var TC = { API: \"#6ea8fe\", WEB: \"#3ccbb4\", GRAPHQL: \"#f06fb5\", RPC: \"#a68bff\", WSS: \"#ffa45c\", SSE: \"#e8c15a\", GRPC: \"#5fd39a\", OTHER: \"#8b95a3\", STATIC: \"#c9a85a\", OUT: \"#8b98a8\" };\n var PALETTE = [\"#6ea8fe\", \"#3ccbb4\", \"#f06fb5\", \"#a68bff\", \"#ffa45c\", \"#e8c15a\", \"#5fd39a\", \"#ff8f82\"];\n var MC = { GET: \"#2f6fdf\", POST: \"#1f9d63\", PUT: \"#c2700f\", PATCH: \"#7c5ce0\", DELETE: \"#d4433b\", ACTION: \"#8a5cd6\", QUERY: \"#2f6fdf\", MUTATION: \"#1f9d63\", SUBSCRIPTION: \"#0e8a8a\" };\n var BUCKETS = [\"sec\", \"min\", \"hour\", \"day\", \"month\", \"year\"];\n // a range picker button says how far back it goes; the step is in CHART_TITLE\n var RANGE_LABEL = { sec: \"1 min\", min: \"1 hour\", hour: \"24 hours\", week: \"7 days\", day: \"30 days\", month: \"12 months\", year: \"5 years\" };\n var RANGE_WHEN = { sec: \"last minute\", min: \"last hour\", hour: \"last 24 hours\", week: \"last 7 days\", day: \"last 30 days\", month: \"last 12 months\", year: \"last 5 years\" };\n var BUCKET_NAME = { sec: \"second\", min: \"minute\", hour: \"hour\", day: \"day\", month: \"month\", year: \"year\" };\n var SLOT_LABEL = { sec: \"Seconds\", min: \"Minutes\", hour: \"Hours\", day: \"Days\", month: \"Months\", year: \"Years\" };\n var SLOT_NOW = { sec: \"this second\", min: \"this minute\", hour: \"this hour\", day: \"today\", month: \"this month\", year: \"this year\" };\n var PREV_LABEL = { sec: \"last second\", min: \"last minute\", hour: \"last hour\", day: \"yesterday\", month: \"last month\", year: \"last year\" };\n var PER = { sec: \"per second\", min: \"per minute\", hour: \"per hour\", day: \"per day\", month: \"per month\", year: \"per year\" };\n var CHART_TITLE = { sec: \"Per second, last minute\", min: \"Per minute, last hour\", hour: \"Per hour, last 24 hours\", week: \"Per day, last 7 days\", day: \"Per day, last 30 days\", month: \"Per month, last 12 months\", year: \"Per year, last 5 years\" };\n var MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n var TYPE_INFO = { API: \"Everything under /api/, and JSON responses elsewhere\", WEB: \"HTML page views\", GRAPHQL: \"GraphQL operations, by name\", RPC: \"JSON-RPC, tRPC and Server Actions by name, anything on a host or route with rpc in it, and calls out to RPC hosts (action OUT, not in the total)\", WSS: \"WebSocket and Socket.IO connections and messages\", SSE: \"Server-sent event streams\", GRPC: \"gRPC, gRPC-Web and Connect calls\", OTHER: \"Requests to unknown URLs (404s, bot scans)\", STATIC: \"Static files (css, js, images)\", OUT: \"Calls your app makes, to your own backend or third parties\" };\n var PERIOD_NAMES = { \"15m\": \"15 min\", \"1h\": \"1 hour\", today: \"Today\", week: \"This week\", month: \"This month\", quarter: \"This quarter\", year: \"This year\" };\n var REFRESH = [[1000, \"Live · every 1 s\"], [5000, \"Every 5 s\"], [30000, \"Every 30 s\"], [60000, \"Every 1 min\"], [300000, \"Every 5 min\"], [900000, \"Every 15 min\"], [0, \"Off\"]];\n var app = document.getElementById(\"app\"), tt = document.getElementById(\"tt\");\n\n // ---------- small helpers ----------\n // Everything that comes from Redis (route names can come from request bodies) goes through esc().\n function esc(s) { return String(s).replace(/[&<>\"']/g, function (c) { return { \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" }[c]; }); }\n function fmt(n) { return n === null || n === undefined || isNaN(n) ? \"—\" : Math.round(n).toLocaleString(\"en-US\"); }\n function short(n) {\n if (n === null || n === undefined) return \"—\";\n var a = Math.abs(n);\n if (a >= 1e9) return (n / 1e9).toFixed(1) + \"B\";\n if (a >= 1e6) return (n / 1e6).toFixed(1) + \"M\";\n if (a >= 1e4) return (n / 1e3).toFixed(1) + \"k\";\n return fmt(n);\n }\n function rate(n) { return n === null || n === undefined ? \"—\" : n >= 100 ? fmt(n) : String(Math.round(n * 10) / 10); }\n function pct(n, d) { return d ? (n / d * 100).toFixed(n / d < 0.1 ? 2 : 1) + \"%\" : \"0%\"; }\n function sum(a) { var s = 0; for (var i = 0; i < a.length; i++) s += a[i] || 0; return s; }\n function last(a) { return a && a.length ? a[a.length - 1] : 0; }\n function color(type) { return TC[type] || \"#8b95a3\"; }\n function method(action) { return '<span class=\"meth mono\" style=\"background:' + (MC[action] || \"#57606b\") + '\">' + esc(action) + \"</span>\"; }\n // outgoing calls: to the app's own backend (its own domains, localhost, private hosts) or to a third party\n function destText(x) { return x.own ? \"your backend\" : \"third-party\"; }\n function destTag(x) { return x.own === undefined ? \"\" : '<span class=\"dest\" title=\"' + (x.own ? \"A call to your own domains or a local/private host\" : \"A call to a service outside your domains\") + '\">' + destText(x) + \"</span>\"; }\n function clone(o) { return JSON.parse(JSON.stringify(o)); }\n function uid() { return Math.random().toString(36).slice(2, 9); }\n var store = {\n get: function (k, d) { try { var v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } },\n set: function (k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} },\n del: function (k) { try { localStorage.removeItem(k); } catch (e) {} }\n };\n function log(msg) { console.info(\"%c[system-tracker]%c \" + msg, \"color:#ef5a4f;font-weight:600\", \"color:inherit\"); }\n function toast(msg) {\n var el = document.createElement(\"div\"); el.textContent = msg;\n document.getElementById(\"toast\").appendChild(el);\n setTimeout(function () { el.remove(); }, 3200);\n }\n function istNow() { try { return new Date().toLocaleTimeString(\"en-GB\", { timeZone: \"Asia/Kolkata\" }); } catch (e) { return new Date().toLocaleTimeString(); } }\n function istMinute() { try { return Number(new Date().toLocaleString(\"en-GB\", { timeZone: \"Asia/Kolkata\", minute: \"2-digit\" })); } catch (e) { return new Date().getMinutes(); } }\n function expiry(sec) {\n if (sec >= 365 * 86400) { var y = Math.round(sec / (365 * 86400)); return y === 1 ? \"1 year\" : y + \" years\"; }\n if (sec >= 86400) { var d = Math.round(sec / 86400); return d === 1 ? \"1 day\" : d + \" days\"; }\n if (sec >= 3600) { var h = Math.round(sec / 3600); return h === 1 ? \"1 hour\" : h + \" hours\"; }\n return Math.round(sec / 60) + \" min\";\n }\n // Bucket ids are IST: 2026-10-30-14-05-32 (sec), 2026-10-30-14-05 (min) ... 2026 (year).\n function axisLabel(id) {\n var p = String(id).split(\"-\");\n if (p.length === 6) return p[3] + \":\" + p[4] + \":\" + p[5];\n if (p.length === 5) return p[3] + \":\" + p[4];\n if (p.length === 4) return p[2] + \" \" + MONTHS[+p[1] - 1] + \" \" + p[3] + \":00\";\n if (p.length === 3) return p[2] + \" \" + MONTHS[+p[1] - 1];\n if (p.length === 2) return MONTHS[+p[1] - 1] + \" \" + p[0];\n return id;\n }\n // short bucket time for tables: 12:55:41 · 12:55 · 2 Nov, 14:00 · Mon 2 Nov · Nov 2026 · 2026\n function bucketText(id) {\n var p = String(id).split(\"-\");\n if (p.length === 4) return p[2] + \" \" + MONTHS[+p[1] - 1] + \", \" + p[3] + \":00\";\n if (p.length === 3) return [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"][new Date(Date.UTC(+p[0], +p[1] - 1, +p[2])).getUTCDay()] + \" \" + +p[2] + \" \" + MONTHS[+p[1] - 1];\n return axisLabel(id);\n }\n function fullTime(id) {\n var p = String(id).split(\"-\");\n if (p.length >= 3) {\n var day = +p[2] + \" \" + MONTHS[+p[1] - 1] + \" \" + p[0];\n if (p.length === 6) return day + \", \" + p[3] + \":\" + p[4] + \":\" + p[5];\n if (p.length === 5) return day + \", \" + p[3] + \":\" + p[4];\n if (p.length === 4) return day + \", \" + p[3] + \":00 – \" + p[3] + \":59\";\n return day;\n }\n return axisLabel(id);\n }\n\n var ICON = {\n moon: '<svg viewBox=\"0 0 16 16\"><path d=\"M13.5 10.2A6 6 0 0 1 5.8 2.5a6 6 0 1 0 7.7 7.7z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg>',\n sun: '<svg viewBox=\"0 0 16 16\"><circle cx=\"8\" cy=\"8\" r=\"3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"/><path d=\"M8 1v2M8 13v2M1 8h2M13 8h2M3 3l1.4 1.4M11.6 11.6L13 13M3 13l1.4-1.4M11.6 4.4L13 3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg>',\n reload: '<svg viewBox=\"0 0 16 16\"><path d=\"M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3.2h-3.2\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>',\n edit: '<svg viewBox=\"0 0 16 16\"><path d=\"M11 2.5l2.5 2.5L6 12.5H3.5V10z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg>',\n copy: '<svg viewBox=\"0 0 16 16\"><rect x=\"5\" y=\"5\" width=\"8.5\" height=\"8.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"/><path d=\"M3 10.5V3.5A1 1 0 0 1 4 2.5h7\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"/></svg>',\n trash: '<svg viewBox=\"0 0 16 16\"><path d=\"M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg>',\n plus: '<svg viewBox=\"0 0 16 16\"><path d=\"M8 3v10M3 8h10\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"/></svg>',\n star: '<svg viewBox=\"0 0 16 16\"><path d=\"M8 1.8l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.6l-3.8 2 .7-4.3-3.1-3 4.3-.6z\" fill=\"currentColor\"/></svg>',\n download: '<svg viewBox=\"0 0 16 16\"><path d=\"M8 2.5v7.5M4.8 7l3.2 3.2L11.2 7M3 13h10\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>',\n check: '<svg viewBox=\"0 0 16 16\" width=\"18\" height=\"18\"><circle cx=\"8\" cy=\"8\" r=\"7\" fill=\"currentColor\" opacity=\".18\"/><path d=\"M4.8 8.2l2.2 2.2 4.2-4.6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>'\n };\n var CHART_ICON = {\n number: '<svg viewBox=\"0 0 34 24\"><text x=\"17\" y=\"17\" text-anchor=\"middle\" font-size=\"14\" font-weight=\"700\" fill=\"currentColor\">42</text></svg>',\n line: '<svg viewBox=\"0 0 34 24\"><polyline points=\"2,18 9,12 15,15 22,6 32,9\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>',\n area: '<svg viewBox=\"0 0 34 24\"><polygon points=\"2,22 2,18 9,12 15,15 22,6 32,9 32,22\" fill=\"currentColor\" opacity=\".35\"/><polyline points=\"2,18 9,12 15,15 22,6 32,9\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>',\n bars: '<svg viewBox=\"0 0 34 24\"><rect x=\"3\" y=\"12\" width=\"5\" height=\"10\" fill=\"currentColor\"/><rect x=\"11\" y=\"6\" width=\"5\" height=\"16\" fill=\"currentColor\"/><rect x=\"19\" y=\"9\" width=\"5\" height=\"13\" fill=\"currentColor\"/><rect x=\"27\" y=\"3\" width=\"5\" height=\"19\" fill=\"currentColor\"/></svg>',\n stack: '<svg viewBox=\"0 0 34 24\"><rect x=\"3\" y=\"14\" width=\"6\" height=\"8\" fill=\"currentColor\"/><rect x=\"3\" y=\"9\" width=\"6\" height=\"5\" fill=\"currentColor\" opacity=\".5\"/><rect x=\"14\" y=\"10\" width=\"6\" height=\"12\" fill=\"currentColor\"/><rect x=\"14\" y=\"4\" width=\"6\" height=\"6\" fill=\"currentColor\" opacity=\".5\"/><rect x=\"25\" y=\"12\" width=\"6\" height=\"10\" fill=\"currentColor\"/><rect x=\"25\" y=\"7\" width=\"6\" height=\"5\" fill=\"currentColor\" opacity=\".5\"/></svg>',\n donut: '<svg viewBox=\"0 0 34 24\"><circle cx=\"17\" cy=\"12\" r=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"5\" stroke-dasharray=\"30 50\" opacity=\".5\"/><circle cx=\"17\" cy=\"12\" r=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"5\" stroke-dasharray=\"20 50\" stroke-dashoffset=\"-30\"/></svg>',\n table: '<svg viewBox=\"0 0 34 24\"><path d=\"M3 5h28M3 11h28M3 17h28\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M3 5h18M3 11h12M3 17h7\" stroke=\"currentColor\" stroke-width=\"4\" opacity=\".4\"/></svg>'\n };\n CHART_ICON.insights = '<svg viewBox=\"0 0 34 24\"><circle cx=\"6\" cy=\"6\" r=\"3\" fill=\"currentColor\"/><path d=\"M12 6h18\" stroke=\"currentColor\" stroke-width=\"2\"/><circle cx=\"6\" cy=\"13\" r=\"3\" fill=\"currentColor\" opacity=\".6\"/><path d=\"M12 13h14\" stroke=\"currentColor\" stroke-width=\"2\" opacity=\".6\"/><circle cx=\"6\" cy=\"20\" r=\"3\" fill=\"currentColor\" opacity=\".35\"/><path d=\"M12 20h16\" stroke=\"currentColor\" stroke-width=\"2\" opacity=\".35\"/></svg>';\n var CHART_NAME = { number: \"Number\", line: \"Line\", area: \"Area\", bars: \"Bars\", stack: \"Stacked\", donut: \"Donut\", table: \"Table\", insights: \"Insights\" };\n\n // ---------- state ----------\n var state = {\n me: null, cfg: null, refresh: refreshSaved(), lastLoad: 0, range: \"\", data: null, error: \"\", loading: false, seq: 0,\n filter: \"\", sort: \"total\", shellKey: \"\", first: true, side: { insights: null, top: null, at: 0 }, storage: null, stAt: 0, stPoll: 0,\n insFilter: \"all\", legendOff: {}, boards: { boards: [], defaultId: null, key: \"\" }, editing: false, ed: null, wdata: {},\n period: periodSaved(), pdata: null, pAt: 0, xp: null, rs: null,\n // the exact request by default; the structure (with :params and <values>) is a choice the browser remembers\n curlMode: store.get(\"system-tracker-curl-mode\", \"sample\") === \"structure\" ? \"structure\" : \"sample\", reqOpen: {}\n };\n function periodSaved() { var v = store.get(\"system-tracker-period\", \"today\"); return PERIOD_NAMES[v] ? v : \"today\"; }\n function refreshSaved() {\n var v = store.get(\"system-tracker-refresh\", null);\n return REFRESH.some(function (r) { return r[0] === v; }) ? v : 30000;\n }\n\n // ---------- charts ----------\n // Every chart registers a model by element id; one mousemove handler draws the crosshair, axis pills and tooltip.\n var CH = {}, sparkSeq = 0;\n function niceMax(v) {\n if (v <= 4) return 4;\n var p = Math.pow(10, Math.floor(Math.log10(v))), steps = [1, 2, 2.5, 5, 10];\n for (var i = 0; i < steps.length; i++) if (steps[i] * p * 4 >= v) return steps[i] * p * 4;\n return 40 * p;\n }\n function tickLabel(t, unit) { if (unit === \"%\") return (Math.round(t * 10) / 10) + \"%\"; return t >= 1e6 ? t / 1e6 + \"M\" : t >= 1e3 ? t / 1e3 + \"k\" : String(Math.round(t * 100) / 100); }\n function valText(v, unit) { return unit === \"%\" ? (Math.round(v * 100) / 100) + \"%\" : fmt(v); }\n\n // A timed refresh doesn't redraw a chart while the pointer is on it, so the bar you're reading holds\n // still; it catches up with the newest data once the pointer leaves. Your own clicks always redraw.\n var held = {}, polled = false;\n function pointed(el) { return !!el.querySelector(\".chart:hover, .donut:hover, [data-spark]:hover\"); }\n function hold(el, redraw) {\n if (polled && pointed(el)) { held[el.id] = { el: el, redraw: redraw }; return true; }\n delete held[el.id];\n return false;\n }\n function paint(id, draw) { var el = document.getElementById(id); if (el && !hold(el, draw)) draw(); }\n function release() {\n Object.keys(held).forEach(function (k) {\n var h = held[k];\n if (!h.el.isConnected) delete held[k];\n else if (!pointed(h.el)) { delete held[k]; h.redraw(); }\n });\n }\n document.addEventListener(\"mouseover\", release);\n // Sections are rebuilt on each refresh, and one can be shorter for a moment (an open insight's chart\n // is drawn after its card). Holding the page at its height until then keeps the scroll position.\n function steady(fn) {\n var main = document.querySelector(\"main\");\n if (!main) return fn();\n main.style.minHeight = main.offsetHeight + \"px\";\n try { fn(); } finally { requestAnimationFrame(function () { main.style.minHeight = \"\"; }); }\n }\n\n // m: { kind: bars|stack|line|area, ids, series: [{ label, color, values, errors? }], h, unit, pick(i, s) }\n function plot(id, m) {\n var host = document.getElementById(id);\n if (!host || hold(host, function () { plot(id, m); })) return;\n m.series.forEach(function (s, i) { s.off = !!state.legendOff[id + \"|\" + s.label]; s.idx = i; });\n var on = m.series.filter(function (s) { return !s.off; });\n var n = m.ids.length, w = Math.max(280, host.clientWidth - 24), h = m.h || 200;\n var padL = 48, padR = 10, padT = 10, padB = 24, cw = w - padL - padR, chh = h - padT - padB, step = cw / Math.max(1, n);\n var top = 0;\n for (var i = 0; i < n; i++) {\n var col = 0;\n on.forEach(function (s) { var v = s.values[i] || 0; col = m.kind === \"stack\" ? col + v : Math.max(col, v); if (s.errors && m.kind === \"bars\") col = Math.max(col, s.errors[i] || 0); });\n top = Math.max(top, col);\n }\n var mx = m.unit === \"%\" ? Math.max(1, Math.ceil(top / 5) * 5 || 1) : niceMax(top);\n if (m.unit === \"%\") mx = mx <= 4 ? 4 : niceMax(mx);\n function Y(v) { return padT + chh - v / mx * chh; }\n var s = \"\";\n var nt = h < 150 ? 2 : 4;\n for (var t = 0; t <= nt; t++) {\n var tv = mx / nt * t;\n s += '<line x1=\"' + padL + '\" x2=\"' + (w - padR) + '\" y1=\"' + Y(tv) + '\" y2=\"' + Y(tv) + '\" stroke=\"var(--grid)\"/>';\n s += '<text x=\"' + (padL - 8) + '\" y=\"' + (Y(tv) + 4) + '\" text-anchor=\"end\" font-size=\"10.5\" fill=\"var(--ink3)\" class=\"mono\">' + tickLabel(tv, m.unit) + \"</text>\";\n }\n // a reference level, like a route's usual minute\n if (m.ref !== undefined && m.ref !== null && m.ref <= mx) {\n s += '<line x1=\"' + padL + '\" x2=\"' + (w - padR) + '\" y1=\"' + Y(m.ref) + '\" y2=\"' + Y(m.ref) + '\" stroke=\"var(--warn)\" stroke-width=\"1.5\" stroke-dasharray=\"6 5\"/>';\n s += '<text x=\"' + (w - padR - 4) + '\" y=\"' + (Y(m.ref) - 6) + '\" text-anchor=\"end\" font-size=\"11\" font-weight=\"600\" fill=\"var(--warn)\">' + esc(m.refLabel || \"\") + \"</text>\";\n }\n if (m.kind === \"line\" || m.kind === \"area\") {\n on.forEach(function (sr) {\n var pts = [];\n for (var i = 0; i < n; i++) pts.push((padL + i * step + step / 2).toFixed(1) + \",\" + Y(sr.values[i] || 0).toFixed(1));\n if (m.kind === \"area\") s += '<polygon points=\"' + (padL + step / 2) + \",\" + Y(0) + \" \" + pts.join(\" \") + \" \" + (padL + (n - 1) * step + step / 2) + \",\" + Y(0) + '\" fill=\"' + sr.color + '\" opacity=\"' + (on.length > 1 ? 0.1 : 0.16) + '\"/>';\n s += '<polyline class=\"ln\" points=\"' + pts.join(\" \") + '\" fill=\"none\" stroke=\"' + sr.color + '\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/>';\n });\n } else {\n for (var j = 0; j < n; j++) {\n var x = padL + j * step + step * 0.14, bw = Math.max(1, step * 0.72);\n if (m.kind === \"stack\") {\n var base = 0;\n on.forEach(function (sr) {\n var v = sr.values[j] || 0;\n if (v > 0) s += '<rect class=\"b\" x=\"' + x.toFixed(1) + '\" y=\"' + Y(base + v).toFixed(1) + '\" width=\"' + bw.toFixed(1) + '\" height=\"' + (Y(base) - Y(base + v)).toFixed(1) + '\" fill=\"' + sr.color + '\"/>';\n base += v;\n });\n } else {\n var gw = bw / Math.max(1, on.length);\n on.forEach(function (sr, k) {\n var v = sr.values[j] || 0, gx = x + k * gw;\n if (v > 0) s += '<rect class=\"b\" x=\"' + gx.toFixed(1) + '\" y=\"' + Y(v).toFixed(1) + '\" width=\"' + Math.max(1, gw - (on.length > 1 ? 1 : 0)).toFixed(1) + '\" height=\"' + (padT + chh - Y(v)).toFixed(1) + '\" rx=\"1.5\" fill=\"' + sr.color + '\" opacity=\".88\"/>';\n var e = sr.errors ? sr.errors[j] || 0 : 0;\n if (e > 0) s += '<rect class=\"b\" x=\"' + gx.toFixed(1) + '\" y=\"' + Y(e).toFixed(1) + '\" width=\"' + Math.max(1, gw - (on.length > 1 ? 1 : 0)).toFixed(1) + '\" height=\"' + (padT + chh - Y(e)).toFixed(1) + '\" rx=\"1.5\" fill=\"var(--bad)\"/>';\n });\n }\n }\n }\n var labelW = String(axisLabel(m.ids[0] || \"\")).length * 6.4 + 18;\n var every = Math.max(1, Math.ceil(n / 6), Math.ceil(n * labelW / cw)), marks = [];\n for (var q = 0; q < n; q += every) marks.push(q);\n if (n - 1 - marks[marks.length - 1] >= every * 0.6) marks.push(n - 1);\n marks.forEach(function (k) {\n s += '<text x=\"' + (padL + k * step + step / 2) + '\" y=\"' + (h - 6) + '\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"var(--ink3)\" class=\"mono\">' + esc(axisLabel(m.ids[k])) + \"</text>\";\n });\n var legend = m.series.length > 1 ? '<div class=\"legend\">' + m.series.map(function (sr) {\n return '<button data-act=\"legend\" data-chart=\"' + id + '\" data-label=\"' + esc(sr.label) + '\" class=\"' + (sr.off ? \"off\" : \"\") + '\"><i style=\"background:' + sr.color + '\"></i><span class=\"trunc\" style=\"max-width:220px\">' + esc(sr.label) + \"</span></button>\";\n }).join(\"\") + \"</div>\" : \"\";\n var wantAnim = state.first;\n host.innerHTML = legend + '<div class=\"chart' + (wantAnim ? \" anim\" : \"\") + '\" data-chart=\"' + id + '\"><svg width=\"' + w + '\" height=\"' + h + '\" viewBox=\"0 0 ' + w + \" \" + h + '\" role=\"img\" aria-label=\"chart\">' + s + '</svg><div class=\"xh\"></div><div class=\"yh\"></div><div class=\"ax x\"></div><div class=\"ax y\"></div></div>';\n m.geo = { padL: padL, padT: padT, chh: chh, step: step, n: n, mx: mx, w: w, h: h, Y: Y };\n CH[id] = m;\n }\n\n function spark(w, h, vals, stroke, ids, label, stretch) {\n if (!vals || vals.length < 2) return \"\";\n var mx = Math.max.apply(null, vals) * 1.08 || 1, pts = [];\n for (var i = 0; i < vals.length; i++) pts.push((i / (vals.length - 1) * w).toFixed(1) + \",\" + (h - vals[i] / mx * h).toFixed(1));\n var id = \"sp\" + (++sparkSeq);\n CH[id] = { kind: \"spark\", ids: ids, values: vals, label: label, color: stroke, w: w };\n return '<svg data-spark=\"' + id + '\" width=\"' + (stretch ? \"100%\" : w) + '\" height=\"' + h + '\" viewBox=\"0 0 ' + w + \" \" + h + '\"' + (stretch ? ' preserveAspectRatio=\"none\"' : \"\") + ' style=\"cursor:crosshair\"><polygon points=\"0,' + h + \" \" + pts.join(\" \") + \" \" + w + \",\" + h + '\" fill=\"' + stroke + '\" opacity=\".12\"/><polyline points=\"' + pts.join(\" \") + '\" fill=\"none\" stroke=\"' + stroke + '\" stroke-width=\"1.6\" stroke-linejoin=\"round\" vector-effect=\"non-scaling-stroke\"/><circle class=\"sd\" r=\"3\" fill=\"' + stroke + '\" visibility=\"hidden\"/></svg>';\n }\n // Sparklines are drawn on every refresh; forget the ones no longer on the page.\n setInterval(function () { Object.keys(CH).forEach(function (k) { if (k.indexOf(\"sp\") === 0 && !document.querySelector('[data-spark=\"' + k + '\"]')) delete CH[k]; }); }, 15000);\n\n // items: [{ label, value, color, go }]\n function donut(id, items, unit, center) {\n var host = document.getElementById(id);\n if (!host || hold(host, function () { donut(id, items, unit, center); })) return;\n var total = sum(items.map(function (x) { return x.value; }));\n if (!total) { host.innerHTML = '<div class=\"empty\">Nothing counted in this range yet.</div>'; return; }\n var S = host.classList.contains(\"wb\") ? Math.max(140, Math.min(250, (host.clientHeight || 0) - 30)) : 170, R = S * 0.44, r = S * 0.29, cx = S / 2, cy = S / 2, a0 = -Math.PI / 2, s = \"\";\n items.forEach(function (it, i) {\n var a1 = a0 + it.value / total * Math.PI * 2;\n if (it.value / total > 0.9999) { s += '<circle cx=\"' + cx + '\" cy=\"' + cy + '\" r=\"' + (R + r) / 2 + '\" fill=\"none\" stroke=\"' + it.color + '\" stroke-width=\"' + (R - r) + '\" data-slice=\"' + i + '\"/>'; }\n else if (it.value > 0) {\n var big = a1 - a0 > Math.PI ? 1 : 0;\n var p = [cx + R * Math.cos(a0), cy + R * Math.sin(a0), cx + R * Math.cos(a1), cy + R * Math.sin(a1), cx + r * Math.cos(a1), cy + r * Math.sin(a1), cx + r * Math.cos(a0), cy + r * Math.sin(a0)].map(function (v) { return v.toFixed(2); });\n s += '<path data-slice=\"' + i + '\" d=\"M' + p[0] + \" \" + p[1] + \"A\" + R + \" \" + R + \" 0 \" + big + \" 1 \" + p[2] + \" \" + p[3] + \"L\" + p[4] + \" \" + p[5] + \"A\" + r + \" \" + r + \" 0 \" + big + \" 0 \" + p[6] + \" \" + p[7] + 'Z\" fill=\"' + it.color + '\" stroke=\"var(--surface)\" stroke-width=\"1.5\" style=\"cursor:pointer\"/>';\n }\n a0 = a1;\n });\n s += '<text x=\"' + cx + '\" y=\"' + (cy - 2) + '\" text-anchor=\"middle\" font-size=\"' + Math.round(S / 8) + '\" font-weight=\"700\" fill=\"var(--ink)\">' + short(total) + '</text><text x=\"' + cx + '\" y=\"' + (cy + 15) + '\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"var(--ink3)\">' + esc(center || (unit === \"errors\" ? \"5xx\" : \"total\")) + \"</text>\";\n host.innerHTML = '<div class=\"donut\"><svg width=\"' + S + '\" height=\"' + S + '\" viewBox=\"0 0 ' + S + \" \" + S + '\" data-donut=\"' + id + '\">' + s + '</svg><div class=\"lg\">' + items.map(function (it, i) {\n return '<button ' + (it.go ? 'data-go=\"' + esc(it.go) + '\"' : \"\") + ' data-slice=\"' + i + '\"><i style=\"background:' + it.color + '\"></i><span class=\"trunc\">' + esc(it.label) + '</span><b class=\"tnum\">' + short(it.value) + '</b><span class=\"muted tnum\" style=\"text-align:right\">' + pct(it.value, total) + \"</span></button>\";\n }).join(\"\") + \"</div></div>\";\n CH[id] = { kind: \"donut\", items: items, total: total };\n }\n\n function hideHover() {\n tt.style.display = \"none\";\n document.querySelectorAll(\".chart .xh, .chart .yh, .chart .ax\").forEach(function (e) { e.style.display = \"none\"; });\n document.querySelectorAll(\"svg .sd\").forEach(function (e) { e.setAttribute(\"visibility\", \"hidden\"); });\n }\n function showTip(html, ev) {\n tt.innerHTML = html; tt.style.display = \"block\";\n var x = ev.clientX + 16, y = ev.clientY + 14, r = tt.getBoundingClientRect();\n if (x + r.width > innerWidth - 8) x = ev.clientX - r.width - 16;\n if (y + r.height > innerHeight - 8) y = ev.clientY - r.height - 14;\n tt.style.left = x + \"px\"; tt.style.top = y + \"px\";\n }\n document.addEventListener(\"mousemove\", function (ev) {\n var t = ev.target;\n var sp = t.closest && t.closest(\"[data-spark]\");\n if (sp) return hoverSpark(sp, ev);\n var dn = t.closest && t.closest(\"[data-donut]\");\n if (dn) return hoverDonut(dn, ev);\n var tr = t.closest && t.closest(\".trunc\");\n if (tr && tr.scrollWidth > tr.clientWidth + 1) { hideHover(); return showTip('<div style=\"white-space:pre-line;word-break:break-all\">' + esc(tr.textContent) + \"</div>\", ev); }\n var tip = t.closest && t.closest(\"[data-tip]\");\n // the text can contain route names from Redis, so it is escaped like everything else\n if (tip) { hideHover(); return showTip('<div style=\"white-space:pre-line\">' + esc(tip.getAttribute(\"data-tip\")) + \"</div>\", ev); }\n var host = t.closest && t.closest(\".chart[data-chart]\");\n if (!host) return hideHover();\n hoverChart(host, ev);\n });\n function hoverSpark(el, ev) {\n var m = CH[el.getAttribute(\"data-spark\")];\n if (!m) return;\n var r = el.getBoundingClientRect(), i = Math.round((ev.clientX - r.left) / r.width * (m.values.length - 1));\n i = Math.max(0, Math.min(m.values.length - 1, i));\n var d = el.querySelector(\".sd\"), vb = el.viewBox.baseVal, mx = Math.max.apply(null, m.values) * 1.08 || 1;\n d.setAttribute(\"cx\", i / (m.values.length - 1) * vb.width); d.setAttribute(\"cy\", vb.height - m.values[i] / mx * vb.height); d.setAttribute(\"visibility\", \"visible\");\n showTip('<div class=\"t\">' + esc(m.ids ? fullTime(m.ids[i]) : \"\") + '</div><div class=\"r\"><i style=\"background:' + m.color + '\"></i>' + esc(m.label || \"Requests\") + \"<b>\" + fmt(m.values[i]) + \"</b></div>\", ev);\n }\n function hoverDonut(el, ev) {\n var m = CH[el.getAttribute(\"data-donut\")], sl = ev.target.getAttribute && ev.target.getAttribute(\"data-slice\");\n if (!m || sl === null) { tt.style.display = \"none\"; return; }\n var it = m.items[+sl];\n showTip('<div class=\"r\"><i style=\"background:' + it.color + '\"></i>' + esc(it.label) + \"<b>\" + fmt(it.value) + '</b></div><div class=\"f\">' + pct(it.value, m.total) + \" of \" + fmt(m.total) + (it.go ? \" · click to open\" : \"\") + \"</div>\", ev);\n }\n function hoverChart(host, ev) {\n var id = host.getAttribute(\"data-chart\"), m = CH[id];\n if (!m || !m.geo) return;\n var g = m.geo, svg = host.querySelector(\"svg\"), r = svg.getBoundingClientRect(), hr = host.getBoundingClientRect();\n var i = Math.floor((ev.clientX - r.left - g.padL) / g.step);\n if (i < 0 || i >= g.n) return hideHover();\n var on = m.series.filter(function (s) { return !s.off; });\n if (!on.length) return hideHover();\n var yv, rows = \"\", total = 0;\n on.forEach(function (s) { total += s.values[i] || 0; });\n if (m.kind === \"stack\") yv = total;\n else {\n var my = ev.clientY - r.top, best = on[0];\n on.forEach(function (s) { if (Math.abs(g.Y(s.values[i] || 0) - my) < Math.abs(g.Y(best.values[i] || 0) - my)) best = s; });\n yv = best.values[i] || 0; m.hot = best;\n }\n if (m.kind === \"stack\" && on.length > 1) rows += '<div class=\"r\" style=\"font-weight:700\"><i style=\"background:var(--ink)\"></i>All types<b>' + fmt(total) + \"</b></div>\";\n on.forEach(function (s) {\n var e = s.errors ? s.errors[i] || 0 : 0;\n rows += '<div class=\"r\"><i style=\"background:' + s.color + '\"></i>' + esc(s.label) + \"<b>\" + valText(s.values[i] || 0, m.unit) + \"</b></div>\" + (e ? '<div class=\"r\"><i style=\"background:var(--bad)\"></i>5xx<b>' + fmt(e) + \" · \" + pct(e, s.values[i]) + \"</b></div>\" : \"\");\n });\n var avg = sum(on.map(function (s) { return sum(s.values) / s.values.length; }));\n var foot = m.unit === \"%\" ? \"\" : (avg ? (total >= avg ? \"+\" : \"\") + Math.round((total / avg - 1) * 100) + \"% vs the average of this range\" : \"\");\n var ox = r.left - hr.left, oy = r.top - hr.top, x = ox + g.padL + i * g.step + g.step / 2, y = oy + g.Y(yv);\n var xh = host.querySelector(\".xh\"), yh = host.querySelector(\".yh\"), ax = host.querySelector(\".ax.x\"), ay = host.querySelector(\".ax.y\");\n xh.style.cssText = \"display:block;left:\" + x + \"px;top:\" + (oy + g.padT) + \"px;height:\" + g.chh + \"px\";\n yh.style.cssText = \"display:block;left:\" + (ox + g.padL) + \"px;top:\" + y + \"px;width:\" + (g.w - g.padL - 10) + \"px\";\n ax.style.cssText = \"display:block;left:\" + x + \"px;top:\" + (oy + g.h - 20) + \"px\"; ax.textContent = axisLabel(m.ids[i]);\n ay.style.cssText = \"display:block;left:\" + (ox + g.padL - 2) + \"px;top:\" + y + \"px\"; ay.textContent = m.unit === \"%\" ? valText(yv, \"%\") : short(yv);\n showTip('<div class=\"t\">' + esc(fullTime(m.ids[i])) + \"</div>\" + rows + (foot ? '<div class=\"f\">' + foot + (m.pick ? \" · click to open\" : \"\") + \"</div>\" : \"\"), ev);\n }\n document.addEventListener(\"mouseleave\", hideHover);\n document.addEventListener(\"scroll\", hideHover, true);\n\n // ---------- data ----------\n function api(path, opts) {\n return fetch(BASE + path, Object.assign({ credentials: \"same-origin\", headers: { \"content-type\": \"application/json\" } }, opts || {})).then(function (res) {\n return res.json().catch(function () { return {}; }).then(function (body) {\n if (res.status === 401 && state.me && state.me.required) { state.me.signedIn = false; render(); throw new Error(\"signed out\"); }\n if (!res.ok) throw new Error(body.error || \"Request failed (\" + res.status + \")\");\n return body;\n });\n });\n }\n function route() {\n var raw = location.hash.replace(/^#\\/?/, \"\");\n var parts = raw.split(\"/\").map(function (p) { try { return decodeURIComponent(p); } catch (e) { return p; } });\n if (parts[0] === \"type\" && parts[1]) return { view: \"type\", type: parts[1] };\n if (parts[0] === \"route\" && parts[3] !== undefined) return { view: \"route\", type: parts[1], action: parts[2], name: parts.slice(3).join(\"/\") };\n if (parts[0] === \"insights\") return { view: \"insights\" };\n if (parts[0] === \"settings\") return { view: \"settings\" };\n if (parts[0] === \"boards\") return { view: \"boards\" };\n if (parts[0] === \"board\" && parts[1]) return { view: \"board\", id: parts[1] };\n if (parts[0] === \"template\" && parts[1]) return { view: \"template\", key: parts[1] };\n // the Overview tab, and the dashboard URL, show your default board when you set one\n if (state.boards.defaultId && boardById(state.boards.defaultId)) return { view: \"board\", id: state.boards.defaultId, landing: true };\n return { view: \"overview\" };\n }\n function viewUrl(r) {\n var q = \"range=\" + encodeURIComponent(state.range);\n if (r.view === \"type\") return \"/api/type?type=\" + encodeURIComponent(r.type) + \"&\" + q;\n if (r.view === \"route\") return \"/api/route?type=\" + encodeURIComponent(r.type) + \"&action=\" + encodeURIComponent(r.action) + \"&name=\" + encodeURIComponent(r.name) + \"&\" + q;\n if (r.view === \"insights\") return \"/api/insights?period=\" + state.period;\n if (r.view === \"overview\") return \"/api/overview?\" + q;\n return null;\n }\n // Polls skip a tick while a request is out; a forced load (new view or range) replaces it.\n function load(force) {\n if ((!force && state.loading) || !state.me || (state.me.required && !state.me.signedIn)) return;\n var r = route(), seq = ++state.seq;\n state.lastLoad = Date.now();\n if (r.view === \"board\" || r.view === \"template\") { state.loading = true; return loadBoard(r, seq, !force).then(function () { if (seq === state.seq) { state.loading = false; state.first = false; } }); }\n var url = viewUrl(r);\n if (!url) return;\n // period stats change slowly and cost a scan, so they refresh every 30 s at most\n if (r.view === \"insights\" && (force || Date.now() - state.pAt >= Math.max(30000, state.refresh))) loadPeriod(!force);\n state.loading = true;\n api(url).then(function (data) {\n if (seq !== state.seq) return;\n if (data.chart) state.range = data.range || data.chart.bucket;\n state.data = data; state.dataFor = JSON.stringify(r); state.error = \"\"; state.updated = istNow();\n polled = !force;\n try { fill(r, data); } finally { polled = false; }\n state.first = false;\n if (r.view === \"overview\" && Date.now() - state.side.at >= Math.max(30000, state.refresh || 30000)) loadSide();\n }).catch(function (err) {\n if (seq === state.seq && err.message !== \"signed out\") { state.error = err.message; showError(); }\n }).then(function () { if (seq === state.seq) state.loading = false; });\n }\n function loadSide() {\n state.side.at = Date.now();\n api(\"/api/insights\").catch(function () { return null; }).then(function (ins) {\n state.side.insights = ins;\n if (route().view === \"overview\") fillSide();\n paintBadge();\n });\n }\n // ---------- storage ----------\n // How much of Redis the tracker takes. Redis's own numbers are cheap; the tracker's share comes\n // from a count the server runs in the background (every 10 minutes), polled while it runs.\n var ST_GROUPS = [[\"sec\", \"Seconds counters\"], [\"min\", \"Minutes counters\"], [\"hour\", \"Hours counters\"], [\"day\", \"Days counters\"], [\"month\", \"Months counters\"], [\"year\", \"Years counters\"],\n [\"otherCounters\", \"Other keys under the prefix\"], [\"samples\", \"Recent requests (curl samples)\"], [\"failures\", \"Recent failures\"], [\"boards\", \"Boards\"], [\"reports\", \"Saved reports\"]];\n function bytes(n) {\n if (n === null || n === undefined) return \"—\";\n var u = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"], i = 0;\n while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }\n return (i && n < 10 ? n.toFixed(1) : Math.round(n)) + \" \" + u[i];\n }\n function agoText(ms) {\n var s = Math.max(0, Math.round((Date.now() - ms) / 1000));\n return s < 60 ? \"just now\" : s < 3600 ? Math.round(s / 60) + \" min ago\" : Math.round(s / 3600) + \" h ago\";\n }\n function loadStorage(recount) {\n state.stAt = Date.now();\n clearTimeout(state.stPoll); state.stPoll = 0;\n return api(\"/api/storage\" + (recount ? \"?recount=1\" : \"\")).then(function (s) {\n state.storage = s;\n paintStorage();\n if (s.scanning) state.stPoll = setTimeout(loadStorage, 2000);\n }).catch(function () {});\n }\n function limitText(r) { return r.limit ? bytes(r.limit) + (r.limitFrom === \"ram\" ? \" RAM\" : \"\") : \"\"; }\n function storageTitle(s) {\n var r = s.redis, t = s.tracker, o = s.other, lines = [];\n lines.push(\"Redis: \" + (r.used !== null ? bytes(r.used) + \" used\" + (r.limit ? \" of \" + limitText(r) + \" (\" + pct(r.used, r.limit) + \")\" : \"\") : \"memory unknown\") + (r.keys !== null ? \" · \" + fmt(r.keys) + \" keys\" : \"\"));\n if (r.limitFrom === \"ram\") lines.push(\"No maxmemory is set, so the limit shown is the server's RAM.\");\n if (t) {\n lines.push(\"Tracker: \" + (t.bytes !== null ? \"≈\" + bytes(t.bytes) + \" in \" : \"\") + fmt(t.keys) + \" keys\");\n if (o) lines.push(\"Other: \" + (o.bytes !== null ? \"≈\" + bytes(o.bytes) : \"—\") + (o.keys !== null ? \" in \" + fmt(o.keys) + \" keys\" : \"\") + \" (other apps' keys and Redis overhead)\");\n lines.push(\"Counted \" + agoText(t.at) + \" · click for details\");\n } else lines.push(\"Counting the tracker's keys…\");\n return lines.join(\"\\n\");\n }\n function storageChip() {\n var s = state.storage;\n if (!s || !state.me || !state.me.signedIn) return '<span id=\"stchip\"></span>';\n var r = s.redis, t = s.tracker, near = r.limitFrom === \"maxmemory\" && r.used !== null && r.used / r.limit >= 0.8;\n var txt = \"Redis \" + (r.used !== null ? bytes(r.used) + (r.limit ? \" / \" + bytes(r.limit) : \"\") : \"—\") +\n \" · tracker \" + (!t ? \"…\" : t.bytes !== null ? \"≈\" + bytes(t.bytes) : short(t.keys) + \" keys\");\n return '<a class=\"stchip mono' + (near ? \" near\" : \"\") + '\" id=\"stchip\" href=\"#/settings\" title=\"' + esc(storageTitle(s)) + '\">' + esc(txt) + \"</a>\";\n }\n function paintStorage() {\n var c = document.getElementById(\"stchip\");\n if (c) c.outerHTML = storageChip();\n if (route().view === \"settings\") fillStorage();\n }\n function fillStorage() {\n var host = document.getElementById(\"storage\"), s = state.storage;\n if (!host) return;\n var head = '<div class=\"ch\"><b>Storage in Redis</b><span class=\"k\">approximate · the tracker\\'s keys are counted every 10 minutes</span><span class=\"right\"><span class=\"k\" id=\"stwhen\">' +\n (s && s.scanning ? \"Counting… \" + fmt(s.scanning.checked) + \" keys checked\" : s && s.tracker ? \"Counted \" + agoText(s.tracker.at) : \"\") + '</span><button class=\"btn\" data-act=\"recount\"' + (s && s.scanning ? \" disabled\" : \"\") + \">Recount</button></span></div>\";\n if (!s) { host.innerHTML = head + '<div class=\"empty\">Loading…</div>'; return; }\n var r = s.redis, t = s.tracker, o = s.other;\n var whole = r.limit || r.used || 0, trB = t && t.bytes !== null ? t.bytes : 0, otB = o && o.bytes !== null ? o.bytes : Math.max(0, (r.used || 0) - trB);\n var meter = whole ? '<div class=\"st-meter\" title=\"Tracker, other and free\"><i class=\"tr\" style=\"width:' + Math.min(100, trB / whole * 100) + '%\"></i><i class=\"ot\" style=\"width:' + Math.min(100, otB / whole * 100) + '%\"></i></div>' : \"\";\n var sum = '<div class=\"st-sum\">' +\n \"<div>Redis used<b>\" + bytes(r.used) + \"</b><span>\" + (r.limit ? \"of \" + limitText(r) + \" · \" + pct(r.used || 0, r.limit) + (r.limitFrom === \"ram\" ? \" (no maxmemory set)\" : \"\") : \"no limit known\") + (r.keys !== null ? \" · \" + fmt(r.keys) + \" keys\" : \"\") + (r.nodes > 1 ? \" · \" + r.nodes + \" masters\" : \"\") + \"</span></div>\" +\n '<div><i style=\"background:var(--bar)\"></i>Tracker<b>' + (t ? (t.bytes !== null ? \"≈\" + bytes(t.bytes) : \"—\") : \"…\") + \"</b><span>\" + (t ? fmt(t.keys) + \" keys\" + (t.bytes !== null && r.used ? \" · \" + pct(t.bytes, r.used) + \" of used\" : \"\") : \"Counting…\") + \"</span></div>\" +\n '<div><i style=\"background:var(--ink3);opacity:.55\"></i>Other<b>' + (o && o.bytes !== null ? \"≈\" + bytes(o.bytes) : \"—\") + \"</b><span>\" + (o && o.keys !== null ? fmt(o.keys) + \" keys · \" : \"\") + \"other apps and Redis overhead</span></div></div>\";\n var rows = \"\";\n if (t) {\n var top = Math.max.apply(null, ST_GROUPS.map(function (g) { var x = t.groups[g[0]]; return t.bytes !== null ? x.bytes || 0 : x.keys; }).concat([1]));\n rows = '<table class=\"tbl\"><thead><tr><th>Keys</th><th>Count</th><th>Size</th><th>Share</th></tr></thead><tbody>' + ST_GROUPS.filter(function (g) { return t.groups[g[0]].keys || t.expiry[g[0]] !== undefined; }).map(function (g) {\n var x = t.groups[g[0]], keep = t.expiry[g[0]], v = t.bytes !== null ? x.bytes || 0 : x.keys;\n return \"<tr><td>\" + g[1] + (keep ? ' <span class=\"muted\">· kept ' + expiry(keep) + \"</span>\" : \"\") + '</td><td class=\"tnum\">' + fmt(x.keys) + '</td><td class=\"tnum\">' + (x.bytes !== null ? \"≈\" + bytes(x.bytes) : \"—\") +\n '</td><td><span class=\"share\"><i style=\"width:' + (v / top * 100) + '%;background:var(--bar)\"></i></span></td></tr>';\n }).join(\"\") + \"</tbody></table>\";\n }\n host.innerHTML = head + meter + sum + rows + (s.notes.length ? '<div class=\"st-notes\">' + s.notes.map(esc).join(\"<br>\") + \"</div>\" : \"\");\n }\n setInterval(function () {\n release(); // also catches the pointer leaving the window\n // storage changes slowly: once a minute at most, or only on Refresh now when refresh is off\n if (state.me && state.me.signedIn && !document.hidden && !state.stPoll && (!state.stAt || (state.refresh && Date.now() - state.stAt >= Math.max(60000, state.refresh)))) loadStorage();\n if (state.refresh && !document.hidden && !state.ed && Date.now() - state.lastLoad >= state.refresh) load();\n if (state.refresh && !document.hidden && state.me && state.me.signedIn && Date.now() - state.side.at >= Math.max(30000, state.refresh)) loadSide();\n }, 250);\n document.addEventListener(\"visibilitychange\", function () { if (!document.hidden && state.refresh && Date.now() - state.lastLoad >= state.refresh) load(true); });\n\n // ---------- settings preview ----------\n // Changes made on the settings page are only a preview: they hide things in this dashboard and\n // produce the init() code to apply them. The app keeps tracking with its current settings.\n function draftKey() { return \"system-tracker-settings:\" + (state.me && state.me.prefix); }\n function draft() { return state.cfg ? store.get(draftKey(), null) || clone(state.cfg.resolved) : null; }\n function changes() {\n if (!state.cfg) return [];\n var d = store.get(draftKey(), null), r = state.cfg.resolved, out = [];\n if (!d) return out;\n Object.keys(r.track).forEach(function (t) { if (d.track[t] !== r.track[t]) out.push(\"track.\" + t); });\n [\"total\", \"perType\"].forEach(function (k) { if (d.global[k] !== r.global[k]) out.push(\"global.\" + k); });\n if (d.failures !== r.failures) out.push(\"failures\");\n if (d.ignore.bots !== r.ignore.bots) out.push(\"ignore.bots\");\n if (d.ignore.methods.join() !== r.ignore.methods.join()) out.push(\"ignore.methods\");\n if (d.ignore.paths.join(\"\\n\") !== r.ignore.paths.join(\"\\n\")) out.push(\"ignore.paths\");\n BUCKETS.forEach(function (b) {\n if (d.timeConfig[b].enable !== r.timeConfig[b].enable) out.push(\"timeConfig.\" + b + \".enable\");\n if (d.timeConfig[b].expiry_in_sec !== r.timeConfig[b].expiry_in_sec) out.push(\"timeConfig.\" + b + \".expiry_in_sec\");\n });\n [\"maxNamesPerType\", \"collapseAfter\"].forEach(function (k) { if (d.limits[k] !== r.limits[k]) out.push(\"limits.\" + k); });\n return out;\n }\n function hiddenType(t) { var d = store.get(draftKey(), null); return !!(d && d.track && d.track[t] === false); }\n function tracked(t) { return !state.cfg || state.cfg.resolved.track[t] !== false; }\n function trackedTypes() {\n if (!state.cfg) return Object.keys(TC);\n return Object.keys(state.cfg.resolved.track).filter(function (t) { return state.cfg.resolved.track[t]; }).concat(state.cfg.resolved.customTypes);\n }\n\n // ---------- frame ----------\n function navOn(r) { var v = r.view; return v === \"type\" || v === \"route\" || r.landing ? \"overview\" : v === \"board\" || v === \"template\" ? \"boards\" : v; }\n function topBar() {\n var me = state.me || {}, light = document.documentElement.dataset.theme === \"light\", r = route(), on = navOn(r);\n var n = changes().length, ins = state.side.insights, crit = ins ? ins.summary.critical : 0, warn = ins ? ins.summary.warning : 0;\n var badge = ins && (crit || warn) ? '<span class=\"badge ' + (crit ? \"critical\" : \"warning\") + '\" id=\"insbadge\">' + (crit + warn) + \"</span>\" : '<span id=\"insbadge\"></span>';\n return '<div class=\"top\"><span class=\"mark\"></span><span class=\"brand\">system-tracker</span>' + (me.prefix ? '<span class=\"pchip mono\">' + esc(me.prefix) + \"</span>\" : \"\") +\n (me.signedIn ? '<nav class=\"nav\"><a href=\"#/overview\" class=\"' + (on === \"overview\" ? \"on\" : \"\") + '\">Overview</a><a href=\"#/insights\" class=\"' + (on === \"insights\" ? \"on\" : \"\") + '\">Insights ' + badge + '</a><a href=\"#/boards\" class=\"' + (on === \"boards\" ? \"on\" : \"\") + '\">Boards</a><a href=\"#/settings\" class=\"' + (on === \"settings\" ? \"on\" : \"\") + '\">Settings</a></nav>' : \"\") +\n '<span class=\"grow\"></span>' + (me.signedIn ? storageChip() : \"\") +\n (me.signedIn && n ? '<a class=\"preview-chip\" href=\"#/settings\" title=\"Settings you changed here are only a preview\">Preview: ' + n + (n === 1 ? \" change\" : \" changes\") + \"</a>\" : \"\") +\n (me.signedIn ? '<label class=\"live' + (state.refresh ? \"\" : \" off\") + '\" title=\"How often the numbers refresh\"><i></i><select id=\"refresh\" aria-label=\"Refresh\">' + REFRESH.map(function (x) { return '<option value=\"' + x[0] + '\"' + (x[0] === state.refresh ? \" selected\" : \"\") + \">\" + x[1] + \"</option>\"; }).join(\"\") + '</select></label><button class=\"ibtn\" data-act=\"reload\" title=\"Refresh now\">' + ICON.reload + \"</button>\" : \"\") +\n '<button class=\"ibtn\" data-act=\"theme\" title=\"Switch to ' + (light ? \"dark\" : \"light\") + ' mode\">' + (light ? ICON.sun : ICON.moon) + \"</button>\" +\n (me.required && me.signedIn ? '<span class=\"user\"><button class=\"link\" data-act=\"logout\">Sign out</button></span>' : \"\") + \"</div>\" +\n (me.required === false ? '<div class=\"banner\">⚠ No login is set, so anyone who can reach this URL can see these stats. Add <span class=\"mono\">dashboard.username</span> and <span class=\"mono\">dashboard.password</span> to <span class=\"mono\">init</span>.</div>' : \"\");\n }\n function paintBadge() {\n var b = document.getElementById(\"insbadge\"), ins = state.side.insights;\n if (!b || !ins) return;\n var c = ins.summary.critical, w = ins.summary.warning;\n b.className = c || w ? \"badge \" + (c ? \"critical\" : \"warning\") : \"\";\n b.textContent = c || w ? c + w : \"\";\n }\n function crumbs(parts, extra, tail) {\n return '<div class=\"crumbs\">' + parts.map(function (p, i) {\n return i < parts.length - 1 ? '<a href=\"' + esc(p[1]) + '\">' + p[0] + '</a><span class=\"sep\">›</span>' : \"<b>\" + p[0] + \"</b>\";\n }).join(\"\") + (extra || \"\") + '<span class=\"meta mono\" id=\"updated\"></span>' + (tail || \"\") + \"</div>\";\n }\n function exportBtn() { return '<button class=\"btn\" data-act=\"export-view\" title=\"Download what this screen shows as CSV, PDF or JSON\">' + ICON.download + \"Export</button>\"; }\n var RANGE_PICKER = '<span id=\"tabs\" title=\"Everything on this page covers this range\"></span>';\n function tabs(buckets, id) {\n return '<div class=\"seg\"' + (id ? ' id=\"' + id + '\"' : \"\") + \">\" + buckets.map(function (b) { return '<button data-range=\"' + b + '\"' + (b === state.range ? ' class=\"on\"' : \"\") + \">\" + RANGE_LABEL[b] + \"</button>\"; }).join(\"\") + \"</div>\";\n }\n function kpi(l, v, s, sp) { return '<div class=\"kpi\"><div class=\"l\">' + l + '</div><div class=\"v tnum\">' + v + '</div><div class=\"s\">' + (s || \"&nbsp;\") + \"</div>\" + (sp || \"\") + \"</div>\"; }\n function resetBtn() {\n var ok = state.me && state.me.required;\n return '<button class=\"btn ghost danger\" data-act=\"reset\"' + (ok ? \"\" : ' disabled data-tip=\"Resetting needs a login: set dashboard.username and dashboard.password in statis.init\"') + \">\" + ICON.trash + \"Reset data</button>\";\n }\n function typeHref(t) { return \"#/type/\" + encodeURIComponent(t); }\n function routeHref(t, a, n) { return \"#/route/\" + encodeURIComponent(t) + \"/\" + encodeURIComponent(a) + \"/\" + encodeURIComponent(n); }\n\n // ---------- overview, type and route ----------\n function shell(r) {\n var p = esc(state.me.prefix || \"\");\n if (r.view === \"overview\") {\n return '<div class=\"crumbs\"><b>Overview</b>' + RANGE_PICKER + '<span class=\"meta mono\" id=\"updated\"></span>' + resetBtn() + exportBtn() + '<button class=\"btn p\" data-act=\"customize\" title=\"Turn this overview into a board you can rearrange; saving makes it your Overview\">' + ICON.edit + \"Customize</button></div>\" + '<div class=\"card\" id=\"strip\"><div class=\"empty\">Looking for anything unusual…</div></div><div class=\"kpis\" id=\"kpis\"></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Traffic by type</b><span class=\"k mono trunc\">' + p + ':{TYPE}:global:<span class=\"rk\"></span>:*</span><span class=\"right\"><span class=\"k\">per <span class=\"rbn\"></span> · hover for totals · click a bar to open its type</span></span></div><div id=\"c-stack\"></div></div>' +\n '<div class=\"cols\"><div class=\"card\"><div class=\"ch\"><b>By type</b><span class=\"k\"><span class=\"rw\"></span></span></div><div id=\"table\"></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Top routes</b><span class=\"k\"><span class=\"rw\"></span> · across all types</span></div><div id=\"top\"><div class=\"empty\">Loading…</div></div></div></div>';\n }\n if (r.view === \"type\") {\n return crumbs([[\"Overview\", \"#/overview\"], [esc(r.type)]], RANGE_PICKER, exportBtn()) + '<div class=\"kpis\" id=\"kpis\"></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>' + esc(r.type) + ' traffic</b><span class=\"k mono\">' + p + \":\" + esc(r.type) + ':global:<span class=\"rk\"></span>:*</span><span class=\"right k\">per <span class=\"rbn\"></span></span></div><div id=\"c-main\"></div></div>' +\n '<div class=\"cols\"><div class=\"card\"><div class=\"ch\"><b>Share by route</b><span class=\"k rw\"></span></div><div id=\"mix\"></div></div><div class=\"card\"><div class=\"ch\"><b>By method</b><span class=\"k rw\"></span></div><div id=\"methods\"></div></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Routes</b><span class=\"k\" id=\"rcount\"></span><span class=\"right\"><input class=\"search\" id=\"filter\" placeholder=\"Filter routes…\" value=\"' + esc(state.filter) + '\"><select id=\"sort\"><option value=\"now\">This minute</option><option value=\"total\">Requests in range</option><option value=\"name\">Name</option></select></span></div><div id=\"table\"></div></div>';\n }\n return crumbs([[\"Overview\", \"#/overview\"], [esc(r.type), typeHref(r.type)], [method(r.action) + '<span class=\"mono trunc\" style=\"max-width:60vw\">' + esc(r.name) + \"</span>\"]], \"\", exportBtn()) +\n '<div style=\"display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap\"><span class=\"keychip mono\" id=\"key\"></span><span class=\"k\" style=\"font-size:12.5px;color:var(--ink3)\" id=\"fnote\"></span></div>' +\n '<div class=\"slots\" id=\"slots\"></div><div class=\"stats\" id=\"stats\"></div>' +\n '<div class=\"split\"><div class=\"card\"><div class=\"ch\"><b id=\"ctitle\"></b><span class=\"k mono\" id=\"crange\"></span></div><div id=\"c-main\"></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Buckets</b><span class=\"k\" style=\"margin-left:auto\">newest first</span></div><div id=\"table\"></div></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Recent requests</b><span class=\"k\" id=\"reqnote\"></span><span class=\"right\"><select id=\"curlmode\" aria-label=\"Curl format\">' +\n '<option value=\"sample\"' + (state.curlMode === \"sample\" ? \" selected\" : \"\") + '>curl sample</option><option value=\"structure\"' + (state.curlMode === \"structure\" ? \" selected\" : \"\") + \">curl structure</option></select></span></div>\" +\n '<div class=\"reqs\" id=\"reqs\"><div class=\"empty\">Loading…</div></div></div>' +\n '<div class=\"card\" id=\"failcard\"><div class=\"ch\"><b>Recent failures</b><span class=\"k\" id=\"failnote\"></span></div><div class=\"reqs\" id=\"fails\"></div></div>';\n }\n // Redraws can come from timers (window resize, legend clicks) just after the view changed;\n // only draw data that was loaded for the view on screen.\n function viewData() { return state.data && state.dataFor === JSON.stringify(route()) ? state.data : null; }\n function fill(r, d) {\n if (!d || state.dataFor !== JSON.stringify(r)) return;\n steady(function () {\n var up = document.getElementById(\"updated\");\n if (up) up.textContent = \"IST · updated \" + state.updated;\n var t = document.getElementById(\"tabs\");\n if (t && d.buckets) t.innerHTML = tabs((d.ranges || d.buckets).filter(function (b) { return !draftBucketOff(b === \"week\" ? \"day\" : b); }));\n var bk = d.chart ? d.chart.bucket : state.range;\n document.querySelectorAll(\".rk\").forEach(function (e) { e.textContent = bk; });\n document.querySelectorAll(\".rw\").forEach(function (e) { e.textContent = RANGE_WHEN[state.range] || \"\"; });\n document.querySelectorAll(\".rbn\").forEach(function (e) { e.textContent = BUCKET_NAME[bk] || \"\"; });\n var so = document.querySelector('#sort option[value=\"now\"]'); if (so) so.textContent = cap(SLOT_NOW[bk] || \"\");\n if (r.view === \"overview\") fillOverview(d);\n else if (r.view === \"type\") fillType(r, d);\n else if (r.view === \"route\") fillRoute(r, d);\n else if (r.view === \"insights\") fillInsights(d);\n var e = document.getElementById(\"err\");\n if (e) e.remove();\n });\n }\n function draftBucketOff(b) { var d = store.get(draftKey(), null); return !!(d && d.timeConfig && d.timeConfig[b] && d.timeConfig[b].enable === false); }\n\n function cap(t) { return t ? t[0].toUpperCase() + t.slice(1) : t; }\n function num(v) { return v < 100 && v % 1 ? rate(v) : fmt(v); }\n // the four numbers at the top: the range's total, its current bucket, the average and the busiest bucket\n function whenOf(d) { return RANGE_WHEN[d.range || d.chart.bucket]; }\n function headKpis(d, first) {\n var b = d.chart.bucket, n = d.now, unit = BUCKET_NAME[b];\n return [kpi(first + \" · \" + whenOf(d), fmt(n.total), \"hover the line for each \" + unit, spark(120, 34, d.chart.counts, \"#3fb979\", d.chart.ids, \"Requests\")),\n kpi(cap(SLOT_NOW[b]), fmt(n.current), n.previous !== null ? PREV_LABEL[b] + \": \" + fmt(n.previous) : \"\"),\n kpi(\"Average per \" + unit, num(n.average), \"complete \" + unit + \"s only\"),\n kpi(\"Busiest \" + unit, fmt(n.peak.count), n.peak.id ? esc(fullTime(n.peak.id)) : \"nothing counted yet\")];\n }\n function fillOverview(d) {\n var rows = d.types.filter(function (x) { return !hiddenType(x.type); });\n paint(\"kpis\", function () { document.getElementById(\"kpis\").innerHTML = headKpis(d, \"Requests\").join(\"\"); });\n // one chart: traffic stacked by type, with an \"All types\" total on hover; without per-type totals, the global count\n var incoming = rows.filter(function (x) { return x.type !== \"OUT\"; });\n if (incoming.length) plot(\"c-stack\", { kind: \"stack\", ids: d.chart.ids, h: 240, series: incoming.map(function (x) { return { label: x.type, color: color(x.type), values: x.chart }; }), pick: function (i, s) { location.hash = typeHref(s.label); } });\n else plot(\"c-stack\", { kind: \"bars\", ids: d.chart.ids, h: 240, series: [{ label: \"All traffic\", color: \"var(--bar)\", values: d.chart.counts }] });\n var tot = sum(incoming.map(function (x) { return x.total; })) || 1;\n paint(\"table\", function () { document.getElementById(\"table\").innerHTML = rows.length ?\n '<table class=\"tbl fit\"><tr><th>Type</th><th class=\"opt\" style=\"width:120px\">Trend</th><th class=\"opt2\" style=\"width:96px\">' + esc(cap(SLOT_NOW[d.chart.bucket])) + '</th><th style=\"width:104px\">' + esc(cap(whenOf(d))) + '</th><th style=\"width:128px\">Share</th><th class=\"opt2\" style=\"width:36px\"></th></tr>' + rows.map(function (x) {\n var c = color(x.type);\n return '<tr data-go=\"' + esc(typeHref(x.type)) + '\"><td><span class=\"tp mono\"><i style=\"background:' + c + '\"></i>' + esc(x.type) + '</span></td><td class=\"opt\">' + spark(100, 22, x.chart, c, d.chart.ids, x.type) + '</td><td class=\"tnum opt2\">' + fmt(x.now) + '</td><td class=\"tnum\">' + fmt(x.total) + '</td><td style=\"white-space:normal\">' + (x.type === \"OUT\" ? '<span class=\"muted\" data-tip=\"Calls your app made: to your own backend (your domains, localhost or private hosts) or to third parties. They aren\\'t requests to your app, so they\\'re not part of the total or the chart.\" style=\"font-size:12px;white-space:normal;display:inline-block;max-width:100%;line-height:1.35;text-align:right\">outgoing, not in total' + (x.own !== undefined ? \"<br>\" + short(x.own) + \" your backend · \" + short(x.thirdParty) + \" third-party\" : \"\") + \"</span>\" : '<span class=\"share\" style=\"width:60px\"><i style=\"width:' + (x.total / tot * 100).toFixed(1) + \"%;background:\" + c + '\"></i></span><span class=\"tnum\" style=\"color:var(--ink2);display:inline-block;width:40px\">' + Math.round(x.total / tot * 100) + \"%</span>\") + '</td><td class=\"chev opt2\">›</td></tr>';\n }).join(\"\") + \"</table>\" :\n '<div class=\"empty\">Nothing counted in the ' + whenOf(d) + \". Requests show up here a moment after they finish.</div>\"; });\n var top = d.top.filter(function (x) { return !hiddenType(x.type); }).slice(0, 8);\n document.getElementById(\"top\").innerHTML = top.length ? '<table class=\"tbl fit\"><tr><th>Route</th><th class=\"opt\" style=\"width:100px\">' + esc(cap(SLOT_NOW[d.chart.bucket])) + '</th><th style=\"width:112px\">' + esc(cap(whenOf(d))) + \"</th>\" + (d.failures ? '<th style=\"width:56px\">5xx</th>' : \"\") + '<th class=\"opt2\" style=\"width:122px\">Share</th></tr>' + top.map(function (x) {\n return '<tr data-go=\"' + esc(routeHref(x.type, x.action, x.name)) + '\"><td class=\"name trunc\"><span class=\"tp mono\" style=\"margin-right:8px\"><i style=\"background:' + color(x.type) + '\"></i></span>' + method(x.action) + '<span class=\"mono\">' + esc(x.name) + '</span></td><td class=\"tnum opt\">' + fmt(x.now) + '</td><td class=\"tnum\">' + fmt(x.total) + \"</td>\" +\n (d.failures ? '<td class=\"tnum' + (x.errors ? \" bad\" : \"\") + '\">' + (x.errors ? fmt(x.errors) : \"0\") + \"</td>\" : \"\") + '<td class=\"tnum opt2\">' + (x.type === \"OUT\" ? '<span class=\"muted\">' + destText(x) + \"</span>\" : (x.share * 100).toFixed(1) + \"%\") + \"</td></tr>\";\n }).join(\"\") + \"</table>\" : '<div class=\"empty\">No routes counted in the ' + whenOf(d) + \".</div>\";\n fillSide();\n }\n function fillSide() {\n var ins = state.side.insights, strip = document.getElementById(\"strip\");\n if (strip && ins) {\n var items = ins.items.filter(function (i) { return !(i.target.type && hiddenType(i.target.type)); });\n var s = ins.summary;\n strip.innerHTML = '<div class=\"strip\"><div class=\"sum\"><div class=\"h2\" style=\"font-size:15px\">Insights</div><div class=\"n\"><span class=\"bad\"><b>' + s.critical + '</b>critical</span><span style=\"color:var(--warn)\"><b>' + s.warning + '</b>warnings</span><span style=\"color:var(--info)\"><b>' + s.info + '</b>info</span></div><a class=\"link\" href=\"#/insights\" style=\"font-size:12.5px\">See all insights →</a></div><div class=\"rows\">' +\n (items.length ? items.slice(0, 3).map(function (it, k) {\n return '<button data-ins=\"' + ins.items.indexOf(it) + '\"><i style=\"background:var(--' + (it.severity === \"critical\" ? \"bad\" : it.severity === \"warning\" ? \"warn\" : \"info\") + ')\"></i><span class=\"trunc\">' + esc(it.title) + '</span><b class=\"tnum\" style=\"text-align:right\">' + esc(it.value) + \"</b></button>\";\n }).join(\"\") : '<div class=\"allclear\">' + ICON.check + \"All clear: nothing unusual in the last 30 minutes.</div>\") + \"</div></div>\";\n }\n }\n\n function fillType(r, d) {\n var when = whenOf(d), total = d.now.total, errs = d.now.errors;\n document.getElementById(\"kpis\").innerHTML = headKpis(d, esc(r.type)).slice(0, 3).join(\"\") +\n (d.failures ? kpi(\"5xx · \" + when, '<span class=\"' + (errs ? \"bad\" : \"\") + '\">' + (total ? (errs / total * 100).toFixed(2) : \"0.00\") + \"%</span>\", fmt(errs) + \" responses with status 5xx\") :\n kpi(\"Routes\", fmt(d.routes.length), \"called in the \" + when));\n plot(\"c-main\", { kind: \"bars\", ids: d.chart.ids, h: 160, series: [{ label: r.type, color: color(r.type), values: d.chart.counts }] });\n var top = d.routes.slice(0, 6), rest = sum(d.routes.slice(6).map(function (x) { return x.total; }));\n var items = top.map(function (x, i) { return { label: x.action + \" \" + x.name, value: x.total, color: PALETTE[i % PALETTE.length], go: routeHref(r.type, x.action, x.name) }; });\n if (rest) items.push({ label: \"Other routes\", value: rest, color: \"#6b7684\" });\n donut(\"mix\", items);\n var mh = document.getElementById(\"methods\").parentNode.querySelector(\".ch b\");\n if (mh) mh.textContent = r.type === \"OUT\" ? \"By destination\" : \"By method\";\n var acts = {}, out = r.type === \"OUT\", DC = { \"your backend\": \"var(--bar)\", \"third-party\": \"#8b95a3\" };\n d.routes.forEach(function (x) { var k = out ? destText(x) : x.action; acts[k] = (acts[k] || 0) + x.total; });\n var list = Object.keys(acts).sort(function (a, c) { return acts[c] - acts[a]; }), amax = Math.max.apply(null, list.map(function (a) { return acts[a]; }).concat([1])), atot = sum(list.map(function (a) { return acts[a]; })) || 1;\n document.getElementById(\"methods\").innerHTML = list.length ? '<div class=\"bars-list\">' + list.map(function (a) {\n return '<div class=\"row\"><span>' + (out ? cap(a) : method(a)) + '</span><span class=\"bar\"><i style=\"width:' + (acts[a] / amax * 100).toFixed(1) + \"%;background:\" + (out ? DC[a] : MC[a] || \"#57606b\") + '\"></i></span><span class=\"tnum\">' + short(acts[a]) + ' <span class=\"muted\">' + pct(acts[a], atot) + \"</span></span></div>\";\n }).join(\"\") + \"</div>\" : '<div class=\"empty\">No calls in the ' + when + \".</div>\";\n fillRoutes(r, d);\n }\n function fillRoutes(r, d) {\n var sort = state.sort === \"now\" || state.sort === \"name\" ? state.sort : \"total\", f = state.filter.toLowerCase(), b = d.chart.bucket, when = whenOf(d);\n var rows = d.routes.filter(function (x) { return !f || (x.action + \" \" + x.name).toLowerCase().indexOf(f) !== -1; });\n rows.sort(function (a, c) { return sort === \"name\" ? (a.name < c.name ? -1 : a.name > c.name ? 1 : 0) : (c[sort] || 0) - (a[sort] || 0); });\n document.getElementById(\"rcount\").textContent = rows.length + \" of \" + d.routes.length;\n document.getElementById(\"table\").innerHTML = d.routes.length ?\n '<table class=\"tbl fit\"><tr><th>Route</th><th class=\"opt\" style=\"width:110px\">' + esc(cap(SLOT_NOW[b])) + '</th><th style=\"width:120px\">' + esc(cap(when)) + \"</th>\" + (d.failures ? '<th style=\"width:120px\">5xx</th>' : \"\") + '<th class=\"opt2\" style=\"width:40px\"></th></tr>' + rows.map(function (x) {\n return '<tr data-go=\"' + esc(routeHref(r.type, x.action, x.name)) + '\"><td class=\"name trunc\">' + method(x.action) + '<span class=\"mono\">' + esc(x.name) + \"</span>\" + destTag(x) + '</td><td class=\"tnum opt\">' + fmt(x.now) + '</td><td class=\"tnum\">' + fmt(x.total) + \"</td>\" +\n (d.failures ? '<td class=\"tnum' + (x.errors ? \" bad\" : \"\") + '\" style=\"' + (x.errors ? \"\" : \"color:var(--ink3)\") + '\">' + (x.errors ? fmt(x.errors) + \" · \" + pct(x.errors, x.total || 1) : \"0\") + \"</td>\" : \"\") + '<td class=\"chev opt2\">›</td></tr>';\n }).join(\"\") + \"</table>\" :\n '<div class=\"empty\">No ' + esc(r.type) + \" routes counted in the \" + when + \".</div>\";\n }\n\n function fillRoute(r, d) {\n fillRequests(r, d);\n document.getElementById(\"key\").textContent = d.key + \":{bucket}:{id}\";\n document.getElementById(\"fnote\").textContent = d.failures ? \"5xx shown in red\" : \"\";\n paint(\"slots\", function () { document.getElementById(\"slots\").innerHTML = d.tiles.map(function (s) {\n return '<button class=\"slot' + (s.bucket === (state.range === \"week\" ? \"day\" : state.range) ? \" on\" : \"\") + '\" data-range=\"' + s.bucket + '\"><div class=\"l\">' + SLOT_LABEL[s.bucket] + '</div><div class=\"v tnum\">' + short(s.current) + '</div><div class=\"s\">' + SLOT_NOW[s.bucket] + \" · kept \" + expiry(s.expiry) + \"</div>\" + spark(140, 24, s.spark, s.bucket === (state.range === \"week\" ? \"day\" : state.range) ? \"#3fb979\" : \"#6ea8fe\", s.ids, SLOT_LABEL[s.bucket], true) + \"</button>\";\n }).join(\"\"); });\n var c = d.chart, total = sum(c.counts), done = c.counts.slice(0, -1), peak = Math.max.apply(null, c.counts), pi = c.counts.indexOf(peak), low = done.length ? Math.min.apply(null, done) : 0, errs = c.errors ? sum(c.errors) : null;\n document.getElementById(\"stats\").innerHTML =\n '<div class=\"stat\"><div class=\"l\">Total in range</div><div class=\"v tnum\">' + fmt(total) + '</div><div class=\"s\">' + CHART_TITLE[c.bucket].split(\",\")[1] + \"</div></div>\" +\n '<div class=\"stat\"><div class=\"l\">Average</div><div class=\"v tnum\">' + fmt(done.length ? sum(done) / done.length : 0) + '</div><div class=\"s\">' + PER[c.bucket] + \"</div></div>\" +\n '<div class=\"stat\"><div class=\"l\">Peak</div><div class=\"v tnum\">' + fmt(peak) + '</div><div class=\"s\">' + esc(fullTime(c.ids[pi])) + \"</div></div>\" +\n '<div class=\"stat\"><div class=\"l\">Lowest</div><div class=\"v tnum\">' + fmt(low) + '</div><div class=\"s\">complete buckets only</div></div>' +\n (errs !== null ? '<div class=\"stat\"><div class=\"l\">Failure rate</div><div class=\"v tnum ' + (errs ? \"bad\" : \"\") + '\">' + pct(errs, total) + '</div><div class=\"s\">' + fmt(errs) + \" responses with 5xx</div></div>\" : \"\");\n document.getElementById(\"ctitle\").textContent = CHART_TITLE[c.bucket];\n document.getElementById(\"crange\").textContent = c.ids[0] + \" → \" + last(c.ids);\n plot(\"c-main\", { kind: \"bars\", ids: c.ids, h: 300, series: [{ label: r.action + \" \" + r.name, color: \"var(--bar)\", values: c.counts, errors: c.errors }] });\n var ids = c.ids.slice().reverse(), counts = c.counts.slice().reverse(), ers = c.errors ? c.errors.slice().reverse() : null;\n // a readable time (12:55, 2 Nov) with the full one on hover; the spare width shows each count against the busiest one\n var peakShown = Math.max.apply(null, counts.slice(0, 10).concat([1]));\n document.getElementById(\"table\").innerHTML = '<table class=\"tbl fit\"><tr><th style=\"width:108px\">Time (IST)</th><th></th><th style=\"width:84px\">Count</th>' + (ers ? '<th style=\"width:62px\">5xx</th>' : \"\") + \"</tr>\" + ids.slice(0, 10).map(function (id, i) {\n return '<tr><td class=\"when tnum\" title=\"' + esc(fullTime(id)) + '\">' + esc(bucketText(id)) + '</td><td><span class=\"share bar\"><i style=\"width:' + (counts[i] / peakShown * 100).toFixed(1) + '%;background:var(--bar)\"></i></span></td><td class=\"tnum\" title=\"' + esc(fmt(counts[i])) + '\">' + fmt(counts[i]) + \"</td>\" + (ers ? '<td class=\"tnum' + (ers[i] ? \" bad\" : \"\") + '\">' + fmt(ers[i]) + \"</td>\" : \"\") + \"</tr>\";\n }).join(\"\") + \"</table>\";\n }\n\n // ---------- recent requests, as curl ----------\n var SECRET_HEADER = /^(authorization|proxy-authorization|cookie|x-api-key|api-key|x-auth-token|x-access-token)$/i;\n var NO_CURL = { WSS: 1, GRPC: 1 };\n function sh(v) { return \"'\" + String(v).replace(/'/g, \"'\\\\''\") + \"'\"; }\n function shapeOf(v) {\n if (Array.isArray(v)) return v.length ? [shapeOf(v[0])] : [];\n if (v && typeof v === \"object\") { var o = {}; Object.keys(v).forEach(function (k) { o[k] = shapeOf(v[k]); }); return o; }\n return v === null ? null : \"<\" + typeof v + \">\";\n }\n // The body's shape: values become <string>, <number>, …; a GraphQL query and a JSON-RPC method stay as they are.\n function bodyShape(s) {\n var j; try { j = JSON.parse(s.body); } catch (e) { j = undefined; }\n if (j === undefined) {\n var ct = (s.headers.filter(function (h) { return /^content-type$/i.test(h[0]); })[0] || [])[1] || \"\";\n return /x-www-form-urlencoded/i.test(ct) ? s.body.split(\"&\").map(function (kv) { return kv.split(\"=\")[0] + \"=<value>\"; }).join(\"&\") : \"<body>\";\n }\n function one(x) {\n if (x && typeof x === \"object\" && !Array.isArray(x)) {\n if (typeof x.query === \"string\") return { query: x.query, operationName: x.operationName, variables: x.variables === undefined ? undefined : shapeOf(x.variables) };\n if (x.jsonrpc === \"2.0\") return { jsonrpc: \"2.0\", method: x.method, params: x.params === undefined ? undefined : shapeOf(x.params), id: x.id === undefined ? undefined : shapeOf(x.id) };\n }\n return shapeOf(x);\n }\n var batch = Array.isArray(j) && j.length && j[0] && typeof j[0] === \"object\" && (typeof j[0].query === \"string\" || j[0].jsonrpc === \"2.0\");\n return JSON.stringify(batch ? j.map(one) : one(j), null, 2);\n }\n // structure: the route with :params, query and body values as <type>, secret headers as placeholders.\n // sample: the request exactly as it was sent.\n function curlOf(s, mode) {\n var st = mode === \"structure\", q = s.url.indexOf(\"?\"), path = q === -1 ? s.url : s.url.slice(0, q), query = q === -1 ? \"\" : s.url.slice(q + 1);\n if (st) {\n path = s.template.replace(/<([^>]+)>/g, \":$1\");\n query = query.split(\"&\").filter(Boolean).map(function (kv) {\n var i = kv.indexOf(\"=\"), v = i === -1 ? \"\" : kv.slice(i + 1);\n return (i === -1 ? kv : kv.slice(0, i)) + \"=\" + (/^-?\\d+(\\.\\d+)?$/.test(v) ? \"<number>\" : \"<string>\");\n }).join(\"&\");\n }\n var out = [\"curl -X \" + s.method + \" \" + sh(s.scheme + \"://\" + s.host + path + (query ? \"?\" + query : \"\"))];\n s.headers.forEach(function (h) { out.push(\"-H \" + sh(h[0] + \": \" + (st && SECRET_HEADER.test(h[0]) ? \"<\" + h[0].toLowerCase() + \">\" : h[1]))); });\n if (s.body) out.push(\"--data-raw \" + sh(st ? bodyShape(s) : s.body));\n return out.join(\" \\\\\\n \");\n }\n function istStamp(ms) {\n try { return new Date(ms).toLocaleString(\"en-GB\", { timeZone: \"Asia/Kolkata\", day: \"2-digit\", month: \"short\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }); } catch (e) { return new Date(ms).toLocaleString(); }\n }\n function reqItem(s, src, i) {\n var k = src + s.at + \"|\" + i, open = state.reqOpen[k] !== undefined ? state.reqOpen[k] : i === 0 && src === \"s\";\n function block(text, act) { return '<div class=\"curl\"><pre class=\"mono\">' + esc(text) + \"</pre>\" + (act ? '<button class=\"btn\" data-act=\"copy-curl\" data-src=\"' + src + '\" data-i=\"' + i + '\" title=\"Copy for a terminal or Postman (Import → Raw text)\">' + ICON.copy + \"Copy</button>\" : \"\") + \"</div>\"; }\n return '<details class=\"req\" data-k=\"' + esc(k) + '\"' + (open ? \" open\" : \"\") + \"><summary>\" + method(s.method) + '<span class=\"st' + (s.status >= 500 || !s.status ? \" bad\" : \"\") + '\">' + (s.status || \"no response\") + '</span><span class=\"mono trunc\">' + esc(s.url) + \"</span>\" + (s.repeats > 1 ? '<span class=\"rep\" data-tip=\"This request (same method, URL and body) was captured ' + s.repeats + ' times. It is kept once; this is the latest.\">×' + s.repeats + \"</span>\" : \"\") + '<span class=\"when\">' + (s.repeats > 1 ? \"last \" : \"\") + esc(istStamp(s.at)) + \" · \" + fmt(s.ms) + \" ms</span>\" + CHEVRON + \"</summary>\" +\n block(curlOf(s, state.curlMode), true) +\n (s.bodyCut ? '<div class=\"note\">The body was larger than 16 KB, so ' + (s.body ? \"it's cut short here.\" : \"it isn't shown.\") + \"</div>\" : \"\") +\n (s.response ? \"<h5>\" + (s.status ? \"Error response\" : \"Error\") + \"</h5>\" + block(s.response) : \"\") +\n (s.stack ? \"<h5>Stack</h5>\" + block(s.stack) : \"\") + \"</details>\";\n }\n function fillRequests(r, d) {\n var reqs = document.getElementById(\"reqs\"), fails = document.getElementById(\"fails\"), card = document.getElementById(\"failcard\");\n if (!reqs) return;\n var sig = state.curlMode + JSON.stringify([d.samples, d.failed]);\n if (reqs.getAttribute(\"data-sig\") === sig) return;\n reqs.setAttribute(\"data-sig\", sig);\n var shared = /^<(unmatched|other|unknown|anonymous)>$/.test(r.name);\n document.getElementById(\"reqnote\").textContent = NO_CURL[r.type] ? \"\" : \"newest first · \" + (shared ? \"the last 20, one every 2 seconds at most\" : \"the last 5, one a minute at most\") + \" · as sent, headers included\";\n if (NO_CURL[r.type]) {\n reqs.innerHTML = '<div class=\"empty\">' + (r.type === \"GRPC\" ? \"gRPC calls\" : \"WebSocket traffic\") + \" can't be shown as curl. Requests are kept for HTTP routes and outgoing calls.</div>\";\n card.hidden = true;\n return;\n }\n reqs.innerHTML = d.samples && d.samples.length ? d.samples.map(function (s, i) { return reqItem(s, \"s\", i); }).join(\"\") :\n '<div class=\"empty\">No requests kept yet. The next ' + (r.type === \"OUT\" ? \"call\" : \"request\") + \" to this route shows up here. Requests are kept only while the dashboard is on, one a minute per route, for 30 days, so counts from before that, or older than 30 days, have no requests to show.</div>\";\n card.hidden = false;\n document.getElementById(\"failnote\").textContent = d.failed && d.failed.length ? (r.type === \"OUT\" ? \"status 500 and above, or no response · the last 20 · with the error\" : \"status 500 and above · the last 20 · with the error response and stack when available\") : \"\";\n fails.innerHTML = d.failed && d.failed.length ? d.failed.map(function (s, i) { return reqItem(s, \"f\", i); }).join(\"\") :\n '<div class=\"empty\">No failed requests (status 500 and above) kept for this route.</div>';\n }\n document.addEventListener(\"toggle\", function (ev) {\n var el = ev.target;\n if (el && el.classList && el.classList.contains(\"req\")) state.reqOpen[el.getAttribute(\"data-k\")] = el.open;\n }, true);\n\n // ---------- insights ----------\n var CATS = [[\"all\", \"All\"], [\"critical\", \"Critical\"], [\"warning\", \"Warning\"], [\"spike\", \"Spikes\"], [\"drop\", \"Drops\"], [\"traffic\", \"High traffic\"], [\"scale\", \"Scale\"], [\"errors\", \"Failures\"], [\"new\", \"New\"]];\n var CAT_LABEL = { errors: \"Failures\", spike: \"Spike\", drop: \"Drop\", traffic: \"High traffic\", scale: \"Scale\", \"new\": \"New route\" };\n var GROUPS = [[\"errors\", \"Failures\"], [\"spike\", \"Spikes\"], [\"drop\", \"Drops\"], [\"traffic\", \"High traffic\"], [\"scale\", \"Scale\"], [\"new\", \"New routes\"]];\n var SEV_COLOR = { critical: \"var(--bad)\", warning: \"var(--warn)\", info: \"var(--info)\" };\n var ADVICE = {\n errors: [\"Open the route and find the minute the 5xx started.\", \"Check your app's logs and error tracker for this route.\", \"A failing dependency (database, upstream API) often shows up here first.\"],\n spike: [\"Look for a client calling this route in a loop, or retrying without backoff.\", \"Check recent releases of your web or mobile clients.\", \"If the load is real, caching or rate limiting helps.\"],\n drop: [\"Check the route is still reachable: health checks, routing, DNS.\", \"A client release may have stopped calling it.\", \"A drop together with 5xx often means an outage.\"],\n traffic: [\"The busiest routes are where caching or batching saves the most.\", \"Make sure this route scales with the rest of your app.\"],\n scale: [\"Raise limits.maxNamesPerType, or group routes with the types or name options.\", \"Bots probing random URLs create many names; try ignore.bots or ignore.paths.\"],\n \"new\": [\"Expected after a deploy? If not, find out which client calls it.\"]\n };\n var CHEVRON = '<svg viewBox=\"0 0 16 16\"><path d=\"M4 6l4 4 4-4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>';\n function insKey(it) { var t = it.target; return [it.category, t.view, t.type || \"\", t.action || \"\", t.name || \"\"].join(\"|\"); }\n function insightsShell() {\n return '<div class=\"crumbs\"><b>Insights</b><span class=\"meta mono\" id=\"updated\"></span>' + resetBtn() + '<button class=\"btn p\" data-act=\"export\">' + ICON.download + \"Export report</button></div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Stats by period</b><span class=\"k\" id=\"prange\"></span><span class=\"right\"><div class=\"seg\" id=\"ptabs\">' + Object.keys(PERIOD_NAMES).map(function (k) { return '<button data-act=\"period\" data-p=\"' + k + '\"' + (k === state.period ? ' class=\"on\"' : \"\") + \">\" + PERIOD_NAMES[k] + \"</button>\"; }).join(\"\") + '</div></span></div><div id=\"pbody\"><div class=\"empty\">Loading…</div></div></div>' +\n '<div class=\"h2\" style=\"margin-top:22px;font-size:16px\">Insights <span class=\"muted\" style=\"font-size:12.5px;font-weight:400\" id=\"ins-when\"></span></div>' +\n '<div class=\"ins-sum\"><div class=\"card\"><div class=\"ch\"><b>Severity</b><span class=\"k\">of what\\'s listed below</span></div><div id=\"ins-sev\"></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>By group</b><span class=\"k\">click to filter</span></div><div id=\"ins-groups\"></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>All traffic</b><span class=\"k\" id=\"ins-gk\"></span></div><div id=\"ins-g\"></div></div></div>' +\n '<div class=\"chips\" id=\"chips\"></div><div class=\"ins-list\" id=\"ins\"></div>';\n }\n // ---------- stats by period, export and reset ----------\n // Calendar periods in IST: today, this week (from Monday), this month, this quarter, this year.\n function loadPeriod(timed) {\n var p = state.period;\n state.pAt = Date.now();\n return api(\"/api/report?period=\" + p).then(function (d) {\n if (p !== state.period || route().view !== \"insights\") return;\n state.pdata = d;\n polled = !!timed;\n try { steady(function () { fillPeriod(d); }); } finally { polled = false; }\n }).catch(function (e) { var el = document.getElementById(\"pbody\"); if (el && e.message !== \"signed out\") el.innerHTML = '<div class=\"empty bad\">' + esc(e.message) + \"</div>\"; });\n }\n function change(now, before) {\n if (!before) return now ? \"new\" : \"\";\n var c = Math.round((now / before - 1) * 100);\n return '<span class=\"' + (c >= 0 ? \"good\" : \"bad\") + '\">' + (c >= 0 ? \"+\" : \"\") + c + \"%</span>\";\n }\n function peakOf(c) {\n if (!c || !c.counts.length) return null;\n var mx = Math.max.apply(null, c.counts);\n return mx ? { id: c.ids[c.counts.indexOf(mx)], count: mx } : null;\n }\n function topHidden() { return !!store.get(\"system-tracker-hide-top-routes\", false); }\n function fillPeriod(d) {\n var body = document.getElementById(\"pbody\"), pr = document.getElementById(\"prange\");\n if (!body) return;\n if (pr) pr.textContent = d.range + \" · IST\";\n var pk = peakOf(d.chart), unit = d.chart ? { min: \"minute\", hour: \"hour\", day: \"day\", month: \"month\" }[d.chart.bucket] : \"\";\n body.innerHTML = '<div class=\"pkpis\">' +\n '<div><div class=\"l\">Requests</div><div class=\"v tnum\">' + fmt(d.total) + '</div><div class=\"s\">' + (d.previous ? esc(d.previous.label) + \": \" + fmt(d.previous.total) + \" · \" + change(d.total, d.previous.total) : \"&nbsp;\") + \"</div></div>\" +\n (d.errors !== null ? '<div><div class=\"l\">5xx</div><div class=\"v tnum' + (d.errors ? \" bad\" : \"\") + '\">' + fmt(d.errors) + '</div><div class=\"s\">' + pct(d.errors, d.total) + \" of requests</div></div>\" : \"\") +\n '<div><div class=\"l\">Routes</div><div class=\"v tnum\">' + fmt(d.routeCount) + '</div><div class=\"s\">' + d.types.length + (d.types.length === 1 ? \" type\" : \" types\") + \"</div></div>\" +\n '<div><div class=\"l\">Busiest ' + unit + '</div><div class=\"v tnum\">' + (pk ? fmt(pk.count) : \"—\") + '</div><div class=\"s trunc\">' + (pk ? esc(fullTime(pk.id)) : \"&nbsp;\") + \"</div></div></div>\" +\n '<div class=\"pgrid\"><div><div class=\"ctitle\">Requests per ' + (unit || \"bucket\") + '</div><div id=\"p-chart\"></div></div><div><div class=\"ctitle\">By type</div><div id=\"p-types\" style=\"padding:8px 4px\"></div></div></div>' +\n '<div style=\"border-top:1px solid var(--line2)\"><button class=\"fold' + (topHidden() ? \"\" : \" open\") + '\" data-act=\"ptop\" aria-expanded=\"' + !topHidden() + '\"><span class=\"ctitle\" style=\"padding:0\">Top routes · ' + esc(d.label.toLowerCase()) + (d.routeCount > 10 ? \" · the export has all \" + fmt(d.routeCount) : \"\") + \"</span><span class=\\\"more\\\">\" + (topHidden() ? \"Show\" : \"Hide\") + CHEVRON + '</span></button><div id=\"p-routes\"' + (topHidden() ? \" hidden\" : \"\") + \"></div></div>\";\n if (d.chart) plot(\"p-chart\", { kind: \"bars\", ids: d.chart.ids, h: 190, series: [{ label: \"Requests\", color: \"var(--bar)\", values: d.chart.counts }] });\n else document.getElementById(\"p-chart\").innerHTML = '<div class=\"empty\">The chart needs the overall total (global.total) and the ' + esc(d.period === \"today\" ? \"hour\" : d.period === \"week\" || d.period === \"month\" ? \"day\" : \"month\") + \" bucket.</div>\";\n var types = d.types.filter(function (t) { return !hiddenType(t.type) && t.type !== \"OUT\"; }), out = d.types.filter(function (t) { return t.type === \"OUT\" && !hiddenType(\"OUT\"); })[0];\n donut(\"p-types\", types.map(function (t) { return { label: t.type, value: t.total, color: color(t.type), go: typeHref(t.type) }; }), \"\", \"requests\");\n if (out) document.getElementById(\"p-types\").insertAdjacentHTML(\"beforeend\", '<div class=\"hint\" style=\"margin:6px 12px 0\"><a class=\"link\" href=\"' + typeHref(\"OUT\") + '\">Outgoing calls (OUT): ' + fmt(out.total) + \"</a>, calls your app made to other services. They aren\\'t requests to your app, so they\\'re not part of the total.</div>\");\n var rows = d.routes.filter(function (x) { return !hiddenType(x.type); }).slice(0, 10);\n document.getElementById(\"p-routes\").innerHTML = rows.length ? '<table class=\"tbl fit\"><tr><th>Route</th><th style=\"width:104px\">Requests</th>' + (d.failures ? '<th style=\"width:124px\">5xx</th>' : \"\") + '<th class=\"opt2\" style=\"width:76px\">Share</th></tr>' + rows.map(function (x) {\n return '<tr data-go=\"' + esc(routeHref(x.type, x.action, x.name)) + '\"><td class=\"name trunc\"><span class=\"tp mono\" style=\"margin-right:8px\"><i style=\"background:' + color(x.type) + '\"></i></span>' + method(x.action) + '<span class=\"mono\">' + esc(x.name) + '</span></td><td class=\"tnum\">' + fmt(x.total) + \"</td>\" +\n (d.failures ? '<td class=\"tnum' + (x.errors ? \" bad\" : \"\") + '\">' + (x.errors ? fmt(x.errors) + \" · \" + pct(x.errors, x.total || 1) : \"0\") + \"</td>\" : \"\") + '<td class=\"tnum opt2\">' + (x.type === \"OUT\" ? '<span class=\"muted\">outgoing</span>' : pct(x.total, d.total || 1)) + \"</td></tr>\";\n }).join(\"\") + \"</table>\" : '<div class=\"empty\">Nothing counted ' + esc(d.label.toLowerCase()) + \" yet.</div>\";\n }\n\n // Export: pick a period, then a format. Every export is saved in Redis, so it can be downloaded again.\n function openExport(mode) {\n var view = mode === \"view\" ? screenReport() : null;\n if (mode === \"view\" && !view) { toast(\"Wait for the numbers to load, then export.\"); return; }\n state.xp = { mode: mode || \"period\", view: view, period: null, format: \"csv\", list: null, busy: false };\n drawExport();\n api(\"/api/reports\").then(function (d) { if (state.xp) { state.xp.list = d.reports; state.xp.key = d.key; drawExport(); } }).catch(function () {});\n }\n function drawExport() {\n var x = state.xp, fm = [[\"csv\", \"CSV\", \"Excel, Sheets\"], [\"pdf\", \"PDF\", \"Print view\"], [\"json\", \"JSON\", \"Raw data\"]];\n var hist = x.list === null ? '<div class=\"empty\">Loading…</div>' : x.list.length ? '<div class=\"hist\">' + x.list.map(function (m) {\n return '<div><span style=\"min-width:0;flex:1\"><b class=\"trunc\" style=\"display:block\">' + esc(m.label) + ' <span class=\"muted\" style=\"font-weight:400\">' + esc(m.range) + '</span></b><span class=\"muted\" style=\"font-size:12px\">' + esc(new Date(m.createdAt).toLocaleString()) + \" · \" + (m.kind === \"view\" ? \"screen\" + (m.total !== null && m.total !== undefined ? \" · \" + fmt(m.total) + \" requests\" : \"\") : fmt(m.total) + \" requests\" + (m.errors ? \" · \" + fmt(m.errors) + \" 5xx\" : \"\")) + \"</span></span>\" +\n fm.map(function (f) { return '<button class=\"btn\" style=\"height:26px;padding:0 8px;font-size:12px\" data-act=\"xp-get\" data-id=\"' + esc(m.id) + '\" data-f=\"' + f[0] + '\">' + f[1] + \"</button>\"; }).join(\"\") +\n '<button class=\"ibtn sm\" data-act=\"xp-del\" data-id=\"' + esc(m.id) + '\" title=\"Delete this saved report\">' + ICON.trash + \"</button></div>\";\n }).join(\"\") + \"</div>\" : '<div class=\"muted\" style=\"font-size:13px\">No reports yet. Each one you export is saved here.</div>';\n var v = x.view, ready = x.mode === \"view\" ? !!v : !!x.period;\n var what = x.mode === \"view\" ? '<h4>1 · What</h4><div class=\"xpwhat\"><b>' + esc(v.title) + \"</b>\" + (v.range ? ' <span class=\"muted\">· ' + esc(v.range) + \"</span>\" : \"\") +\n '<div class=\"hint\" style=\"margin-top:4px\">What this screen shows: ' + [v.kpis.length ? \"the numbers at the top\" : \"\", v.charts.length ? v.charts.length + (v.charts.length === 1 ? \" chart\" : \" charts\") : \"\", v.tables.length ? v.tables.length + (v.tables.length === 1 ? \" table\" : \" tables\") : \"\"].filter(Boolean).join(\", \") + \". \" +\n (v.screen === \"board\" || v.screen === \"template\" ? \"Each chart keeps its own range.\" : v.screen === \"settings\" ? \"\" : \"To export another range, pick it on the page first.\") + (v.screen === \"route\" ? \" Recent requests and failures are left out, since they can hold tokens.\" : \"\") + \"</div></div>\"\n : \"<h4>1 · Period</h4><div class=\\\"pick\\\">\" + Object.keys(PERIOD_NAMES).map(function (k) { return '<button data-act=\"xp-period\" data-p=\"' + k + '\" class=\"' + (x.period === k ? \"on\" : \"\") + '\"><b>' + PERIOD_NAMES[k] + \"</b></button>\"; }).join(\"\") + \"</div>\";\n modal('<div class=\"modal sm\" style=\"width:min(680px,100%)\"><div class=\"mh\"><b>' + (x.mode === \"view\" ? \"Export this screen\" : \"Export report\") + '</b><button class=\"ibtn\" data-act=\"modal-close\" title=\"Close\">✕</button></div><div class=\"mb\">' + what +\n '<h4>2 · Format</h4><div class=\"pick\">' + fm.map(function (f) { return '<button data-act=\"xp-format\" data-f=\"' + f[0] + '\" class=\"' + (x.format === f[0] ? \"on\" : \"\") + '\"><b>' + f[1] + \"</b><span>\" + f[2] + \"</span></button>\"; }).join(\"\") + \"</div>\" +\n \"<h4>Saved reports</h4>\" + hist + (x.key ? '<div class=\"hint\">Kept in your Redis at <span class=\"mono\">' + esc(x.key) + \"</span> (the newest 100).</div>\" : \"\") +\n '</div><div class=\"mf\"><span class=\"hint\" style=\"margin:0 auto 0 0\">' + (ready ? \"\" : \"Pick a period to export.\") + '</span><button class=\"btn ghost\" data-act=\"modal-close\">Cancel</button><button class=\"btn p\" data-act=\"xp-go\"' + (ready && !x.busy ? \"\" : \" disabled\") + \">\" + ICON.download + (x.busy ? \"Exporting…\" : \"Export\") + \"</button></div></div>\");\n }\n function doExport() {\n var x = state.xp;\n if (!x || x.busy || (x.mode === \"view\" ? !x.view : !x.period)) return;\n x.busy = true; drawExport();\n var body = x.mode === \"view\" ? { view: x.view, format: x.format } : { period: x.period, format: x.format };\n api(\"/api/reports\", { method: \"POST\", body: JSON.stringify(body) }).then(function (d) {\n deliver(d.report, x.format);\n log(\"Report saved in Redis at \" + (x.key || \"system-tracker:reports:\" + state.me.prefix) + \" (\" + d.meta.id + \").\");\n toast(x.format === \"pdf\" ? \"Report saved. Choose \\\"Save as PDF\\\" in the print dialog.\" : \"Report saved and downloaded.\");\n x.busy = false; x.period = null;\n return api(\"/api/reports\").then(function (l) { if (state.xp) { x.list = l.reports; drawExport(); } });\n }).catch(function (e) { x.busy = false; if (state.xp) drawExport(); toast(\"Could not export: \" + e.message); });\n }\n function deliver(rep, format) {\n var view = rep.kind === \"view\", day = String(rep.generatedAt).slice(0, 10);\n var name = \"system-tracker-\" + rep.prefix + \"-\" + (view ? String(rep.title).toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\").slice(0, 60) || \"screen\" : rep.period) + \"-\" + day;\n if (format === \"pdf\") return view ? printView(rep) : printReport(rep);\n if (format === \"json\") return download(name + \".json\", JSON.stringify(rep, null, 2), \"application/json\");\n download(name + \".csv\", \"\\uFEFF\" + (view ? csvOfView(rep) : csvOf(rep)), \"text/csv;charset=utf-8\");\n }\n\n // ---------- exporting a screen ----------\n // What the current screen shows, as numbers, charts (time series) and tables, built from the data\n // already on the page. The server checks its shape and keeps a copy with the other reports.\n function rangeOf(ids, bucket, when) {\n return cap(when) + \", \" + PER[bucket] + (ids && ids.length ? \" · \" + fullTime(ids[0]) + \" – \" + fullTime(ids[ids.length - 1]) : \"\");\n }\n function headNums(d, first) {\n var b = d.chart.bucket, n = d.now, unit = BUCKET_NAME[b];\n return [\n { label: first + \" · \" + whenOf(d), value: n.total },\n { label: cap(SLOT_NOW[b]), value: n.current, note: n.previous !== null ? PREV_LABEL[b] + \": \" + n.previous : \"\" },\n { label: \"Average per \" + unit, value: Math.round(n.average * 10) / 10, note: \"complete \" + unit + \"s only\" },\n { label: \"Busiest \" + unit, value: n.peak.count, note: n.peak.id ? fullTime(n.peak.id) : \"\" }\n ];\n }\n function screenReport() {\n var r = route(), d = state.data, ready = d && state.dataFor === JSON.stringify(r);\n if (r.view === \"overview\") {\n if (!ready) return null;\n var rows = d.types.filter(function (x) { return !hiddenType(x.type); }), incoming = rows.filter(function (x) { return x.type !== \"OUT\"; }), tot = sum(incoming.map(function (x) { return x.total; })) || 1;\n return { screen: \"overview\", title: \"Overview\", range: rangeOf(d.chart.ids, d.chart.bucket, whenOf(d)), total: d.now.total, kpis: headNums(d, \"Requests\"),\n charts: [{ title: \"Traffic by type\", ids: d.chart.ids, series: incoming.length ? incoming.map(function (x) { return { label: x.type, values: x.chart }; }) : [{ label: \"All traffic\", values: d.chart.counts }] }],\n tables: [\n { title: \"By type\", columns: [\"Type\", cap(SLOT_NOW[d.chart.bucket]), cap(whenOf(d)), \"Share\"], rows: rows.map(function (x) { return [x.type, x.now, x.total, x.type === \"OUT\" ? \"outgoing\" + (x.own !== undefined ? \": \" + x.own + \" your backend, \" + x.thirdParty + \" third-party\" : \"\") : pct(x.total, tot)]; }) },\n { title: \"Top routes\", columns: [\"Type\", \"Method\", \"Route\", cap(SLOT_NOW[d.chart.bucket]), cap(whenOf(d))].concat(d.failures ? [\"5xx\"] : []).concat([\"Share\"]), rows: d.top.filter(function (x) { return !hiddenType(x.type); }).map(function (x) { return [x.type, x.action, x.name, x.now, x.total].concat(d.failures ? [x.errors || 0] : []).concat([x.type === \"OUT\" ? destText(x) : (x.share * 100).toFixed(1) + \"%\"]); }) }\n ] };\n }\n if (r.view === \"type\") {\n if (!ready) return null;\n var out = r.type === \"OUT\", k = headNums(d, r.type);\n if (d.failures) k.push({ label: \"5xx · \" + whenOf(d), value: d.now.errors, note: pct(d.now.errors || 0, d.now.total || 1) });\n return { screen: \"type\", title: r.type, range: rangeOf(d.chart.ids, d.chart.bucket, whenOf(d)), total: d.now.total, kpis: k,\n charts: [{ title: r.type + \" traffic\", ids: d.chart.ids, series: [{ label: r.type, values: d.chart.counts }] }],\n tables: [{ title: \"Routes\", columns: [\"Method\", \"Route\", cap(SLOT_NOW[d.chart.bucket]), cap(whenOf(d))].concat(d.failures ? [\"5xx\"] : []).concat(out ? [\"Destination\"] : []),\n rows: d.routes.map(function (x) { return [x.action, x.name, x.now, x.total].concat(d.failures ? [x.errors || 0] : []).concat(out ? [destText(x)] : []); }) }] };\n }\n if (r.view === \"route\") {\n if (!ready) return null;\n var c = d.chart, total = sum(c.counts), done = c.counts.slice(0, -1), peak = Math.max.apply(null, c.counts), errs = c.errors ? sum(c.errors) : null, when = RANGE_WHEN[state.range] || RANGE_WHEN[c.bucket];\n var kp = [{ label: \"Total in range\", value: total }, { label: \"Average\", value: done.length ? Math.round(sum(done) / done.length * 10) / 10 : 0, note: PER[c.bucket] }, { label: \"Peak\", value: peak, note: fullTime(c.ids[c.counts.indexOf(peak)]) }, { label: \"Lowest\", value: done.length ? Math.min.apply(null, done) : 0, note: \"complete buckets only\" }];\n if (errs !== null) kp.push({ label: \"Failure rate\", value: pct(errs, total), note: errs + \" responses with 5xx\" });\n var ser = [{ label: \"Requests\", values: c.counts }];\n if (c.errors) ser.push({ label: \"5xx\", values: c.errors });\n return { screen: \"route\", title: r.type + \" \" + r.action + \" \" + r.name, range: rangeOf(c.ids, c.bucket, when), total: total, kpis: kp,\n charts: [{ title: CHART_TITLE[c.bucket], ids: c.ids, series: ser }],\n tables: [\n { title: \"Now, by bucket\", columns: [\"Bucket\", \"Now\", \"Kept for\"], rows: d.tiles.map(function (t) { return [SLOT_LABEL[t.bucket] + \" (\" + SLOT_NOW[t.bucket] + \")\", t.current, expiry(t.expiry)]; }) },\n { title: \"Buckets, newest first\", columns: [\"Time (IST)\", \"Requests\"].concat(c.errors ? [\"5xx\"] : []), rows: c.ids.slice().reverse().map(function (id, i) { var j = c.ids.length - 1 - i; return [fullTime(id), c.counts[j]].concat(c.errors ? [c.errors[j]] : []); }) }\n ] };\n }\n if (r.view === \"board\" || r.view === \"template\") {\n var b = boardFor(r);\n if (!b) return null;\n var charts = [], tables = [];\n b.widgets.forEach(function (w) {\n var wd = state.wdata[w.id];\n if (!wd) return;\n if (w.chart === \"insights\") { tables.push({ title: w.title, columns: [\"Severity\", \"Problem\", \"Value\"], rows: (wd.items || []).map(function (it) { return [it.severity, it.title, it.value]; }) }); return; }\n var metric = w.metric || \"count\";\n if (w.chart === \"donut\" || w.chart === \"table\") {\n tables.push({ title: w.title + \" · \" + RANGE_WHEN[w.range], columns: [\"Name\", \"Requests\"].concat(wd.failures ? [\"5xx\"] : []), rows: (wd.breakdown || []).map(function (x) { return [x.label, x.total].concat(wd.failures ? [x.errors || 0] : []); }).sort(function (a, c2) { return c2[1] - a[1]; }) });\n return;\n }\n charts.push({ title: w.title, unit: metric === \"rate\" ? \"%\" : \"\", ids: wd.series[0].ids, series: wd.series.map(function (sr) { return { label: sr.label, values: metricOf(sr, metric).map(function (v) { return Math.round(v * 100) / 100; }) }; }) });\n });\n if (!charts.length && !tables.length && b.widgets.length) return null;\n return { screen: r.view, title: (r.view === \"template\" ? \"Template: \" : r.landing ? \"Overview: \" : \"Board: \") + b.name, range: \"Each chart has its own range\", kpis: [], charts: charts, tables: tables };\n }\n if (r.view === \"settings\") {\n var st = state.storage, cfg = state.cfg, tb = [];\n if (!st && !cfg) return null;\n var kps = [];\n if (st) {\n kps.push({ label: \"Redis used\", value: bytes(st.redis.used), note: st.redis.limit ? \"of \" + limitText(st.redis) : \"\" }, { label: \"Redis keys\", value: st.redis.keys });\n if (st.tracker) {\n kps.push({ label: \"Tracker\", value: st.tracker.bytes !== null ? \"≈\" + bytes(st.tracker.bytes) : \"—\", note: st.tracker.keys + \" keys\" }, { label: \"Other\", value: st.other && st.other.bytes !== null ? \"≈\" + bytes(st.other.bytes) : \"—\", note: \"other apps and Redis overhead\" });\n tb.push({ title: \"Storage in Redis (counted \" + agoText(st.tracker.at) + \")\", columns: [\"Keys\", \"Count\", \"Size (bytes, approx.)\", \"Kept for\"], rows: ST_GROUPS.filter(function (g) { return st.tracker.groups[g[0]].keys || st.tracker.expiry[g[0]] !== undefined; }).map(function (g) { var x = st.tracker.groups[g[0]], kept = st.tracker.expiry[g[0]]; return [g[1], x.keys, x.bytes, kept ? expiry(kept) : \"\"]; }) });\n }\n }\n if (cfg) {\n var rs = cfg.resolved;\n tb.push({ title: \"What's tracked\", columns: [\"Type\", \"Tracked\"], rows: Object.keys(rs.track).map(function (t) { return [t, rs.track[t] ? \"yes\" : \"no\"]; }) });\n tb.push({ title: \"Time buckets\", columns: [\"Bucket\", \"On\", \"Kept for\"], rows: BUCKETS.map(function (b2) { return [SLOT_LABEL[b2], rs.timeConfig[b2].enable ? \"yes\" : \"no\", expiry(rs.timeConfig[b2].expiry_in_sec)]; }) });\n }\n return { screen: \"settings\", title: \"Settings and storage\", range: \"\", kpis: kps, charts: [], tables: tb };\n }\n return null;\n }\n function csvOfView(r) {\n var out = [], row = function () { out.push([].slice.call(arguments).map(cell).join(\",\")); }, line = function (a) { out.push(a.map(cell).join(\",\")); };\n row(\"system-tracker export\"); row(\"Screen\", r.title); if (r.range) row(\"Range\", r.range + \" (IST)\"); row(\"Prefix\", r.prefix); row(\"Generated\", new Date(r.generatedAt).toLocaleString());\n if (r.kpis.length) { row(); row(\"Numbers\"); row(\"Name\", \"Value\", \"Note\"); r.kpis.forEach(function (k) { row(k.label, k.value, k.note); }); }\n r.charts.forEach(function (c) {\n row(); row(c.title + (c.unit ? \" (\" + c.unit + \")\" : \"\")); line([\"Time (IST)\"].concat(c.series.map(function (s) { return s.label; })));\n c.ids.forEach(function (id, i) { line([fullTime(id)].concat(c.series.map(function (s) { return s.values[i]; }))); });\n });\n r.tables.forEach(function (t) { row(); row(t.title); line(t.columns); t.rows.forEach(line); });\n return out.join(\"\\r\\n\") + \"\\r\\n\";\n }\n function printView(r) {\n var el = document.getElementById(\"print\");\n if (!el) { el = document.createElement(\"div\"); el.id = \"print\"; document.body.appendChild(el); }\n var val = function (v) { return typeof v === \"number\" ? fmt(v) : v === null || v === undefined ? \"—\" : esc(v); };\n var charts = r.charts.map(function (c) {\n var n = c.ids.length, W = 680, H = 160, totals = c.ids.map(function (_, i) { return sum(c.series.map(function (s) { return s.values[i] || 0; })); });\n // series on top of each other, except a route's 5xx, which are part of its requests\n var stacked = !(c.series.length === 2 && c.series[1].label === \"5xx\"), mx = Math.max.apply(null, (stacked ? totals : c.series[0].values).concat([1])), st = W / Math.max(n, 1), svg = \"\";\n c.ids.forEach(function (_, i) {\n var base = 0;\n c.series.forEach(function (s, j) {\n var v = s.values[i] || 0, h = v / mx * (H - 20), y = H - 16 - (stacked ? base : 0) - h;\n if (h > 0) svg += '<rect x=\"' + (i * st + st * 0.15).toFixed(1) + '\" y=\"' + y.toFixed(1) + '\" width=\"' + Math.max(1, st * 0.7).toFixed(1) + '\" height=\"' + h.toFixed(1) + '\" fill=\"' + (s.label === \"5xx\" ? \"#d4433b\" : PALETTE[j % PALETTE.length]) + '\"/>';\n if (stacked) base += h;\n });\n });\n if (n) [0, Math.floor((n - 1) / 2), n - 1].forEach(function (i) { svg += '<text x=\"' + (i * st + st / 2).toFixed(1) + '\" y=\"' + (H - 3) + '\" font-size=\"9\" text-anchor=\"middle\" fill=\"#555\">' + esc(axisLabel(c.ids[i])) + \"</text>\"; });\n var legend = c.series.length > 1 ? '<div class=\"sub\">' + c.series.map(function (s, j) { return '<span style=\"display:inline-block;width:9px;height:9px;margin:0 4px 0 10px;background:' + (s.label === \"5xx\" ? \"#d4433b\" : PALETTE[j % PALETTE.length]) + '\"></span>' + esc(s.label); }).join(\"\") + \"</div>\" : \"\";\n return \"<h2>\" + esc(c.title) + (c.unit ? \" (\" + esc(c.unit) + \")\" : \"\") + \"</h2>\" + legend + '<svg width=\"100%\" viewBox=\"0 0 ' + W + \" \" + H + '\">' + svg + \"</svg>\";\n }).join(\"\");\n var tables = r.tables.map(function (t) {\n return \"<h2>\" + esc(t.title) + \"</h2><table><tr>\" + t.columns.map(function (c, i) { return \"<th\" + (i ? \"\" : ' class=\"l\"') + \">\" + esc(c) + \"</th>\"; }).join(\"\") + \"</tr>\" +\n t.rows.map(function (rw) { return \"<tr>\" + rw.map(function (v, i) { return \"<td\" + (typeof v === \"number\" ? \"\" : ' class=\"l\"') + \">\" + val(v) + \"</td>\"; }).join(\"\") + \"</tr>\"; }).join(\"\") + \"</table>\";\n }).join(\"\");\n el.innerHTML = \"<h1>\" + esc(r.title) + '</h1><div class=\"sub\">' + (r.range ? esc(r.range) + \" (IST) · \" : \"\") + \"prefix \" + esc(r.prefix) + \" · generated \" + esc(new Date(r.generatedAt).toLocaleString()) + \"</div>\" +\n (r.kpis.length ? '<div class=\"kp\" style=\"flex-wrap:wrap\">' + r.kpis.map(function (k) { return \"<div>\" + esc(k.label) + \"<b>\" + val(k.value) + \"</b>\" + (k.note ? '<span class=\"sub\">' + esc(k.note) + \"</span>\" : \"\") + \"</div>\"; }).join(\"\") + \"</div>\" : \"\") + charts + tables;\n var done = function () { el.innerHTML = \"\"; removeEventListener(\"afterprint\", done); };\n addEventListener(\"afterprint\", done);\n setTimeout(function () { window.print(); }, 50);\n }\n function download(name, text, type) {\n var a = document.createElement(\"a\"), url = URL.createObjectURL(new Blob([text], { type: type }));\n a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();\n setTimeout(function () { URL.revokeObjectURL(url); }, 1000);\n }\n // route names come from requests; a leading = + - @ would run as a formula in a spreadsheet\n function cell(v) {\n var s = v === null || v === undefined ? \"\" : String(v);\n if (/^[=+\\-@\\t\\r]/.test(s)) s = \"'\" + s;\n return /[\",\\r\\n]/.test(s) ? '\"' + s.replace(/\"/g, '\"\"') + '\"' : s;\n }\n function csvOf(r) {\n var out = [], row = function () { out.push([].slice.call(arguments).map(cell).join(\",\")); };\n row(\"system-tracker report\"); row(\"Prefix\", r.prefix); row(\"Period\", r.label, r.range + \" (IST)\"); row(\"Generated\", new Date(r.generatedAt).toLocaleString());\n row(\"Requests\", r.total); if (r.errors !== null) row(\"5xx\", r.errors, pct(r.errors, r.total)); if (r.previous) row(r.previous.label, r.previous.total);\n if (r.chart) { row(); row(\"Requests over time\"); row(\"Time (IST)\", \"Requests\"); r.chart.ids.forEach(function (id, i) { row(fullTime(id), r.chart.counts[i]); }); }\n row(); row(\"By type\"); row(\"Type\", \"Requests\", \"5xx\"); r.types.forEach(function (t) { row(t.type, t.total, t.errors); });\n row(); row(r.routes.length < r.routeCount ? \"Routes (the busiest \" + r.routes.length + \" of \" + r.routeCount + \")\" : \"Routes\"); row(\"Type\", \"Method\", \"Route\", \"Requests\", \"5xx\", \"5xx rate\");\n r.routes.forEach(function (x) { row(x.type, x.action, x.name, x.total, x.errors, x.errors !== null ? pct(x.errors, x.total || 1) : \"\"); });\n return out.join(\"\\r\\n\") + \"\\r\\n\";\n }\n function printReport(r) {\n var el = document.getElementById(\"print\");\n if (!el) { el = document.createElement(\"div\"); el.id = \"print\"; document.body.appendChild(el); }\n var bars = \"\";\n if (r.chart && r.chart.counts.length) {\n var n = r.chart.counts.length, W = 680, H = 160, mx = Math.max.apply(null, r.chart.counts) || 1, st = W / n;\n r.chart.counts.forEach(function (v, i) { var h = v / mx * (H - 20); bars += '<rect x=\"' + (i * st + st * 0.15).toFixed(1) + '\" y=\"' + (H - 16 - h).toFixed(1) + '\" width=\"' + Math.max(1, st * 0.7).toFixed(1) + '\" height=\"' + h.toFixed(1) + '\" fill=\"#4c8ef5\"/>'; });\n [0, Math.floor((n - 1) / 2), n - 1].forEach(function (i) { bars += '<text x=\"' + (i * st + st / 2).toFixed(1) + '\" y=\"' + (H - 3) + '\" font-size=\"9\" text-anchor=\"middle\" fill=\"#555\">' + esc(axisLabel(r.chart.ids[i])) + \"</text>\"; });\n bars = '<h2>Requests over time</h2><svg width=\"100%\" viewBox=\"0 0 ' + W + \" \" + H + '\">' + bars + \"</svg>\";\n }\n el.innerHTML = '<h1>system-tracker report: ' + esc(r.label) + '</h1><div class=\"sub\">' + esc(r.range) + \" (IST) · prefix \" + esc(r.prefix) + \" · generated \" + esc(new Date(r.generatedAt).toLocaleString()) + \"</div>\" +\n '<div class=\"kp\"><div>Requests<b>' + fmt(r.total) + \"</b></div>\" + (r.errors !== null ? \"<div>5xx<b>\" + fmt(r.errors) + \" (\" + pct(r.errors, r.total) + \")</b></div>\" : \"\") + (r.previous ? \"<div>\" + esc(r.previous.label) + \"<b>\" + fmt(r.previous.total) + \"</b></div>\" : \"\") + \"<div>Routes<b>\" + fmt(r.routeCount) + \"</b></div></div>\" + bars +\n '<h2>By type</h2><table><tr><th>Type</th><th>Requests</th><th>Share</th><th>5xx</th></tr>' + r.types.map(function (t) { return \"<tr><td>\" + esc(t.type) + \"</td><td>\" + fmt(t.total) + \"</td><td>\" + pct(t.total, r.total || 1) + \"</td><td>\" + (t.errors === null ? \"—\" : fmt(t.errors)) + \"</td></tr>\"; }).join(\"\") + \"</table>\" +\n \"<h2>Routes\" + (r.routes.length < r.routeCount ? \" (the busiest \" + r.routes.length + \" of \" + fmt(r.routeCount) + \")\" : \"\") + '</h2><table><tr><th>Type</th><th class=\"l\">Route</th><th>Requests</th><th>5xx</th></tr>' + r.routes.map(function (x) { return \"<tr><td>\" + esc(x.type) + '</td><td class=\"l\">' + esc(x.action + \" \" + x.name) + \"</td><td>\" + fmt(x.total) + \"</td><td>\" + (x.errors === null ? \"—\" : fmt(x.errors)) + \"</td></tr>\"; }).join(\"\") + \"</table>\";\n var done = function () { el.innerHTML = \"\"; removeEventListener(\"afterprint\", done); };\n addEventListener(\"afterprint\", done);\n setTimeout(function () { window.print(); }, 50);\n }\n\n // Reset: what (everything, a type, a route) and when (all time or a period), checked first with a dry run.\n function openReset() {\n var types = trackedTypes();\n state.rs = { what: \"all\", type: types[0] || \"API\", action: \"\", name: \"\", period: \"all\", check: null, busy: false, routes: null, q: \"\", types: types };\n drawReset();\n }\n function drawReset() {\n var x = state.rs, whats = [[\"all\", \"Everything\", \"Every counter of this app\"], [\"type\", \"One type\", \"API, WEB, RPC…\"], [\"route\", \"One route\", \"A single endpoint\"]];\n var whens = [[\"all\", \"All time\"]].concat(Object.keys(PERIOD_NAMES).map(function (k) { return [k, PERIOD_NAMES[k]]; }));\n var src = \"\";\n if (x.what === \"type\") src = '<div class=\"typechips\">' + x.types.map(function (t) { return '<button data-act=\"rs-type\" data-t=\"' + esc(t) + '\" class=\"' + (x.type === t ? \"on\" : \"\") + '\"><i style=\"background:' + color(t) + '\"></i>' + esc(t) + \"</button>\"; }).join(\"\") + \"</div>\";\n if (x.what === \"route\") src = '<div class=\"row2\" style=\"margin-top:10px\"><select id=\"rs-rtype\">' + x.types.map(function (t) { return '<option value=\"' + esc(t) + '\"' + (x.type === t ? \" selected\" : \"\") + \">\" + esc(t) + \"</option>\"; }).join(\"\") + '</select><input class=\"inp\" id=\"rs-search\" placeholder=\"Search routes…\" style=\"flex:1\" value=\"' + esc(x.q) + '\"></div><div class=\"rlist\" id=\"rs-list\"><div class=\"empty\">Loading routes…</div></div>';\n var c = x.check, sumText = !c ? \"Checking what this deletes…\" : c.error ? '<span class=\"bad\">' + esc(c.error) + \"</span>\" : c.deleted + c.lowered ? \"This deletes <b>\" + fmt(c.deleted) + \"</b> counters\" + (c.lowered ? \" and lowers <b>\" + fmt(c.lowered) + \"</b> totals that include them\" : \"\") + \".\" : \"Nothing to delete for this choice.\";\n var ready = c && !c.error && c.deleted + c.lowered > 0 && !x.busy && (x.what !== \"route\" || x.name);\n modal('<div class=\"modal sm\" style=\"width:min(640px,100%)\"><div class=\"mh\"><b>Reset data</b><button class=\"ibtn\" data-act=\"modal-close\" title=\"Close\">✕</button></div><div class=\"mb\">' +\n '<div class=\"danger-box\"><b>These details will be cleared from tracking.</b> They won\\'t be accessible any more and are lost for good. Only system-tracker\\'s own counters under <span class=\"mono\">' + esc(state.me.prefix) + \"</span> are deleted; your boards and saved reports stay.</div>\" +\n '<h4>1 · What</h4><div class=\"pick\">' + whats.map(function (w) { return '<button data-act=\"rs-what\" data-w=\"' + w[0] + '\" class=\"' + (x.what === w[0] ? \"on\" : \"\") + '\"><b>' + w[1] + \"</b><span>\" + w[2] + \"</span></button>\"; }).join(\"\") + \"</div>\" + src +\n '<h4>2 · When</h4><div class=\"seg\" style=\"display:inline-flex;flex-wrap:wrap\">' + whens.map(function (w) { return '<button data-act=\"rs-when\" data-p=\"' + w[0] + '\" class=\"' + (x.period === w[0] ? \"on\" : \"\") + '\">' + w[1] + \"</button>\"; }).join(\"\") + \"</div>\" +\n '<div class=\"hint\" id=\"rs-sum\" style=\"margin-top:14px\">' + sumText + \"</div>\" +\n '</div><div class=\"mf\"><span class=\"hint\" style=\"margin:0 auto 0 0\">Are you sure? This can\\'t be undone.</span><button class=\"btn ghost\" data-act=\"modal-close\">Cancel</button><button class=\"btn d\" data-act=\"rs-go\"' + (ready ? \"\" : \" disabled\") + \">\" + ICON.trash + (x.busy ? \"Deleting…\" : \"Yes, delete\") + \"</button></div></div>\");\n if (x.what === \"route\") showResetRoutes();\n }\n function resetBody(dry) { var x = state.rs; return JSON.stringify({ what: x.what, type: x.type, action: x.action, name: x.name, period: x.period, dryRun: dry }); }\n var rsTimer;\n function checkReset() {\n var x = state.rs;\n x.check = null; drawReset();\n if (x.what === \"route\" && !x.name) { x.check = { deleted: 0, lowered: 0, error: \"Pick a route from the list.\" }; return drawReset(); }\n clearTimeout(rsTimer);\n rsTimer = setTimeout(function () {\n var want = resetBody(true);\n api(\"/api/reset\", { method: \"POST\", body: want }).then(function (d) { if (state.rs && resetBody(true) === want) { state.rs.check = d; drawReset(); } })\n .catch(function (e) { if (state.rs) { state.rs.check = { deleted: 0, lowered: 0, error: e.message }; drawReset(); } });\n }, 200);\n }\n // routes seen this year (or today, when years are off), for the chosen type\n function showResetRoutes() {\n var x = state.rs, el = document.getElementById(\"rs-list\");\n if (!el) return;\n if (!x.routes) {\n x.routes = [];\n api(\"/api/report?period=year\").catch(function () { return api(\"/api/report?period=today\"); }).then(function (d) { if (state.rs) { x.routes = d.routes; x.loaded = true; showResetRoutes(); } }).catch(function () { x.loaded = true; showResetRoutes(); });\n return;\n }\n if (!x.loaded) return;\n var q = x.q.toLowerCase(), rows = x.routes.filter(function (r) { return r.type === x.type && (!q || (r.action + \" \" + r.name).toLowerCase().indexOf(q) !== -1); }).slice(0, 80);\n el.innerHTML = rows.length ? rows.map(function (r) { var on = r.action === x.action && r.name === x.name; return '<button data-act=\"rs-route\" data-a=\"' + esc(r.action) + '\" data-n=\"' + esc(r.name) + '\" class=\"' + (on ? \"on\" : \"\") + '\">' + method(r.action) + '<span class=\"mono trunc\">' + esc(r.name) + '</span><span class=\"tnum\">' + short(r.total) + \"</span></button>\"; }).join(\"\") : '<div class=\"empty\">No ' + esc(x.type) + \" routes counted this year.</div>\";\n }\n function doReset() {\n var x = state.rs;\n if (!x || x.busy) return;\n x.busy = true; drawReset();\n api(\"/api/reset\", { method: \"POST\", body: resetBody(false) }).then(function (d) {\n log(\"Reset: deleted \" + d.deleted + \" counters, lowered \" + d.lowered + \" totals.\");\n toast(\"Deleted \" + fmt(d.deleted) + \" counters.\");\n closeModal(); state.wdata = {}; state.pdata = null; state.pAt = 0; state.side.at = 0;\n load(true); loadSide();\n }).catch(function (e) { x.busy = false; if (state.rs) drawReset(); toast(\"Could not reset: \" + e.message); });\n }\n\n function match(it, f) { return f === \"all\" || it.severity === f || it.category === f; }\n function median(a) { var x = a.slice().sort(function (p, q) { return p - q; }); return x.length ? (x[(x.length - 1) >> 1] + x[x.length >> 1]) / 2 : 0; }\n var UNIT_SHORT = { minute: \"min\", hour: \"h\", day: \"day\", month: \"mo\" };\n function winOf(d) { return d.window || { when: \"in the last 30 minutes\", unit: \"minute\", steps: 30 }; }\n function fillInsights(d) {\n var win = winOf(d);\n var span = \"last \" + win.steps + \" \" + win.unit + \"s\";\n state.insOpen = state.insOpen || {};\n var wh = document.getElementById(\"ins-when\"); if (wh) wh.textContent = \"anything unusual \" + win.when;\n var gk = document.getElementById(\"ins-gk\"); if (gk) gk.textContent = span + \" · dashed line: the usual \" + win.unit;\n var items = d.items.filter(function (i) { return !(i.target.type && hiddenType(i.target.type)); });\n var sev = [\"critical\", \"warning\", \"info\"].map(function (k) { return { label: k[0].toUpperCase() + k.slice(1), value: items.filter(function (i) { return i.severity === k; }).length, color: k === \"critical\" ? \"#f0706a\" : k === \"warning\" ? \"#e3b341\" : \"#6ea8fe\" }; });\n if (items.length) donut(\"ins-sev\", sev, \"\", items.length === 1 ? \"issue\" : \"issues\");\n else document.getElementById(\"ins-sev\").innerHTML = '<div class=\"allclear\">' + ICON.check + \"All clear</div>\";\n var gmax = Math.max.apply(null, GROUPS.map(function (g) { return items.filter(function (i) { return i.category === g[0]; }).length; }).concat([1]));\n document.getElementById(\"ins-groups\").innerHTML = '<div class=\"gbars\">' + GROUPS.map(function (g) {\n var list = items.filter(function (i) { return i.category === g[0]; }), worst = list.some(function (i) { return i.severity === \"critical\"; }) ? \"critical\" : list.some(function (i) { return i.severity === \"warning\"; }) ? \"warning\" : \"info\";\n return '<button data-act=\"insf\" data-f=\"' + g[0] + '\" class=\"' + (state.insFilter === g[0] ? \"on\" : \"\") + '\" data-tip=\"' + esc(list.length ? list.map(function (i) { return \"• \" + i.title; }).join(\"\\n\") : \"Nothing in this group\") + '\"><span>' + g[1] + '</span><i style=\"width:' + (list.length / gmax * 100) + \"%;background:\" + (list.length ? SEV_COLOR[worst] : \"var(--track)\") + '\"></i><b>' + list.length + \"</b></button>\";\n }).join(\"\") + \"</div>\";\n if (d.global) {\n var done = d.global.counts.slice(0, -1);\n var usual = d.global.usual !== undefined ? d.global.usual : median(done.slice(-31, -1));\n plot(\"ins-g\", { kind: \"area\", ids: d.global.ids, h: 150, series: [{ label: \"All traffic\", color: \"var(--bar)\", values: d.global.counts }], ref: usual, refLabel: \"usual \" + fmt(usual) + \"/\" + UNIT_SHORT[win.unit] });\n }\n document.getElementById(\"chips\").innerHTML = CATS.map(function (c) {\n var n = items.filter(function (i) { return match(i, c[0]); }).length;\n return '<button data-act=\"insf\" data-f=\"' + c[0] + '\" class=\"' + (state.insFilter === c[0] ? \"on\" : \"\") + '\">' + c[1] + \" <b>\" + n + \"</b></button>\";\n }).join(\"\");\n var list = items.filter(function (i) { return match(i, state.insFilter); });\n var up = document.getElementById(\"updated\");\n if (up) up.textContent = \"checked \" + d.checked + \" routes · IST \" + state.updated;\n paint(\"ins\", function () { insList(d, items, list); });\n }\n function insList(d, items, list) {\n var win = winOf(d), just = state.insJust; state.insJust = null; // only a card you just opened slides in\n document.getElementById(\"ins\").innerHTML = list.length ? list.map(function (it) {\n var i = d.items.indexOf(it), key = insKey(it), open = !!state.insOpen[key], col = SEV_COLOR[it.severity], t = it.target;\n var where = t.view === \"route\" ? \"Open \" + t.action + \" \" + t.name : t.view === \"type\" ? \"Open \" + t.type : \"Open the overview\";\n return '<div class=\"ins ' + it.severity + (open ? \" open\" + (key === just ? \" opening\" : \"\") : \"\") + '\"><button class=\"ins-h\" data-act=\"ins-toggle\" data-k=\"' + esc(key) + '\" data-tip=\"' + esc(it.detail + (open ? \"\" : \"\\n\\nClick to see the chart and details.\")) + '\"><span class=\"bar\"></span><span style=\"min-width:0\"><span class=\"tag\">' + it.severity + \" · \" + CAT_LABEL[it.category] + (it.from ? \" · \" + esc(it.from) : \"\") + '</span><div class=\"t trunc\">' + esc(it.title) + '</div><div class=\"d trunc\">' + esc(it.detail) + \"</div></span><span>\" + (it.series ? spark(170, 34, it.series.counts, col, it.series.ids, \"Calls\") : \"\") + '</span><span class=\"val tnum\">' + esc(it.value) + '</span><span class=\"more\">' + (open ? \"Hide\" : \"Details\") + CHEVRON + \"</span></button>\" +\n '<div class=\"ins-b\">' + (open ? '<div><div class=\"ctitle\">' + (it.series ? \"Last \" + it.series.ids.length + \" \" + (it.unit || win.unit) + \"s\" : \"\") + (it.baseline !== undefined ? \" · dashed line: the usual \" + (it.unit || win.unit) : \"\") + (it.series && it.series.errors && sum(it.series.errors) ? \" · 5xx in red\" : \"\") + '</div><div id=\"ic-' + i + '\"></div></div>' +\n '<div class=\"side\"><div class=\"facts\">' + (it.facts || []).map(function (f) { return '<div class=\"fact\"><div class=\"l\">' + esc(f.label) + '</div><div class=\"v tnum\">' + esc(f.value) + \"</div></div>\"; }).join(\"\") + \"</div>\" +\n \"<div><h5>What to check</h5><ul>\" + (ADVICE[it.category] || []).map(function (a) { return \"<li>\" + esc(a) + \"</li>\"; }).join(\"\") + \"</ul></div>\" +\n '<div class=\"row2\"><button class=\"btn p\" data-ins=\"' + i + '\">' + esc(where) + ' →</button><button class=\"btn\" data-act=\"ins-watch\" data-i=\"' + i + '\" data-tip=\"Adds a chart for this to your Watchlist board, so you can keep an eye on it\">' + ICON.plus + \"Watchlist</button></div></div>\" : \"\") + \"</div></div>\";\n }).join(\"\") : '<div class=\"card\"><div class=\"allclear\">' + ICON.check + (items.length ? \"Nothing in this group.\" : \"All clear: nothing unusual \" + esc(win.when) + \".\") + \"</div></div>\";\n list.forEach(function (it) {\n var i = d.items.indexOf(it);\n if (!state.insOpen[insKey(it)]) return;\n if (it.series) plot(\"ic-\" + i, { kind: \"bars\", ids: it.series.ids, h: 220, series: [{ label: it.target.view === \"route\" ? it.target.action + \" \" + it.target.name : \"Calls\", color: \"var(--bar)\", values: it.series.counts, errors: it.series.errors }], ref: it.baseline, refLabel: it.baseline !== undefined ? \"usual \" + fmt(it.baseline) + \"/\" + UNIT_SHORT[it.unit || win.unit] : \"\" });\n else { var el = document.getElementById(\"ic-\" + i); if (el) el.innerHTML = '<div class=\"empty\">No minute-by-minute chart for this one. Open it to see its time slots.</div>'; }\n });\n }\n // Adds a chart for an insight to the Watchlist board, creating the board the first time.\n function shownInsights() {\n var d = route().view === \"insights\" ? viewData() : null;\n return d ? d.items : state.side.insights ? state.side.insights.items : [];\n }\n function watchInsight(i) {\n var it = shownInsights()[i], t = it && it.target;\n if (!it) return;\n var b = state.boards.boards.filter(function (x) { return x.name === \"Watchlist\"; })[0];\n if (!b) { b = { id: uid(), name: \"Watchlist\", widgets: [] }; state.boards.boards.push(b); }\n var src = t.view === \"route\" ? { kind: \"route\", type: t.type, action: t.action, name: t.name } : t.view === \"type\" ? { kind: \"type\", type: t.type } : { kind: \"global\" };\n var title = (t.view === \"route\" ? t.action + \" \" + t.name : t.view === \"type\" ? t.type : \"All traffic\") + (it.category === \"errors\" ? \", 5xx \" : \", \") + PER[t.range];\n if (b.widgets.some(function (w) { return w.title === title; })) return toast('\"' + title + '\" is already on your Watchlist.');\n b.widgets.push({ id: uid(), title: title, chart: \"bars\", range: t.range, size: \"M\", metric: it.category === \"errors\" && src.kind === \"route\" ? \"errors\" : \"count\", series: [src] });\n saveBoards('Added \"' + title + '\" to your Watchlist board.');\n }\n function openInsight(i) {\n var it = shownInsights()[i];\n if (!it) return;\n state.range = it.target.range;\n location.hash = it.target.view === \"route\" ? routeHref(it.target.type, it.target.action, it.target.name) : it.target.view === \"type\" ? typeHref(it.target.type) : \"#/overview\";\n if (it.target.view === \"overview\") load(true);\n }\n\n // ---------- settings ----------\n var EXPIRY = [[3600, \"1 hour\"], [6 * 3600, \"6 hours\"], [86400, \"1 day\"], [7 * 86400, \"7 days\"], [30 * 86400, \"30 days\"], [90 * 86400, \"90 days\"], [180 * 86400, \"180 days\"], [365 * 86400, \"1 year\"], [5 * 365 * 86400, \"5 years\"], [10 * 365 * 86400, \"10 years\"]];\n function settingsShell() {\n return '<div class=\"crumbs\"><b>Settings</b><span class=\"meta\"></span>' + exportBtn() + '</div><div id=\"setnote\"></div><div class=\"card\" id=\"storage\"></div><div class=\"set-grid\"><div id=\"setform\"></div><div id=\"setcode\"></div></div>';\n }\n function tg(path, on, disabled) { return '<label class=\"tgl\"><input type=\"checkbox\" data-set=\"' + path + '\"' + (on ? \" checked\" : \"\") + (disabled ? \" disabled\" : \"\") + \"><span></span></label>\"; }\n function row(path, title, sub, control) { return '<div class=\"set-row' + (changes().indexOf(path) !== -1 ? \" changed\" : \"\") + '\"><div class=\"txt\"><b>' + title + \"</b><span>\" + sub + \"</span></div>\" + control + \"</div>\"; }\n function fillSettings() {\n if (!state.cfg) return;\n var d = draft(), n = changes();\n document.getElementById(\"setnote\").innerHTML = n.length ?\n '<div class=\"notice\"><div><b>Preview only: ' + n.length + (n.length === 1 ? \" change\" : \" changes\") + \".</b> This changes only what you see in this dashboard (types you turned off are hidden). Your app keeps tracking with its current settings. To apply them, update <span class=\\\"mono\\\">statis.init()</span> with the highlighted lines and restart your app.</div><button class=\\\"btn\\\" data-act=\\\"set-reset\\\" style=\\\"margin-left:auto\\\">Discard changes</button></div>\" :\n '<div class=\"notice info\"><div><b>These are your app\\'s current settings.</b> Change anything to preview it here and get the <span class=\"mono\">statis.init()</span> code to apply it. Nothing changes in your app until you update that code.</div></div>';\n var cs = state.cfg.resolved.customTypes;\n var types = Object.keys(d.track).map(function (t) { return row(\"track.\" + t, '<span class=\"tp mono\"><i style=\"background:' + color(t) + '\"></i>' + t + \"</span>\", esc(TYPE_INFO[t] || \"\"), tg(\"track.\" + t, d.track[t])); }).join(\"\");\n var buckets = BUCKETS.map(function (b) {\n var tc = d.timeConfig[b], opts = EXPIRY.slice();\n if (!opts.some(function (o) { return o[0] === tc.expiry_in_sec; })) opts.push([tc.expiry_in_sec, expiry(tc.expiry_in_sec)]);\n var changed = changes().indexOf(\"timeConfig.\" + b + \".enable\") !== -1 || changes().indexOf(\"timeConfig.\" + b + \".expiry_in_sec\") !== -1;\n return '<div class=\"set-row' + (changed ? \" changed\" : \"\") + '\"><div class=\"txt\"><b>' + SLOT_LABEL[b] + \"</b><span>\" + CHART_TITLE[b] + \" · key ends in :\" + b + ':{id}</span></div><select data-set=\"timeConfig.' + b + '.expiry_in_sec\"' + (tc.enable ? \"\" : \" disabled\") + \">\" + opts.map(function (o) { return '<option value=\"' + o[0] + '\"' + (o[0] === tc.expiry_in_sec ? \" selected\" : \"\") + \">keep \" + o[1] + \"</option>\"; }).join(\"\") + \"</select>\" + tg(\"timeConfig.\" + b + \".enable\", tc.enable) + \"</div>\";\n }).join(\"\");\n document.getElementById(\"setform\").innerHTML =\n '<div class=\"card\"><div class=\"ch\"><b>What to track</b><span class=\"k\">track</span></div>' + types + (cs.length ? '<div class=\"set-row\"><div class=\"txt\"><b>Custom types</b><span>' + cs.map(esc).join(\", \") + \" · set with the types and operations options</span></div></div>\" : \"\") + \"</div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Totals and failures</b><span class=\"k\">global · failures</span></div>' +\n row(\"global.total\", \"Overall total\", \"One {prefix}:global counter for every request\", tg(\"global.total\", d.global.total)) +\n row(\"global.perType\", \"Per-type totals\", \"A {prefix}:{TYPE}:global counter for each type\", tg(\"global.perType\", d.global.perType)) +\n row(\"failures\", \"Failures (5xx)\", \"An extra :5xx counter for responses with status 500 or higher\", tg(\"failures\", d.failures)) + \"</div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Skip requests</b><span class=\"k\">ignore</span></div>' +\n row(\"ignore.bots\", \"Bots and crawlers\", \"Skip search engines and crawlers\", tg(\"ignore.bots\", d.ignore.bots)) +\n row(\"ignore.methods\", \"Methods\", \"Requests with these methods aren't counted\", '<span class=\"row2\">' + [\"OPTIONS\", \"HEAD\"].map(function (m) { return '<label style=\"display:flex;gap:6px;align-items:center;font-size:13px\"><input type=\"checkbox\" data-set=\"ignore.methods\" value=\"' + m + '\"' + (d.ignore.methods.indexOf(m) !== -1 ? \" checked\" : \"\") + \">\" + m + \"</label>\"; }).join(\"\") + \"</span>\") +\n '<div class=\"set-row' + (changes().indexOf(\"ignore.paths\") !== -1 ? \" changed\" : \"\") + '\" style=\"flex-wrap:wrap\"><div class=\"txt\"><b>Paths</b><span>End a path with * to skip everything below it, like /health or /admin/*</span></div><input class=\"inp mono\" id=\"addpath\" placeholder=\"/health\" style=\"width:180px\"><button class=\"btn\" data-act=\"addpath\">Add</button>' +\n '<div class=\"paths\" style=\"width:100%;padding:8px 0 0\">' + (d.ignore.paths.length ? d.ignore.paths.map(function (p, i) { return '<span class=\"mono\">' + esc(p) + '<button class=\"ibtn sm\" data-act=\"delpath\" data-i=\"' + i + '\" title=\"Remove\">✕</button></span>'; }).join(\"\") : '<span class=\"muted\" style=\"border:0;background:none\">No paths skipped</span>') + \"</div></div></div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Time buckets</b><span class=\"k\">timeConfig</span></div>' + buckets + \"</div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Limits</b><span class=\"k\">limits</span></div>' +\n row(\"limits.maxNamesPerType\", \"Names per type\", \"More names than this are counted as &lt;other&gt;\", '<input class=\"inp tnum\" type=\"number\" min=\"1\" max=\"100000\" data-set=\"limits.maxNamesPerType\" value=\"' + d.limits.maxNamesPerType + '\" style=\"width:110px\">') +\n row(\"limits.collapseAfter\", \"Collapse a URL part after\", \"A part with more distinct values becomes &lt;slug&gt;\", '<input class=\"inp tnum\" type=\"number\" min=\"1\" max=\"100000\" data-set=\"limits.collapseAfter\" value=\"' + d.limits.collapseAfter + '\" style=\"width:110px\">') + \"</div>\";\n fillCode();\n }\n function setPath(d, path, value) {\n var p = path.split(\".\"), o = d;\n for (var i = 0; i < p.length - 1; i++) o = o[p[i]];\n o[p[p.length - 1]] = value;\n }\n function saveDraft(d) {\n store.set(draftKey(), d);\n if (!changes().length) store.del(draftKey());\n fillSettings();\n app.querySelector(\".top\").outerHTML = topBar().replace(/<div class=\"banner\">[\\s\\S]*$/, \"\");\n }\n\n // The init() call as written (secrets come masked from the server), with the preview applied.\n var ORDER = [\"redis\", \"prefix\", \"timeConfig\", \"ignore\", \"track\", \"global\", \"failures\", \"types\", \"operations\", \"limits\", \"ownDomains\", \"name\", \"enabled\", \"dashboard\"];\n function applyDraft(opts, d, r, def) {\n var o = clone(opts);\n function put(obj, key, val, dflt) { if (JSON.stringify(val) === JSON.stringify(dflt)) delete obj[key]; else obj[key] = val; }\n function child(key) { if (!o[key] || typeof o[key] !== \"object\" || Array.isArray(o[key])) o[key] = {}; return o[key]; }\n function tidy(key) { if (o[key] && typeof o[key] === \"object\" && !Object.keys(o[key]).length) delete o[key]; }\n Object.keys(d.track).forEach(function (t) { if (d.track[t] !== r.track[t]) put(child(\"track\"), t, d.track[t], def.track[t]); });\n tidy(\"track\");\n [\"total\", \"perType\"].forEach(function (k) { if (d.global[k] !== r.global[k]) put(child(\"global\"), k, d.global[k], true); });\n tidy(\"global\");\n if (d.failures !== r.failures) put(o, \"failures\", d.failures, false);\n if (d.ignore.bots !== r.ignore.bots) put(child(\"ignore\"), \"bots\", d.ignore.bots, false);\n if (d.ignore.methods.join() !== r.ignore.methods.join()) put(child(\"ignore\"), \"methods\", d.ignore.methods, def.ignore.methods);\n if (d.ignore.paths.join(\"\\n\") !== r.ignore.paths.join(\"\\n\")) put(child(\"ignore\"), \"paths\", d.ignore.paths, []);\n tidy(\"ignore\");\n BUCKETS.forEach(function (b) {\n var a = d.timeConfig[b], c = r.timeConfig[b], df = def.timeConfig[b];\n if (a.enable === c.enable && a.expiry_in_sec === c.expiry_in_sec) return;\n var tc = child(\"timeConfig\"), v = {};\n if (a.enable !== df.enable) v.enable = a.enable;\n if (a.enable && a.expiry_in_sec !== df.expiry_in_sec) v.expiry_in_sec = a.expiry_in_sec;\n put(tc, b, v, {});\n });\n tidy(\"timeConfig\");\n [\"maxNamesPerType\", \"collapseAfter\"].forEach(function (k) { if (d.limits[k] !== r.limits[k]) put(child(\"limits\"), k, d.limits[k], def.limits[k]); });\n tidy(\"limits\");\n // keep the user's order; a new key goes before the first existing key that follows it in ORDER\n var keys = Object.keys(opts);\n ORDER.forEach(function (k) {\n if (keys.indexOf(k) !== -1 || !(k in o)) return;\n var at = keys.findIndex(function (x) { return ORDER.indexOf(x) > ORDER.indexOf(k); });\n if (at === -1) keys.push(k); else keys.splice(at, 0, k);\n });\n var out = {};\n keys.forEach(function (k) { if (k in o) out[k] = o[k]; });\n Object.keys(o).forEach(function (k) { if (!(k in out)) out[k] = o[k]; });\n return out;\n }\n function jsKey(k) { return /^[A-Za-z_$][\\w$]*$/.test(k) ? k : JSON.stringify(k); }\n function inline(v) {\n if (v && v.$mask) return '\"••••••\"';\n if (v && v.$code) return v.$code;\n if (Array.isArray(v)) return \"[\" + v.map(inline).join(\", \") + \"]\";\n if (v && typeof v === \"object\") { var ks = Object.keys(v); return ks.length ? \"{ \" + ks.map(function (k) { return jsKey(k) + \": \" + inline(v[k]); }).join(\", \") + \" }\" : \"{}\"; }\n return JSON.stringify(v);\n }\n function codeLines(v, indent, key, out, tail) {\n var pad = new Array(indent + 1).join(\" \"), head = pad + (key === null ? \"\" : jsKey(key) + \": \");\n var one = inline(v);\n if (!v || typeof v !== \"object\" || v.$mask || v.$code || (head + one).length <= 64) { out.push(head + one + tail); return; }\n var arr = Array.isArray(v);\n out.push(head + (arr ? \"[\" : \"{\"));\n (arr ? v : Object.keys(v)).forEach(function (x, i) { codeLines(arr ? x : v[x], indent + 1, arr ? null : x, out, \",\"); });\n out.push(pad + (arr ? \"]\" : \"}\") + tail);\n }\n function codeOf(opts) { var out = [\"statis.init({\"]; Object.keys(opts).forEach(function (k) { codeLines(opts[k], 1, k, out, \",\"); }); out.push(\"});\"); return out; }\n // Line diff (LCS): kept, added and removed lines, in order.\n function diff(a, b) {\n var n = a.length, m = b.length, dp = [];\n for (var i = 0; i <= n; i++) { dp.push(new Array(m + 1).fill(0)); }\n for (i = n - 1; i >= 0; i--) for (var j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);\n var out = []; i = 0; j = 0;\n while (i < n && j < m) {\n if (a[i] === b[j]) { out.push([\" \", b[j]]); i++; j++; }\n else if (dp[i + 1][j] >= dp[i][j + 1]) out.push([\"-\", a[i++]]);\n else out.push([\"+\", b[j++]]);\n }\n while (i < n) out.push([\"-\", a[i++]]);\n while (j < m) out.push([\"+\", b[j++]]);\n return out;\n }\n function hlCode(line) {\n return esc(line)\n .replace(/(&quot;(?:[^&]|&(?!quot;))*?&quot;)/g, '<span class=\"k-s\">$1</span>')\n .replace(/\\b(true|false|null)\\b/g, '<span class=\"k-k\">$1</span>')\n .replace(/^(\\s*)([A-Za-z_$][\\w$]*)(:)/, '$1<span class=\"k-p\">$2</span>$3')\n .replace(/(:\\s)(\\d[\\d_]*)/g, '$1<span class=\"k-n\">$2</span>')\n .replace(/(••••••)/g, '<span class=\"k-m\">$1</span>');\n }\n function fillCode() {\n var host = document.getElementById(\"setcode\");\n if (!host || !state.cfg) return;\n var c = state.cfg, d = draft(), before = codeOf(c.options), after = codeOf(applyDraft(c.options, d, c.resolved, c.defaults));\n var lines = diff(before, after), adds = lines.filter(function (l) { return l[0] === \"+\"; }).length, dels = lines.filter(function (l) { return l[0] === \"-\"; }).length, k = 0;\n state.codeText = after.join(\"\\n\");\n host.innerHTML = '<div class=\"card\" style=\"position:sticky;top:70px\"><div class=\"ch\"><b>Your statis.init()</b><span class=\"k\">' + (adds || dels ? '<span class=\"good\">+' + adds + '</span> <span class=\"bad\">−' + dels + \"</span> lines\" : \"no changes yet\") + '</span><span class=\"right\"><button class=\"btn\" data-act=\"copycode\">' + ICON.copy + \"Copy code</button></span></div>\" +\n '<div class=\"code mono\">' + lines.map(function (l) {\n var cls = l[0] === \"+\" ? \"add\" : l[0] === \"-\" ? \"del\" : \"\", g = l[0] === \"-\" ? \"\" : ++k;\n return '<div class=\"ln ' + cls + '\"><span class=\"g\">' + g + '</span><span class=\"s\">' + (l[0] === \" \" ? \"\" : l[0]) + \"</span><span>\" + hlCode(l[1]) + \"</span></div>\";\n }).join(\"\") + '</div><div class=\"hint\" style=\"padding:10px 16px 14px\">Hidden values (<span class=\"k-m\">••••••</span>) are your Redis and dashboard credentials. They never leave your server, so keep your real values when you copy this.</div></div>';\n }\n\n // ---------- boards ----------\n // Boards are saved in Redis (one key per prefix), so restarts and redeploys keep them.\n function boardById(id) { return state.boards.boards.filter(function (b) { return b.id === id; })[0]; }\n function saveBoards(msg) {\n var doc = { boards: state.boards.boards, defaultId: state.boards.defaultId };\n return api(\"/api/boards\", { method: \"PUT\", body: JSON.stringify(doc) }).then(function () {\n if (msg) { log(msg + \" Saved in Redis at \" + state.boards.key + \" (\" + doc.boards.length + (doc.boards.length === 1 ? \" board\" : \" boards\") + \").\"); toast(msg); }\n }).catch(function (e) { toast(\"Could not save: \" + e.message); });\n }\n function loadBoards() {\n return api(\"/api/boards\").then(function (doc) {\n state.boards = doc;\n log(\"Loaded \" + doc.boards.length + (doc.boards.length === 1 ? \" board\" : \" boards\") + \" from Redis (\" + doc.key + \").\");\n }).catch(function () {});\n }\n\n // ---------- board layout ----------\n // Charts sit on a 4-column grid, sized in width x height units (one row is 170px). Each kind of chart\n // has a smallest and largest size so nothing gets cramped: [min width, max width, min height, max height].\n var LIMITS = { number: [1, 2, 1, 2], line: [1, 4, 1, 3], area: [1, 4, 1, 3], bars: [1, 4, 1, 3], stack: [2, 4, 1, 3], donut: [2, 4, 2, 3], table: [2, 4, 2, 4], insights: [2, 4, 1, 3] };\n var LEGACY = { S: [1, 1], M: [2, 2], L: [4, 2] };\n var ROW_H = 170, GAP = 14;\n function dims(w) {\n var l = LIMITS[w.chart] || LIMITS.line, x = w.w, y = w.h;\n if (!x || !y) { var g = LEGACY[w.size] || [2, 2]; x = g[0]; y = w.chart === \"number\" ? 1 : g[1]; }\n return { w: Math.max(l[0], Math.min(l[1], x)), h: Math.max(l[2], Math.min(l[3], y)) };\n }\n\n // Default templates, one per role or use. They are built in code, so editing one always saves a copy.\n // Charts that need types the app doesn't track, or failures: true, are left out.\n var TPL_CACHE = {};\n function templates() {\n var f = !!(state.cfg && state.cfg.resolved.failures);\n function w(title, chart, range, size, series, metric) { return { title: title, chart: chart, range: range, w: size[0], h: size[1], metric: metric || \"count\", series: series }; }\n var G = { kind: \"global\" };\n function T(t) { return { kind: \"type\", type: t }; }\n function P(t, a, n) { return { kind: \"pattern\", type: t, action: a || \"*\", name: n || \"*\" }; }\n var TYPES = [\"API\", \"WEB\", \"GRAPHQL\", \"RPC\", \"WSS\"].map(T);\n var list = [\n { key: \"everyday\", name: \"Everyday overview\", role: \"Default\", about: \"The day at a glance: what's unusual, totals, the mix by type and the busiest routes.\", widgets: [\n w(\"Insights\", \"insights\", \"min\", [4, 1], [G]),\n w(\"Requests per second\", \"number\", \"sec\", [1, 1], [G]), w(\"This minute\", \"number\", \"min\", [1, 1], [G]), w(\"This hour\", \"number\", \"hour\", [1, 1], [G]), w(\"Today\", \"number\", \"day\", [1, 1], [G]),\n w(\"Traffic by type, last hour\", \"stack\", \"min\", [4, 2], TYPES),\n w(\"Mix by type, last 30 days\", \"donut\", \"day\", [2, 2], [G]), w(\"Busiest routes, last 24 hours\", \"table\", \"hour\", [2, 2], [P(\"*\")]),\n w(\"All traffic, last 30 days\", \"area\", \"day\", [4, 2], [G])] },\n { key: \"devops\", name: \"DevOps / SRE\", role: \"DevOps\", about: \"Live load, failures and failure rate, spikes and the routes that fail most.\", widgets: [\n w(\"Requests per second\", \"number\", \"sec\", [1, 1], [G]), w(\"5xx this minute\", \"number\", \"min\", [1, 1], [P(\"*\")], \"errors\"), w(\"Failure rate this hour\", \"number\", \"hour\", [1, 1], [P(\"*\")], \"rate\"), w(\"This minute\", \"number\", \"min\", [1, 1], [G]),\n w(\"Requests per second, last minute\", \"bars\", \"sec\", [4, 2], [G]),\n w(\"Insights\", \"insights\", \"min\", [2, 2], [G]), w(\"Failure rate, last hour\", \"line\", \"min\", [2, 2], [P(\"*\")], \"rate\"),\n w(\"Traffic by type per minute\", \"stack\", \"min\", [2, 2], TYPES), w(\"Routes with 5xx, last 24 hours\", \"table\", \"hour\", [2, 2], [P(\"*\")], \"errors\"),\n w(\"All traffic, last 24 hours\", \"area\", \"hour\", [4, 2], [G])] },\n { key: \"product\", name: \"Product owner\", role: \"Product\", about: \"Usage over days and months, the features people use most, and page views next to API calls.\", widgets: [\n w(\"Requests today\", \"number\", \"day\", [1, 1], [G]), w(\"Page views today\", \"number\", \"day\", [1, 1], [T(\"WEB\")]), w(\"API calls today\", \"number\", \"day\", [1, 1], [T(\"API\")]), w(\"This month\", \"number\", \"month\", [1, 1], [G]),\n w(\"Daily traffic, last 30 days\", \"area\", \"day\", [4, 2], [G]),\n w(\"Most used features, last 30 days\", \"donut\", \"day\", [2, 2], [T(\"API\")]), w(\"Most viewed pages, last 30 days\", \"table\", \"day\", [2, 2], [T(\"WEB\")]),\n w(\"Page views vs API calls, last 30 days\", \"line\", \"day\", [2, 2], [T(\"WEB\"), T(\"API\")]), w(\"Monthly traffic, last 12 months\", \"bars\", \"month\", [2, 2], [G])] },\n { key: \"developer\", name: \"Backend developer\", role: \"Developer\", about: \"Every API route with its 5xx, calls by HTTP method, and GraphQL and RPC operations.\", widgets: [\n w(\"API this minute\", \"number\", \"min\", [1, 1], [T(\"API\")]), w(\"API 5xx today\", \"number\", \"day\", [1, 1], [P(\"API\")], \"errors\"), w(\"GraphQL this minute\", \"number\", \"min\", [1, 1], [T(\"GRAPHQL\")]), w(\"RPC this minute\", \"number\", \"min\", [1, 1], [T(\"RPC\")]),\n w(\"API calls by method, last hour\", \"stack\", \"min\", [4, 2], [P(\"API\", \"GET\"), P(\"API\", \"POST\"), P(\"API\", \"PUT\"), P(\"API\", \"PATCH\"), P(\"API\", \"DELETE\")]),\n w(\"API routes, last 24 hours\", \"table\", \"hour\", [2, 3], [T(\"API\")]), w(\"Methods, last 24 hours\", \"donut\", \"hour\", [2, 2], [P(\"API\", \"GET\"), P(\"API\", \"POST\"), P(\"API\", \"PUT\"), P(\"API\", \"PATCH\"), P(\"API\", \"DELETE\")]),\n w(\"GraphQL operations, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"GRAPHQL\")]), w(\"RPC methods, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"RPC\")])] },\n { key: \"qa\", name: \"QA & testing\", role: \"QA\", about: \"Failures while you test: 5xx by minute and route, failure rate and anything new or unusual.\", widgets: [\n w(\"5xx this minute\", \"number\", \"min\", [1, 1], [P(\"*\")], \"errors\"), w(\"5xx today\", \"number\", \"day\", [1, 1], [P(\"*\")], \"errors\"), w(\"Failure rate today\", \"number\", \"day\", [1, 1], [P(\"*\")], \"rate\"), w(\"Requests this minute\", \"number\", \"min\", [1, 1], [G]),\n w(\"5xx per minute, last hour\", \"bars\", \"min\", [2, 2], [P(\"*\")], \"errors\"), w(\"Failure rate per hour, last 24 hours\", \"line\", \"hour\", [2, 2], [P(\"*\")], \"rate\"),\n w(\"Routes with 5xx, last 24 hours\", \"table\", \"hour\", [4, 2], [P(\"*\")], \"errors\"), w(\"Insights\", \"insights\", \"min\", [4, 1], [G])] },\n { key: \"api\", name: \"API health\", role: \"API team\", about: \"API calls, failures and failure rate, route by route.\", widgets: [\n w(\"API calls per minute\", \"line\", \"min\", [2, 2], [T(\"API\")]), w(\"API 5xx per minute\", \"bars\", \"min\", [2, 2], [P(\"API\")], \"errors\"),\n w(\"API routes, last 30 days\", \"table\", \"day\", [4, 2], [T(\"API\")]), w(\"API failure rate, last 24 hours\", \"line\", \"hour\", [2, 2], [P(\"API\")], \"rate\"),\n w(\"API routes, last 24 hours\", \"donut\", \"hour\", [2, 2], [T(\"API\")]), w(\"API calls, last 30 days\", \"area\", \"day\", [4, 2], [T(\"API\")])] },\n { key: \"live\", name: \"Live wall\", role: \"Operations\", about: \"For a big screen next to Live refresh: per-second traffic, this minute and what's unusual.\", widgets: [\n w(\"Requests per second\", \"bars\", \"sec\", [4, 2], [G]),\n w(\"This second\", \"number\", \"sec\", [1, 1], [G]), w(\"This minute\", \"number\", \"min\", [1, 1], [G]), w(\"API this minute\", \"number\", \"min\", [1, 1], [T(\"API\")]), w(\"Page views this minute\", \"number\", \"min\", [1, 1], [T(\"WEB\")]),\n w(\"Types per second\", \"stack\", \"sec\", [4, 2], TYPES), w(\"Insights\", \"insights\", \"min\", [4, 1], [G])] },\n { key: \"capacity\", name: \"Capacity planning\", role: \"Engineering lead\", about: \"Long-range growth: hours, days, months and years, and how the mix changes.\", widgets: [\n w(\"Today\", \"number\", \"day\", [1, 1], [G]), w(\"This month\", \"number\", \"month\", [1, 1], [G]), w(\"This year\", \"number\", \"year\", [1, 1], [G]), w(\"This hour\", \"number\", \"hour\", [1, 1], [G]),\n w(\"Requests per month, last 12 months\", \"area\", \"month\", [4, 2], [G]),\n w(\"Requests per day, last 30 days\", \"line\", \"day\", [2, 2], [G]), w(\"Requests per hour, last 24 hours\", \"bars\", \"hour\", [2, 2], [G]),\n w(\"Mix by type, last 12 months\", \"donut\", \"month\", [2, 2], [G]), w(\"Requests per year\", \"bars\", \"year\", [2, 2], [G])] },\n { key: \"web\", name: \"Web & pages\", role: \"Frontend\", about: \"Page views next to the API calls they trigger, top pages and the daily trend.\", widgets: [\n w(\"Page views this minute\", \"number\", \"min\", [1, 1], [T(\"WEB\")]), w(\"Page views today\", \"number\", \"day\", [1, 1], [T(\"WEB\")]), w(\"API calls this minute\", \"number\", \"min\", [1, 1], [T(\"API\")]), w(\"API calls today\", \"number\", \"day\", [1, 1], [T(\"API\")]),\n w(\"Page views vs API calls, last hour\", \"line\", \"min\", [4, 2], [T(\"WEB\"), T(\"API\")]),\n w(\"Top pages, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"WEB\")]), w(\"Pages, last 30 days\", \"donut\", \"day\", [2, 2], [T(\"WEB\")]),\n w(\"Page views, last 30 days\", \"area\", \"day\", [4, 2], [T(\"WEB\")])] },\n { key: \"integrations\", name: \"Integrations & realtime\", role: \"Platform\", about: \"Calls to other services by host, WebSocket and event streams, GraphQL and RPC together.\", widgets: [\n w(\"Outgoing this minute\", \"number\", \"min\", [1, 1], [T(\"OUT\")]), w(\"WebSocket this minute\", \"number\", \"min\", [1, 1], [T(\"WSS\")]), w(\"RPC this minute\", \"number\", \"min\", [1, 1], [T(\"RPC\")]), w(\"GraphQL this minute\", \"number\", \"min\", [1, 1], [T(\"GRAPHQL\")]),\n w(\"Realtime and RPC per minute\", \"stack\", \"min\", [4, 2], [T(\"WSS\"), T(\"SSE\"), T(\"GRPC\"), T(\"RPC\"), T(\"GRAPHQL\")]),\n w(\"Outgoing calls by host, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"OUT\")]), w(\"Outgoing calls per minute\", \"line\", \"min\", [2, 2], [T(\"OUT\")]),\n w(\"RPC methods, last 24 hours\", \"donut\", \"hour\", [2, 2], [T(\"RPC\")]), w(\"GraphQL operations, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"GRAPHQL\")])] }\n ];\n list.forEach(function (t) {\n var needs = {};\n t.widgets = t.widgets.map(function (x) {\n if (x.metric !== \"count\" && !f) { needs[\"failures: true\"] = 1; return null; }\n x.series = x.series.filter(function (s) { var ok = !s.type || s.type === \"*\" || tracked(s.type); if (!ok) needs[s.type] = 1; return ok; });\n return x.series.length ? x : null;\n }).filter(Boolean);\n t.needs = Object.keys(needs);\n t.available = t.widgets.length >= 3;\n });\n return list;\n }\n // A template as a board, built once per visit so its charts keep their ids while it's on screen.\n function tplBoard(key) {\n if (!TPL_CACHE[key]) {\n var t = templates().filter(function (x) { return x.key === key; })[0];\n if (!t) return null;\n TPL_CACHE[key] = { id: \"tpl-\" + key, name: t.name, template: t, widgets: t.widgets.map(function (x) { x = clone(x); x.id = \"tpl-\" + key + \"-\" + uid(); return x; }) };\n }\n return TPL_CACHE[key];\n }\n function copyOf(b, name) { var c = clone(b); c.id = uid(); c.name = name || b.name; delete c.template; c.widgets.forEach(function (x) { x.id = uid(); }); return c; }\n // The built-in overview as a board, for \"Customize\".\n function overviewBoard() {\n var G = { kind: \"global\" }, types = trackedTypes().filter(function (t) { return t !== \"STATIC\" && t !== \"OUT\"; }).slice(0, 6);\n function w(title, chart, range, size, series) { return { id: uid(), title: title, chart: chart, range: range, w: size[0], h: size[1], metric: \"count\", series: series }; }\n return { id: uid(), name: \"My overview\", widgets: [\n w(\"Insights\", \"insights\", \"min\", [4, 1], [G]),\n w(\"Requests per second\", \"number\", \"sec\", [1, 1], [G]), w(\"This minute\", \"number\", \"min\", [1, 1], [G]), w(\"This hour\", \"number\", \"hour\", [1, 1], [G]), w(\"Today\", \"number\", \"day\", [1, 1], [G]),\n w(\"Traffic by type\", \"stack\", \"min\", [4, 2], types.map(function (t) { return { kind: \"type\", type: t }; })),\n w(\"By type, last 24 hours\", \"table\", \"hour\", [2, 2], [G]), w(\"Top routes, last 24 hours\", \"table\", \"hour\", [2, 2], [{ kind: \"pattern\", type: \"*\", action: \"*\", name: \"*\" }])\n ] };\n }\n function miniPreview(widgets) { return '<div class=\"mini\">' + widgets.slice(0, 8).map(function (w) { var d = dims(w); return '<i class=\"' + (d.w >= 3 ? \"l\" : d.w === 2 ? \"m\" : \"\") + '\"></i>'; }).join(\"\") + \"</div>\"; }\n function boardsView() {\n var bs = state.boards.boards, def = state.boards.defaultId, d = state.draft;\n function card(href, body) { return '<a class=\"bcard\" href=\"' + href + '\" style=\"text-decoration:none;color:inherit\">' + body + \"</a>\"; }\n return '<div class=\"crumbs\"><b>Boards</b></div>' +\n '<div class=\"notice info\"><div><b>Your templates are saved in your Redis</b> at <span class=\"mono\">' + esc(state.boards.key || \"\") + '</span>, so restarts and redeploys keep them, and everyone using this dashboard sees the same ones. Default templates never change: editing one saves your own copy.</div></div>' +\n (d ? '<div class=\"notice\"><div><b>Unsaved changes</b> on \"' + esc(d.board.name) + '\".</div><a class=\"btn\" href=\"#/board/' + encodeURIComponent(d.board.id) + '\" style=\"margin-left:auto;text-decoration:none\">Continue editing</a></div>' : \"\") +\n '<div class=\"h2\" style=\"margin-top:22px\">Your templates</div><div class=\"boards\">' +\n (bs.length ? bs.map(function (b) {\n return card(\"#/board/\" + encodeURIComponent(b.id), '<span class=\"nm\">' + (b.id === def ? '<span class=\"star\" title=\"Opens at the dashboard URL\">' + ICON.star.replace(\"<svg\", '<svg width=\"15\" height=\"15\"') + \"</span>\" : \"\") + '<span class=\"trunc\">' + esc(b.name) + '</span><button class=\"ibtn sm rn\" data-act=\"b-rename\" data-id=\"' + esc(b.id) + '\" title=\"Rename\">' + ICON.edit + '</button></span><span class=\"muted\" style=\"font-size:12.5px\">' + b.widgets.length + (b.widgets.length === 1 ? \" chart\" : \" charts\") + (b.id === def ? \" · default\" : \"\") + \"</span>\" + miniPreview(b.widgets));\n }).join(\"\") : '<div class=\"muted\" style=\"padding:6px 0\">None yet. Open a default template below and use or customize it, start blank, or customize the Overview.</div>') +\n '<button class=\"bcard tpl\" data-act=\"newboard\" data-tpl=\"\"><span class=\"nm\">' + ICON.plus.replace(\"<svg\", '<svg width=\"16\" height=\"16\"') + 'Blank board</span><span class=\"muted\" style=\"font-size:12.5px\">Start empty and add your own charts.</span></button>' +\n '<button class=\"bcard tpl\" data-act=\"customize\"><span class=\"nm\">' + ICON.edit.replace(\"<svg\", '<svg width=\"15\" height=\"15\"') + 'Customize the Overview</span><span class=\"muted\" style=\"font-size:12.5px\">The Overview tab as a board you can rearrange. Saving makes it your default.</span>' + miniPreview(overviewBoard().widgets) + \"</button></div>\" +\n '<div class=\"h2\" style=\"margin-top:26px\">Default templates</div><div class=\"hint\" style=\"margin-top:4px\">Ready-made boards for different roles. Open one to see it with your live data, then use it as it is or customize it.</div><div class=\"boards\">' +\n templates().map(function (t) {\n var body = '<span class=\"role\">' + esc(t.role) + '</span><span class=\"nm\"><span class=\"trunc\">' + esc(t.name) + '</span></span><span class=\"muted\" style=\"font-size:12.5px\">' + esc(t.about) + \"</span>\" +\n (t.available ? '<span class=\"muted\" style=\"font-size:12px\">' + t.widgets.length + \" charts\" + (t.needs.length ? \" · some need \" + esc(t.needs.join(\", \")) : \"\") + \"</span>\" + miniPreview(t.widgets) : '<span class=\"bad\" style=\"font-size:12px\">Needs ' + esc(t.needs.join(\", \")) + \" in statis.init</span>\");\n return t.available ? card(\"#/template/\" + t.key, body) : '<div class=\"bcard off\" data-tip=\"Turn on ' + esc(t.needs.join(\", \")) + ' in statis.init to use this template\">' + body + \"</div>\";\n }).join(\"\") + \"</div>\";\n }\n function templateShell(b) {\n var t = b.template;\n return '<div class=\"crumbs\"><a href=\"#/boards\">Boards</a><span class=\"sep\">›</span><a href=\"#/boards\">Default templates</a><span class=\"sep\">›</span><b class=\"trunc\" style=\"max-width:50vw\">' + esc(b.name) + '</b><span class=\"meta mono\" id=\"updated\"></span>' + exportBtn() + \"</div>\" +\n '<div class=\"notice info\"><div><b>' + esc(t.role) + \" template, with your live data.</b> \" + esc(t.about) + \" Use it as it is, or customize it first; either way you get your own copy and this template stays the same.\" + (t.needs.length ? \" Charts that need \" + esc(t.needs.join(\", \")) + \" are left out.\" : \"\") + \"</div></div>\" +\n '<div class=\"row2\" style=\"margin-top:12px\"><button class=\"btn p\" data-act=\"tpl-use\">' + ICON.plus + 'Use this template</button><button class=\"btn\" data-act=\"tpl-customize\">' + ICON.edit + \"Customize</button></div>\" +\n '<div class=\"wgrid\" id=\"wgrid\">' + b.widgets.map(widgetCard).join(\"\") + \"</div>\";\n }\n\n // ---------- editing a board ----------\n // Changes while editing go to a draft copy; Save writes it to Redis and opens the board, Cancel drops it.\n function currentBoard(id) { return state.draft && state.draft.board.id === id ? state.draft.board : boardById(id); }\n function editingBoard(id) { return !!(state.draft && state.draft.board.id === id); }\n function startEdit(b, meta) {\n meta = meta || {};\n state.draft = { board: clone(b), isNew: !!meta.isNew, makeDefault: !!meta.makeDefault, from: meta.from || \"#/boards\", dirty: !!meta.isNew, template: meta.template || \"\" };\n }\n function markDirty() { if (!state.draft) return; state.draft.dirty = true; var s = document.getElementById(\"savebtn\"); if (s) s.disabled = false; }\n function saveEdit() {\n var d = state.draft, b = d.board, i = state.boards.boards.map(function (x) { return x.id; }).indexOf(b.id);\n if (i === -1) state.boards.boards.push(b); else state.boards.boards[i] = b;\n if (d.makeDefault) state.boards.defaultId = b.id;\n state.draft = null;\n saveBoards('Board \"' + b.name + '\" ' + (i === -1 ? \"created\" : \"saved\") + (d.makeDefault ? \" and set as your default\" : \"\") + \".\").then(function () {\n state.shellKey = \"\";\n var r = route(), to = d.makeDefault ? \"#/overview\" : \"#/board/\" + encodeURIComponent(b.id);\n if ((r.view === \"board\" && r.id === b.id && (r.landing || !d.makeDefault)) || location.hash === to) render(); else location.hash = to;\n });\n }\n function cancelEdit() {\n var d = state.draft;\n state.draft = null;\n log('Changes to \"' + d.board.name + '\" discarded.');\n if (d.isNew) location.hash = d.from; else { state.shellKey = \"\"; render(); }\n }\n function newBoard() {\n var b = { id: uid(), name: \"Board \" + (state.boards.boards.length + 1), widgets: [] };\n startEdit(b, { isNew: true });\n location.hash = \"#/board/\" + b.id;\n }\n function useTemplate(key, customize) {\n var t = tplBoard(key), b = copyOf(t, t.name);\n if (customize) { startEdit(b, { isNew: true, template: t.name, from: \"#/template/\" + key }); location.hash = \"#/board/\" + b.id; return; }\n state.boards.boards.push(b);\n saveBoards('Board \"' + b.name + '\" created from the ' + t.template.role + \" template.\").then(function () { location.hash = \"#/board/\" + b.id; });\n }\n function customizeOverview() {\n var b = overviewBoard();\n startEdit(b, { isNew: true, makeDefault: true, from: \"#/overview\", template: \"the Overview\" });\n location.hash = \"#/board/\" + b.id;\n }\n\n // Renaming saves at once. While the board is being edited, the new name goes into the draft\n // too, so Save board keeps it and Cancel doesn't bring back the old one.\n function renameBoard(id) {\n var b = boardById(id);\n if (!b) return;\n modal('<div class=\"modal sm\"><div class=\"mh\"><b>Rename board</b><button class=\"ibtn\" data-act=\"modal-close\" title=\"Close\">✕</button></div><div class=\"mb\">' +\n '<input class=\"inp\" id=\"rn-name\" maxlength=\"80\" value=\"' + esc(b.name) + '\" aria-label=\"Board name\" style=\"width:100%;font-size:15px;padding:8px 10px\"><div class=\"hint\" id=\"rn-err\" style=\"margin-top:6px\"></div></div>' +\n '<div class=\"mf\"><button class=\"btn ghost\" data-act=\"modal-close\">Cancel</button><button class=\"btn p\" data-act=\"rn-save\" data-id=\"' + esc(id) + '\">Save name</button></div></div>');\n var inp = document.getElementById(\"rn-name\");\n inp.focus(); inp.select();\n }\n function saveRename(id) {\n var b = boardById(id), v = document.getElementById(\"rn-name\").value.trim().slice(0, 80);\n if (!b) return closeModal();\n if (!v) { document.getElementById(\"rn-err\").textContent = \"Give the board a name.\"; return; }\n closeModal();\n if (v === b.name) return;\n var old = b.name;\n b.name = v;\n if (state.draft && state.draft.board.id === id) state.draft.board.name = v;\n saveBoards('Board \"' + old + '\" renamed to \"' + v + '\".').then(function () { state.shellKey = \"\"; render(); });\n }\n\n function boardShell(b) {\n var def = state.boards.defaultId === b.id, ed = editingBoard(b.id), d = state.draft, landing = route().landing;\n var pen = '<button class=\"ibtn sm\" data-act=\"b-rename\" data-id=\"' + esc(b.id) + '\" title=\"Rename this board\">' + ICON.edit + \"</button>\";\n var head = landing ? '<b>Overview</b><span class=\"sep\">·</span><span class=\"namewrap\"><span class=\"muted trunc\" style=\"max-width:50vw\">' + esc(b.name) + \"</span>\" + pen + \"</span>\" : '<a href=\"#/boards\">Boards</a><span class=\"sep\">›</span><span class=\"namewrap\"><b class=\"trunc\" style=\"max-width:60vw\">' + esc(b.name) + \"</b>\" + pen + \"</span>\";\n if (!ed) {\n return '<div class=\"crumbs\">' + head + '<span class=\"meta mono\" id=\"updated\"></span>' + (landing ? resetBtn() : \"\") + exportBtn() + \"</div>\" +\n '<div class=\"row2\" style=\"margin-top:10px\"><button class=\"btn p\" data-act=\"b-edit\">' + ICON.edit + 'Edit board</button>' +\n (def ? '<button class=\"btn on\" data-act=\"b-default\" title=\"This board is your Overview\">' + ICON.star + 'Default board</button><button class=\"btn\" data-act=\"b-undefault\" title=\"Show the built-in Overview again\">Reset to default Overview</button>'\n : '<button class=\"btn\" data-act=\"b-default\" title=\"Show this board on the Overview tab and at the dashboard URL\">' + ICON.star + \"Set as default</button>\") +\n '<span class=\"grow\"></span><button class=\"btn ghost\" data-act=\"b-dup\">' + ICON.copy + 'Duplicate</button><button class=\"btn ghost danger\" data-act=\"b-del\">' + ICON.trash + \"Delete</button></div>\" +\n (def ? '<div class=\"hint\">This board is your Overview: it opens on the Overview tab and at the dashboard URL.</div>' : \"\") +\n '<div class=\"wgrid\" id=\"wgrid\">' + b.widgets.map(widgetCard).join(\"\") + \"</div>\" +\n (b.widgets.length ? \"\" : '<div class=\"card\"><div class=\"empty\">This board is empty. <button class=\"link\" data-act=\"b-edit\">Edit it</button> to add charts.</div></div>');\n }\n return '<div class=\"crumbs\">' + (landing ? \"<b>Overview</b>\" : '<a href=\"#/boards\">Boards</a>') + '<span class=\"sep\">›</span><label class=\"namewrap\" title=\"Board name\"><input class=\"namein\" id=\"bname\" maxlength=\"80\" value=\"' + esc(b.name) + '\" aria-label=\"Board name\"><span class=\"ibtn sm\">' + ICON.edit + '</span></label><span class=\"meta mono\" id=\"updated\"></span></div>' +\n '<div class=\"notice info\"><div><b>Editing' + (d.isNew ? \" a new board\" + (d.template ? \" from \" + esc(d.template) : \"\") : \"\") + '.</b> Drag a chart to move it (the others make room and keep their sizes), drag any edge or corner to resize it (sizes snap to the grid, from 1×1 up), and use its buttons to edit, duplicate or remove it. Nothing is saved until you click Save' + (d.makeDefault ? \", which also makes this board your default\" : \"\") + \".</div></div>\" +\n '<div class=\"row2\" style=\"margin-top:12px\"><button class=\"btn\" data-act=\"w-add\">' + ICON.plus + 'Add chart</button><span class=\"grow\"></span><button class=\"btn ghost\" data-act=\"b-cancel\">Cancel</button><button class=\"btn p\" id=\"savebtn\" data-act=\"b-save\"' + (d.dirty ? \"\" : \" disabled\") + \">Save board</button></div>\" +\n '<div class=\"wgrid editing\" id=\"wgrid\">' + b.widgets.map(widgetCard).join(\"\") + \"</div>\" +\n (b.widgets.length ? \"\" : '<div class=\"card\"><div class=\"empty\">No charts yet. <button class=\"link\" data-act=\"w-add\">Add your first chart</button>.</div></div>');\n }\n function widgetCard(w) {\n var ed = state.draft && route().view === \"board\" && editingBoard(route().id), d = dims(w), id = esc(w.id);\n return '<div class=\"w\" data-w=\"' + id + '\" data-cw=\"' + d.w + '\" data-ch=\"' + d.h + '\"><div class=\"wh\"><b class=\"trunc\">' + esc(w.title) + \"</b>\" +\n (ed ? '<span class=\"tools\"><button class=\"ibtn sm\" data-act=\"w-edit\" data-w=\"' + id + '\" title=\"Edit\">' + ICON.edit + '</button><button class=\"ibtn sm\" data-act=\"w-dup\" data-w=\"' + id + '\" title=\"Duplicate\">' + ICON.copy + '</button><button class=\"ibtn sm\" data-act=\"w-del\" data-w=\"' + id + '\" title=\"Remove\">' + ICON.trash + \"</button></span>\" : \"\") +\n '</div><div class=\"wk mono trunc\">' + esc(sourceText(w)) + '</div><div class=\"wb\" id=\"wb-' + id + '\"><div class=\"empty\">Loading…</div></div>' +\n (ed ? '<span class=\"dims\">' + d.w + \"×\" + d.h + \"</span>\" + [\"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\", \"nw\"].map(function (k) { return '<span class=\"rsz\" data-dir=\"' + k + '\" title=\"Drag to resize\"></span>'; }).join(\"\") : \"\") + \"</div>\";\n }\n function sourceText(w) {\n if (w.chart === \"insights\") return \"found automatically · refreshed with the page\";\n return w.series.map(function (s) { return keyOf(s); }).join(\" · \") + \" · \" + CHART_TITLE[w.range].toLowerCase() + (w.metric === \"errors\" ? \" · 5xx\" : w.metric === \"rate\" ? \" · failure rate\" : \"\");\n }\n function keyOf(s) {\n var p = (state.me && state.me.prefix) || \"{prefix}\";\n if (s.kind === \"global\") return p + \":global\";\n if (s.kind === \"type\") return p + \":\" + s.type + \":global\";\n return p + \":\" + s.type + \":\" + s.action + \":\" + s.name;\n }\n function labelOf(s) { return s.kind === \"global\" ? \"All traffic\" : s.kind === \"type\" ? s.type : s.kind === \"route\" ? s.action + \" \" + s.name : s.type + \":\" + s.action + \":\" + s.name; }\n function queryWidget(w) {\n if (w.chart === \"insights\") return api(\"/api/insights\").then(function (d) { state.side.insights = d; paintBadge(); return d; });\n return api(\"/api/query?q=\" + encodeURIComponent(JSON.stringify({ range: w.range, series: w.series, breakdown: w.chart === \"donut\" || w.chart === \"table\" })));\n }\n function boardFor(r) { return r.view === \"template\" ? tplBoard(r.key) : r.view === \"board\" ? currentBoard(r.id) : null; }\n function loadBoard(r, seq, timed) {\n var b = boardFor(r);\n if (!b) return Promise.resolve();\n state.updated = istNow();\n var up = document.getElementById(\"updated\");\n return Promise.all(b.widgets.map(function (w) {\n return queryWidget(w).then(function (d) {\n if (seq !== state.seq) return;\n state.wdata[w.id] = d;\n polled = timed;\n try { drawWidget(w, d, \"wb-\" + w.id); } finally { polled = false; }\n })\n .catch(function (e) { var el = document.getElementById(\"wb-\" + w.id); if (el && seq === state.seq) el.innerHTML = '<div class=\"empty bad\">' + esc(e.message) + \"</div>\"; });\n })).then(function () { if (up) up.textContent = \"IST · updated \" + state.updated; });\n }\n function metricOf(s, metric) {\n if (metric === \"errors\") return s.errors || s.counts.map(function () { return 0; });\n if (metric === \"rate\") return s.counts.map(function (c, i) { return c ? (s.errors ? s.errors[i] : 0) / c * 100 : 0; });\n return s.counts;\n }\n // Where a chart's series leads when clicked, like the built-in views.\n function srcHref(src) {\n if (!src || src.kind === \"global\") return \"#/overview\";\n if (src.kind === \"route\") return routeHref(src.type, src.action, src.name);\n return src.type && src.type !== \"*\" ? typeHref(src.type) : \"#/overview\";\n }\n function drawWidget(w, d, hostId) {\n var host = document.getElementById(hostId);\n if (!host || hold(host, function () { drawWidget(w, d, hostId); })) return;\n var nav = hostId !== \"ed-prev\" && !(state.draft && route().view === \"board\" && editingBoard(route().id)); // the editor's preview and a board being edited don't navigate\n if (w.chart === \"insights\") {\n var all = d.items || [], fit = Math.max(1, Math.floor((host.clientHeight - 6) / 38));\n host.innerHTML = all.length ? '<div class=\"wins\">' + all.slice(0, fit).map(function (it) {\n return '<button data-ins=\"' + all.indexOf(it) + '\"><i style=\"background:' + SEV_COLOR[it.severity] + '\"></i><span class=\"trunc\">' + esc(it.title) + '</span><b class=\"tnum\">' + esc(it.value) + \"</b></button>\";\n }).join(\"\") + (all.length > fit ? '<a class=\"link\" href=\"#/insights\" style=\"font-size:12px;padding:2px 14px\">+' + (all.length - fit) + \" more in Insights</a>\" : \"\") + \"</div>\" : '<div class=\"allclear\">' + ICON.check + \"All clear: nothing unusual right now.</div>\";\n return;\n }\n var metric = w.metric || \"count\", unit = metric === \"rate\" ? \"%\" : \"\";\n var series = d.series.map(function (s, i) { return { label: d.series.length > 1 ? s.label : (metric === \"errors\" ? \"5xx\" : metric === \"rate\" ? \"Failure rate\" : s.label), color: w.series.length > 1 ? (w.series[i].kind === \"type\" ? color(w.series[i].type) : PALETTE[i % PALETTE.length]) : w.series[0].kind === \"type\" ? color(w.series[0].type) : \"var(--bar)\", values: metricOf(s, metric), errors: metric === \"count\" && w.chart === \"bars\" && d.series.length === 1 ? s.errors : null }; });\n if (w.chart === \"number\") {\n // the current bucket is still filling, so show the last full one next to it rather than a % change\n var s0 = series[0], prev = s0.values[s0.values.length - 2] || 0, now = last(s0.values);\n host.innerHTML = '<div class=\"num\"' + (nav ? ' data-go=\"' + esc(srcHref(w.series[0])) + '\" style=\"cursor:pointer\" data-tip=\"Click to open ' + esc(labelOf(w.series[0])) + '\"' : \"\") + '><div class=\"v tnum trunc\">' + valText(now, unit) + '</div><div class=\"s\"><span>' + SLOT_NOW[d.range] + \"</span><span>·</span><span class=\\\"trunc\\\">\" + PREV_LABEL[d.range] + \": \" + valText(prev, unit) + \"</span></div>\" +\n (series.length > 1 ? '<div class=\"rows\">' + series.slice(1).map(function (s) { return '<div><span class=\"trunc\">' + esc(s.label) + '</span><b class=\"tnum\">' + valText(last(s.values), unit) + \"</b></div>\"; }).join(\"\") + \"</div>\" : \"\") +\n '<div style=\"margin-top:6px\">' + spark(400, 28, s0.values, typeof s0.color === \"string\" && s0.color.indexOf(\"var\") === 0 ? \"#6ea8fe\" : s0.color, d.series[0].ids, s0.label, true) + \"</div></div>\";\n return;\n }\n if (w.chart === \"donut\" || w.chart === \"table\") {\n var items = (d.breakdown || []).filter(function (b) { return !(b.target.kind === \"type\" && hiddenType(b.target.type)); }).map(function (b, i) {\n return { label: b.label, total: b.total, errors: b.errors, value: metric === \"errors\" ? b.errors || 0 : b.total, color: b.target.kind === \"type\" ? color(b.target.type) : PALETTE[i % PALETTE.length], go: nav ? srcHref(b.target) : null };\n }).sort(function (a, b) { return b.value - a.value; });\n // one route on its own splits into calls with and without 5xx, so any chart can become a donut\n if (items.length === 1 && items[0].errors !== null && metric !== \"errors\") {\n var one = items[0];\n items = [{ label: \"Without 5xx\", total: one.total - one.errors, errors: null, value: one.total - one.errors, color: \"#3fb979\", go: one.go }, { label: \"5xx\", total: one.errors, errors: one.errors, value: one.errors, color: \"#f0706a\", go: one.go }];\n }\n if (w.chart === \"donut\") {\n var top = items.slice(0, 7), rest = sum(items.slice(7).map(function (x) { return x.value; }));\n if (rest) top.push({ label: \"Other\", value: rest, color: \"#6b7684\" });\n donut(hostId, top, metric === \"errors\" ? \"errors\" : \"\");\n return;\n }\n var mx = Math.max.apply(null, items.map(function (x) { return x.value; }).concat([1])), was = host.querySelector(\".tscroll\"), keep = was ? was.scrollTop : 0;\n host.innerHTML = items.length ? '<div class=\"tscroll\" style=\"height:100%;overflow-y:auto\"><table class=\"tbl fit\"><tr><th>Name</th><th style=\"width:100px\">' + (metric === \"errors\" ? \"5xx\" : \"Requests\") + '</th><th class=\"opt\" style=\"width:100px\"></th>' + (d.failures && metric !== \"errors\" ? '<th style=\"width:124px\">5xx</th>' : \"\") + \"</tr>\" + items.map(function (x) {\n return \"<tr\" + (x.go ? ' data-go=\"' + esc(x.go) + '\"' : \"\") + '><td class=\"name mono trunc\" title=\"' + esc(x.label) + '\">' + esc(x.label) + '</td><td class=\"tnum\">' + fmt(x.value) + '</td><td class=\"opt\"><span class=\"share\" style=\"width:80px\"><i style=\"width:' + (x.value / mx * 100).toFixed(1) + \"%;background:\" + x.color + '\"></i></span></td>' + (d.failures && metric !== \"errors\" ? '<td class=\"tnum' + (x.errors ? \" bad\" : \"\") + '\">' + (x.errors ? fmt(x.errors) + \" · \" + pct(x.errors, x.total) : \"0\") + \"</td>\" : \"\") + \"</tr>\";\n }).join(\"\") + \"</table></div>\" : '<div class=\"empty\">Nothing counted in this range yet.</div>';\n if (keep && host.firstChild) host.firstChild.scrollTop = keep; // a refresh keeps your place in a long table\n return;\n }\n plot(hostId, { kind: w.chart, ids: d.series[0].ids, h: Math.max(70, host.clientHeight - 10 - (series.length > 1 ? 30 : 0)), unit: unit, series: series,\n pick: nav ? function (i, sr) { state.range = w.range; location.hash = srcHref(w.series[sr.idx] || w.series[0]); load(true); } : null });\n }\n\n // ---------- chart editor ----------\n function editWidget(w) {\n var types = trackedTypes();\n state.ed = { w: w ? clone(w) : { id: uid(), title: \"\", chart: \"line\", range: \"min\", metric: \"count\", w: 2, h: 2, series: [{ kind: \"global\" }] }, at: 0, isNew: !w, routes: {}, match: null, previewSeq: 0, types: types };\n var dd = dims(state.ed.w); state.ed.w.w = dd.w; state.ed.w.h = dd.h; delete state.ed.w.size;\n drawEditor();\n previewWidget();\n }\n function edSeries() { return state.ed.w.series[state.ed.at]; }\n function failuresOn() { return state.cfg ? state.cfg.resolved.failures : false; }\n function canErrors() { return failuresOn() && state.ed.w.series.every(function (s) { return s.kind === \"route\" || s.kind === \"pattern\"; }); }\n // donuts and tables split types and patterns by route, global by type, several series against each\n // other, and a single route into calls with and without 5xx, so every source can use them\n function canBreakdown() { return true; }\n function autoTitle(w) {\n if (w.chart === \"insights\") return \"Insights\";\n var what = w.series.length > 1 ? w.series.map(labelOf).join(\" vs \") : labelOf(w.series[0]);\n var m = w.metric === \"errors\" ? \"5xx: \" : w.metric === \"rate\" ? \"Failure rate: \" : \"\";\n return m + what + (w.chart === \"number\" ? \", \" + SLOT_NOW[w.range] : \", \" + RANGE_WHEN[w.range]);\n }\n function drawEditor() {\n var e = state.ed, w = e.w, s = edSeries(), buckets = (state.cfg ? BUCKETS.filter(function (b) { return state.cfg.resolved.timeConfig[b].enable; }) : BUCKETS), ins = w.chart === \"insights\";\n var kinds = [[\"global\", \"All traffic\", \"Every request\"], [\"type\", \"A type\", \"API, WEB, RPC…\"], [\"route\", \"One route\", \"A single endpoint\"], [\"pattern\", \"A pattern\", \"Many, with * wildcards\"]];\n var srcEditor = \"\";\n if (s.kind === \"type\") {\n srcEditor = '<div class=\"typechips\">' + Object.keys(TC).concat(state.cfg ? state.cfg.resolved.customTypes : []).map(function (t) {\n var ok = e.types.indexOf(t) !== -1;\n return '<button data-act=\"ed-type\" data-t=\"' + esc(t) + '\" class=\"' + (s.type === t ? \"on\" : \"\") + '\"' + (ok ? \"\" : ' disabled title=\"' + esc(t) + ' isn\\'t tracked. Turn it on with track in statis.init first.\"') + '><i style=\"background:' + color(t) + '\"></i>' + esc(t) + \"</button>\";\n }).join(\"\") + '</div><div class=\"hint\">Crossed-out types aren\\'t tracked by your app, so there is nothing to chart. Turn them on with <span class=\"mono\">track</span> in <span class=\"mono\">statis.init()</span>.</div>';\n } else if (s.kind === \"route\") {\n srcEditor = '<div class=\"row2\" style=\"margin-top:10px\"><select id=\"ed-rtype\">' + e.types.map(function (t) { return '<option value=\"' + esc(t) + '\"' + (s.type === t ? \" selected\" : \"\") + \">\" + esc(t) + \"</option>\"; }).join(\"\") + '</select><input class=\"inp\" id=\"ed-rsearch\" placeholder=\"Search routes…\" style=\"flex:1\" value=\"' + esc(e.rsearch || \"\") + '\"></div><div class=\"rlist\" id=\"ed-rlist\"><div class=\"empty\">Loading routes…</div></div>';\n } else if (s.kind === \"pattern\") {\n srcEditor = '<div class=\"pat\"><label>Type<input class=\"inp mono\" id=\"ed-pt\" list=\"dl-types\" value=\"' + esc(s.type) + '\"></label><label>Method / action<input class=\"inp mono\" id=\"ed-pa\" list=\"dl-acts\" value=\"' + esc(s.action) + '\"></label><label>Name<input class=\"inp mono\" id=\"ed-pn\" value=\"' + esc(s.name) + '\" placeholder=\"api/users*\"></label></div>' +\n '<datalist id=\"dl-types\"><option value=\"*\">' + e.types.map(function (t) { return '<option value=\"' + esc(t) + '\">'; }).join(\"\") + '</datalist><datalist id=\"dl-acts\"><option value=\"*\"><option value=\"GET\"><option value=\"POST\"><option value=\"PUT\"><option value=\"PATCH\"><option value=\"DELETE\"><option value=\"QUERY\"><option value=\"MUTATION\"><option value=\"CALL\"><option value=\"MESSAGE\"><option value=\"CONNECT\"></datalist>' +\n '<div class=\"keyline\">Searches <code class=\"mono\" id=\"ed-key\">' + esc(keyOf(s)) + ':{bucket}:{id}</code> · <span id=\"ed-match\">checking…</span></div><div class=\"rlist\" id=\"ed-mlist\" style=\"max-height:140px\"></div>';\n } else {\n srcEditor = '<div class=\"hint\" style=\"margin-top:10px\">Every request your app receives: <span class=\"mono\">' + esc(keyOf(s)) + \"</span>. WebSocket messages and outgoing calls aren't part of it.</div>\";\n }\n var breakdownOk = canBreakdown(), errOk = canErrors(), l = LIMITS[w.chart] || LIMITS.line;\n var charts = [\"number\", \"line\", \"area\", \"bars\", \"stack\", \"donut\", \"table\", \"insights\"].map(function (c) {\n var ok = (c !== \"donut\" && c !== \"table\") || breakdownOk;\n return '<button data-act=\"ed-chart\" data-c=\"' + c + '\" class=\"' + (w.chart === c ? \"on\" : \"\") + '\"' + (ok ? \"\" : ' disabled title=\"A single route can\\'t be split up; pick a type, a pattern or several series\"') + \">\" + CHART_ICON[c] + \"<b>\" + CHART_NAME[c] + \"</b></button>\";\n }).join(\"\");\n var sizes = '<span class=\"muted\" style=\"font-size:12px\">Width</span><div class=\"seg\">' + [1, 2, 3, 4].map(function (n) { return '<button data-act=\"ed-w\" data-n=\"' + n + '\" class=\"' + (w.w === n ? \"on\" : \"\") + '\"' + (n < l[0] || n > l[1] ? \" disabled\" : \"\") + \">\" + n + \"</button>\"; }).join(\"\") + '</div><span class=\"muted\" style=\"font-size:12px\">Height</span><div class=\"seg\">' + [1, 2, 3, 4].map(function (n) { return '<button data-act=\"ed-h\" data-n=\"' + n + '\" class=\"' + (w.h === n ? \"on\" : \"\") + '\"' + (n < l[2] || n > l[3] ? \" disabled\" : \"\") + \">\" + n + \"</button>\"; }).join(\"\") + \"</div>\";\n var body = '<div class=\"ed\"><div>' +\n (ins ? '<h4><i>1</i>What to track</h4><div class=\"hint\" style=\"margin:0\">Insights shows the latest problems found for you; there is nothing to pick.</div>' :\n '<h4><i>1</i>What to track</h4><div class=\"serieses\">' + w.series.map(function (x, i) { return '<button data-act=\"ed-at\" data-i=\"' + i + '\" class=\"' + (i === e.at ? \"on\" : \"\") + '\"><i style=\"background:' + (w.series.length > 1 ? PALETTE[i % PALETTE.length] : \"var(--bar)\") + '\"></i><span class=\"mono trunc\">' + esc(labelOf(x)) + \"</span>\" + (w.series.length > 1 ? '<span data-act=\"ed-del\" data-i=\"' + i + '\" title=\"Remove\" style=\"margin-left:4px;color:var(--ink3)\">✕</span>' : \"\") + \"</button>\"; }).join(\"\") +\n (w.series.length < 6 ? '<button data-act=\"ed-add\" title=\"Compare with another series\">' + ICON.plus.replace(\"<svg\", '<svg width=\"12\" height=\"12\"') + \"Compare</button>\" : \"\") + \"</div>\" +\n '<div class=\"kinds\">' + kinds.map(function (k) { return '<button data-act=\"ed-kind\" data-k=\"' + k[0] + '\" class=\"' + (s.kind === k[0] ? \"on\" : \"\") + '\"><b>' + k[1] + \"</b><span>\" + k[2] + \"</span></button>\"; }).join(\"\") + \"</div>\" + srcEditor) +\n '<h4><i>2</i>Show as</h4><div class=\"charts-pick\">' + charts + \"</div>\" +\n (ins ? \"\" : '<h4><i>3</i>Measure</h4><div class=\"row2\"><div class=\"seg\">' + [[\"count\", \"Requests\"], [\"errors\", \"5xx\"], [\"rate\", \"Failure rate\"]].map(function (m) { var ok = m[0] === \"count\" || errOk; return '<button data-act=\"ed-metric\" data-m=\"' + m[0] + '\" class=\"' + (w.metric === m[0] ? \"on\" : \"\") + '\"' + (ok ? \"\" : \" disabled\") + \">\" + m[1] + \"</button>\"; }).join(\"\") + \"</div>\" + (errOk ? \"\" : '<span class=\"hint\" style=\"margin:0\">' + (failuresOn() ? \"5xx needs routes or patterns\" : \"5xx needs failures: true in init\") + \"</span>\") + \"</div>\" +\n '<h4><i>4</i>Time range</h4><div class=\"seg\" style=\"display:inline-flex\">' + buckets.map(function (b) { return '<button data-act=\"ed-range\" data-r=\"' + b + '\" class=\"' + (w.range === b ? \"on\" : \"\") + '\">' + RANGE_LABEL[b] + \"</button>\"; }).join(\"\") + \"</div>\") +\n '<h4><i>' + (ins ? 3 : 5) + '</i>Size on the grid</h4><div class=\"row2\">' + sizes + '<span class=\"hint\" style=\"margin:0\">' + esc(CHART_NAME[w.chart]) + \": \" + l[0] + \"×\" + l[2] + \" to \" + l[1] + \"×\" + l[3] + \"</span></div>\" +\n '<h4><i>' + (ins ? 4 : 6) + '</i>Title</h4><input class=\"inp\" id=\"ed-title\" style=\"width:100%\" placeholder=\"' + esc(autoTitle(w)) + '\" value=\"' + esc(w.title) + '\">' +\n '</div><div><div class=\"preview\"><div class=\"hint\" style=\"margin:0 0 8px\">Live preview · ' + w.w + \"×\" + w.h + '</div><div class=\"w\" style=\"height:' + (w.h * ROW_H + (w.h - 1) * GAP) + \"px;max-width:\" + (w.w >= 2 ? \"100%\" : \"300px\") + '\"><div class=\"wh\"><b class=\"trunc\">' + esc(w.title || autoTitle(w)) + '</b></div><div class=\"wk mono trunc\">' + esc(sourceText(w)) + '</div><div class=\"wb\" id=\"ed-prev\"><div class=\"empty\">Loading…</div></div></div></div></div></div>';\n modal('<div class=\"modal\"><div class=\"mh\"><b>' + (e.isNew ? \"Add a chart\" : \"Edit chart\") + '</b><button class=\"ibtn\" data-act=\"ed-cancel\" title=\"Close\">✕</button></div><div class=\"mb\">' + body + '</div><div class=\"mf\"><span class=\"hint\" style=\"margin:0 auto 0 0\">The board is saved when you click Save board.</span><button class=\"btn ghost\" data-act=\"ed-cancel\">Cancel</button><button class=\"btn p\" data-act=\"ed-save\">' + (e.isNew ? \"Add to board\" : \"Update chart\") + \"</button></div></div>\");\n if (!ins && s.kind === \"route\") loadRouteList();\n if (!ins && s.kind === \"pattern\") loadMatches();\n }\n function loadRouteList() {\n var e = state.ed, s = edSeries(), t = document.getElementById(\"ed-rtype\") ? document.getElementById(\"ed-rtype\").value : s.type;\n var show = function (list) {\n var q = (e.rsearch || \"\").toLowerCase(), el = document.getElementById(\"ed-rlist\");\n if (!el) return;\n var rows = list.filter(function (x) { return !q || (x.action + \" \" + x.name).toLowerCase().indexOf(q) !== -1; }).slice(0, 80);\n el.innerHTML = rows.length ? rows.map(function (x) { var on = s.kind === \"route\" && s.type === t && s.action === x.action && s.name === x.name; return '<button data-act=\"ed-route\" data-t=\"' + esc(t) + '\" data-a=\"' + esc(x.action) + '\" data-n=\"' + esc(x.name) + '\" class=\"' + (on ? \"on\" : \"\") + '\">' + method(x.action) + '<span class=\"mono trunc\">' + esc(x.name) + '</span><span class=\"tnum\">' + short(x.day) + \" today</span></button>\"; }).join(\"\") : '<div class=\"empty\">No routes counted today for ' + esc(t) + \".</div>\";\n };\n if (e.routes[t]) return show(e.routes[t]);\n api(\"/api/type?type=\" + encodeURIComponent(t)).then(function (d) { e.routes[t] = d.routes; show(d.routes); }).catch(function (er) { var el = document.getElementById(\"ed-rlist\"); if (el) el.innerHTML = '<div class=\"empty bad\">' + esc(er.message) + \"</div>\"; });\n }\n var matchTimer;\n function loadMatches() {\n clearTimeout(matchTimer);\n matchTimer = setTimeout(function () {\n var s = edSeries(), m = document.getElementById(\"ed-match\"), list = document.getElementById(\"ed-mlist\");\n if (!m) return;\n api(\"/api/match?type=\" + encodeURIComponent(s.type) + \"&action=\" + encodeURIComponent(s.action) + \"&name=\" + encodeURIComponent(s.name)).then(function (d) {\n m.innerHTML = '<b class=\"' + (d.routes.length ? \"good\" : \"bad\") + '\">' + (d.routes.length ? \"matches \" + d.routes.length + (d.routes.length === 1 ? \" route\" : \" routes\") + \" today\" : \"matches nothing today\") + \"</b>\";\n list.innerHTML = d.routes.slice(0, 30).map(function (x) { return '<button disabled style=\"cursor:default\"><span class=\"tp mono\" style=\"margin-right:6px\"><i style=\"background:' + color(x.type) + '\"></i>' + esc(x.type) + \"</span>\" + method(x.action) + '<span class=\"mono trunc\">' + esc(x.name) + '</span><span class=\"tnum\">' + short(x.day) + \"</span></button>\"; }).join(\"\");\n }).catch(function (er) { m.innerHTML = '<b class=\"bad\">' + esc(er.message) + \"</b>\"; list.innerHTML = \"\"; });\n }, 250);\n }\n var prevTimer;\n function previewWidget() {\n clearTimeout(prevTimer);\n prevTimer = setTimeout(function () {\n var e = state.ed;\n if (!e) return;\n var w = e.w, seq = ++e.previewSeq;\n if (unpicked(w)) { var el = document.getElementById(\"ed-prev\"); if (el) el.innerHTML = '<div class=\"empty\">Pick a route from the list.</div>'; return; }\n queryWidget(w).then(function (d) { if (state.ed && seq === state.ed.previewSeq) drawWidget(w, d, \"ed-prev\"); })\n .catch(function (er) { var el = document.getElementById(\"ed-prev\"); if (el) el.innerHTML = '<div class=\"empty bad\">' + esc(er.message) + \"</div>\"; });\n }, 200);\n }\n function edChanged(redraw) {\n var w = state.ed.w;\n if (!canBreakdown() && (w.chart === \"donut\" || w.chart === \"table\")) w.chart = \"line\";\n if (!canErrors()) w.metric = \"count\";\n var d = dims(w); w.w = d.w; w.h = d.h;\n if (redraw) drawEditor(); else { var b = document.querySelector(\".preview .wh b\"); if (b) b.textContent = w.title || autoTitle(w); var k = document.querySelector(\".preview .wk\"); if (k) k.textContent = sourceText(w); }\n previewWidget();\n }\n function unpicked(w) { return w.chart !== \"insights\" && w.series.some(function (s) { return s.kind === \"route\" && !s.name; }); }\n function saveWidget() {\n var e = state.ed, r = route(), b = currentBoard(r.id);\n if (!b) return;\n if (unpicked(e.w)) return toast(\"Pick a route for each series first.\");\n if (!editingBoard(b.id)) { startEdit(b); b = state.draft.board; }\n if (!e.w.title) e.w.title = autoTitle(e.w);\n var i = b.widgets.map(function (x) { return x.id; }).indexOf(e.w.id);\n if (i === -1) b.widgets.push(e.w); else b.widgets[i] = e.w;\n delete state.wdata[e.w.id];\n closeModal();\n markDirty();\n state.shellKey = \"\"; render();\n }\n\n // ---------- resizing charts (edit mode) ----------\n var rs = null;\n document.addEventListener(\"mousedown\", function (ev) {\n var h = ev.target.closest && ev.target.closest(\".rsz\");\n if (!h || !state.draft) return;\n ev.preventDefault();\n var card = h.closest(\".w\"), grid = document.getElementById(\"wgrid\"), cs = getComputedStyle(grid);\n var cols = cs.gridTemplateColumns.split(\" \").length, gap = parseFloat(cs.columnGap) || GAP;\n var w = state.draft.board.widgets.filter(function (x) { return x.id === card.getAttribute(\"data-w\"); })[0];\n if (!w) return;\n var r = card.getBoundingClientRect();\n rs = { card: card, w: w, cols: cols, gap: gap, colW: (grid.clientWidth - gap * (cols - 1)) / cols, dir: h.getAttribute(\"data-dir\") || \"se\", x0: ev.clientX, y0: ev.clientY, w0: r.width, h0: r.height, size: dims(w) };\n card.classList.add(\"resizing\");\n });\n document.addEventListener(\"mousemove\", function (ev) {\n if (!rs) return;\n edgeScroll(ev);\n var l = LIMITS[rs.w.chart] || LIMITS.line;\n // left and top handles grow the chart when dragged outwards, like the right and bottom ones\n var dx = ev.clientX - rs.x0, dy = ev.clientY - rs.y0, dir = rs.dir;\n var pw = rs.w0 + (dir.indexOf(\"e\") !== -1 ? dx : dir.indexOf(\"w\") !== -1 ? -dx : 0), ph = rs.h0 + (dir.indexOf(\"s\") !== -1 ? dy : dir.indexOf(\"n\") !== -1 ? -dy : 0);\n var W = Math.round((pw + rs.gap) / (rs.colW + rs.gap)), H = Math.round((ph + rs.gap) / (ROW_H + rs.gap));\n W = Math.max(l[0], Math.min(l[1], rs.cols, W)); H = Math.max(l[2], Math.min(l[3], H));\n if (W === rs.size.w && H === rs.size.h) return;\n rs.size = { w: W, h: H };\n rs.card.setAttribute(\"data-cw\", W); rs.card.setAttribute(\"data-ch\", H);\n rs.card.querySelector(\".dims\").textContent = W + \"×\" + H;\n });\n document.addEventListener(\"mouseup\", function () {\n if (!rs) return;\n var x = rs; rs = null; edgeScroll(null);\n x.card.classList.remove(\"resizing\");\n if (x.size.w === dims(x.w).w && x.size.h === dims(x.w).h) return;\n x.w.w = x.size.w; x.w.h = x.size.h; delete x.w.size;\n markDirty();\n if (state.wdata[x.w.id]) drawWidget(x.w, state.wdata[x.w.id], \"wb-\" + x.w.id);\n });\n\n // ---------- modal ----------\n function modal(html) { closeModal(true); var d = document.createElement(\"div\"); d.className = \"modal-back\"; d.id = \"modal\"; d.innerHTML = html; document.body.appendChild(d); }\n function closeModal(keep) { var m = document.getElementById(\"modal\"); if (m) m.remove(); if (!keep) { state.ed = null; state.xp = null; state.rs = null; } }\n\n function showError() {\n var main = document.querySelector(\"main\");\n if (!main) return;\n var e = document.getElementById(\"err\");\n if (!e) { e = document.createElement(\"div\"); e.id = \"err\"; e.className = \"err\"; main.insertBefore(e, main.children[1] || null); }\n e.textContent = state.error;\n }\n\n function loginView() {\n return '<form class=\"login\" id=\"login\"><div style=\"display:flex;align-items:center;gap:12px\"><span class=\"mark\" style=\"width:30px;height:30px;border-radius:8px\"></span><div><div style=\"font-size:19px;font-weight:600\">Sign in</div><div style=\"font-size:13px;color:var(--ink2)\">system-tracker dashboard</div></div></div>' +\n '<label for=\"u\">Username</label><input class=\"in\" id=\"u\" name=\"username\" autocomplete=\"username\" required>' +\n '<label for=\"pw\">Password</label><input class=\"in\" id=\"pw\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required>' +\n '<div class=\"bad\" id=\"lerr\" style=\"font-size:13px;margin-top:12px;min-height:18px\"></div>' +\n '<button class=\"btn p\" type=\"submit\">Sign in</button></form>';\n }\n\n function render() {\n var me = state.me;\n hideHover();\n if (!me) { app.innerHTML = topBar(); return; }\n if (me.required && !me.signedIn) {\n state.shellKey = \"\";\n app.innerHTML = topBar() + loginView();\n var u = document.getElementById(\"u\");\n if (u) u.focus();\n return;\n }\n var r = route(), cb = boardFor(r);\n var key = JSON.stringify(r) + \"|\" + (cb ? JSON.stringify(cb) + editingBoard(cb.id) : \"\");\n if (key !== state.shellKey) {\n state.shellKey = key; state.first = true;\n if (r.view !== \"type\") state.filter = \"\";\n var body;\n if (r.view === \"insights\") body = insightsShell();\n else if (r.view === \"settings\") body = settingsShell();\n else if (r.view === \"boards\") body = boardsView();\n else if (r.view === \"template\") body = cb ? templateShell(cb) : '<div class=\"card\"><div class=\"empty\">There is no such template. <a class=\"link\" href=\"#/boards\">See all templates</a>.</div></div>';\n else if (r.view === \"board\") body = cb ? boardShell(cb) : '<div class=\"card\"><div class=\"empty\">That board doesn\\'t exist any more. <a class=\"link\" href=\"#/boards\">See all boards</a>.</div></div>';\n else body = shell(r);\n app.innerHTML = topBar() + \"<main>\" + body + \"</main>\";\n var s = document.getElementById(\"sort\");\n if (s) s.value = state.sort;\n state.data = null;\n if (r.view === \"settings\") { fillSettings(); fillStorage(); }\n } else {\n app.querySelector(\".top\").outerHTML = topBar().replace(/<div class=\"banner\">[\\s\\S]*$/, \"\");\n }\n if (state.data) fill(r, state.data);\n if (cb) b2(r);\n load(true);\n }\n function b2(r) { var b = boardFor(r); b.widgets.forEach(function (w) { if (state.wdata[w.id]) drawWidget(w, state.wdata[w.id], \"wb-\" + w.id); }); }\n\n // ---------- events ----------\n document.addEventListener(\"click\", function (ev) {\n var t = ev.target.closest ? ev.target : ev.target.parentElement;\n if (t.classList && t.classList.contains(\"modal-back\")) { closeModal(); return; }\n if (t.closest(\".wgrid.editing\") && !t.closest(\"[data-act]\")) return;\n var hit = t.closest(\".chart[data-chart]\");\n if (hit && CH[hit.getAttribute(\"data-chart\")] && CH[hit.getAttribute(\"data-chart\")].pick) {\n var m = CH[hit.getAttribute(\"data-chart\")], g = m.geo, rr = hit.querySelector(\"svg\").getBoundingClientRect(), idx = Math.floor((ev.clientX - rr.left - g.padL) / g.step);\n if (idx >= 0 && idx < g.n) {\n var my = ev.clientY - rr.top, base = 0, hitS = null, vis = m.series.filter(function (s) { return !s.off; });\n if (m.kind === \"stack\") vis.forEach(function (s) { var v = s.values[idx] || 0; if (my <= g.Y(base) && my >= g.Y(base + v)) hitS = s; base += v; });\n else vis.forEach(function (s) { if (!hitS || Math.abs(g.Y(s.values[idx] || 0) - my) < Math.abs(g.Y(hitS.values[idx] || 0) - my)) hitS = s; });\n if (hitS) return m.pick(idx, hitS);\n }\n }\n var ins = t.closest(\"[data-ins]\");\n if (ins) return openInsight(+ins.getAttribute(\"data-ins\"));\n var go = t.closest(\"[data-go]\");\n if (go && !t.closest(\"[data-act]\")) { closeModal(); location.hash = go.getAttribute(\"data-go\"); return; }\n var rb = t.closest(\"[data-range]\");\n if (rb) { state.range = rb.getAttribute(\"data-range\"); load(true); return; }\n var act = t.closest(\"[data-act]\");\n if (!act) return;\n var a = act.getAttribute(\"data-act\"), r = route(), b = r.view === \"board\" ? currentBoard(r.id) : null, e = state.ed;\n var wid = act.getAttribute(\"data-w\"), widget = b && wid ? b.widgets.filter(function (x) { return x.id === wid; })[0] : null;\n switch (a) {\n case \"reload\": load(true); if (!state.stPoll) loadStorage(); break;\n case \"recount\": loadStorage(true); break;\n case \"theme\":\n var light = document.documentElement.dataset.theme !== \"light\";\n document.documentElement.dataset.theme = light ? \"light\" : \"dark\";\n try { localStorage.setItem(\"system-tracker-theme\", light ? \"light\" : \"dark\"); } catch (x) {}\n render(); break;\n case \"logout\": api(\"/api/logout\", { method: \"POST\" }).then(function () { state.me.signedIn = false; state.storage = null; state.stAt = 0; render(); }); break;\n case \"legend\":\n var k = act.getAttribute(\"data-chart\") + \"|\" + act.getAttribute(\"data-label\");\n state.legendOff[k] = !state.legendOff[k];\n if (state.data) fill(route(), state.data); else if (b) b2(r); break;\n case \"insf\": state.insFilter = act.getAttribute(\"data-f\"); if (viewData()) steady(function () { fillInsights(viewData()); }); break;\n case \"ins-toggle\":\n state.insOpen = state.insOpen || {};\n var ik = act.getAttribute(\"data-k\"); state.insOpen[ik] = !state.insOpen[ik]; state.insJust = state.insOpen[ik] ? ik : null;\n hideHover(); if (viewData()) steady(function () { fillInsights(viewData()); }); break;\n case \"ins-watch\": watchInsight(+act.getAttribute(\"data-i\")); break;\n case \"ptop\": store.set(\"system-tracker-hide-top-routes\", !topHidden()); if (state.pdata) steady(function () { fillPeriod(state.pdata); }); break;\n case \"period\":\n state.period = act.getAttribute(\"data-p\"); store.set(\"system-tracker-period\", state.period);\n document.querySelectorAll(\"#ptabs button\").forEach(function (x) { x.classList.toggle(\"on\", x.getAttribute(\"data-p\") === state.period); });\n state.insOpen = {}; load(true); break;\n case \"modal-close\": closeModal(); break;\n case \"export\": openExport(\"period\"); break;\n case \"export-view\": openExport(\"view\"); break;\n case \"xp-period\": state.xp.period = act.getAttribute(\"data-p\"); drawExport(); break;\n case \"xp-format\": state.xp.format = act.getAttribute(\"data-f\"); drawExport(); break;\n case \"xp-go\": doExport(); break;\n case \"xp-get\":\n var fmtWanted = act.getAttribute(\"data-f\");\n api(\"/api/reports/one?id=\" + encodeURIComponent(act.getAttribute(\"data-id\"))).then(function (rep) { deliver(rep, fmtWanted); }).catch(function (er) { toast(er.message); }); break;\n case \"xp-del\":\n if (!window.confirm(\"Delete this saved report? This can't be undone.\")) break;\n api(\"/api/reports/one?id=\" + encodeURIComponent(act.getAttribute(\"data-id\")), { method: \"DELETE\" }).then(function () { return api(\"/api/reports\"); }).then(function (l) { if (state.xp) { state.xp.list = l.reports; drawExport(); } }).catch(function (er) { toast(er.message); }); break;\n case \"reset\": openReset(); checkReset(); break;\n case \"rs-what\": state.rs.what = act.getAttribute(\"data-w\"); state.rs.action = state.rs.name = \"\"; checkReset(); break;\n case \"rs-type\": state.rs.type = act.getAttribute(\"data-t\"); checkReset(); break;\n case \"rs-when\": state.rs.period = act.getAttribute(\"data-p\"); checkReset(); break;\n case \"rs-route\": state.rs.action = act.getAttribute(\"data-a\"); state.rs.name = act.getAttribute(\"data-n\"); checkReset(); break;\n case \"rs-go\": doReset(); break;\n case \"set-reset\": store.del(draftKey()); saveDraft(clone(state.cfg.resolved)); break;\n case \"copy-curl\":\n var kept = viewData(), list = kept && (act.getAttribute(\"data-src\") === \"f\" ? kept.failed : kept.samples), one = list && list[+act.getAttribute(\"data-i\")];\n if (!one) break;\n (navigator.clipboard ? navigator.clipboard.writeText(curlOf(one, state.curlMode)) : Promise.reject()).then(function () { toast(state.curlMode === \"structure\" ? \"Copied the curl structure. Fill in the :params and <values>.\" : \"Copied. Paste it in a terminal, or in Postman with Import → Raw text.\"); }, function () { toast(\"Select the command and copy it.\"); }); break;\n case \"copycode\":\n (navigator.clipboard ? navigator.clipboard.writeText(state.codeText) : Promise.reject()).then(function () { toast(\"Copied. Put your real credentials back where you see ••••••.\"); }, function () { toast(\"Select the code and copy it.\"); }); break;\n case \"addpath\":\n var inp = document.getElementById(\"addpath\"), v = inp.value.trim();\n if (v) { var d = draft(); if (d.ignore.paths.indexOf(v) === -1) d.ignore.paths.push(v); saveDraft(d); } break;\n case \"delpath\": var dd = draft(); dd.ignore.paths.splice(+act.getAttribute(\"data-i\"), 1); saveDraft(dd); break;\n case \"newboard\": newBoard(); break;\n case \"tpl-use\": useTemplate(r.key, false); break;\n case \"tpl-customize\": useTemplate(r.key, true); break;\n case \"customize\": customizeOverview(); break;\n case \"b-edit\": startEdit(b); state.shellKey = \"\"; render(); break;\n case \"b-rename\": ev.preventDefault(); renameBoard(act.getAttribute(\"data-id\")); break;\n case \"rn-save\": saveRename(act.getAttribute(\"data-id\")); break;\n case \"b-save\": saveEdit(); break;\n case \"b-cancel\": cancelEdit(); break;\n case \"b-default\":\n if (state.boards.defaultId === b.id) break;\n state.boards.defaultId = b.id;\n saveBoards('\"' + b.name + '\" is now your Overview.').then(function () { state.shellKey = \"\"; render(); }); break;\n case \"b-undefault\":\n state.boards.defaultId = null;\n saveBoards(\"The Overview tab shows the built-in Overview again.\").then(function () { state.shellKey = \"\"; render(); }); break;\n case \"b-dup\": var nb = clone(b); nb.id = uid(); nb.name = b.name + \" (copy)\"; nb.widgets.forEach(function (x) { x.id = uid(); }); state.boards.boards.push(nb); saveBoards('Board \"' + nb.name + '\" created.').then(function () { location.hash = \"#/board/\" + nb.id; }); break;\n case \"b-del\":\n if (!confirmDelete(b)) break;\n state.boards.boards = state.boards.boards.filter(function (x) { return x.id !== b.id; });\n if (state.boards.defaultId === b.id) state.boards.defaultId = null;\n saveBoards('Board \"' + b.name + '\" deleted.').then(function () { location.hash = \"#/boards\"; }); break;\n case \"w-add\": if (!editingBoard(b.id)) { startEdit(b); state.shellKey = \"\"; render(); } editWidget(null); break;\n case \"w-edit\": editWidget(widget); break;\n case \"w-dup\": var nw = clone(widget); nw.id = uid(); nw.title = widget.title + \" (copy)\"; b.widgets.splice(b.widgets.indexOf(widget) + 1, 0, nw); if (state.wdata[widget.id]) state.wdata[nw.id] = state.wdata[widget.id]; markDirty(); render(); break;\n case \"w-del\": b.widgets = b.widgets.filter(function (x) { return x !== widget; }); markDirty(); render(); break;\n case \"ed-cancel\": closeModal(); break;\n case \"ed-save\": saveWidget(); break;\n case \"ed-at\": if (ev.target.getAttribute(\"data-act\") === \"ed-del\") break; e.at = +act.getAttribute(\"data-i\"); edChanged(true); break;\n case \"ed-del\": e.w.series.splice(+act.getAttribute(\"data-i\"), 1); e.at = 0; edChanged(true); break;\n case \"ed-add\": e.w.series.push({ kind: \"type\", type: e.types[0] || \"API\" }); e.at = e.w.series.length - 1; edChanged(true); break;\n case \"ed-kind\":\n var kk = act.getAttribute(\"data-k\"), cur = edSeries(), T0 = cur.type && cur.type !== \"*\" && e.types.indexOf(cur.type) !== -1 ? cur.type : e.types[0] || \"API\";\n e.w.series[e.at] = kk === \"global\" ? { kind: \"global\" } : kk === \"type\" ? { kind: \"type\", type: T0 } : kk === \"route\" ? { kind: \"route\", type: T0, action: \"\", name: \"\" } : { kind: \"pattern\", type: T0, action: \"*\", name: \"*\" };\n edChanged(true); break;\n case \"ed-type\": edSeries().type = act.getAttribute(\"data-t\"); edChanged(true); break;\n case \"ed-route\": e.w.series[e.at] = { kind: \"route\", type: act.getAttribute(\"data-t\"), action: act.getAttribute(\"data-a\"), name: act.getAttribute(\"data-n\") }; edChanged(true); break;\n case \"ed-chart\": e.w.chart = act.getAttribute(\"data-c\"); edChanged(true); break;\n case \"ed-metric\": e.w.metric = act.getAttribute(\"data-m\"); edChanged(true); break;\n case \"ed-range\": e.w.range = act.getAttribute(\"data-r\"); edChanged(true); break;\n case \"ed-w\": e.w.w = +act.getAttribute(\"data-n\"); edChanged(true); break;\n case \"ed-h\": e.w.h = +act.getAttribute(\"data-n\"); edChanged(true); break;\n }\n });\n function confirmDelete(b) { return window.confirm('Delete the board \"' + b.name + '\"? This can\\'t be undone.'); }\n document.addEventListener(\"input\", function (ev) {\n var id = ev.target.id, e = state.ed;\n if (id === \"filter\") { state.filter = ev.target.value; if (viewData()) fillRoutes(route(), viewData()); }\n if (e && id === \"ed-title\") { e.w.title = ev.target.value; edChanged(false); }\n if (e && id === \"ed-rsearch\") { e.rsearch = ev.target.value; loadRouteList(); }\n if (state.rs && id === \"rs-search\") { state.rs.q = ev.target.value; showResetRoutes(); }\n if (e && (id === \"ed-pt\" || id === \"ed-pa\" || id === \"ed-pn\")) {\n var s = edSeries(), v = ev.target.value.trim() || \"*\";\n if (id === \"ed-pt\") s.type = v; else if (id === \"ed-pa\") s.action = v; else s.name = v;\n var k = document.getElementById(\"ed-key\"); if (k) k.textContent = keyOf(s) + \":{bucket}:{id}\";\n var chip = document.querySelector(\".serieses button.on span.mono\"); if (chip) chip.textContent = labelOf(s);\n loadMatches(); edChanged(false);\n }\n });\n document.addEventListener(\"change\", function (ev) {\n var t = ev.target, id = t.id;\n if (id === \"sort\") { state.sort = t.value; if (viewData()) fillRoutes(route(), viewData()); }\n if (id === \"curlmode\") { state.curlMode = t.value === \"sample\" ? \"sample\" : \"structure\"; store.set(\"system-tracker-curl-mode\", state.curlMode); if (viewData()) fillRequests(route(), viewData()); }\n if (id === \"refresh\") {\n state.refresh = Number(t.value);\n store.set(\"system-tracker-refresh\", state.refresh);\n t.parentElement.classList.toggle(\"off\", !state.refresh);\n if (state.refresh) load(true);\n }\n if (id === \"ed-rtype\") loadRouteList();\n if (id === \"rs-rtype\" && state.rs) { state.rs.type = t.value; state.rs.action = state.rs.name = \"\"; checkReset(); }\n if (id === \"bname\" && state.draft) {\n var v = t.value.trim();\n if (v && v !== state.draft.board.name) { state.draft.board.name = v.slice(0, 80); markDirty(); }\n }\n var path = t.getAttribute && t.getAttribute(\"data-set\");\n if (path && state.cfg) {\n var d = draft();\n if (path === \"ignore.methods\") {\n var ms = []; document.querySelectorAll('[data-set=\"ignore.methods\"]').forEach(function (c) { if (c.checked) ms.push(c.value); });\n d.ignore.methods = ms;\n } else if (t.type === \"checkbox\") setPath(d, path, t.checked);\n else setPath(d, path, Math.max(1, Math.round(Number(t.value)) || 1));\n saveDraft(d);\n }\n });\n document.addEventListener(\"keydown\", function (ev) {\n if (ev.key === \"Escape\" && document.getElementById(\"modal\")) closeModal();\n if (ev.key === \"Enter\" && ev.target.id === \"addpath\") { ev.preventDefault(); document.querySelector('[data-act=\"addpath\"]').click(); }\n if (ev.key === \"Enter\" && ev.target.id === \"bname\") ev.target.blur();\n if (ev.key === \"Enter\" && ev.target.id === \"rn-name\") { ev.preventDefault(); document.querySelector('[data-act=\"rn-save\"]').click(); }\n if (ev.key === \"/\" && !/INPUT|TEXTAREA|SELECT/.test(ev.target.tagName)) { var f = document.getElementById(\"filter\"); if (f) { ev.preventDefault(); f.focus(); } }\n });\n // ---------- moving charts (edit mode) ----------\n // Press on a chart and drag: the others make room as you go, and every chart keeps its size.\n var mv = null;\n document.addEventListener(\"mousedown\", function (ev) {\n var card = ev.target.closest && ev.target.closest(\".wgrid.editing .w[data-w]\");\n if (!card || rs || ev.button !== 0 || ev.target.closest(\"button, input, select, a, .rsz\")) return;\n ev.preventDefault();\n mv = { card: card, id: card.getAttribute(\"data-w\"), x0: ev.clientX, y0: ev.clientY, on: false, at: 0, last: \"\" };\n });\n function moveTo(ev) {\n var b = state.draft && state.draft.board, grid = document.getElementById(\"wgrid\");\n var under = document.elementFromPoint(ev.clientX, ev.clientY), t = under && under.closest && under.closest(\".wgrid .w[data-w]\");\n if (!b || !grid || !t || t === mv.card || Date.now() - mv.at < 140) return;\n var r = t.getBoundingClientRect(), after = ev.clientY > r.bottom - r.height / 3 || (ev.clientY >= r.top + r.height / 3 && ev.clientX > r.left + r.width / 2);\n var key = t.getAttribute(\"data-w\") + (after ? \">\" : \"<\");\n if (key === mv.last) return;\n mv.last = key; mv.at = Date.now();\n grid.insertBefore(mv.card, after ? t.nextSibling : t);\n var order = [].map.call(grid.querySelectorAll(\".w[data-w]\"), function (e) { return e.getAttribute(\"data-w\"); });\n b.widgets.sort(function (p, q) { return order.indexOf(p.id) - order.indexOf(q.id); });\n mv.moved = true;\n }\n document.addEventListener(\"mousemove\", function (ev) {\n if (!mv) return;\n if (!mv.on) {\n if (Math.abs(ev.clientX - mv.x0) + Math.abs(ev.clientY - mv.y0) < 6) return;\n mv.on = true; mv.card.classList.add(\"dragging\"); document.body.classList.add(\"moving\");\n }\n edgeScroll(ev);\n moveTo(ev);\n });\n document.addEventListener(\"mouseup\", function () {\n if (!mv) return;\n var m = mv; mv = null; edgeScroll(null);\n m.card.classList.remove(\"dragging\"); document.body.classList.remove(\"moving\");\n if (m.moved) markDirty();\n });\n // While moving or resizing near the top or bottom of the window, the page scrolls.\n var edge = { dy: 0, timer: 0 };\n function edgeScroll(ev) {\n edge.dy = !ev ? 0 : ev.clientY > innerHeight - 60 ? 14 : ev.clientY < 60 ? -14 : 0;\n if (edge.dy && !edge.timer) edge.timer = setInterval(function () { if (!edge.dy) { clearInterval(edge.timer); edge.timer = 0; return; } scrollBy(0, edge.dy); }, 16);\n }\n document.addEventListener(\"submit\", function (ev) {\n if (ev.target.id !== \"login\") return;\n ev.preventDefault();\n var btn = ev.target.querySelector(\"button\"), err = document.getElementById(\"lerr\");\n btn.disabled = true; err.textContent = \"\";\n fetch(BASE + \"/api/login\", { method: \"POST\", credentials: \"same-origin\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ username: document.getElementById(\"u\").value, password: document.getElementById(\"pw\").value }) })\n .then(function (res) {\n if (!res.ok) { err.textContent = \"Wrong username or password\"; btn.disabled = false; return; }\n return start();\n })\n .catch(function () { err.textContent = \"Could not reach the server\"; btn.disabled = false; });\n });\n window.addEventListener(\"hashchange\", render);\n var resizeTimer;\n window.addEventListener(\"resize\", function () {\n clearTimeout(resizeTimer);\n resizeTimer = setTimeout(function () { var r = route(); if (state.data) fill(r, state.data); if (boardFor(r)) b2(r); }, 150);\n });\n\n function start() {\n return fetch(BASE + \"/api/me\", { credentials: \"same-origin\" }).then(function (res) { return res.json(); }).then(function (me) {\n state.me = me;\n if (!me.signedIn) return render();\n return Promise.all([api(\"/api/config\").then(function (c) { state.cfg = c; }).catch(function () {}), loadBoards()]).then(function () {\n render();\n if (route().view !== \"overview\" && route().view !== \"insights\") loadSide();\n });\n }).catch(function () { app.innerHTML = topBar() + '<main><div class=\"err\">Could not reach the dashboard API.</div></main>'; });\n }\n start();\n})();\n</script>\n</body>\n</html>\n";
2
+ module.exports = "<!doctype html>\n<html lang=\"en\" data-theme=\"dark\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex\">\n<title>system-tracker dashboard</title>\n<script nonce=\"__NONCE__\">\n try { if (localStorage.getItem(\"system-tracker-theme\") === \"light\") document.documentElement.dataset.theme = \"light\"; } catch (e) {}\n</script>\n<style>\n:root {\n --bg: #0d1117; --surface: #151b23; --surface2: #1b222c; --line: #262f3a; --line2: #1e2630;\n --ink: #e6edf3; --ink2: #9aa7b4; --ink3: #6b7684; --hover: rgba(110, 168, 254, 0.08); --track: #222a34;\n --grid: #1f2731; --tip: #1f2937; --tipink: #e6edf3; --tipline: #374151; --bad: #f0706a; --ok: #3fb979; --brand: #ef5a4f; --bar: #6ea8fe;\n --warn: #e3b341; --info: #6ea8fe; --warnbg: rgba(210, 153, 34, 0.12); --warnink: #e3b341; --pill: #2d3748;\n --add: rgba(46, 160, 67, 0.5); --del: rgba(248, 81, 73, 0.4); --code: #0b0f14;\n --sel: #3fb979; --selbg: rgba(63, 185, 121, 0.16); --pri: #2f81f7; --pribg: rgba(47, 129, 247, 0.14);\n color-scheme: dark;\n}\n[data-theme=\"light\"] {\n --bg: #f6f8fa; --surface: #ffffff; --surface2: #f3f5f8; --line: #d8dee4; --line2: #e8ecf0;\n --ink: #1f2328; --ink2: #57606a; --ink3: #8c959f; --hover: rgba(9, 105, 218, 0.06); --track: #e6eaef;\n --grid: #eef1f4; --tip: #ffffff; --tipink: #1f2328; --tipline: #d8dee4; --bad: #cf222e; --ok: #1a7f37; --bar: #4c8ef5;\n --warn: #9a6700; --info: #0969da; --warnbg: rgba(210, 153, 34, 0.12); --warnink: #9a6700; --pill: #57606a;\n --add: rgba(46, 160, 67, 0.5); --del: rgba(248, 81, 73, 0.35); --code: #f6f8fa;\n --sel: #1a7f37; --selbg: rgba(26, 127, 55, 0.14); --pri: #0969da; --pribg: rgba(9, 105, 218, 0.1);\n color-scheme: light;\n}\n* { box-sizing: border-box; margin: 0; padding: 0; }\nbody { background: var(--bg); color: var(--ink); font: 14px/1.4 system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", sans-serif; min-height: 100vh; }\nbutton, input, select, textarea { font: inherit; color: inherit; }\nbutton { cursor: pointer; background: none; border: 0; }\na { color: inherit; }\n.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace; }\n.tnum { font-variant-numeric: tabular-nums; }\n.muted { color: var(--ink3); }\n\n/* top bar and navigation */\n.top { height: 56px; display: flex; align-items: center; gap: 12px; padding: 0 24px; border-bottom: 1px solid var(--line); background: var(--surface); position: sticky; top: 0; z-index: 20; }\n.mark { flex: none; width: 22px; height: 22px; border-radius: 6px; background: var(--brand); position: relative; }\n.mark::before, .mark::after { content: \"\"; position: absolute; left: 5px; right: 5px; height: 2.5px; border-radius: 2px; background: #fff; }\n.mark::before { top: 7px; } .mark::after { top: 12.5px; right: 9px; }\n.brand { font-size: 16px; font-weight: 600; }\n.pchip { font-size: 12.5px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--line); color: var(--ink2); }\n.nav { display: flex; gap: 2px; margin-left: 18px; height: 100%; }\n.nav a { display: flex; align-items: center; gap: 7px; padding: 0 12px; text-decoration: none; color: var(--ink2); font-weight: 500; border-bottom: 2px solid transparent; }\n.nav a:hover { color: var(--ink); }\n.nav a.on { color: var(--ink); border-bottom-color: var(--sel); }\n.badge { min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; color: #fff; background: var(--ink3); }\n.badge.critical { background: #d93a33; } .badge.warning { background: #b7791f; }\n.grow { flex: 1; }\n.live { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink2); padding: 0 4px 0 10px; height: 32px; border: 1px solid var(--line); border-radius: 8px; }\n.live i { width: 8px; height: 8px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 0 3px rgba(63, 185, 121, 0.18); }\n.live.off i { background: var(--ink3); box-shadow: none; }\n.live select { border: 0; background: transparent; padding: 4px 2px; font-size: 13px; color: var(--ink); cursor: pointer; }\n.live select:focus { outline: none; }\n.live select option, select option { background: var(--surface); color: var(--ink); }\n.ibtn { width: 34px; height: 32px; border: 1px solid var(--line); border-radius: 8px; display: inline-flex; align-items: center; justify-content: center; color: var(--ink2); flex: none; }\n.ibtn:hover { color: var(--ink); border-color: var(--ink3); }\n.ibtn svg { width: 16px; height: 16px; }\n.ibtn.sm { width: 28px; height: 26px; border-radius: 6px; }\n.ibtn.sm svg { width: 14px; height: 14px; }\n.user { font-size: 13px; color: var(--ink2); }\n.link { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }\n.dest { display: inline-block; font-size: 11px; padding: 1px 6px; border-radius: 5px; border: 1px solid var(--line); color: var(--ink2); margin-left: 8px; vertical-align: 1px; font-family: system-ui, -apple-system, \"Segoe UI\", sans-serif; }\n.stchip { font-size: 12px; padding: 4px 9px; border-radius: 8px; border: 1px solid var(--line); color: var(--ink2); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 340px; }\n.stchip:hover { border-color: var(--ink3); color: var(--ink); }\n.stchip.near { border-color: var(--warn); color: var(--warn); }\n.preview-chip { font-size: 12.5px; padding: 5px 10px; border-radius: 8px; border: 1px dashed var(--warn); color: var(--warn); text-decoration: none; white-space: nowrap; }\n.banner { display: block; line-height: 1.6; overflow-wrap: anywhere; padding: 9px 24px; font-size: 13px; background: var(--warnbg); color: var(--warnink); border-bottom: 1px solid rgba(210, 153, 34, 0.35); }\nmain { padding: 18px 24px 48px; max-width: 1600px; margin: 0 auto; }\n\n/* headings and cards */\n.crumbs { display: flex; align-items: center; gap: 8px; font-size: 15px; min-height: 30px; flex-wrap: wrap; }\n.crumbs a { color: var(--ink2); text-decoration: none; }\n.crumbs a:hover { color: var(--ink); text-decoration: underline; }\n.crumbs .sep { color: var(--ink3); }\n.crumbs b { font-weight: 600; display: inline-flex; align-items: center; gap: 6px; }\n.crumbs .meta { margin-left: auto; font-size: 12.5px; color: var(--ink3); }\n.h2 { font-size: 20px; font-weight: 650; display: flex; align-items: center; gap: 10px; }\n.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); gap: 14px; margin-top: 14px; }\n.kpi { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; min-height: 92px; position: relative; overflow: hidden; }\n.kpi .l { font-size: 12.5px; color: var(--ink2); display: flex; align-items: center; gap: 6px; }\n.kpi .v { font-size: 27px; font-weight: 600; margin-top: 4px; letter-spacing: -0.01em; }\n.kpi .s { font-size: 12px; color: var(--ink3); margin-top: 2px; }\n.kpi svg { position: absolute; right: 12px; bottom: 12px; }\n.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); display: inline-block; flex: none; }\n.bad { color: var(--bad); } .good { color: var(--ok); }\n.card { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; margin-top: 14px; overflow: hidden; }\n.ch { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-bottom: 1px solid var(--line2); flex-wrap: wrap; min-height: 49px; }\n.ch b { font-size: 14.5px; font-weight: 600; }\n.ch .k { font-size: 12px; color: var(--ink3); }\n.ch .right { margin-left: auto; display: flex; align-items: center; gap: 8px; }\n.cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; }\n.cols > .card { margin-top: 14px; }\n.split { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 14px; }\n@media (max-width: 1100px) { .cols, .split { grid-template-columns: 1fr; } }\n#tabs { display: flex; min-width: 0; max-width: 100%; }\n.seg { display: flex; border: 1px solid var(--line); border-radius: 8px; overflow-x: auto; max-width: 100%; scrollbar-width: none; }\n.seg button { flex: none; white-space: nowrap; font-size: 12.5px; padding: 5px 11px; color: var(--ink2); border-left: 1px solid var(--line); }\n.seg button:first-child { border-left: 0; }\n.seg button.on { background: var(--selbg); color: var(--ink); font-weight: 600; box-shadow: inset 0 -2px 0 var(--sel); }\n.seg button:disabled { opacity: 0.4; cursor: not-allowed; }\n.empty { padding: 36px 16px; text-align: center; color: var(--ink3); }\n.err { margin-top: 14px; padding: 10px 14px; border-radius: 8px; border: 1px solid var(--bad); color: var(--bad); font-size: 13px; }\n\n/* charts */\n.chart { position: relative; padding: 10px 12px 4px; }\n.chart svg { display: block; }\n.legend { display: flex; flex-wrap: wrap; gap: 6px 14px; padding: 8px 16px 0; font-size: 12.5px; }\n.legend button { display: inline-flex; align-items: center; gap: 6px; color: var(--ink2); }\n.legend button i { width: 10px; height: 10px; border-radius: 3px; }\n.legend button.off { opacity: 0.4; text-decoration: line-through; }\n.legend button:hover { color: var(--ink); }\n.xh, .yh { position: absolute; pointer-events: none; display: none; }\n.xh { width: 0; border-left: 1px dashed var(--ink3); }\n.yh { height: 0; border-top: 1px dashed var(--ink3); }\n.ax { position: absolute; pointer-events: none; display: none; background: var(--pill); color: #fff; font-size: 10.5px; padding: 1px 6px; border-radius: 4px; white-space: nowrap; transform: translate(-50%, 0); }\n.ax.y { transform: translate(-100%, -50%); }\n#tt { position: fixed; z-index: 80; pointer-events: none; display: none; min-width: 150px; max-width: 320px; background: var(--tip); color: var(--tipink); border: 1px solid var(--tipline); border-radius: 8px; padding: 8px 10px; font-size: 12.5px; box-shadow: 0 12px 30px -12px rgba(0, 0, 0, 0.45); }\n#tt .t { font-size: 11.5px; opacity: 0.7; margin-bottom: 4px; }\n#tt .r { display: flex; align-items: center; gap: 8px; line-height: 1.6; }\n#tt .r i { width: 8px; height: 8px; border-radius: 2px; flex: none; }\n#tt .r b { margin-left: auto; padding-left: 12px; font-variant-numeric: tabular-nums; }\n#tt .f { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--tipline); opacity: 0.8; font-size: 11.5px; }\nsvg .b { transition: opacity 0.15s; }\n.anim svg .b { transform-box: fill-box; transform-origin: 50% 100%; animation: rise 0.55s cubic-bezier(.2, .7, .2, 1) both; }\n.anim svg .ln { stroke-dasharray: 4000; animation: draw 0.9s ease-out both; }\n@keyframes rise { from { transform: scaleY(0); } to { transform: scaleY(1); } }\n@keyframes draw { from { stroke-dashoffset: 4000; } to { stroke-dashoffset: 0; } }\n\n/* tables */\n.tbl { width: 100%; border-collapse: collapse; }\n.tbl th { font-size: 12px; font-weight: 500; color: var(--ink3); text-align: right; padding: 7px 16px; border-bottom: 1px solid var(--line2); white-space: nowrap; }\n.tbl th:first-child, .tbl td:first-child { text-align: left; }\n.tbl td { font-size: 13.5px; text-align: right; padding: 0 16px; height: 36px; border-bottom: 1px solid var(--line2); white-space: nowrap; }\n.tbl tr:last-child td { border-bottom: 0; }\n/* tables that fit their card: fixed number columns, the first one takes the rest and ends in \"…\" */\n.tbl.fit { table-layout: fixed; }\n.tbl.fit th, .tbl.fit td { padding-left: 10px; padding-right: 10px; }\n.tbl.fit th:first-child, .tbl.fit td:first-child { padding-left: 16px; }\n.tbl.fit th:last-child, .tbl.fit td:last-child { padding-right: 16px; }\n.tbl.fit td { overflow: hidden; text-overflow: ellipsis; } /* a long value never pushes the table wider than its card */\n/* optional columns give way when the card is narrow: .opt first, then .opt2 */\n#table, #top, #p-routes, .wb > .tscroll { container-type: inline-size; }\n@container (max-width: 560px) { .tbl .opt { display: none; } }\n@container (max-width: 420px) { .tbl .opt2 { display: none; } }\n.tbl .bar { width: 100%; margin: 0; }\n.tbl .when { font-size: 13px; }\n.tbl .when small { color: var(--ink3); font-size: 11.5px; margin-left: 6px; }\n.tbl tr[data-go] { cursor: pointer; }\n.tbl tr[data-go]:hover td { background: var(--hover); }\n.tbl .chev { color: var(--ink3); width: 30px; }\n.tbl td.name { max-width: 460px; overflow: hidden; text-overflow: ellipsis; }\n.tp { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; font-size: 13px; }\n.tp i { width: 9px; height: 9px; border-radius: 3px; }\n.meth { display: inline-block; min-width: 54px; text-align: center; font-size: 11.5px; font-weight: 600; color: #fff; border-radius: 5px; padding: 2px 6px; margin-right: 10px; background: #57606b; }\n.share { display: inline-block; width: 90px; height: 6px; border-radius: 3px; background: var(--track); vertical-align: middle; margin-right: 8px; overflow: hidden; }\n.share i { display: block; height: 100%; border-radius: 3px; }\n.search, .inp { border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px; font-size: 13px; background: var(--bg); }\n.search { width: 230px; }\n.search:focus, .inp:focus, select:focus, .in:focus, textarea:focus { outline: none; border-color: var(--bar); box-shadow: 0 0 0 3px rgba(110, 168, 254, 0.18); }\nselect { border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; font-size: 13px; background: var(--bg); }\n.bars-list { padding: 10px 16px 14px; display: grid; gap: 10px; }\n.bars-list .row { display: grid; grid-template-columns: 150px 1fr 90px; align-items: center; gap: 12px; font-size: 13px; }\n.bars-list .row .bar { height: 8px; border-radius: 4px; background: var(--track); overflow: hidden; }\n.bars-list .row .bar i { display: block; height: 100%; border-radius: 4px; }\n.bars-list .row span:last-child { text-align: right; }\n\n/* route view */\n.slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-top: 14px; }\n.slot { text-align: left; background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px 44px; position: relative; }\n.slot:hover { border-color: var(--ink3); }\n.slot.on { border-color: var(--sel); box-shadow: 0 0 0 3px var(--selbg); }\n.slot .l { font-size: 12px; color: var(--ink2); }\n.slot .v { font-size: 21px; font-weight: 600; margin-top: 2px; }\n.slot .s { font-size: 11px; color: var(--ink3); }\n.slot svg { position: absolute; left: 12px; bottom: 10px; width: calc(100% - 24px); }\n.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-top: 14px; }\n.stat { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }\n.stat .l { font-size: 12px; color: var(--ink2); }\n.stat .v { font-size: 19px; font-weight: 600; margin-top: 2px; }\n.stat .s { font-size: 11.5px; color: var(--ink3); }\n.keychip { font-size: 12.5px; padding: 4px 10px; border-radius: 6px; background: var(--surface2); border: 1px solid var(--line); color: var(--ink2); word-break: break-all; }\n\n/* insights */\n.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 14px; }\n.chips button { display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); color: var(--ink2); font-size: 13px; }\n.chips button.on { border-color: var(--ink); color: var(--ink); background: var(--hover); }\n.chips button b { font-size: 12px; color: var(--ink3); }\n.ins-list { display: grid; gap: 10px; margin-top: 14px; }\n.pgrid { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); border-top: 1px solid var(--line2); }\n.pgrid > div + div { border-left: 1px solid var(--line2); }\n@media (max-width: 1000px) { .pgrid { grid-template-columns: 1fr; } .pgrid > div + div { border-left: 0; border-top: 1px solid var(--line2); } }\n.pkpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(170px, 100%), 1fr)); }\n.pkpis > div { padding: 12px 16px; border-left: 1px solid var(--line2); }\n.pkpis > div:first-child { border-left: 0; }\n.pkpis .l { font-size: 12px; color: var(--ink2); } .pkpis .v { font-size: 22px; font-weight: 650; margin-top: 2px; } .pkpis .s { font-size: 12px; color: var(--ink3); margin-top: 2px; }\n.ctitle { padding: 12px 16px 0; font-size: 12.5px; color: var(--ink3); }\n.fold { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 10px 16px; }\n.fold:hover { background: var(--hover); }\n.fold .more { margin-left: auto; display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--ink2); }\n.fold .more svg { width: 14px; height: 14px; transition: transform 0.2s; }\n.fold.open .more svg { transform: rotate(180deg); }\n.pick { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 8px; }\n.pick button { text-align: left; border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; background: var(--bg); }\n.pick button b { display: block; font-size: 13px; } .pick button span { display: block; font-size: 11.5px; color: var(--ink3); margin-top: 2px; }\n.pick button.on { border-color: var(--sel); box-shadow: 0 0 0 3px var(--selbg); }\n.mb h4 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink3); margin: 18px 0 8px; }\n.mb h4:first-child { margin-top: 0; }\n.danger-box { padding: 12px 14px; border-radius: 10px; border: 1px solid var(--bad); background: rgba(240, 112, 106, 0.1); font-size: 13.5px; }\n.danger-box b { color: var(--bad); }\n.hist { display: grid; gap: 6px; max-height: 300px; overflow-y: auto; }\n.hist > div { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--line2); border-radius: 8px; font-size: 13px; }\n.hist .ibtn.sm { flex: none; }\n#print { display: none; }\n@media print {\n @page { margin: 14mm; }\n body { background: #fff !important; min-height: 0; }\n body > *:not(#print) { display: none !important; }\n #print { display: block; color: #111; font: 11.5px/1.45 system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif; }\n #print h1 { font-size: 20px; margin-bottom: 2px; } #print h2 { font-size: 14px; margin: 18px 0 6px; }\n #print .sub { color: #555; }\n #print .kp { display: flex; gap: 28px; margin-top: 14px; } #print .kp b { display: block; font-size: 18px; }\n #print table { width: 100%; border-collapse: collapse; } #print th, #print td { text-align: right; padding: 4px 6px; border-bottom: 1px solid #ddd; }\n #print th:first-child, #print td:first-child, #print td.l, #print th.l { text-align: left; }\n #print tr { break-inside: avoid; }\n}\n.ins-sum { display: grid; grid-template-columns: 300px minmax(0, 0.9fr) minmax(0, 1.4fr); gap: 14px; margin-top: 14px; }\n@media (max-width: 1150px) { .ins-sum { grid-template-columns: 1fr; } }\n.ins-sum .card { margin-top: 0; }\n.ins { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; transition: border-color 0.15s; }\n.ins:hover { border-color: var(--ink3); }\n.ins-h { display: grid; grid-template-columns: 4px minmax(0, 1fr) 180px 96px 92px; align-items: center; gap: 14px; text-align: left; width: 100%; padding: 12px 16px 12px 0; }\n.ins-h:hover { background: var(--hover); }\n.ins .bar { align-self: stretch; margin: -12px 0; }\n.ins.critical .bar { background: var(--bad); } .ins.warning .bar { background: var(--warn); } .ins.info .bar { background: var(--info); }\n.ins .tag { display: inline-flex; align-items: center; gap: 6px; font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }\n.ins.critical .tag { color: var(--bad); } .ins.warning .tag { color: var(--warn); } .ins.info .tag { color: var(--info); }\n.ins .t { font-size: 14.5px; font-weight: 600; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.ins .d { font-size: 12.5px; color: var(--ink2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.ins.open .d { white-space: normal; }\n.ins .val { font-size: 20px; font-weight: 650; text-align: right; }\n.ins.critical .val { color: var(--bad); } .ins.warning .val { color: var(--warn); }\n.ins .more { display: inline-flex; align-items: center; justify-content: flex-end; gap: 5px; font-size: 12.5px; color: var(--ink2); white-space: nowrap; }\n.ins .more svg { width: 14px; height: 14px; transition: transform 0.2s; }\n.ins.open .more svg { transform: rotate(180deg); }\n.ins-b { display: none; border-top: 1px solid var(--line2); grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }\n.ins.open .ins-b { display: grid; }\n.ins.opening .ins-b { animation: open 0.2s ease-out; }\n@keyframes open { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }\n@media (max-width: 1000px) { .ins-b { grid-template-columns: 1fr; } }\n.ins-b .side { padding: 14px 16px; border-left: 1px solid var(--line2); display: flex; flex-direction: column; gap: 14px; }\n.ins-b h5 { font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink3); margin-bottom: 6px; }\n.ins-b ul { margin: 0 0 0 18px; font-size: 13px; color: var(--ink2); display: grid; gap: 4px; }\n.ins-b .ctitle { padding: 12px 16px 0; font-size: 12.5px; color: var(--ink3); }\n.facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 8px; }\n.fact { background: var(--surface2); border: 1px solid var(--line2); border-radius: 8px; padding: 8px 10px; }\n.fact .l { font-size: 11.5px; color: var(--ink3); }\n.fact .v { font-size: 15px; font-weight: 650; margin-top: 2px; }\n.gbars { padding: 12px 16px 14px; display: grid; gap: 7px; }\n.gbars button { display: grid; grid-template-columns: 96px 1fr 28px; align-items: center; gap: 10px; text-align: left; font-size: 13px; padding: 2px 4px; border-radius: 6px; }\n.gbars button:hover, .gbars button.on { background: var(--hover); }\n.gbars button i { display: block; height: 10px; border-radius: 4px; min-width: 2px; }\n.gbars button b { text-align: right; font-variant-numeric: tabular-nums; }\n.strip { display: grid; grid-template-columns: 260px 1fr; }\n.strip .sum { padding: 14px 16px; border-right: 1px solid var(--line2); display: flex; flex-direction: column; gap: 8px; }\n.strip .sum .n { display: flex; gap: 14px; font-size: 13px; }\n.strip .sum .n b { font-size: 22px; display: block; }\n.strip .rows { display: grid; }\n.strip .rows button { display: grid; grid-template-columns: 8px 1fr 80px; align-items: center; gap: 10px; text-align: left; padding: 9px 16px; border-bottom: 1px solid var(--line2); font-size: 13px; }\n.strip .rows button:last-child { border-bottom: 0; }\n.strip .rows button:hover { background: var(--hover); }\n.strip .rows i { width: 8px; height: 8px; border-radius: 50%; }\n.allclear { display: flex; align-items: center; gap: 10px; color: var(--ok); font-weight: 600; padding: 16px; }\n\n/* settings */\n.notice { margin-top: 14px; display: flex; gap: 12px; align-items: flex-start; padding: 12px 16px; border-radius: 10px; border: 1px solid rgba(210, 153, 34, 0.45); background: var(--warnbg); color: var(--ink); font-size: 13.5px; }\n.notice b { color: var(--warnink); }\n.notice.info { border-color: rgba(110, 168, 254, 0.45); background: rgba(110, 168, 254, 0.08); }\n.notice.info b { color: var(--info); }\n.st-meter { display: flex; height: 10px; border-radius: 5px; background: var(--track); overflow: hidden; margin: 14px 16px 4px; }\n.st-meter i { display: block; height: 100%; }\n.st-meter .tr { background: var(--bar); } .st-meter .ot { background: var(--ink3); opacity: 0.55; }\n.st-sum { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); gap: 4px 18px; padding: 10px 16px 14px; border-bottom: 1px solid var(--line2); }\n.st-sum div { font-size: 12.5px; color: var(--ink2); display: flex; align-items: center; gap: 7px; }\n.st-sum b { font-size: 19px; font-weight: 600; color: var(--ink); margin-left: 2px; }\n.st-sum i { width: 10px; height: 10px; border-radius: 3px; flex: none; }\n.st-sum span { display: block; width: 100%; font-size: 12px; color: var(--ink3); }\n.st-sum > div { flex-wrap: wrap; }\n.st-notes { padding: 10px 16px; font-size: 12.5px; color: var(--warnink); border-top: 1px solid var(--line2); }\n.set-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: 14px; align-items: start; }\n@media (max-width: 1100px) { .set-grid { grid-template-columns: 1fr; } }\n.set-row { display: flex; align-items: center; gap: 14px; padding: 10px 16px; border-bottom: 1px solid var(--line2); }\n.set-row:last-child { border-bottom: 0; }\n.set-row .txt { flex: 1; min-width: 0; }\n.set-row .txt b { font-weight: 600; font-size: 13.5px; display: flex; align-items: center; gap: 8px; }\n.set-row .txt span { display: block; font-size: 12px; color: var(--ink3); margin-top: 1px; }\n.set-row.changed { background: rgba(46, 160, 67, 0.08); }\n.set-row.changed .txt b::after { content: \"changed\"; font-size: 10.5px; font-weight: 600; padding: 1px 6px; border-radius: 4px; background: var(--add); color: var(--ink); }\n.tgl { position: relative; width: 40px; height: 22px; flex: none; }\n.tgl input { position: absolute; inset: 0; opacity: 0; cursor: pointer; margin: 0; z-index: 1; }\n.tgl span { position: absolute; inset: 0; border-radius: 11px; background: var(--track); border: 1px solid var(--line); transition: background 0.15s; }\n.tgl span::after { content: \"\"; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); transition: transform 0.15s; }\n.tgl input:checked + span { background: var(--ok); border-color: var(--ok); }\n.tgl input:checked + span::after { transform: translateX(18px); }\n.tgl input:disabled + span { opacity: 0.45; }\n.paths { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 16px 0; }\n.paths span { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 3px 4px 3px 9px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface2); }\n.code { font-size: 12.8px; line-height: 1.65; background: var(--code); padding: 10px 0; overflow-x: auto; }\n.code .ln { display: flex; white-space: pre; padding-right: 16px; }\n.code .ln .g { flex: none; width: 46px; text-align: right; padding-right: 10px; color: var(--ink3); user-select: none; }\n.code .ln .s { flex: none; width: 16px; color: var(--ink3); user-select: none; }\n.code .ln.add { background: var(--add); }\n.code .ln.add .s { color: var(--ink); }\n.reqs .req + .req { border-top: 1px solid var(--line2); }\n.req summary { display: flex; align-items: center; gap: 10px; padding: 9px 16px; cursor: pointer; list-style: none; font-size: 13px; min-width: 0; }\n.req summary::-webkit-details-marker { display: none; }\n.req summary:hover { background: var(--hover); }\n.req summary .st { font-weight: 600; color: var(--ok); } .req summary .st.bad { color: var(--bad); }\n.req summary .rep { flex: none; font-size: 11.5px; font-weight: 600; padding: 1px 7px; border-radius: 10px; background: var(--pribg); color: var(--pri); }\n.req summary .when { margin-left: auto; color: var(--ink2); white-space: nowrap; font-size: 12.5px; }\n.req summary svg { flex: none; width: 14px; height: 14px; color: var(--ink3); transition: transform 0.2s; }\n.req[open] summary svg { transform: rotate(180deg); }\n.req h5 { margin: 2px 16px 6px; font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink2); }\n.req .note { margin: -4px 16px 10px; font-size: 12.5px; color: var(--ink3); }\n.curl { position: relative; margin: 0 16px 12px; }\n.curl pre { margin: 0; font-size: 12.5px; line-height: 1.6; background: var(--code); border: 1px solid var(--line); border-radius: 8px; padding: 10px 84px 10px 12px; overflow-x: auto; white-space: pre; max-height: 360px; }\n.curl .btn { position: absolute; top: 6px; right: 6px; height: 26px; padding: 0 8px; font-size: 12px; }\n.code .ln.del { background: var(--del); text-decoration: line-through; text-decoration-color: rgba(127, 127, 127, 0.6); }\n.k-k { color: #ff7aa2; } .k-s { color: #8fd6a8; } .k-n { color: #ffb37a; } .k-c { color: var(--ink3); font-style: italic; } .k-p { color: #8cb8ff; } .k-m { color: var(--warn); }\n[data-theme=\"light\"] .k-k { color: #cf222e; } [data-theme=\"light\"] .k-s { color: #0a7f3f; } [data-theme=\"light\"] .k-n { color: #953800; } [data-theme=\"light\"] .k-p { color: #0550ae; }\n\n/* buttons, boards, editor */\n.btn { display: inline-flex; align-items: center; justify-content: center; gap: 7px; height: 32px; padding: 0 12px; border-radius: 8px; font-size: 13px; font-weight: 600; border: 1px solid var(--line); background: var(--surface); color: var(--ink); white-space: nowrap; }\n.btn:hover { border-color: var(--ink3); }\n.btn.p { background: var(--pri); border-color: var(--pri); color: #fff; }\n.btn.d { background: var(--bad); border-color: var(--bad); color: #fff; }\n.btn.d:hover { filter: brightness(1.08); }\n.btn.on { color: var(--sel); border-color: var(--sel); background: var(--selbg); }\n.btn.p:hover { filter: brightness(1.08); }\n.btn.ghost { border-color: transparent; background: transparent; color: var(--ink2); }\n.btn.ghost:hover { color: var(--ink); background: var(--hover); }\n.btn.danger { color: var(--bad); }\n.btn svg { width: 14px; height: 14px; }\n.btn:disabled { opacity: 0.5; cursor: not-allowed; }\n/* an unavailable button looks grey and doesn't react to hover */\n.btn:disabled:hover { border-color: var(--line); filter: none; }\n.btn.ghost:disabled, .btn.ghost:disabled:hover { color: var(--ink3); background: transparent; border-color: transparent; }\n.boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); gap: 14px; margin-top: 14px; }\n.bcard { text-align: left; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 14px; display: flex; flex-direction: column; gap: 10px; min-height: 150px; }\n.bcard:hover { border-color: var(--ink3); }\n.bcard .nm { font-size: 15.5px; font-weight: 650; display: flex; align-items: center; gap: 8px; min-width: 0; }\n.bcard .mini { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-top: auto; }\n.bcard .mini i { height: 22px; border-radius: 4px; background: var(--track); }\n.bcard .mini i.m { grid-column: span 2; } .bcard .mini i.l { grid-column: span 4; }\n.bcard.tpl { border-style: dashed; }\n.bcard .role { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--sel); }\n.bcard.off { opacity: 0.55; cursor: not-allowed; }\n.star { color: #e3b341; }\n.wgrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); grid-auto-rows: 170px; grid-auto-flow: row dense; gap: 14px; margin-top: 14px; }\n.w { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; overflow: hidden; display: flex; flex-direction: column; min-width: 0; position: relative; }\n.w[data-cw=\"1\"] { grid-column: span 1; } .w[data-cw=\"2\"] { grid-column: span 2; } .w[data-cw=\"3\"] { grid-column: span 3; } .w[data-cw=\"4\"] { grid-column: span 4; }\n.w[data-ch=\"1\"] { grid-row: span 1; } .w[data-ch=\"2\"] { grid-row: span 2; } .w[data-ch=\"3\"] { grid-row: span 3; } .w[data-ch=\"4\"] { grid-row: span 4; }\n.rsz { position: absolute; z-index: 3; width: 16px; height: 16px; }\n.rsz::after { content: \"\"; position: absolute; border: 0 solid var(--pri); }\n.rsz:hover::after { border-color: var(--ink); }\n.rsz[data-dir=\"se\"] { right: 0; bottom: 0; cursor: nwse-resize; } .rsz[data-dir=\"se\"]::after { right: 4px; bottom: 4px; width: 8px; height: 8px; border-right-width: 2px; border-bottom-width: 2px; border-radius: 0 0 3px 0; }\n.rsz[data-dir=\"sw\"] { left: 0; bottom: 0; cursor: nesw-resize; } .rsz[data-dir=\"sw\"]::after { left: 4px; bottom: 4px; width: 8px; height: 8px; border-left-width: 2px; border-bottom-width: 2px; border-radius: 0 0 0 3px; }\n.rsz[data-dir=\"ne\"] { right: 0; top: 0; cursor: nesw-resize; } .rsz[data-dir=\"ne\"]::after { right: 4px; top: 4px; width: 8px; height: 8px; border-right-width: 2px; border-top-width: 2px; border-radius: 0 3px 0 0; }\n.rsz[data-dir=\"nw\"] { left: 0; top: 0; cursor: nwse-resize; } .rsz[data-dir=\"nw\"]::after { left: 4px; top: 4px; width: 8px; height: 8px; border-left-width: 2px; border-top-width: 2px; border-radius: 3px 0 0 0; }\n.rsz[data-dir=\"n\"], .rsz[data-dir=\"s\"] { left: calc(50% - 16px); width: 32px; height: 10px; cursor: ns-resize; }\n.rsz[data-dir=\"n\"] { top: 0; } .rsz[data-dir=\"s\"] { bottom: 0; }\n.rsz[data-dir=\"n\"]::after, .rsz[data-dir=\"s\"]::after { left: 6px; right: 6px; border-top-width: 3px; border-radius: 2px; }\n.rsz[data-dir=\"n\"]::after { top: 2px; } .rsz[data-dir=\"s\"]::after { bottom: 2px; }\n.rsz[data-dir=\"e\"], .rsz[data-dir=\"w\"] { top: calc(50% - 16px); width: 10px; height: 32px; cursor: ew-resize; }\n.rsz[data-dir=\"e\"] { right: 0; } .rsz[data-dir=\"w\"] { left: 0; }\n.rsz[data-dir=\"e\"]::after, .rsz[data-dir=\"w\"]::after { top: 6px; bottom: 6px; border-left-width: 3px; border-radius: 2px; }\n.rsz[data-dir=\"e\"]::after { right: 2px; } .rsz[data-dir=\"w\"]::after { left: 2px; }\n.dims { position: absolute; right: 26px; bottom: 6px; z-index: 2; font-size: 11px; font-weight: 600; padding: 1px 6px; border-radius: 4px; background: var(--pill); color: #fff; }\n.w.resizing { box-shadow: 0 0 0 2px var(--sel); }\n.wins { display: grid; padding: 2px 0 6px; }\n.wins button { display: grid; grid-template-columns: 8px minmax(0, 1fr) auto; align-items: center; gap: 10px; text-align: left; padding: 9px 14px; font-size: 13px; border-bottom: 1px solid var(--line2); }\n.wins button:hover { background: var(--hover); }\n.wins button i { width: 8px; height: 8px; border-radius: 50%; }\n.trunc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }\n.w .wh { display: flex; align-items: center; gap: 8px; padding: 10px 12px 6px 14px; min-height: 40px; }\n.w .wh b { font-size: 13.5px; font-weight: 600; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.w .wh .tools { display: flex; gap: 4px; }\n.w .wk { font-size: 11px; color: var(--ink3); padding: 0 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n.w .wb { flex: 1; min-height: 0; position: relative; overflow: hidden; }\n.editing .w { cursor: grab; border-style: dashed; border-color: var(--pri); user-select: none; }\n.w.dragging { opacity: 0.45; pointer-events: none; box-shadow: 0 0 0 2px var(--pri); }\nbody.moving, body.moving * { cursor: grabbing !important; }\n/* room below the last chart, to drag or stretch charts past the bottom of the window */\n.wgrid.editing { padding-bottom: 360px; }\n.num { padding: 8px 14px 12px; }\n.num .v { font-size: 34px; font-weight: 650; letter-spacing: -0.02em; }\n.num .s { font-size: 12px; color: var(--ink3); display: flex; gap: 8px; }\n.num .rows { display: grid; gap: 4px; margin-top: 4px; }\n.num .rows div { display: flex; justify-content: space-between; font-size: 13px; }\n.donut { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; padding: 8px 16px 14px; }\n.donut .lg { flex: 1; min-width: 180px; display: grid; gap: 4px; font-size: 12.5px; }\n.donut .lg button { display: grid; grid-template-columns: 10px 1fr auto 46px; align-items: center; gap: 8px; text-align: left; padding: 2px 4px; border-radius: 5px; }\n.donut .lg button:hover { background: var(--hover); }\n.donut .lg i { width: 10px; height: 10px; border-radius: 3px; }\n.donut .lg span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.toast { position: fixed; right: 20px; bottom: 20px; z-index: 70; display: grid; gap: 8px; }\n.toast div { background: var(--tip); color: var(--tipink); border: 1px solid var(--tipline); border-radius: 8px; padding: 10px 14px; font-size: 13px; box-shadow: 0 12px 30px -12px rgba(0, 0, 0, 0.5); }\n.modal-back { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.55); display: flex; align-items: flex-start; justify-content: center; padding: 40px 20px; overflow-y: auto; }\n.modal { width: min(1180px, 100%); background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6); }\n.modal.sm { width: min(520px, 100%); }\n.modal .mh { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--line2); }\n.modal .mh b { font-size: 16px; font-weight: 650; flex: 1; }\n.modal .mb { padding: 16px 18px; }\n.modal .mf { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--line2); }\n.ed { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; }\n@media (max-width: 1000px) { .ed { grid-template-columns: 1fr; } }\n.ed h4 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink3); margin: 16px 0 8px; display: flex; align-items: center; gap: 8px; }\n.ed h4:first-child { margin-top: 0; }\n.ed h4 i { font-style: normal; width: 18px; height: 18px; border-radius: 50%; background: var(--pri); color: #fff; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; }\n.kinds { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }\n.kinds button, .charts-pick button { text-align: left; border: 1px solid var(--line); border-radius: 10px; padding: 9px 10px; background: var(--bg); }\n.kinds button b, .charts-pick button b { display: block; font-size: 13px; }\n.kinds button span { display: block; font-size: 11.5px; color: var(--ink3); margin-top: 2px; }\n.kinds button.on, .charts-pick button.on { border-color: var(--sel); box-shadow: 0 0 0 3px var(--selbg); }\n.charts-pick { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; }\n.charts-pick button { text-align: center; padding: 8px 4px; }\n.charts-pick button svg { width: 34px; height: 24px; display: block; margin: 0 auto 4px; color: var(--ink2); }\n.charts-pick button:disabled { opacity: 0.35; cursor: not-allowed; }\n.serieses { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }\n.serieses button { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); max-width: 100%; }\n.serieses button.on { border-color: var(--sel); box-shadow: 0 0 0 2px var(--selbg); }\n.serieses button i { width: 9px; height: 9px; border-radius: 2px; flex: none; }\n.serieses button span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n.typechips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }\n.typechips button { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 999px; border: 1px solid var(--line); font-size: 12.5px; font-weight: 600; }\n.typechips button.on { border-color: var(--sel); background: var(--selbg); }\n.typechips button:disabled { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }\n.typechips button i { width: 8px; height: 8px; border-radius: 2px; }\n.rlist { max-height: 210px; overflow-y: auto; border: 1px solid var(--line); border-radius: 8px; margin-top: 8px; }\n.rlist button { display: flex; align-items: center; width: 100%; text-align: left; padding: 6px 10px; font-size: 12.5px; border-bottom: 1px solid var(--line2); }\n.rlist button:last-child { border-bottom: 0; }\n.rlist button:hover, .rlist button.on { background: var(--hover); }\n.rlist button span:last-child { margin-left: auto; color: var(--ink3); }\n.pat { display: grid; grid-template-columns: 1fr 1fr 2fr; gap: 8px; margin-top: 10px; }\n.pat label { font-size: 11.5px; color: var(--ink3); display: grid; gap: 4px; min-width: 0; }\n.pat input { width: 100%; min-width: 0; }\n.keyline { margin-top: 8px; font-size: 12px; color: var(--ink2); }\n.keyline code { color: var(--ink); background: var(--surface2); border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; }\n.row2 { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }\n.preview { background: var(--bg); border: 1px solid var(--line); border-radius: 12px; padding: 12px; position: sticky; top: 0; }\n.preview .w { border-color: var(--line); }\n.hint { font-size: 12px; color: var(--ink3); margin-top: 6px; }\n.namein { font-size: 20px; font-weight: 650; background: var(--bg); border: 1px dashed var(--line); border-radius: 8px; padding: 2px 8px; min-width: 120px; }\n.namein:hover { border-color: var(--ink3); }\n.namewrap { display: inline-flex; align-items: center; gap: 4px; min-width: 0; }\n.namewrap .ibtn { color: var(--ink3); flex: none; }\n.namewrap .ibtn:hover { color: var(--ink); }\n.bcard .nm .rn { margin-left: auto; color: var(--ink3); flex: none; }\n.bcard .nm .rn:hover { color: var(--ink); }\n.namein:focus { outline: none; border-color: var(--bar); }\n.login { max-width: 400px; margin: 12vh auto 0; width: calc(100% - 24px); background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 30px; }\n.login label { display: block; font-size: 13px; color: var(--ink2); margin: 16px 0 6px; }\n.in { width: 100%; height: 40px; border: 1px solid var(--line); border-radius: 8px; padding: 0 12px; font-size: 14px; background: var(--bg); }\n.login .btn { margin-top: 22px; width: 100%; height: 42px; font-size: 15px; }\n\n/* responsive: large screens keep 4 board columns, tablets 2, phones 1; everything else stacks */\n#table, #top, .wb > div { overflow-x: auto; }\n.tbl { min-width: 480px; }\n.tbl.fit { min-width: 0; } /* sized to its card */\n@media (max-width: 1279px) {\n .wgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); }\n .w[data-cw=\"3\"], .w[data-cw=\"4\"] { grid-column: span 2; }\n .charts-pick { grid-template-columns: repeat(4, 1fr); }\n}\n@media (max-width: 1023px) {\n .top { flex-wrap: wrap; height: auto; padding: 8px 16px; row-gap: 4px; }\n .nav { order: 10; width: 100%; margin-left: 0; height: 40px; overflow-x: auto; }\n .nav a { padding: 0 10px; white-space: nowrap; }\n .strip { grid-template-columns: 1fr; }\n .strip .sum { border-right: 0; border-bottom: 1px solid var(--line2); }\n .ins-h { grid-template-columns: 4px minmax(0, 1fr) 80px 72px; }\n .ins-h > span:nth-child(3) { display: none; }\n .kinds { grid-template-columns: repeat(2, 1fr); }\n}\n@media (max-width: 767px) {\n main { padding: 12px 12px 40px; }\n .pchip, .user, .stchip { display: none; }\n .banner { padding: 8px 16px; }\n .live { font-size: 12px; }\n .wgrid { grid-template-columns: minmax(0, 1fr); grid-auto-rows: 160px; }\n .w[data-cw] { grid-column: span 1; }\n .h2 { font-size: 18px; }\n .kpi .v { font-size: 23px; }\n .search { width: 100%; }\n .ch .right { margin-left: 0; width: 100%; flex-wrap: wrap; }\n .ins-h { grid-template-columns: 4px minmax(0, 1fr) 64px; gap: 10px; }\n .ins-h .more { display: none; }\n .set-row { flex-wrap: wrap; }\n .pat { grid-template-columns: 1fr; }\n .charts-pick { grid-template-columns: repeat(3, 1fr); }\n .modal-back { padding: 12px 8px; }\n .bars-list .row { grid-template-columns: 90px 1fr 80px; }\n}\n@media (max-width: 639px) {\n .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }\n .kpi svg { position: static; display: block; width: 100%; margin-top: 6px; }\n .seg { flex-wrap: nowrap; overflow-x: auto; max-width: 100%; }\n .seg button { flex: none; }\n .live { padding-left: 8px; }\n .live select { max-width: 112px; }\n .crumbs .meta { margin-left: 0; width: 100%; }\n}\n</style>\n</head>\n<body>\n<div id=\"app\"></div>\n<div id=\"tt\"></div>\n<div class=\"toast\" id=\"toast\"></div>\n<script nonce=\"__NONCE__\">\n(function () {\n \"use strict\";\n var BASE = __BASE__;\n var TC = { API: \"#6ea8fe\", WEB: \"#3ccbb4\", GRAPHQL: \"#f06fb5\", RPC: \"#a68bff\", WSS: \"#ffa45c\", SSE: \"#e8c15a\", GRPC: \"#5fd39a\", OTHER: \"#8b95a3\", STATIC: \"#c9a85a\", OUT: \"#8b98a8\" };\n var PALETTE = [\"#6ea8fe\", \"#3ccbb4\", \"#f06fb5\", \"#a68bff\", \"#ffa45c\", \"#e8c15a\", \"#5fd39a\", \"#ff8f82\"];\n var MC = { GET: \"#2f6fdf\", POST: \"#1f9d63\", PUT: \"#c2700f\", PATCH: \"#7c5ce0\", DELETE: \"#d4433b\", ACTION: \"#8a5cd6\", QUERY: \"#2f6fdf\", MUTATION: \"#1f9d63\", SUBSCRIPTION: \"#0e8a8a\" };\n var BUCKETS = [\"sec\", \"min\", \"hour\", \"day\", \"month\", \"year\"];\n // a range picker button says how far back it goes; the step is in CHART_TITLE\n var RANGE_LABEL = { sec: \"1 min\", min: \"1 hour\", hour: \"24 hours\", week: \"7 days\", day: \"30 days\", month: \"12 months\", year: \"5 years\" };\n var RANGE_WHEN = { sec: \"last minute\", min: \"last hour\", hour: \"last 24 hours\", week: \"last 7 days\", day: \"last 30 days\", month: \"last 12 months\", year: \"last 5 years\" };\n var BUCKET_NAME = { sec: \"second\", min: \"minute\", hour: \"hour\", day: \"day\", month: \"month\", year: \"year\" };\n var SLOT_LABEL = { sec: \"Seconds\", min: \"Minutes\", hour: \"Hours\", day: \"Days\", month: \"Months\", year: \"Years\" };\n var SLOT_NOW = { sec: \"this second\", min: \"this minute\", hour: \"this hour\", day: \"today\", month: \"this month\", year: \"this year\" };\n var PREV_LABEL = { sec: \"last second\", min: \"last minute\", hour: \"last hour\", day: \"yesterday\", month: \"last month\", year: \"last year\" };\n var PER = { sec: \"per second\", min: \"per minute\", hour: \"per hour\", day: \"per day\", month: \"per month\", year: \"per year\" };\n var CHART_TITLE = { sec: \"Per second, last minute\", min: \"Per minute, last hour\", hour: \"Per hour, last 24 hours\", week: \"Per day, last 7 days\", day: \"Per day, last 30 days\", month: \"Per month, last 12 months\", year: \"Per year, last 5 years\" };\n var MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\n var TYPE_INFO = { API: \"Everything under /api/, and JSON responses elsewhere\", WEB: \"HTML page views\", GRAPHQL: \"GraphQL operations, by name\", RPC: \"JSON-RPC, tRPC and Server Actions by name, anything on a host or route with rpc in it, and calls out to RPC hosts (action OUT, not in the total)\", WSS: \"WebSocket and Socket.IO connections and messages\", SSE: \"Server-sent event streams\", GRPC: \"gRPC, gRPC-Web and Connect calls\", OTHER: \"Requests to unknown URLs (404s, bot scans)\", STATIC: \"Static files (css, js, images)\", OUT: \"Calls your app makes, to your own backend or third parties\" };\n var PERIOD_NAMES = { \"15m\": \"15 min\", \"1h\": \"1 hour\", today: \"Today\", week: \"This week\", month: \"This month\", quarter: \"This quarter\", year: \"This year\" };\n var REFRESH = [[1000, \"Live · every 1 s\"], [5000, \"Every 5 s\"], [30000, \"Every 30 s\"], [60000, \"Every 1 min\"], [300000, \"Every 5 min\"], [900000, \"Every 15 min\"], [0, \"Off\"]];\n var app = document.getElementById(\"app\"), tt = document.getElementById(\"tt\");\n\n // ---------- small helpers ----------\n // Everything that comes from Redis (route names can come from request bodies) goes through esc().\n function esc(s) { return String(s).replace(/[&<>\"']/g, function (c) { return { \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" }[c]; }); }\n function fmt(n) { return n === null || n === undefined || isNaN(n) ? \"—\" : Math.round(n).toLocaleString(\"en-US\"); }\n function short(n) {\n if (n === null || n === undefined) return \"—\";\n var a = Math.abs(n);\n if (a >= 1e9) return (n / 1e9).toFixed(1) + \"B\";\n if (a >= 1e6) return (n / 1e6).toFixed(1) + \"M\";\n if (a >= 1e4) return (n / 1e3).toFixed(1) + \"k\";\n return fmt(n);\n }\n function rate(n) { return n === null || n === undefined ? \"—\" : n >= 100 ? fmt(n) : String(Math.round(n * 10) / 10); }\n function pct(n, d) { return d ? (n / d * 100).toFixed(n / d < 0.1 ? 2 : 1) + \"%\" : \"0%\"; }\n function sum(a) { var s = 0; for (var i = 0; i < a.length; i++) s += a[i] || 0; return s; }\n function last(a) { return a && a.length ? a[a.length - 1] : 0; }\n function color(type) { return TC[type] || \"#8b95a3\"; }\n function method(action) { return '<span class=\"meth mono\" style=\"background:' + (MC[action] || \"#57606b\") + '\">' + esc(action) + \"</span>\"; }\n // outgoing calls: to the app's own backend (its own domains, localhost, private hosts) or to a third party\n function destText(x) { return x.own ? \"your backend\" : \"third-party\"; }\n function destTag(x) { return x.own === undefined ? \"\" : '<span class=\"dest\" title=\"' + (x.own ? \"A call to your own domains or a local/private host\" : \"A call to a service outside your domains\") + '\">' + destText(x) + \"</span>\"; }\n function clone(o) { return JSON.parse(JSON.stringify(o)); }\n function uid() { return Math.random().toString(36).slice(2, 9); }\n var store = {\n get: function (k, d) { try { var v = localStorage.getItem(k); return v === null ? d : JSON.parse(v); } catch (e) { return d; } },\n set: function (k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} },\n del: function (k) { try { localStorage.removeItem(k); } catch (e) {} }\n };\n function log(msg) { console.info(\"%c[system-tracker]%c \" + msg, \"color:#ef5a4f;font-weight:600\", \"color:inherit\"); }\n function toast(msg) {\n var el = document.createElement(\"div\"); el.textContent = msg;\n document.getElementById(\"toast\").appendChild(el);\n setTimeout(function () { el.remove(); }, 3200);\n }\n function istNow() { try { return new Date().toLocaleTimeString(\"en-GB\", { timeZone: \"Asia/Kolkata\" }); } catch (e) { return new Date().toLocaleTimeString(); } }\n function istMinute() { try { return Number(new Date().toLocaleString(\"en-GB\", { timeZone: \"Asia/Kolkata\", minute: \"2-digit\" })); } catch (e) { return new Date().getMinutes(); } }\n function expiry(sec) {\n if (sec >= 365 * 86400) { var y = Math.round(sec / (365 * 86400)); return y === 1 ? \"1 year\" : y + \" years\"; }\n if (sec >= 86400) { var d = Math.round(sec / 86400); return d === 1 ? \"1 day\" : d + \" days\"; }\n if (sec >= 3600) { var h = Math.round(sec / 3600); return h === 1 ? \"1 hour\" : h + \" hours\"; }\n return Math.round(sec / 60) + \" min\";\n }\n // Bucket ids are IST: 2026-10-30-14-05-32 (sec), 2026-10-30-14-05 (min) ... 2026 (year).\n function axisLabel(id) {\n var p = String(id).split(\"-\");\n if (p.length === 6) return p[3] + \":\" + p[4] + \":\" + p[5];\n if (p.length === 5) return p[3] + \":\" + p[4];\n if (p.length === 4) return p[2] + \" \" + MONTHS[+p[1] - 1] + \" \" + p[3] + \":00\";\n if (p.length === 3) return p[2] + \" \" + MONTHS[+p[1] - 1];\n if (p.length === 2) return MONTHS[+p[1] - 1] + \" \" + p[0];\n return id;\n }\n // short bucket time for tables: 12:55:41 · 12:55 · 2 Nov, 14:00 · Mon 2 Nov · Nov 2026 · 2026\n function bucketText(id) {\n var p = String(id).split(\"-\");\n if (p.length === 4) return p[2] + \" \" + MONTHS[+p[1] - 1] + \", \" + p[3] + \":00\";\n if (p.length === 3) return [\"Sun\", \"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\"][new Date(Date.UTC(+p[0], +p[1] - 1, +p[2])).getUTCDay()] + \" \" + +p[2] + \" \" + MONTHS[+p[1] - 1];\n return axisLabel(id);\n }\n function fullTime(id) {\n var p = String(id).split(\"-\");\n if (p.length >= 3) {\n var day = +p[2] + \" \" + MONTHS[+p[1] - 1] + \" \" + p[0];\n if (p.length === 6) return day + \", \" + p[3] + \":\" + p[4] + \":\" + p[5];\n if (p.length === 5) return day + \", \" + p[3] + \":\" + p[4];\n if (p.length === 4) return day + \", \" + p[3] + \":00 – \" + p[3] + \":59\";\n return day;\n }\n return axisLabel(id);\n }\n\n var ICON = {\n moon: '<svg viewBox=\"0 0 16 16\"><path d=\"M13.5 10.2A6 6 0 0 1 5.8 2.5a6 6 0 1 0 7.7 7.7z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg>',\n sun: '<svg viewBox=\"0 0 16 16\"><circle cx=\"8\" cy=\"8\" r=\"3\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"/><path d=\"M8 1v2M8 13v2M1 8h2M13 8h2M3 3l1.4 1.4M11.6 11.6L13 13M3 13l1.4-1.4M11.6 4.4L13 3\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\"/></svg>',\n reload: '<svg viewBox=\"0 0 16 16\"><path d=\"M13.5 8a5.5 5.5 0 1 1-1.6-3.9M13.5 2.5v3.2h-3.2\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>',\n edit: '<svg viewBox=\"0 0 16 16\"><path d=\"M11 2.5l2.5 2.5L6 12.5H3.5V10z\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg>',\n copy: '<svg viewBox=\"0 0 16 16\"><rect x=\"5\" y=\"5\" width=\"8.5\" height=\"8.5\" rx=\"1.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"/><path d=\"M3 10.5V3.5A1 1 0 0 1 4 2.5h7\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\"/></svg>',\n trash: '<svg viewBox=\"0 0 16 16\"><path d=\"M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linejoin=\"round\"/></svg>',\n plus: '<svg viewBox=\"0 0 16 16\"><path d=\"M8 3v10M3 8h10\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\"/></svg>',\n star: '<svg viewBox=\"0 0 16 16\"><path d=\"M8 1.8l1.9 3.9 4.3.6-3.1 3 .7 4.3L8 11.6l-3.8 2 .7-4.3-3.1-3 4.3-.6z\" fill=\"currentColor\"/></svg>',\n download: '<svg viewBox=\"0 0 16 16\"><path d=\"M8 2.5v7.5M4.8 7l3.2 3.2L11.2 7M3 13h10\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>',\n check: '<svg viewBox=\"0 0 16 16\" width=\"18\" height=\"18\"><circle cx=\"8\" cy=\"8\" r=\"7\" fill=\"currentColor\" opacity=\".18\"/><path d=\"M4.8 8.2l2.2 2.2 4.2-4.6\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>'\n };\n var CHART_ICON = {\n number: '<svg viewBox=\"0 0 34 24\"><text x=\"17\" y=\"17\" text-anchor=\"middle\" font-size=\"14\" font-weight=\"700\" fill=\"currentColor\">42</text></svg>',\n line: '<svg viewBox=\"0 0 34 24\"><polyline points=\"2,18 9,12 15,15 22,6 32,9\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>',\n area: '<svg viewBox=\"0 0 34 24\"><polygon points=\"2,22 2,18 9,12 15,15 22,6 32,9 32,22\" fill=\"currentColor\" opacity=\".35\"/><polyline points=\"2,18 9,12 15,15 22,6 32,9\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\"/></svg>',\n bars: '<svg viewBox=\"0 0 34 24\"><rect x=\"3\" y=\"12\" width=\"5\" height=\"10\" fill=\"currentColor\"/><rect x=\"11\" y=\"6\" width=\"5\" height=\"16\" fill=\"currentColor\"/><rect x=\"19\" y=\"9\" width=\"5\" height=\"13\" fill=\"currentColor\"/><rect x=\"27\" y=\"3\" width=\"5\" height=\"19\" fill=\"currentColor\"/></svg>',\n stack: '<svg viewBox=\"0 0 34 24\"><rect x=\"3\" y=\"14\" width=\"6\" height=\"8\" fill=\"currentColor\"/><rect x=\"3\" y=\"9\" width=\"6\" height=\"5\" fill=\"currentColor\" opacity=\".5\"/><rect x=\"14\" y=\"10\" width=\"6\" height=\"12\" fill=\"currentColor\"/><rect x=\"14\" y=\"4\" width=\"6\" height=\"6\" fill=\"currentColor\" opacity=\".5\"/><rect x=\"25\" y=\"12\" width=\"6\" height=\"10\" fill=\"currentColor\"/><rect x=\"25\" y=\"7\" width=\"6\" height=\"5\" fill=\"currentColor\" opacity=\".5\"/></svg>',\n donut: '<svg viewBox=\"0 0 34 24\"><circle cx=\"17\" cy=\"12\" r=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"5\" stroke-dasharray=\"30 50\" opacity=\".5\"/><circle cx=\"17\" cy=\"12\" r=\"8\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"5\" stroke-dasharray=\"20 50\" stroke-dashoffset=\"-30\"/></svg>',\n table: '<svg viewBox=\"0 0 34 24\"><path d=\"M3 5h28M3 11h28M3 17h28\" stroke=\"currentColor\" stroke-width=\"2\"/><path d=\"M3 5h18M3 11h12M3 17h7\" stroke=\"currentColor\" stroke-width=\"4\" opacity=\".4\"/></svg>'\n };\n CHART_ICON.insights = '<svg viewBox=\"0 0 34 24\"><circle cx=\"6\" cy=\"6\" r=\"3\" fill=\"currentColor\"/><path d=\"M12 6h18\" stroke=\"currentColor\" stroke-width=\"2\"/><circle cx=\"6\" cy=\"13\" r=\"3\" fill=\"currentColor\" opacity=\".6\"/><path d=\"M12 13h14\" stroke=\"currentColor\" stroke-width=\"2\" opacity=\".6\"/><circle cx=\"6\" cy=\"20\" r=\"3\" fill=\"currentColor\" opacity=\".35\"/><path d=\"M12 20h16\" stroke=\"currentColor\" stroke-width=\"2\" opacity=\".35\"/></svg>';\n var CHART_NAME = { number: \"Number\", line: \"Line\", area: \"Area\", bars: \"Bars\", stack: \"Stacked\", donut: \"Donut\", table: \"Table\", insights: \"Insights\" };\n\n // ---------- state ----------\n var state = {\n me: null, cfg: null, refresh: refreshSaved(), lastLoad: 0, range: \"\", data: null, error: \"\", loading: false, seq: 0,\n filter: \"\", sort: \"total\", shellKey: \"\", first: true, side: { insights: null, top: null, at: 0 }, storage: null, stAt: 0, stPoll: 0,\n insFilter: \"all\", legendOff: {}, boards: { boards: [], defaultId: null, key: \"\" }, editing: false, ed: null, wdata: {},\n period: periodSaved(), pdata: null, pAt: 0, xp: null, rs: null,\n // the exact request by default; the structure (with :params and <values>) is a choice the browser remembers\n curlMode: store.get(\"system-tracker-curl-mode\", \"sample\") === \"structure\" ? \"structure\" : \"sample\", reqOpen: {}\n };\n function periodSaved() { var v = store.get(\"system-tracker-period\", \"today\"); return PERIOD_NAMES[v] ? v : \"today\"; }\n function refreshSaved() {\n var v = store.get(\"system-tracker-refresh\", null);\n return REFRESH.some(function (r) { return r[0] === v; }) ? v : 30000;\n }\n\n // ---------- charts ----------\n // Every chart registers a model by element id; one mousemove handler draws the crosshair, axis pills and tooltip.\n var CH = {}, sparkSeq = 0;\n function niceMax(v) {\n if (v <= 4) return 4;\n var p = Math.pow(10, Math.floor(Math.log10(v))), steps = [1, 2, 2.5, 5, 10];\n for (var i = 0; i < steps.length; i++) if (steps[i] * p * 4 >= v) return steps[i] * p * 4;\n return 40 * p;\n }\n function tickLabel(t, unit) { if (unit === \"%\") return (Math.round(t * 10) / 10) + \"%\"; return t >= 1e6 ? t / 1e6 + \"M\" : t >= 1e3 ? t / 1e3 + \"k\" : String(Math.round(t * 100) / 100); }\n function valText(v, unit) { return unit === \"%\" ? (Math.round(v * 100) / 100) + \"%\" : fmt(v); }\n\n // A timed refresh doesn't redraw a chart while the pointer is on it, so the bar you're reading holds\n // still; it catches up with the newest data once the pointer leaves. Your own clicks always redraw.\n var held = {}, polled = false;\n function pointed(el) { return !!el.querySelector(\".chart:hover, .donut:hover, [data-spark]:hover\"); }\n function hold(el, redraw) {\n if (polled && pointed(el)) { held[el.id] = { el: el, redraw: redraw }; return true; }\n delete held[el.id];\n return false;\n }\n function paint(id, draw) { var el = document.getElementById(id); if (el && !hold(el, draw)) draw(); }\n function release() {\n Object.keys(held).forEach(function (k) {\n var h = held[k];\n if (!h.el.isConnected) delete held[k];\n else if (!pointed(h.el)) { delete held[k]; h.redraw(); }\n });\n }\n document.addEventListener(\"mouseover\", release);\n // Sections are rebuilt on each refresh, and one can be shorter for a moment (an open insight's chart\n // is drawn after its card). Holding the page at its height until then keeps the scroll position.\n function steady(fn) {\n var main = document.querySelector(\"main\");\n if (!main) return fn();\n main.style.minHeight = main.offsetHeight + \"px\";\n try { fn(); } finally { requestAnimationFrame(function () { main.style.minHeight = \"\"; }); }\n }\n\n // m: { kind: bars|stack|line|area, ids, series: [{ label, color, values, errors? }], h, unit, pick(i, s) }\n function plot(id, m) {\n var host = document.getElementById(id);\n if (!host || hold(host, function () { plot(id, m); })) return;\n m.series.forEach(function (s, i) { s.off = !!state.legendOff[id + \"|\" + s.label]; s.idx = i; });\n var on = m.series.filter(function (s) { return !s.off; });\n var n = m.ids.length, w = Math.max(280, host.clientWidth - 24), h = m.h || 200;\n var padL = 48, padR = 10, padT = 10, padB = 24, cw = w - padL - padR, chh = h - padT - padB, step = cw / Math.max(1, n);\n var top = 0;\n for (var i = 0; i < n; i++) {\n var col = 0;\n on.forEach(function (s) { var v = s.values[i] || 0; col = m.kind === \"stack\" ? col + v : Math.max(col, v); if (s.errors && m.kind === \"bars\") col = Math.max(col, s.errors[i] || 0); });\n top = Math.max(top, col);\n }\n var mx = m.unit === \"%\" ? Math.max(1, Math.ceil(top / 5) * 5 || 1) : niceMax(top);\n if (m.unit === \"%\") mx = mx <= 4 ? 4 : niceMax(mx);\n function Y(v) { return padT + chh - v / mx * chh; }\n var s = \"\";\n var nt = h < 150 ? 2 : 4;\n for (var t = 0; t <= nt; t++) {\n var tv = mx / nt * t;\n s += '<line x1=\"' + padL + '\" x2=\"' + (w - padR) + '\" y1=\"' + Y(tv) + '\" y2=\"' + Y(tv) + '\" stroke=\"var(--grid)\"/>';\n s += '<text x=\"' + (padL - 8) + '\" y=\"' + (Y(tv) + 4) + '\" text-anchor=\"end\" font-size=\"10.5\" fill=\"var(--ink3)\" class=\"mono\">' + tickLabel(tv, m.unit) + \"</text>\";\n }\n // a reference level, like a route's usual minute\n if (m.ref !== undefined && m.ref !== null && m.ref <= mx) {\n s += '<line x1=\"' + padL + '\" x2=\"' + (w - padR) + '\" y1=\"' + Y(m.ref) + '\" y2=\"' + Y(m.ref) + '\" stroke=\"var(--warn)\" stroke-width=\"1.5\" stroke-dasharray=\"6 5\"/>';\n s += '<text x=\"' + (w - padR - 4) + '\" y=\"' + (Y(m.ref) - 6) + '\" text-anchor=\"end\" font-size=\"11\" font-weight=\"600\" fill=\"var(--warn)\">' + esc(m.refLabel || \"\") + \"</text>\";\n }\n if (m.kind === \"line\" || m.kind === \"area\") {\n on.forEach(function (sr) {\n var pts = [];\n for (var i = 0; i < n; i++) pts.push((padL + i * step + step / 2).toFixed(1) + \",\" + Y(sr.values[i] || 0).toFixed(1));\n if (m.kind === \"area\") s += '<polygon points=\"' + (padL + step / 2) + \",\" + Y(0) + \" \" + pts.join(\" \") + \" \" + (padL + (n - 1) * step + step / 2) + \",\" + Y(0) + '\" fill=\"' + sr.color + '\" opacity=\"' + (on.length > 1 ? 0.1 : 0.16) + '\"/>';\n s += '<polyline class=\"ln\" points=\"' + pts.join(\" \") + '\" fill=\"none\" stroke=\"' + sr.color + '\" stroke-width=\"2\" stroke-linejoin=\"round\" stroke-linecap=\"round\"/>';\n });\n } else {\n for (var j = 0; j < n; j++) {\n var x = padL + j * step + step * 0.14, bw = Math.max(1, step * 0.72);\n if (m.kind === \"stack\") {\n var base = 0;\n on.forEach(function (sr) {\n var v = sr.values[j] || 0;\n if (v > 0) s += '<rect class=\"b\" x=\"' + x.toFixed(1) + '\" y=\"' + Y(base + v).toFixed(1) + '\" width=\"' + bw.toFixed(1) + '\" height=\"' + (Y(base) - Y(base + v)).toFixed(1) + '\" fill=\"' + sr.color + '\"/>';\n base += v;\n });\n } else {\n var gw = bw / Math.max(1, on.length);\n on.forEach(function (sr, k) {\n var v = sr.values[j] || 0, gx = x + k * gw;\n if (v > 0) s += '<rect class=\"b\" x=\"' + gx.toFixed(1) + '\" y=\"' + Y(v).toFixed(1) + '\" width=\"' + Math.max(1, gw - (on.length > 1 ? 1 : 0)).toFixed(1) + '\" height=\"' + (padT + chh - Y(v)).toFixed(1) + '\" rx=\"1.5\" fill=\"' + sr.color + '\" opacity=\".88\"/>';\n var e = sr.errors ? sr.errors[j] || 0 : 0;\n if (e > 0) s += '<rect class=\"b\" x=\"' + gx.toFixed(1) + '\" y=\"' + Y(e).toFixed(1) + '\" width=\"' + Math.max(1, gw - (on.length > 1 ? 1 : 0)).toFixed(1) + '\" height=\"' + (padT + chh - Y(e)).toFixed(1) + '\" rx=\"1.5\" fill=\"var(--bad)\"/>';\n });\n }\n }\n }\n var labelW = String(axisLabel(m.ids[0] || \"\")).length * 6.4 + 18;\n var every = Math.max(1, Math.ceil(n / 6), Math.ceil(n * labelW / cw)), marks = [];\n for (var q = 0; q < n; q += every) marks.push(q);\n if (n - 1 - marks[marks.length - 1] >= every * 0.6) marks.push(n - 1);\n marks.forEach(function (k) {\n s += '<text x=\"' + (padL + k * step + step / 2) + '\" y=\"' + (h - 6) + '\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"var(--ink3)\" class=\"mono\">' + esc(axisLabel(m.ids[k])) + \"</text>\";\n });\n var legend = m.series.length > 1 ? '<div class=\"legend\">' + m.series.map(function (sr) {\n return '<button data-act=\"legend\" data-chart=\"' + id + '\" data-label=\"' + esc(sr.label) + '\" class=\"' + (sr.off ? \"off\" : \"\") + '\"><i style=\"background:' + sr.color + '\"></i><span class=\"trunc\" style=\"max-width:220px\">' + esc(sr.label) + \"</span></button>\";\n }).join(\"\") + \"</div>\" : \"\";\n var wantAnim = state.first;\n host.innerHTML = legend + '<div class=\"chart' + (wantAnim ? \" anim\" : \"\") + '\" data-chart=\"' + id + '\"><svg width=\"' + w + '\" height=\"' + h + '\" viewBox=\"0 0 ' + w + \" \" + h + '\" role=\"img\" aria-label=\"chart\">' + s + '</svg><div class=\"xh\"></div><div class=\"yh\"></div><div class=\"ax x\"></div><div class=\"ax y\"></div></div>';\n m.geo = { padL: padL, padT: padT, chh: chh, step: step, n: n, mx: mx, w: w, h: h, Y: Y };\n CH[id] = m;\n }\n\n function spark(w, h, vals, stroke, ids, label, stretch) {\n if (!vals || vals.length < 2) return \"\";\n var mx = Math.max.apply(null, vals) * 1.08 || 1, pts = [];\n for (var i = 0; i < vals.length; i++) pts.push((i / (vals.length - 1) * w).toFixed(1) + \",\" + (h - vals[i] / mx * h).toFixed(1));\n var id = \"sp\" + (++sparkSeq);\n CH[id] = { kind: \"spark\", ids: ids, values: vals, label: label, color: stroke, w: w };\n return '<svg data-spark=\"' + id + '\" width=\"' + (stretch ? \"100%\" : w) + '\" height=\"' + h + '\" viewBox=\"0 0 ' + w + \" \" + h + '\"' + (stretch ? ' preserveAspectRatio=\"none\"' : \"\") + ' style=\"cursor:crosshair\"><polygon points=\"0,' + h + \" \" + pts.join(\" \") + \" \" + w + \",\" + h + '\" fill=\"' + stroke + '\" opacity=\".12\"/><polyline points=\"' + pts.join(\" \") + '\" fill=\"none\" stroke=\"' + stroke + '\" stroke-width=\"1.6\" stroke-linejoin=\"round\" vector-effect=\"non-scaling-stroke\"/><circle class=\"sd\" r=\"3\" fill=\"' + stroke + '\" visibility=\"hidden\"/></svg>';\n }\n // Sparklines are drawn on every refresh; forget the ones no longer on the page.\n setInterval(function () { Object.keys(CH).forEach(function (k) { if (k.indexOf(\"sp\") === 0 && !document.querySelector('[data-spark=\"' + k + '\"]')) delete CH[k]; }); }, 15000);\n\n // items: [{ label, value, color, go }]\n function donut(id, items, unit, center) {\n var host = document.getElementById(id);\n if (!host || hold(host, function () { donut(id, items, unit, center); })) return;\n var total = sum(items.map(function (x) { return x.value; }));\n if (!total) { host.innerHTML = '<div class=\"empty\">Nothing counted in this range yet.</div>'; return; }\n var S = host.classList.contains(\"wb\") ? Math.max(140, Math.min(250, (host.clientHeight || 0) - 30)) : 170, R = S * 0.44, r = S * 0.29, cx = S / 2, cy = S / 2, a0 = -Math.PI / 2, s = \"\";\n items.forEach(function (it, i) {\n var a1 = a0 + it.value / total * Math.PI * 2;\n if (it.value / total > 0.9999) { s += '<circle cx=\"' + cx + '\" cy=\"' + cy + '\" r=\"' + (R + r) / 2 + '\" fill=\"none\" stroke=\"' + it.color + '\" stroke-width=\"' + (R - r) + '\" data-slice=\"' + i + '\"/>'; }\n else if (it.value > 0) {\n var big = a1 - a0 > Math.PI ? 1 : 0;\n var p = [cx + R * Math.cos(a0), cy + R * Math.sin(a0), cx + R * Math.cos(a1), cy + R * Math.sin(a1), cx + r * Math.cos(a1), cy + r * Math.sin(a1), cx + r * Math.cos(a0), cy + r * Math.sin(a0)].map(function (v) { return v.toFixed(2); });\n s += '<path data-slice=\"' + i + '\" d=\"M' + p[0] + \" \" + p[1] + \"A\" + R + \" \" + R + \" 0 \" + big + \" 1 \" + p[2] + \" \" + p[3] + \"L\" + p[4] + \" \" + p[5] + \"A\" + r + \" \" + r + \" 0 \" + big + \" 0 \" + p[6] + \" \" + p[7] + 'Z\" fill=\"' + it.color + '\" stroke=\"var(--surface)\" stroke-width=\"1.5\" style=\"cursor:pointer\"/>';\n }\n a0 = a1;\n });\n s += '<text x=\"' + cx + '\" y=\"' + (cy - 2) + '\" text-anchor=\"middle\" font-size=\"' + Math.round(S / 8) + '\" font-weight=\"700\" fill=\"var(--ink)\">' + short(total) + '</text><text x=\"' + cx + '\" y=\"' + (cy + 15) + '\" text-anchor=\"middle\" font-size=\"10.5\" fill=\"var(--ink3)\">' + esc(center || (unit === \"errors\" ? \"5xx\" : \"total\")) + \"</text>\";\n host.innerHTML = '<div class=\"donut\"><svg width=\"' + S + '\" height=\"' + S + '\" viewBox=\"0 0 ' + S + \" \" + S + '\" data-donut=\"' + id + '\">' + s + '</svg><div class=\"lg\">' + items.map(function (it, i) {\n return '<button ' + (it.go ? 'data-go=\"' + esc(it.go) + '\"' : \"\") + ' data-slice=\"' + i + '\"><i style=\"background:' + it.color + '\"></i><span class=\"trunc\">' + esc(it.label) + '</span><b class=\"tnum\">' + short(it.value) + '</b><span class=\"muted tnum\" style=\"text-align:right\">' + pct(it.value, total) + \"</span></button>\";\n }).join(\"\") + \"</div></div>\";\n CH[id] = { kind: \"donut\", items: items, total: total };\n }\n\n function hideHover() {\n tt.style.display = \"none\";\n document.querySelectorAll(\".chart .xh, .chart .yh, .chart .ax\").forEach(function (e) { e.style.display = \"none\"; });\n document.querySelectorAll(\"svg .sd\").forEach(function (e) { e.setAttribute(\"visibility\", \"hidden\"); });\n }\n function showTip(html, ev) {\n tt.innerHTML = html; tt.style.display = \"block\";\n var x = ev.clientX + 16, y = ev.clientY + 14, r = tt.getBoundingClientRect();\n if (x + r.width > innerWidth - 8) x = ev.clientX - r.width - 16;\n if (y + r.height > innerHeight - 8) y = ev.clientY - r.height - 14;\n tt.style.left = x + \"px\"; tt.style.top = y + \"px\";\n }\n document.addEventListener(\"mousemove\", function (ev) {\n var t = ev.target;\n var sp = t.closest && t.closest(\"[data-spark]\");\n if (sp) return hoverSpark(sp, ev);\n var dn = t.closest && t.closest(\"[data-donut]\");\n if (dn) return hoverDonut(dn, ev);\n var tr = t.closest && t.closest(\".trunc\");\n if (tr && tr.scrollWidth > tr.clientWidth + 1) { hideHover(); return showTip('<div style=\"white-space:pre-line;word-break:break-all\">' + esc(tr.textContent) + \"</div>\", ev); }\n var tip = t.closest && t.closest(\"[data-tip]\");\n // the text can contain route names from Redis, so it is escaped like everything else\n if (tip) { hideHover(); return showTip('<div style=\"white-space:pre-line\">' + esc(tip.getAttribute(\"data-tip\")) + \"</div>\", ev); }\n var host = t.closest && t.closest(\".chart[data-chart]\");\n if (!host) return hideHover();\n hoverChart(host, ev);\n });\n function hoverSpark(el, ev) {\n var m = CH[el.getAttribute(\"data-spark\")];\n if (!m) return;\n var r = el.getBoundingClientRect(), i = Math.round((ev.clientX - r.left) / r.width * (m.values.length - 1));\n i = Math.max(0, Math.min(m.values.length - 1, i));\n var d = el.querySelector(\".sd\"), vb = el.viewBox.baseVal, mx = Math.max.apply(null, m.values) * 1.08 || 1;\n d.setAttribute(\"cx\", i / (m.values.length - 1) * vb.width); d.setAttribute(\"cy\", vb.height - m.values[i] / mx * vb.height); d.setAttribute(\"visibility\", \"visible\");\n showTip('<div class=\"t\">' + esc(m.ids ? fullTime(m.ids[i]) : \"\") + '</div><div class=\"r\"><i style=\"background:' + m.color + '\"></i>' + esc(m.label || \"Requests\") + \"<b>\" + fmt(m.values[i]) + \"</b></div>\", ev);\n }\n function hoverDonut(el, ev) {\n var m = CH[el.getAttribute(\"data-donut\")], sl = ev.target.getAttribute && ev.target.getAttribute(\"data-slice\");\n if (!m || sl === null) { tt.style.display = \"none\"; return; }\n var it = m.items[+sl];\n showTip('<div class=\"r\"><i style=\"background:' + it.color + '\"></i>' + esc(it.label) + \"<b>\" + fmt(it.value) + '</b></div><div class=\"f\">' + pct(it.value, m.total) + \" of \" + fmt(m.total) + (it.go ? \" · click to open\" : \"\") + \"</div>\", ev);\n }\n function hoverChart(host, ev) {\n var id = host.getAttribute(\"data-chart\"), m = CH[id];\n if (!m || !m.geo) return;\n var g = m.geo, svg = host.querySelector(\"svg\"), r = svg.getBoundingClientRect(), hr = host.getBoundingClientRect();\n var i = Math.floor((ev.clientX - r.left - g.padL) / g.step);\n if (i < 0 || i >= g.n) return hideHover();\n var on = m.series.filter(function (s) { return !s.off; });\n if (!on.length) return hideHover();\n var yv, rows = \"\", total = 0;\n on.forEach(function (s) { total += s.values[i] || 0; });\n if (m.kind === \"stack\") yv = total;\n else {\n var my = ev.clientY - r.top, best = on[0];\n on.forEach(function (s) { if (Math.abs(g.Y(s.values[i] || 0) - my) < Math.abs(g.Y(best.values[i] || 0) - my)) best = s; });\n yv = best.values[i] || 0; m.hot = best;\n }\n if (m.kind === \"stack\" && on.length > 1) rows += '<div class=\"r\" style=\"font-weight:700\"><i style=\"background:var(--ink)\"></i>All types<b>' + fmt(total) + \"</b></div>\";\n on.forEach(function (s) {\n var e = s.errors ? s.errors[i] || 0 : 0;\n rows += '<div class=\"r\"><i style=\"background:' + s.color + '\"></i>' + esc(s.label) + \"<b>\" + valText(s.values[i] || 0, m.unit) + \"</b></div>\" + (e ? '<div class=\"r\"><i style=\"background:var(--bad)\"></i>5xx<b>' + fmt(e) + \" · \" + pct(e, s.values[i]) + \"</b></div>\" : \"\");\n });\n var avg = sum(on.map(function (s) { return sum(s.values) / s.values.length; }));\n var foot = m.unit === \"%\" ? \"\" : (avg ? (total >= avg ? \"+\" : \"\") + Math.round((total / avg - 1) * 100) + \"% vs the average of this range\" : \"\");\n var ox = r.left - hr.left, oy = r.top - hr.top, x = ox + g.padL + i * g.step + g.step / 2, y = oy + g.Y(yv);\n var xh = host.querySelector(\".xh\"), yh = host.querySelector(\".yh\"), ax = host.querySelector(\".ax.x\"), ay = host.querySelector(\".ax.y\");\n xh.style.cssText = \"display:block;left:\" + x + \"px;top:\" + (oy + g.padT) + \"px;height:\" + g.chh + \"px\";\n yh.style.cssText = \"display:block;left:\" + (ox + g.padL) + \"px;top:\" + y + \"px;width:\" + (g.w - g.padL - 10) + \"px\";\n ax.style.cssText = \"display:block;left:\" + x + \"px;top:\" + (oy + g.h - 20) + \"px\"; ax.textContent = axisLabel(m.ids[i]);\n ay.style.cssText = \"display:block;left:\" + (ox + g.padL - 2) + \"px;top:\" + y + \"px\"; ay.textContent = m.unit === \"%\" ? valText(yv, \"%\") : short(yv);\n showTip('<div class=\"t\">' + esc(fullTime(m.ids[i])) + \"</div>\" + rows + (foot ? '<div class=\"f\">' + foot + (m.pick ? \" · click to open\" : \"\") + \"</div>\" : \"\"), ev);\n }\n document.addEventListener(\"mouseleave\", hideHover);\n document.addEventListener(\"scroll\", hideHover, true);\n\n // ---------- data ----------\n function api(path, opts) {\n return fetch(BASE + path, Object.assign({ credentials: \"same-origin\", headers: { \"content-type\": \"application/json\" } }, opts || {})).then(function (res) {\n return res.json().catch(function () { return {}; }).then(function (body) {\n if (res.status === 401 && state.me && state.me.required) { state.me.signedIn = false; render(); throw new Error(\"signed out\"); }\n if (!res.ok) throw new Error(body.error || \"Request failed (\" + res.status + \")\");\n return body;\n });\n });\n }\n function route() {\n var raw = location.hash.replace(/^#\\/?/, \"\");\n var parts = raw.split(\"/\").map(function (p) { try { return decodeURIComponent(p); } catch (e) { return p; } });\n if (parts[0] === \"type\" && parts[1]) return { view: \"type\", type: parts[1] };\n if (parts[0] === \"route\" && parts[3] !== undefined) return { view: \"route\", type: parts[1], action: parts[2], name: parts.slice(3).join(\"/\") };\n if (parts[0] === \"insights\") return { view: \"insights\" };\n if (parts[0] === \"settings\") return { view: \"settings\" };\n if (parts[0] === \"boards\") return { view: \"boards\" };\n if (parts[0] === \"board\" && parts[1]) return { view: \"board\", id: parts[1] };\n if (parts[0] === \"template\" && parts[1]) return { view: \"template\", key: parts[1] };\n // the Overview tab, and the dashboard URL, show your default board when you set one\n if (state.boards.defaultId && boardById(state.boards.defaultId)) return { view: \"board\", id: state.boards.defaultId, landing: true };\n return { view: \"overview\" };\n }\n function viewUrl(r) {\n var q = \"range=\" + encodeURIComponent(state.range);\n if (r.view === \"type\") return \"/api/type?type=\" + encodeURIComponent(r.type) + \"&\" + q;\n if (r.view === \"route\") return \"/api/route?type=\" + encodeURIComponent(r.type) + \"&action=\" + encodeURIComponent(r.action) + \"&name=\" + encodeURIComponent(r.name) + \"&\" + q;\n if (r.view === \"insights\") return \"/api/insights?period=\" + state.period;\n if (r.view === \"overview\") return \"/api/overview?\" + q;\n return null;\n }\n // Polls skip a tick while a request is out; a forced load (new view or range) replaces it.\n function load(force) {\n if ((!force && state.loading) || !state.me || (state.me.required && !state.me.signedIn)) return;\n var r = route(), seq = ++state.seq;\n state.lastLoad = Date.now();\n if (r.view === \"board\" || r.view === \"template\") { state.loading = true; return loadBoard(r, seq, !force).then(function () { if (seq === state.seq) { state.loading = false; state.first = false; } }); }\n var url = viewUrl(r);\n if (!url) return;\n // period stats change slowly and cost a scan, so they refresh every 30 s at most\n if (r.view === \"insights\" && (force || Date.now() - state.pAt >= Math.max(30000, state.refresh))) loadPeriod(!force);\n state.loading = true;\n api(url).then(function (data) {\n if (seq !== state.seq) return;\n if (data.chart) state.range = data.range || data.chart.bucket;\n state.data = data; state.dataFor = JSON.stringify(r); state.error = \"\"; state.updated = istNow();\n polled = !force;\n try { fill(r, data); } finally { polled = false; }\n state.first = false;\n if (r.view === \"overview\" && Date.now() - state.side.at >= Math.max(30000, state.refresh || 30000)) loadSide();\n }).catch(function (err) {\n if (seq === state.seq && err.message !== \"signed out\") { state.error = err.message; showError(); }\n }).then(function () { if (seq === state.seq) state.loading = false; });\n }\n function loadSide() {\n state.side.at = Date.now();\n api(\"/api/insights\").catch(function () { return null; }).then(function (ins) {\n state.side.insights = ins;\n if (route().view === \"overview\") fillSide();\n paintBadge();\n });\n }\n // ---------- storage ----------\n // How much of Redis the tracker takes. Redis's own numbers are cheap; the tracker's share comes\n // from a count the server runs in the background (every 10 minutes), polled while it runs.\n var ST_GROUPS = [[\"sec\", \"Seconds counters\"], [\"min\", \"Minutes counters\"], [\"hour\", \"Hours counters\"], [\"day\", \"Days counters\"], [\"month\", \"Months counters\"], [\"year\", \"Years counters\"],\n [\"otherCounters\", \"Other keys under the prefix\"], [\"samples\", \"Recent requests (curl samples)\"], [\"failures\", \"Recent failures\"], [\"boards\", \"Boards\"], [\"reports\", \"Saved reports\"]];\n function bytes(n) {\n if (n === null || n === undefined) return \"—\";\n var u = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"], i = 0;\n while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }\n return (i && n < 10 ? n.toFixed(1) : Math.round(n)) + \" \" + u[i];\n }\n function agoText(ms) {\n var s = Math.max(0, Math.round((Date.now() - ms) / 1000));\n return s < 60 ? \"just now\" : s < 3600 ? Math.round(s / 60) + \" min ago\" : Math.round(s / 3600) + \" h ago\";\n }\n function loadStorage(recount) {\n state.stAt = Date.now();\n clearTimeout(state.stPoll); state.stPoll = 0;\n return api(\"/api/storage\" + (recount ? \"?recount=1\" : \"\")).then(function (s) {\n state.storage = s;\n paintStorage();\n if (s.scanning) state.stPoll = setTimeout(loadStorage, 2000);\n }).catch(function () {});\n }\n function limitText(r) { return r.limit ? bytes(r.limit) + (r.limitFrom === \"ram\" ? \" RAM\" : \"\") : \"\"; }\n function storageTitle(s) {\n var r = s.redis, t = s.tracker, o = s.other, lines = [];\n lines.push(\"Redis: \" + (r.used !== null ? bytes(r.used) + \" used\" + (r.limit ? \" of \" + limitText(r) + \" (\" + pct(r.used, r.limit) + \")\" : \"\") : \"memory unknown\") + (r.keys !== null ? \" · \" + fmt(r.keys) + \" keys\" : \"\"));\n if (r.limitFrom === \"ram\") lines.push(\"No maxmemory is set, so the limit shown is the server's RAM.\");\n if (t) {\n lines.push(\"Tracker: \" + (t.bytes !== null ? \"≈\" + bytes(t.bytes) + \" in \" : \"\") + fmt(t.keys) + \" keys\");\n if (o) lines.push(\"Other: \" + (o.bytes !== null ? \"≈\" + bytes(o.bytes) : \"—\") + (o.keys !== null ? \" in \" + fmt(o.keys) + \" keys\" : \"\") + \" (other apps' keys and Redis overhead)\");\n lines.push(\"Counted \" + agoText(t.at) + \" · click for details\");\n } else lines.push(\"Counting the tracker's keys…\");\n return lines.join(\"\\n\");\n }\n function storageChip() {\n var s = state.storage;\n if (!s || !state.me || !state.me.signedIn) return '<span id=\"stchip\"></span>';\n var r = s.redis, t = s.tracker, near = r.limitFrom === \"maxmemory\" && r.used !== null && r.used / r.limit >= 0.8;\n var txt = \"Redis \" + (r.used !== null ? bytes(r.used) + (r.limit ? \" / \" + bytes(r.limit) : \"\") : \"—\") +\n \" · tracker \" + (!t ? \"…\" : t.bytes !== null ? \"≈\" + bytes(t.bytes) : short(t.keys) + \" keys\");\n return '<a class=\"stchip mono' + (near ? \" near\" : \"\") + '\" id=\"stchip\" href=\"#/settings\" title=\"' + esc(storageTitle(s)) + '\">' + esc(txt) + \"</a>\";\n }\n function paintStorage() {\n var c = document.getElementById(\"stchip\");\n if (c) c.outerHTML = storageChip();\n if (route().view === \"settings\") fillStorage();\n }\n function fillStorage() {\n var host = document.getElementById(\"storage\"), s = state.storage;\n if (!host) return;\n var head = '<div class=\"ch\"><b>Storage in Redis</b><span class=\"k\">approximate · the tracker\\'s keys are counted every 10 minutes</span><span class=\"right\"><span class=\"k\" id=\"stwhen\">' +\n (s && s.scanning ? \"Counting… \" + fmt(s.scanning.checked) + \" keys checked\" : s && s.tracker ? \"Counted \" + agoText(s.tracker.at) : \"\") + '</span><button class=\"btn\" data-act=\"recount\"' + (s && s.scanning ? \" disabled\" : \"\") + \">Recount</button></span></div>\";\n if (!s) { host.innerHTML = head + '<div class=\"empty\">Loading…</div>'; return; }\n var r = s.redis, t = s.tracker, o = s.other;\n var whole = r.limit || r.used || 0, trB = t && t.bytes !== null ? t.bytes : 0, otB = o && o.bytes !== null ? o.bytes : Math.max(0, (r.used || 0) - trB);\n var meter = whole ? '<div class=\"st-meter\" title=\"Tracker, other and free\"><i class=\"tr\" style=\"width:' + Math.min(100, trB / whole * 100) + '%\"></i><i class=\"ot\" style=\"width:' + Math.min(100, otB / whole * 100) + '%\"></i></div>' : \"\";\n var sum = '<div class=\"st-sum\">' +\n \"<div>Redis used<b>\" + bytes(r.used) + \"</b><span>\" + (r.limit ? \"of \" + limitText(r) + \" · \" + pct(r.used || 0, r.limit) + (r.limitFrom === \"ram\" ? \" (no maxmemory set)\" : \"\") : \"no limit known\") + (r.keys !== null ? \" · \" + fmt(r.keys) + \" keys\" : \"\") + (r.nodes > 1 ? \" · \" + r.nodes + \" masters\" : \"\") + \"</span></div>\" +\n '<div><i style=\"background:var(--bar)\"></i>Tracker<b>' + (t ? (t.bytes !== null ? \"≈\" + bytes(t.bytes) : \"—\") : \"…\") + \"</b><span>\" + (t ? fmt(t.keys) + \" keys\" + (t.bytes !== null && r.used ? \" · \" + pct(t.bytes, r.used) + \" of used\" : \"\") : \"Counting…\") + \"</span></div>\" +\n '<div><i style=\"background:var(--ink3);opacity:.55\"></i>Other<b>' + (o && o.bytes !== null ? \"≈\" + bytes(o.bytes) : \"—\") + \"</b><span>\" + (o && o.keys !== null ? fmt(o.keys) + \" keys · \" : \"\") + \"other apps and Redis overhead</span></div></div>\";\n var rows = \"\";\n if (t) {\n var top = Math.max.apply(null, ST_GROUPS.map(function (g) { var x = t.groups[g[0]]; return t.bytes !== null ? x.bytes || 0 : x.keys; }).concat([1]));\n rows = '<table class=\"tbl\"><thead><tr><th>Keys</th><th>Count</th><th>Size</th><th>Share</th></tr></thead><tbody>' + ST_GROUPS.filter(function (g) { return t.groups[g[0]].keys || t.expiry[g[0]] !== undefined; }).map(function (g) {\n var x = t.groups[g[0]], keep = t.expiry[g[0]], v = t.bytes !== null ? x.bytes || 0 : x.keys;\n return \"<tr><td>\" + g[1] + (keep ? ' <span class=\"muted\">· kept ' + expiry(keep) + \"</span>\" : \"\") + '</td><td class=\"tnum\">' + fmt(x.keys) + '</td><td class=\"tnum\">' + (x.bytes !== null ? \"≈\" + bytes(x.bytes) : \"—\") +\n '</td><td><span class=\"share\"><i style=\"width:' + (v / top * 100) + '%;background:var(--bar)\"></i></span></td></tr>';\n }).join(\"\") + \"</tbody></table>\";\n }\n host.innerHTML = head + meter + sum + rows + (s.notes.length ? '<div class=\"st-notes\">' + s.notes.map(esc).join(\"<br>\") + \"</div>\" : \"\");\n }\n setInterval(function () {\n release(); // also catches the pointer leaving the window\n // storage changes slowly: once a minute at most, or only on Refresh now when refresh is off\n if (state.me && state.me.signedIn && !document.hidden && !state.stPoll && (!state.stAt || (state.refresh && Date.now() - state.stAt >= Math.max(60000, state.refresh)))) loadStorage();\n if (state.refresh && !document.hidden && !state.ed && Date.now() - state.lastLoad >= state.refresh) load();\n if (state.refresh && !document.hidden && state.me && state.me.signedIn && Date.now() - state.side.at >= Math.max(30000, state.refresh)) loadSide();\n }, 250);\n document.addEventListener(\"visibilitychange\", function () { if (!document.hidden && state.refresh && Date.now() - state.lastLoad >= state.refresh) load(true); });\n\n // ---------- settings preview ----------\n // Changes made on the settings page are only a preview: they hide things in this dashboard and\n // produce the init() code to apply them. The app keeps tracking with its current settings.\n function draftKey() { return \"system-tracker-settings:\" + (state.me && state.me.prefix); }\n function draft() { return state.cfg ? store.get(draftKey(), null) || clone(state.cfg.resolved) : null; }\n function changes() {\n if (!state.cfg) return [];\n var d = store.get(draftKey(), null), r = state.cfg.resolved, out = [];\n if (!d) return out;\n Object.keys(r.track).forEach(function (t) { if (d.track[t] !== r.track[t]) out.push(\"track.\" + t); });\n [\"total\", \"perType\"].forEach(function (k) { if (d.global[k] !== r.global[k]) out.push(\"global.\" + k); });\n if (d.failures !== r.failures) out.push(\"failures\");\n if (d.ignore.bots !== r.ignore.bots) out.push(\"ignore.bots\");\n if (d.ignore.methods.join() !== r.ignore.methods.join()) out.push(\"ignore.methods\");\n if (d.ignore.paths.join(\"\\n\") !== r.ignore.paths.join(\"\\n\")) out.push(\"ignore.paths\");\n BUCKETS.forEach(function (b) {\n if (d.timeConfig[b].enable !== r.timeConfig[b].enable) out.push(\"timeConfig.\" + b + \".enable\");\n if (d.timeConfig[b].expiry_in_sec !== r.timeConfig[b].expiry_in_sec) out.push(\"timeConfig.\" + b + \".expiry_in_sec\");\n });\n [\"maxNamesPerType\", \"collapseAfter\"].forEach(function (k) { if (d.limits[k] !== r.limits[k]) out.push(\"limits.\" + k); });\n return out;\n }\n function hiddenType(t) { var d = store.get(draftKey(), null); return !!(d && d.track && d.track[t] === false); }\n function tracked(t) { return !state.cfg || state.cfg.resolved.track[t] !== false; }\n function trackedTypes() {\n if (!state.cfg) return Object.keys(TC);\n return Object.keys(state.cfg.resolved.track).filter(function (t) { return state.cfg.resolved.track[t]; }).concat(state.cfg.resolved.customTypes);\n }\n\n // ---------- frame ----------\n function navOn(r) { var v = r.view; return v === \"type\" || v === \"route\" || r.landing ? \"overview\" : v === \"board\" || v === \"template\" ? \"boards\" : v; }\n function topBar() {\n var me = state.me || {}, light = document.documentElement.dataset.theme === \"light\", r = route(), on = navOn(r);\n var n = changes().length, ins = state.side.insights, crit = ins ? ins.summary.critical : 0, warn = ins ? ins.summary.warning : 0;\n var badge = ins && (crit || warn) ? '<span class=\"badge ' + (crit ? \"critical\" : \"warning\") + '\" id=\"insbadge\">' + (crit + warn) + \"</span>\" : '<span id=\"insbadge\"></span>';\n return '<div class=\"top\"><span class=\"mark\"></span><span class=\"brand\">system-tracker</span>' + (me.prefix ? '<span class=\"pchip mono\">' + esc(me.prefix) + \"</span>\" : \"\") +\n (me.signedIn ? '<nav class=\"nav\"><a href=\"#/overview\" class=\"' + (on === \"overview\" ? \"on\" : \"\") + '\">Overview</a><a href=\"#/insights\" class=\"' + (on === \"insights\" ? \"on\" : \"\") + '\">Insights ' + badge + '</a><a href=\"#/boards\" class=\"' + (on === \"boards\" ? \"on\" : \"\") + '\">Boards</a><a href=\"#/settings\" class=\"' + (on === \"settings\" ? \"on\" : \"\") + '\">Settings</a></nav>' : \"\") +\n '<span class=\"grow\"></span>' + (me.signedIn ? storageChip() : \"\") +\n (me.signedIn && n ? '<a class=\"preview-chip\" href=\"#/settings\" title=\"Settings you changed here are only a preview\">Preview: ' + n + (n === 1 ? \" change\" : \" changes\") + \"</a>\" : \"\") +\n (me.signedIn ? '<label class=\"live' + (state.refresh ? \"\" : \" off\") + '\" title=\"How often the numbers refresh\"><i></i><select id=\"refresh\" aria-label=\"Refresh\">' + REFRESH.map(function (x) { return '<option value=\"' + x[0] + '\"' + (x[0] === state.refresh ? \" selected\" : \"\") + \">\" + x[1] + \"</option>\"; }).join(\"\") + '</select></label><button class=\"ibtn\" data-act=\"reload\" title=\"Refresh now\">' + ICON.reload + \"</button>\" : \"\") +\n '<button class=\"ibtn\" data-act=\"theme\" title=\"Switch to ' + (light ? \"dark\" : \"light\") + ' mode\">' + (light ? ICON.sun : ICON.moon) + \"</button>\" +\n (me.required && me.signedIn ? '<span class=\"user\"><button class=\"link\" data-act=\"logout\">Sign out</button></span>' : \"\") + \"</div>\" +\n (me.required === false ? '<div class=\"banner\">⚠ No login is set, so anyone who can reach this URL can see these stats. Add <span class=\"mono\">dashboard.users</span> to <span class=\"mono\">init</span>.</div>' : \"\");\n }\n function paintBadge() {\n var b = document.getElementById(\"insbadge\"), ins = state.side.insights;\n if (!b || !ins) return;\n var c = ins.summary.critical, w = ins.summary.warning;\n b.className = c || w ? \"badge \" + (c ? \"critical\" : \"warning\") : \"\";\n b.textContent = c || w ? c + w : \"\";\n }\n function crumbs(parts, extra, tail) {\n return '<div class=\"crumbs\">' + parts.map(function (p, i) {\n return i < parts.length - 1 ? '<a href=\"' + esc(p[1]) + '\">' + p[0] + '</a><span class=\"sep\">›</span>' : \"<b>\" + p[0] + \"</b>\";\n }).join(\"\") + (extra || \"\") + '<span class=\"meta mono\" id=\"updated\"></span>' + (tail || \"\") + \"</div>\";\n }\n function exportBtn() { return '<button class=\"btn\" data-act=\"export-view\" title=\"Download what this screen shows as CSV, PDF or JSON\">' + ICON.download + \"Export</button>\"; }\n var RANGE_PICKER = '<span id=\"tabs\" title=\"Everything on this page covers this range\"></span>';\n function tabs(buckets, id) {\n return '<div class=\"seg\"' + (id ? ' id=\"' + id + '\"' : \"\") + \">\" + buckets.map(function (b) { return '<button data-range=\"' + b + '\"' + (b === state.range ? ' class=\"on\"' : \"\") + \">\" + RANGE_LABEL[b] + \"</button>\"; }).join(\"\") + \"</div>\";\n }\n function kpi(l, v, s, sp) { return '<div class=\"kpi\"><div class=\"l\">' + l + '</div><div class=\"v tnum\">' + v + '</div><div class=\"s\">' + (s || \"&nbsp;\") + \"</div>\" + (sp || \"\") + \"</div>\"; }\n function resetBtn() {\n var ok = state.me && state.me.required;\n return '<button class=\"btn ghost danger\" data-act=\"reset\"' + (ok ? \"\" : ' disabled data-tip=\"Resetting needs a login: set dashboard.users in statis.init\"') + \">\" + ICON.trash + \"Reset data</button>\";\n }\n function typeHref(t) { return \"#/type/\" + encodeURIComponent(t); }\n function routeHref(t, a, n) { return \"#/route/\" + encodeURIComponent(t) + \"/\" + encodeURIComponent(a) + \"/\" + encodeURIComponent(n); }\n\n // ---------- overview, type and route ----------\n function shell(r) {\n var p = esc(state.me.prefix || \"\");\n if (r.view === \"overview\") {\n return '<div class=\"crumbs\"><b>Overview</b>' + RANGE_PICKER + '<span class=\"meta mono\" id=\"updated\"></span>' + resetBtn() + exportBtn() + '<button class=\"btn p\" data-act=\"customize\" title=\"Turn this overview into a board you can rearrange; saving makes it your Overview\">' + ICON.edit + \"Customize</button></div>\" + '<div class=\"card\" id=\"strip\"><div class=\"empty\">Looking for anything unusual…</div></div><div class=\"kpis\" id=\"kpis\"></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Traffic by type</b><span class=\"k mono trunc\">' + p + ':{TYPE}:global:<span class=\"rk\"></span>:*</span><span class=\"right\"><span class=\"k\">per <span class=\"rbn\"></span> · hover for totals · click a bar to open its type</span></span></div><div id=\"c-stack\"></div></div>' +\n '<div class=\"cols\"><div class=\"card\"><div class=\"ch\"><b>By type</b><span class=\"k\"><span class=\"rw\"></span></span></div><div id=\"table\"></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Top routes</b><span class=\"k\"><span class=\"rw\"></span> · across all types</span></div><div id=\"top\"><div class=\"empty\">Loading…</div></div></div></div>';\n }\n if (r.view === \"type\") {\n return crumbs([[\"Overview\", \"#/overview\"], [esc(r.type)]], RANGE_PICKER, exportBtn()) + '<div class=\"kpis\" id=\"kpis\"></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>' + esc(r.type) + ' traffic</b><span class=\"k mono\">' + p + \":\" + esc(r.type) + ':global:<span class=\"rk\"></span>:*</span><span class=\"right k\">per <span class=\"rbn\"></span></span></div><div id=\"c-main\"></div></div>' +\n '<div class=\"cols\"><div class=\"card\"><div class=\"ch\"><b>Share by route</b><span class=\"k rw\"></span></div><div id=\"mix\"></div></div><div class=\"card\"><div class=\"ch\"><b>By method</b><span class=\"k rw\"></span></div><div id=\"methods\"></div></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Routes</b><span class=\"k\" id=\"rcount\"></span><span class=\"right\"><input class=\"search\" id=\"filter\" placeholder=\"Filter routes…\" value=\"' + esc(state.filter) + '\"><select id=\"sort\"><option value=\"now\">This minute</option><option value=\"total\">Requests in range</option><option value=\"name\">Name</option></select></span></div><div id=\"table\"></div></div>';\n }\n return crumbs([[\"Overview\", \"#/overview\"], [esc(r.type), typeHref(r.type)], [method(r.action) + '<span class=\"mono trunc\" style=\"max-width:60vw\">' + esc(r.name) + \"</span>\"]], \"\", exportBtn()) +\n '<div style=\"display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap\"><span class=\"keychip mono\" id=\"key\"></span><span class=\"k\" style=\"font-size:12.5px;color:var(--ink3)\" id=\"fnote\"></span></div>' +\n '<div class=\"slots\" id=\"slots\"></div><div class=\"stats\" id=\"stats\"></div>' +\n '<div class=\"split\"><div class=\"card\"><div class=\"ch\"><b id=\"ctitle\"></b><span class=\"k mono\" id=\"crange\"></span></div><div id=\"c-main\"></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Buckets</b><span class=\"k\" style=\"margin-left:auto\">newest first</span></div><div id=\"table\"></div></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>Recent requests</b><span class=\"k\" id=\"reqnote\"></span><span class=\"right\"><select id=\"curlmode\" aria-label=\"Curl format\">' +\n '<option value=\"sample\"' + (state.curlMode === \"sample\" ? \" selected\" : \"\") + '>curl sample</option><option value=\"structure\"' + (state.curlMode === \"structure\" ? \" selected\" : \"\") + \">curl structure</option></select></span></div>\" +\n '<div class=\"reqs\" id=\"reqs\"><div class=\"empty\">Loading…</div></div></div>' +\n '<div class=\"card\" id=\"failcard\"><div class=\"ch\"><b>Recent failures</b><span class=\"k\" id=\"failnote\"></span></div><div class=\"reqs\" id=\"fails\"></div></div>';\n }\n // Redraws can come from timers (window resize, legend clicks) just after the view changed;\n // only draw data that was loaded for the view on screen.\n function viewData() { return state.data && state.dataFor === JSON.stringify(route()) ? state.data : null; }\n function fill(r, d) {\n if (!d || state.dataFor !== JSON.stringify(r)) return;\n steady(function () {\n var up = document.getElementById(\"updated\");\n if (up) up.textContent = \"IST · updated \" + state.updated;\n var t = document.getElementById(\"tabs\");\n if (t && d.buckets) t.innerHTML = tabs((d.ranges || d.buckets).filter(function (b) { return !draftBucketOff(b === \"week\" ? \"day\" : b); }));\n var bk = d.chart ? d.chart.bucket : state.range;\n document.querySelectorAll(\".rk\").forEach(function (e) { e.textContent = bk; });\n document.querySelectorAll(\".rw\").forEach(function (e) { e.textContent = RANGE_WHEN[state.range] || \"\"; });\n document.querySelectorAll(\".rbn\").forEach(function (e) { e.textContent = BUCKET_NAME[bk] || \"\"; });\n var so = document.querySelector('#sort option[value=\"now\"]'); if (so) so.textContent = cap(SLOT_NOW[bk] || \"\");\n if (r.view === \"overview\") fillOverview(d);\n else if (r.view === \"type\") fillType(r, d);\n else if (r.view === \"route\") fillRoute(r, d);\n else if (r.view === \"insights\") fillInsights(d);\n var e = document.getElementById(\"err\");\n if (e) e.remove();\n });\n }\n function draftBucketOff(b) { var d = store.get(draftKey(), null); return !!(d && d.timeConfig && d.timeConfig[b] && d.timeConfig[b].enable === false); }\n\n function cap(t) { return t ? t[0].toUpperCase() + t.slice(1) : t; }\n function num(v) { return v < 100 && v % 1 ? rate(v) : fmt(v); }\n // the four numbers at the top: the range's total, its current bucket, the average and the busiest bucket\n function whenOf(d) { return RANGE_WHEN[d.range || d.chart.bucket]; }\n function headKpis(d, first) {\n var b = d.chart.bucket, n = d.now, unit = BUCKET_NAME[b];\n return [kpi(first + \" · \" + whenOf(d), fmt(n.total), \"hover the line for each \" + unit, spark(120, 34, d.chart.counts, \"#3fb979\", d.chart.ids, \"Requests\")),\n kpi(cap(SLOT_NOW[b]), fmt(n.current), n.previous !== null ? PREV_LABEL[b] + \": \" + fmt(n.previous) : \"\"),\n kpi(\"Average per \" + unit, num(n.average), \"complete \" + unit + \"s only\"),\n kpi(\"Busiest \" + unit, fmt(n.peak.count), n.peak.id ? esc(fullTime(n.peak.id)) : \"nothing counted yet\")];\n }\n function fillOverview(d) {\n var rows = d.types.filter(function (x) { return !hiddenType(x.type); });\n paint(\"kpis\", function () { document.getElementById(\"kpis\").innerHTML = headKpis(d, \"Requests\").join(\"\"); });\n // one chart: traffic stacked by type, with an \"All types\" total on hover; without per-type totals, the global count\n var incoming = rows.filter(function (x) { return x.type !== \"OUT\"; });\n if (incoming.length) plot(\"c-stack\", { kind: \"stack\", ids: d.chart.ids, h: 240, series: incoming.map(function (x) { return { label: x.type, color: color(x.type), values: x.chart }; }), pick: function (i, s) { location.hash = typeHref(s.label); } });\n else plot(\"c-stack\", { kind: \"bars\", ids: d.chart.ids, h: 240, series: [{ label: \"All traffic\", color: \"var(--bar)\", values: d.chart.counts }] });\n var tot = sum(incoming.map(function (x) { return x.total; })) || 1;\n paint(\"table\", function () { document.getElementById(\"table\").innerHTML = rows.length ?\n '<table class=\"tbl fit\"><tr><th>Type</th><th class=\"opt\" style=\"width:120px\">Trend</th><th class=\"opt2\" style=\"width:96px\">' + esc(cap(SLOT_NOW[d.chart.bucket])) + '</th><th style=\"width:104px\">' + esc(cap(whenOf(d))) + '</th><th style=\"width:128px\">Share</th><th class=\"opt2\" style=\"width:36px\"></th></tr>' + rows.map(function (x) {\n var c = color(x.type);\n return '<tr data-go=\"' + esc(typeHref(x.type)) + '\"><td><span class=\"tp mono\"><i style=\"background:' + c + '\"></i>' + esc(x.type) + '</span></td><td class=\"opt\">' + spark(100, 22, x.chart, c, d.chart.ids, x.type) + '</td><td class=\"tnum opt2\">' + fmt(x.now) + '</td><td class=\"tnum\">' + fmt(x.total) + '</td><td style=\"white-space:normal\">' + (x.type === \"OUT\" ? '<span class=\"muted\" data-tip=\"Calls your app made: to your own backend (your domains, localhost or private hosts) or to third parties. They aren\\'t requests to your app, so they\\'re not part of the total or the chart.\" style=\"font-size:12px;white-space:normal;display:inline-block;max-width:100%;line-height:1.35;text-align:right\">outgoing, not in total' + (x.own !== undefined ? \"<br>\" + short(x.own) + \" your backend · \" + short(x.thirdParty) + \" third-party\" : \"\") + \"</span>\" : '<span class=\"share\" style=\"width:60px\"><i style=\"width:' + (x.total / tot * 100).toFixed(1) + \"%;background:\" + c + '\"></i></span><span class=\"tnum\" style=\"color:var(--ink2);display:inline-block;width:40px\">' + Math.round(x.total / tot * 100) + \"%</span>\") + '</td><td class=\"chev opt2\">›</td></tr>';\n }).join(\"\") + \"</table>\" :\n '<div class=\"empty\">Nothing counted in the ' + whenOf(d) + \". Requests show up here a moment after they finish.</div>\"; });\n var top = d.top.filter(function (x) { return !hiddenType(x.type); }).slice(0, 8);\n document.getElementById(\"top\").innerHTML = top.length ? '<table class=\"tbl fit\"><tr><th>Route</th><th class=\"opt\" style=\"width:100px\">' + esc(cap(SLOT_NOW[d.chart.bucket])) + '</th><th style=\"width:112px\">' + esc(cap(whenOf(d))) + \"</th>\" + (d.failures ? '<th style=\"width:56px\">5xx</th>' : \"\") + '<th class=\"opt2\" style=\"width:122px\">Share</th></tr>' + top.map(function (x) {\n return '<tr data-go=\"' + esc(routeHref(x.type, x.action, x.name)) + '\"><td class=\"name trunc\"><span class=\"tp mono\" style=\"margin-right:8px\"><i style=\"background:' + color(x.type) + '\"></i></span>' + method(x.action) + '<span class=\"mono\">' + esc(x.name) + '</span></td><td class=\"tnum opt\">' + fmt(x.now) + '</td><td class=\"tnum\">' + fmt(x.total) + \"</td>\" +\n (d.failures ? '<td class=\"tnum' + (x.errors ? \" bad\" : \"\") + '\">' + (x.errors ? fmt(x.errors) : \"0\") + \"</td>\" : \"\") + '<td class=\"tnum opt2\">' + (x.type === \"OUT\" ? '<span class=\"muted\">' + destText(x) + \"</span>\" : (x.share * 100).toFixed(1) + \"%\") + \"</td></tr>\";\n }).join(\"\") + \"</table>\" : '<div class=\"empty\">No routes counted in the ' + whenOf(d) + \".</div>\";\n fillSide();\n }\n function fillSide() {\n var ins = state.side.insights, strip = document.getElementById(\"strip\");\n if (strip && ins) {\n var items = ins.items.filter(function (i) { return !(i.target.type && hiddenType(i.target.type)); });\n var s = ins.summary;\n strip.innerHTML = '<div class=\"strip\"><div class=\"sum\"><div class=\"h2\" style=\"font-size:15px\">Insights</div><div class=\"n\"><span class=\"bad\"><b>' + s.critical + '</b>critical</span><span style=\"color:var(--warn)\"><b>' + s.warning + '</b>warnings</span><span style=\"color:var(--info)\"><b>' + s.info + '</b>info</span></div><a class=\"link\" href=\"#/insights\" style=\"font-size:12.5px\">See all insights →</a></div><div class=\"rows\">' +\n (items.length ? items.slice(0, 3).map(function (it, k) {\n return '<button data-ins=\"' + ins.items.indexOf(it) + '\"><i style=\"background:var(--' + (it.severity === \"critical\" ? \"bad\" : it.severity === \"warning\" ? \"warn\" : \"info\") + ')\"></i><span class=\"trunc\">' + esc(it.title) + '</span><b class=\"tnum\" style=\"text-align:right\">' + esc(it.value) + \"</b></button>\";\n }).join(\"\") : '<div class=\"allclear\">' + ICON.check + \"All clear: nothing unusual in the last 30 minutes.</div>\") + \"</div></div>\";\n }\n }\n\n function fillType(r, d) {\n var when = whenOf(d), total = d.now.total, errs = d.now.errors;\n document.getElementById(\"kpis\").innerHTML = headKpis(d, esc(r.type)).slice(0, 3).join(\"\") +\n (d.failures ? kpi(\"5xx · \" + when, '<span class=\"' + (errs ? \"bad\" : \"\") + '\">' + (total ? (errs / total * 100).toFixed(2) : \"0.00\") + \"%</span>\", fmt(errs) + \" responses with status 5xx\") :\n kpi(\"Routes\", fmt(d.routes.length), \"called in the \" + when));\n plot(\"c-main\", { kind: \"bars\", ids: d.chart.ids, h: 160, series: [{ label: r.type, color: color(r.type), values: d.chart.counts }] });\n var top = d.routes.slice(0, 6), rest = sum(d.routes.slice(6).map(function (x) { return x.total; }));\n var items = top.map(function (x, i) { return { label: x.action + \" \" + x.name, value: x.total, color: PALETTE[i % PALETTE.length], go: routeHref(r.type, x.action, x.name) }; });\n if (rest) items.push({ label: \"Other routes\", value: rest, color: \"#6b7684\" });\n donut(\"mix\", items);\n var mh = document.getElementById(\"methods\").parentNode.querySelector(\".ch b\");\n if (mh) mh.textContent = r.type === \"OUT\" ? \"By destination\" : \"By method\";\n var acts = {}, out = r.type === \"OUT\", DC = { \"your backend\": \"var(--bar)\", \"third-party\": \"#8b95a3\" };\n d.routes.forEach(function (x) { var k = out ? destText(x) : x.action; acts[k] = (acts[k] || 0) + x.total; });\n var list = Object.keys(acts).sort(function (a, c) { return acts[c] - acts[a]; }), amax = Math.max.apply(null, list.map(function (a) { return acts[a]; }).concat([1])), atot = sum(list.map(function (a) { return acts[a]; })) || 1;\n document.getElementById(\"methods\").innerHTML = list.length ? '<div class=\"bars-list\">' + list.map(function (a) {\n return '<div class=\"row\"><span>' + (out ? cap(a) : method(a)) + '</span><span class=\"bar\"><i style=\"width:' + (acts[a] / amax * 100).toFixed(1) + \"%;background:\" + (out ? DC[a] : MC[a] || \"#57606b\") + '\"></i></span><span class=\"tnum\">' + short(acts[a]) + ' <span class=\"muted\">' + pct(acts[a], atot) + \"</span></span></div>\";\n }).join(\"\") + \"</div>\" : '<div class=\"empty\">No calls in the ' + when + \".</div>\";\n fillRoutes(r, d);\n }\n function fillRoutes(r, d) {\n var sort = state.sort === \"now\" || state.sort === \"name\" ? state.sort : \"total\", f = state.filter.toLowerCase(), b = d.chart.bucket, when = whenOf(d);\n var rows = d.routes.filter(function (x) { return !f || (x.action + \" \" + x.name).toLowerCase().indexOf(f) !== -1; });\n rows.sort(function (a, c) { return sort === \"name\" ? (a.name < c.name ? -1 : a.name > c.name ? 1 : 0) : (c[sort] || 0) - (a[sort] || 0); });\n document.getElementById(\"rcount\").textContent = rows.length + \" of \" + d.routes.length;\n document.getElementById(\"table\").innerHTML = d.routes.length ?\n '<table class=\"tbl fit\"><tr><th>Route</th><th class=\"opt\" style=\"width:110px\">' + esc(cap(SLOT_NOW[b])) + '</th><th style=\"width:120px\">' + esc(cap(when)) + \"</th>\" + (d.failures ? '<th style=\"width:120px\">5xx</th>' : \"\") + '<th class=\"opt2\" style=\"width:40px\"></th></tr>' + rows.map(function (x) {\n return '<tr data-go=\"' + esc(routeHref(r.type, x.action, x.name)) + '\"><td class=\"name trunc\">' + method(x.action) + '<span class=\"mono\">' + esc(x.name) + \"</span>\" + destTag(x) + '</td><td class=\"tnum opt\">' + fmt(x.now) + '</td><td class=\"tnum\">' + fmt(x.total) + \"</td>\" +\n (d.failures ? '<td class=\"tnum' + (x.errors ? \" bad\" : \"\") + '\" style=\"' + (x.errors ? \"\" : \"color:var(--ink3)\") + '\">' + (x.errors ? fmt(x.errors) + \" · \" + pct(x.errors, x.total || 1) : \"0\") + \"</td>\" : \"\") + '<td class=\"chev opt2\">›</td></tr>';\n }).join(\"\") + \"</table>\" :\n '<div class=\"empty\">No ' + esc(r.type) + \" routes counted in the \" + when + \".</div>\";\n }\n\n function fillRoute(r, d) {\n fillRequests(r, d);\n document.getElementById(\"key\").textContent = d.key + \":{bucket}:{id}\";\n document.getElementById(\"fnote\").textContent = d.failures ? \"5xx shown in red\" : \"\";\n paint(\"slots\", function () { document.getElementById(\"slots\").innerHTML = d.tiles.map(function (s) {\n return '<button class=\"slot' + (s.bucket === (state.range === \"week\" ? \"day\" : state.range) ? \" on\" : \"\") + '\" data-range=\"' + s.bucket + '\"><div class=\"l\">' + SLOT_LABEL[s.bucket] + '</div><div class=\"v tnum\">' + short(s.current) + '</div><div class=\"s\">' + SLOT_NOW[s.bucket] + \" · kept \" + expiry(s.expiry) + \"</div>\" + spark(140, 24, s.spark, s.bucket === (state.range === \"week\" ? \"day\" : state.range) ? \"#3fb979\" : \"#6ea8fe\", s.ids, SLOT_LABEL[s.bucket], true) + \"</button>\";\n }).join(\"\"); });\n var c = d.chart, total = sum(c.counts), done = c.counts.slice(0, -1), peak = Math.max.apply(null, c.counts), pi = c.counts.indexOf(peak), low = done.length ? Math.min.apply(null, done) : 0, errs = c.errors ? sum(c.errors) : null;\n document.getElementById(\"stats\").innerHTML =\n '<div class=\"stat\"><div class=\"l\">Total in range</div><div class=\"v tnum\">' + fmt(total) + '</div><div class=\"s\">' + CHART_TITLE[c.bucket].split(\",\")[1] + \"</div></div>\" +\n '<div class=\"stat\"><div class=\"l\">Average</div><div class=\"v tnum\">' + fmt(done.length ? sum(done) / done.length : 0) + '</div><div class=\"s\">' + PER[c.bucket] + \"</div></div>\" +\n '<div class=\"stat\"><div class=\"l\">Peak</div><div class=\"v tnum\">' + fmt(peak) + '</div><div class=\"s\">' + esc(fullTime(c.ids[pi])) + \"</div></div>\" +\n '<div class=\"stat\"><div class=\"l\">Lowest</div><div class=\"v tnum\">' + fmt(low) + '</div><div class=\"s\">complete buckets only</div></div>' +\n (errs !== null ? '<div class=\"stat\"><div class=\"l\">Failure rate</div><div class=\"v tnum ' + (errs ? \"bad\" : \"\") + '\">' + pct(errs, total) + '</div><div class=\"s\">' + fmt(errs) + \" responses with 5xx</div></div>\" : \"\");\n document.getElementById(\"ctitle\").textContent = CHART_TITLE[c.bucket];\n document.getElementById(\"crange\").textContent = c.ids[0] + \" → \" + last(c.ids);\n plot(\"c-main\", { kind: \"bars\", ids: c.ids, h: 300, series: [{ label: r.action + \" \" + r.name, color: \"var(--bar)\", values: c.counts, errors: c.errors }] });\n var ids = c.ids.slice().reverse(), counts = c.counts.slice().reverse(), ers = c.errors ? c.errors.slice().reverse() : null;\n // a readable time (12:55, 2 Nov) with the full one on hover; the spare width shows each count against the busiest one\n var peakShown = Math.max.apply(null, counts.slice(0, 10).concat([1]));\n document.getElementById(\"table\").innerHTML = '<table class=\"tbl fit\"><tr><th style=\"width:108px\">Time (IST)</th><th></th><th style=\"width:84px\">Count</th>' + (ers ? '<th style=\"width:62px\">5xx</th>' : \"\") + \"</tr>\" + ids.slice(0, 10).map(function (id, i) {\n return '<tr><td class=\"when tnum\" title=\"' + esc(fullTime(id)) + '\">' + esc(bucketText(id)) + '</td><td><span class=\"share bar\"><i style=\"width:' + (counts[i] / peakShown * 100).toFixed(1) + '%;background:var(--bar)\"></i></span></td><td class=\"tnum\" title=\"' + esc(fmt(counts[i])) + '\">' + fmt(counts[i]) + \"</td>\" + (ers ? '<td class=\"tnum' + (ers[i] ? \" bad\" : \"\") + '\">' + fmt(ers[i]) + \"</td>\" : \"\") + \"</tr>\";\n }).join(\"\") + \"</table>\";\n }\n\n // ---------- recent requests, as curl ----------\n var SECRET_HEADER = /^(authorization|proxy-authorization|cookie|x-api-key|api-key|x-auth-token|x-access-token)$/i;\n var NO_CURL = { WSS: 1, GRPC: 1 };\n function sh(v) { return \"'\" + String(v).replace(/'/g, \"'\\\\''\") + \"'\"; }\n function shapeOf(v) {\n if (Array.isArray(v)) return v.length ? [shapeOf(v[0])] : [];\n if (v && typeof v === \"object\") { var o = {}; Object.keys(v).forEach(function (k) { o[k] = shapeOf(v[k]); }); return o; }\n return v === null ? null : \"<\" + typeof v + \">\";\n }\n // The body's shape: values become <string>, <number>, …; a GraphQL query and a JSON-RPC method stay as they are.\n function bodyShape(s) {\n var j; try { j = JSON.parse(s.body); } catch (e) { j = undefined; }\n if (j === undefined) {\n var ct = (s.headers.filter(function (h) { return /^content-type$/i.test(h[0]); })[0] || [])[1] || \"\";\n return /x-www-form-urlencoded/i.test(ct) ? s.body.split(\"&\").map(function (kv) { return kv.split(\"=\")[0] + \"=<value>\"; }).join(\"&\") : \"<body>\";\n }\n function one(x) {\n if (x && typeof x === \"object\" && !Array.isArray(x)) {\n if (typeof x.query === \"string\") return { query: x.query, operationName: x.operationName, variables: x.variables === undefined ? undefined : shapeOf(x.variables) };\n if (x.jsonrpc === \"2.0\") return { jsonrpc: \"2.0\", method: x.method, params: x.params === undefined ? undefined : shapeOf(x.params), id: x.id === undefined ? undefined : shapeOf(x.id) };\n }\n return shapeOf(x);\n }\n var batch = Array.isArray(j) && j.length && j[0] && typeof j[0] === \"object\" && (typeof j[0].query === \"string\" || j[0].jsonrpc === \"2.0\");\n return JSON.stringify(batch ? j.map(one) : one(j), null, 2);\n }\n // structure: the route with :params, query and body values as <type>, secret headers as placeholders.\n // sample: the request exactly as it was sent.\n function curlOf(s, mode) {\n var st = mode === \"structure\", q = s.url.indexOf(\"?\"), path = q === -1 ? s.url : s.url.slice(0, q), query = q === -1 ? \"\" : s.url.slice(q + 1);\n if (st) {\n path = s.template.replace(/<([^>]+)>/g, \":$1\");\n query = query.split(\"&\").filter(Boolean).map(function (kv) {\n var i = kv.indexOf(\"=\"), v = i === -1 ? \"\" : kv.slice(i + 1);\n return (i === -1 ? kv : kv.slice(0, i)) + \"=\" + (/^-?\\d+(\\.\\d+)?$/.test(v) ? \"<number>\" : \"<string>\");\n }).join(\"&\");\n }\n var out = [\"curl -X \" + s.method + \" \" + sh(s.scheme + \"://\" + s.host + path + (query ? \"?\" + query : \"\"))];\n s.headers.forEach(function (h) { out.push(\"-H \" + sh(h[0] + \": \" + (st && SECRET_HEADER.test(h[0]) ? \"<\" + h[0].toLowerCase() + \">\" : h[1]))); });\n if (s.body) out.push(\"--data-raw \" + sh(st ? bodyShape(s) : s.body));\n return out.join(\" \\\\\\n \");\n }\n function istStamp(ms) {\n try { return new Date(ms).toLocaleString(\"en-GB\", { timeZone: \"Asia/Kolkata\", day: \"2-digit\", month: \"short\", hour: \"2-digit\", minute: \"2-digit\", second: \"2-digit\" }); } catch (e) { return new Date(ms).toLocaleString(); }\n }\n function reqItem(s, src, i) {\n var k = src + s.at + \"|\" + i, open = state.reqOpen[k] !== undefined ? state.reqOpen[k] : i === 0 && src === \"s\";\n function block(text, act) { return '<div class=\"curl\"><pre class=\"mono\">' + esc(text) + \"</pre>\" + (act ? '<button class=\"btn\" data-act=\"copy-curl\" data-src=\"' + src + '\" data-i=\"' + i + '\" title=\"Copy for a terminal or Postman (Import → Raw text)\">' + ICON.copy + \"Copy</button>\" : \"\") + \"</div>\"; }\n return '<details class=\"req\" data-k=\"' + esc(k) + '\"' + (open ? \" open\" : \"\") + \"><summary>\" + method(s.method) + '<span class=\"st' + (s.status >= 500 || !s.status ? \" bad\" : \"\") + '\">' + (s.status || \"no response\") + '</span><span class=\"mono trunc\">' + esc(s.url) + \"</span>\" + (s.repeats > 1 ? '<span class=\"rep\" data-tip=\"This request (same method, URL and body) was captured ' + s.repeats + ' times. It is kept once; this is the latest.\">×' + s.repeats + \"</span>\" : \"\") + '<span class=\"when\">' + (s.repeats > 1 ? \"last \" : \"\") + esc(istStamp(s.at)) + \" · \" + fmt(s.ms) + \" ms</span>\" + CHEVRON + \"</summary>\" +\n block(curlOf(s, state.curlMode), true) +\n (s.bodyCut ? '<div class=\"note\">The body was larger than 16 KB, so ' + (s.body ? \"it's cut short here.\" : \"it isn't shown.\") + \"</div>\" : \"\") +\n (s.response ? \"<h5>\" + (s.status ? \"Error response\" : \"Error\") + \"</h5>\" + block(s.response) : \"\") +\n (s.stack ? \"<h5>Stack</h5>\" + block(s.stack) : \"\") + \"</details>\";\n }\n function fillRequests(r, d) {\n var reqs = document.getElementById(\"reqs\"), fails = document.getElementById(\"fails\"), card = document.getElementById(\"failcard\");\n if (!reqs) return;\n var sig = state.curlMode + JSON.stringify([d.samples, d.failed]);\n if (reqs.getAttribute(\"data-sig\") === sig) return;\n reqs.setAttribute(\"data-sig\", sig);\n var shared = /^<(unmatched|other|unknown|anonymous)>$/.test(r.name);\n document.getElementById(\"reqnote\").textContent = NO_CURL[r.type] ? \"\" : \"newest first · \" + (shared ? \"the last 20, one every 2 seconds at most\" : \"the last 5, one a minute at most\") + \" · as sent, headers included\";\n if (d.samplesOff) {\n document.getElementById(\"reqnote\").textContent = \"\";\n reqs.innerHTML = '<div class=\"empty\">Requests aren\\'t kept, because <span class=\"mono\">dashboard.saveSampleCurl</span> is <span class=\"mono\">false</span> in <span class=\"mono\">init</span>. Set it to <span class=\"mono\">true</span> (the default) to see recent requests as curl and recent failures here.</div>';\n card.hidden = true;\n return;\n }\n if (NO_CURL[r.type]) {\n reqs.innerHTML = '<div class=\"empty\">' + (r.type === \"GRPC\" ? \"gRPC calls\" : \"WebSocket traffic\") + \" can't be shown as curl. Requests are kept for HTTP routes and outgoing calls.</div>\";\n card.hidden = true;\n return;\n }\n reqs.innerHTML = d.samples && d.samples.length ? d.samples.map(function (s, i) { return reqItem(s, \"s\", i); }).join(\"\") :\n '<div class=\"empty\">No requests kept yet. The next ' + (r.type === \"OUT\" ? \"call\" : \"request\") + \" to this route shows up here. Requests are kept only while the dashboard is on, one a minute per route, for 30 days, so counts from before that, or older than 30 days, have no requests to show.</div>\";\n card.hidden = false;\n document.getElementById(\"failnote\").textContent = d.failed && d.failed.length ? (r.type === \"OUT\" ? \"status 500 and above, or no response · the last 20 · with the error\" : \"status 500 and above · the last 20 · with the error response and stack when available\") : \"\";\n fails.innerHTML = d.failed && d.failed.length ? d.failed.map(function (s, i) { return reqItem(s, \"f\", i); }).join(\"\") :\n '<div class=\"empty\">No failed requests (status 500 and above) kept for this route.</div>';\n }\n document.addEventListener(\"toggle\", function (ev) {\n var el = ev.target;\n if (el && el.classList && el.classList.contains(\"req\")) state.reqOpen[el.getAttribute(\"data-k\")] = el.open;\n }, true);\n\n // ---------- insights ----------\n var CATS = [[\"all\", \"All\"], [\"critical\", \"Critical\"], [\"warning\", \"Warning\"], [\"spike\", \"Spikes\"], [\"drop\", \"Drops\"], [\"traffic\", \"High traffic\"], [\"scale\", \"Scale\"], [\"errors\", \"Failures\"], [\"new\", \"New\"]];\n var CAT_LABEL = { errors: \"Failures\", spike: \"Spike\", drop: \"Drop\", traffic: \"High traffic\", scale: \"Scale\", \"new\": \"New route\" };\n var GROUPS = [[\"errors\", \"Failures\"], [\"spike\", \"Spikes\"], [\"drop\", \"Drops\"], [\"traffic\", \"High traffic\"], [\"scale\", \"Scale\"], [\"new\", \"New routes\"]];\n var SEV_COLOR = { critical: \"var(--bad)\", warning: \"var(--warn)\", info: \"var(--info)\" };\n var ADVICE = {\n errors: [\"Open the route and find the minute the 5xx started.\", \"Check your app's logs and error tracker for this route.\", \"A failing dependency (database, upstream API) often shows up here first.\"],\n spike: [\"Look for a client calling this route in a loop, or retrying without backoff.\", \"Check recent releases of your web or mobile clients.\", \"If the load is real, caching or rate limiting helps.\"],\n drop: [\"Check the route is still reachable: health checks, routing, DNS.\", \"A client release may have stopped calling it.\", \"A drop together with 5xx often means an outage.\"],\n traffic: [\"The busiest routes are where caching or batching saves the most.\", \"Make sure this route scales with the rest of your app.\"],\n scale: [\"Raise limits.maxNamesPerType, or group routes with the types or name options.\", \"Bots probing random URLs create many names; try ignore.bots or ignore.paths.\"],\n \"new\": [\"Expected after a deploy? If not, find out which client calls it.\"]\n };\n var CHEVRON = '<svg viewBox=\"0 0 16 16\"><path d=\"M4 6l4 4 4-4\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>';\n function insKey(it) { var t = it.target; return [it.category, t.view, t.type || \"\", t.action || \"\", t.name || \"\"].join(\"|\"); }\n function insightsShell() {\n return '<div class=\"crumbs\"><b>Insights</b><span class=\"meta mono\" id=\"updated\"></span>' + resetBtn() + '<button class=\"btn p\" data-act=\"export\">' + ICON.download + \"Export report</button></div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Stats by period</b><span class=\"k\" id=\"prange\"></span><span class=\"right\"><div class=\"seg\" id=\"ptabs\">' + Object.keys(PERIOD_NAMES).map(function (k) { return '<button data-act=\"period\" data-p=\"' + k + '\"' + (k === state.period ? ' class=\"on\"' : \"\") + \">\" + PERIOD_NAMES[k] + \"</button>\"; }).join(\"\") + '</div></span></div><div id=\"pbody\"><div class=\"empty\">Loading…</div></div></div>' +\n '<div class=\"h2\" style=\"margin-top:22px;font-size:16px\">Insights <span class=\"muted\" style=\"font-size:12.5px;font-weight:400\" id=\"ins-when\"></span></div>' +\n '<div class=\"ins-sum\"><div class=\"card\"><div class=\"ch\"><b>Severity</b><span class=\"k\">of what\\'s listed below</span></div><div id=\"ins-sev\"></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>By group</b><span class=\"k\">click to filter</span></div><div id=\"ins-groups\"></div></div>' +\n '<div class=\"card\"><div class=\"ch\"><b>All traffic</b><span class=\"k\" id=\"ins-gk\"></span></div><div id=\"ins-g\"></div></div></div>' +\n '<div class=\"chips\" id=\"chips\"></div><div class=\"ins-list\" id=\"ins\"></div>';\n }\n // ---------- stats by period, export and reset ----------\n // Calendar periods in IST: today, this week (from Monday), this month, this quarter, this year.\n function loadPeriod(timed) {\n var p = state.period;\n state.pAt = Date.now();\n return api(\"/api/report?period=\" + p).then(function (d) {\n if (p !== state.period || route().view !== \"insights\") return;\n state.pdata = d;\n polled = !!timed;\n try { steady(function () { fillPeriod(d); }); } finally { polled = false; }\n }).catch(function (e) { var el = document.getElementById(\"pbody\"); if (el && e.message !== \"signed out\") el.innerHTML = '<div class=\"empty bad\">' + esc(e.message) + \"</div>\"; });\n }\n function change(now, before) {\n if (!before) return now ? \"new\" : \"\";\n var c = Math.round((now / before - 1) * 100);\n return '<span class=\"' + (c >= 0 ? \"good\" : \"bad\") + '\">' + (c >= 0 ? \"+\" : \"\") + c + \"%</span>\";\n }\n function peakOf(c) {\n if (!c || !c.counts.length) return null;\n var mx = Math.max.apply(null, c.counts);\n return mx ? { id: c.ids[c.counts.indexOf(mx)], count: mx } : null;\n }\n function topHidden() { return !!store.get(\"system-tracker-hide-top-routes\", false); }\n function fillPeriod(d) {\n var body = document.getElementById(\"pbody\"), pr = document.getElementById(\"prange\");\n if (!body) return;\n if (pr) pr.textContent = d.range + \" · IST\";\n var pk = peakOf(d.chart), unit = d.chart ? { min: \"minute\", hour: \"hour\", day: \"day\", month: \"month\" }[d.chart.bucket] : \"\";\n body.innerHTML = '<div class=\"pkpis\">' +\n '<div><div class=\"l\">Requests</div><div class=\"v tnum\">' + fmt(d.total) + '</div><div class=\"s\">' + (d.previous ? esc(d.previous.label) + \": \" + fmt(d.previous.total) + \" · \" + change(d.total, d.previous.total) : \"&nbsp;\") + \"</div></div>\" +\n (d.errors !== null ? '<div><div class=\"l\">5xx</div><div class=\"v tnum' + (d.errors ? \" bad\" : \"\") + '\">' + fmt(d.errors) + '</div><div class=\"s\">' + pct(d.errors, d.total) + \" of requests</div></div>\" : \"\") +\n '<div><div class=\"l\">Routes</div><div class=\"v tnum\">' + fmt(d.routeCount) + '</div><div class=\"s\">' + d.types.length + (d.types.length === 1 ? \" type\" : \" types\") + \"</div></div>\" +\n '<div><div class=\"l\">Busiest ' + unit + '</div><div class=\"v tnum\">' + (pk ? fmt(pk.count) : \"—\") + '</div><div class=\"s trunc\">' + (pk ? esc(fullTime(pk.id)) : \"&nbsp;\") + \"</div></div></div>\" +\n '<div class=\"pgrid\"><div><div class=\"ctitle\">Requests per ' + (unit || \"bucket\") + '</div><div id=\"p-chart\"></div></div><div><div class=\"ctitle\">By type</div><div id=\"p-types\" style=\"padding:8px 4px\"></div></div></div>' +\n '<div style=\"border-top:1px solid var(--line2)\"><button class=\"fold' + (topHidden() ? \"\" : \" open\") + '\" data-act=\"ptop\" aria-expanded=\"' + !topHidden() + '\"><span class=\"ctitle\" style=\"padding:0\">Top routes · ' + esc(d.label.toLowerCase()) + (d.routeCount > 10 ? \" · the export has all \" + fmt(d.routeCount) : \"\") + \"</span><span class=\\\"more\\\">\" + (topHidden() ? \"Show\" : \"Hide\") + CHEVRON + '</span></button><div id=\"p-routes\"' + (topHidden() ? \" hidden\" : \"\") + \"></div></div>\";\n if (d.chart) plot(\"p-chart\", { kind: \"bars\", ids: d.chart.ids, h: 190, series: [{ label: \"Requests\", color: \"var(--bar)\", values: d.chart.counts }] });\n else document.getElementById(\"p-chart\").innerHTML = '<div class=\"empty\">The chart needs the overall total (global.total) and the ' + esc(d.period === \"today\" ? \"hour\" : d.period === \"week\" || d.period === \"month\" ? \"day\" : \"month\") + \" bucket.</div>\";\n var types = d.types.filter(function (t) { return !hiddenType(t.type) && t.type !== \"OUT\"; }), out = d.types.filter(function (t) { return t.type === \"OUT\" && !hiddenType(\"OUT\"); })[0];\n donut(\"p-types\", types.map(function (t) { return { label: t.type, value: t.total, color: color(t.type), go: typeHref(t.type) }; }), \"\", \"requests\");\n if (out) document.getElementById(\"p-types\").insertAdjacentHTML(\"beforeend\", '<div class=\"hint\" style=\"margin:6px 12px 0\"><a class=\"link\" href=\"' + typeHref(\"OUT\") + '\">Outgoing calls (OUT): ' + fmt(out.total) + \"</a>, calls your app made to other services. They aren\\'t requests to your app, so they\\'re not part of the total.</div>\");\n var rows = d.routes.filter(function (x) { return !hiddenType(x.type); }).slice(0, 10);\n document.getElementById(\"p-routes\").innerHTML = rows.length ? '<table class=\"tbl fit\"><tr><th>Route</th><th style=\"width:104px\">Requests</th>' + (d.failures ? '<th style=\"width:124px\">5xx</th>' : \"\") + '<th class=\"opt2\" style=\"width:76px\">Share</th></tr>' + rows.map(function (x) {\n return '<tr data-go=\"' + esc(routeHref(x.type, x.action, x.name)) + '\"><td class=\"name trunc\"><span class=\"tp mono\" style=\"margin-right:8px\"><i style=\"background:' + color(x.type) + '\"></i></span>' + method(x.action) + '<span class=\"mono\">' + esc(x.name) + '</span></td><td class=\"tnum\">' + fmt(x.total) + \"</td>\" +\n (d.failures ? '<td class=\"tnum' + (x.errors ? \" bad\" : \"\") + '\">' + (x.errors ? fmt(x.errors) + \" · \" + pct(x.errors, x.total || 1) : \"0\") + \"</td>\" : \"\") + '<td class=\"tnum opt2\">' + (x.type === \"OUT\" ? '<span class=\"muted\">outgoing</span>' : pct(x.total, d.total || 1)) + \"</td></tr>\";\n }).join(\"\") + \"</table>\" : '<div class=\"empty\">Nothing counted ' + esc(d.label.toLowerCase()) + \" yet.</div>\";\n }\n\n // Export: pick a period, then a format. Every export is saved in Redis, so it can be downloaded again.\n function openExport(mode) {\n var view = mode === \"view\" ? screenReport() : null;\n if (mode === \"view\" && !view) { toast(\"Wait for the numbers to load, then export.\"); return; }\n state.xp = { mode: mode || \"period\", view: view, period: null, format: \"csv\", list: null, busy: false };\n drawExport();\n api(\"/api/reports\").then(function (d) { if (state.xp) { state.xp.list = d.reports; state.xp.key = d.key; drawExport(); } }).catch(function () {});\n }\n function drawExport() {\n var x = state.xp, fm = [[\"csv\", \"CSV\", \"Excel, Sheets\"], [\"pdf\", \"PDF\", \"Print view\"], [\"json\", \"JSON\", \"Raw data\"]];\n var hist = x.list === null ? '<div class=\"empty\">Loading…</div>' : x.list.length ? '<div class=\"hist\">' + x.list.map(function (m) {\n return '<div><span style=\"min-width:0;flex:1\"><b class=\"trunc\" style=\"display:block\">' + esc(m.label) + ' <span class=\"muted\" style=\"font-weight:400\">' + esc(m.range) + '</span></b><span class=\"muted\" style=\"font-size:12px\">' + esc(new Date(m.createdAt).toLocaleString()) + \" · \" + (m.kind === \"view\" ? \"screen\" + (m.total !== null && m.total !== undefined ? \" · \" + fmt(m.total) + \" requests\" : \"\") : fmt(m.total) + \" requests\" + (m.errors ? \" · \" + fmt(m.errors) + \" 5xx\" : \"\")) + \"</span></span>\" +\n fm.map(function (f) { return '<button class=\"btn\" style=\"height:26px;padding:0 8px;font-size:12px\" data-act=\"xp-get\" data-id=\"' + esc(m.id) + '\" data-f=\"' + f[0] + '\">' + f[1] + \"</button>\"; }).join(\"\") +\n '<button class=\"ibtn sm\" data-act=\"xp-del\" data-id=\"' + esc(m.id) + '\" title=\"Delete this saved report\">' + ICON.trash + \"</button></div>\";\n }).join(\"\") + \"</div>\" : '<div class=\"muted\" style=\"font-size:13px\">No reports yet. Each one you export is saved here.</div>';\n var v = x.view, ready = x.mode === \"view\" ? !!v : !!x.period;\n var what = x.mode === \"view\" ? '<h4>1 · What</h4><div class=\"xpwhat\"><b>' + esc(v.title) + \"</b>\" + (v.range ? ' <span class=\"muted\">· ' + esc(v.range) + \"</span>\" : \"\") +\n '<div class=\"hint\" style=\"margin-top:4px\">What this screen shows: ' + [v.kpis.length ? \"the numbers at the top\" : \"\", v.charts.length ? v.charts.length + (v.charts.length === 1 ? \" chart\" : \" charts\") : \"\", v.tables.length ? v.tables.length + (v.tables.length === 1 ? \" table\" : \" tables\") : \"\"].filter(Boolean).join(\", \") + \". \" +\n (v.screen === \"board\" || v.screen === \"template\" ? \"Each chart keeps its own range.\" : v.screen === \"settings\" ? \"\" : \"To export another range, pick it on the page first.\") + (v.screen === \"route\" ? \" Recent requests and failures are left out, since they can hold tokens.\" : \"\") + \"</div></div>\"\n : \"<h4>1 · Period</h4><div class=\\\"pick\\\">\" + Object.keys(PERIOD_NAMES).map(function (k) { return '<button data-act=\"xp-period\" data-p=\"' + k + '\" class=\"' + (x.period === k ? \"on\" : \"\") + '\"><b>' + PERIOD_NAMES[k] + \"</b></button>\"; }).join(\"\") + \"</div>\";\n modal('<div class=\"modal sm\" style=\"width:min(680px,100%)\"><div class=\"mh\"><b>' + (x.mode === \"view\" ? \"Export this screen\" : \"Export report\") + '</b><button class=\"ibtn\" data-act=\"modal-close\" title=\"Close\">✕</button></div><div class=\"mb\">' + what +\n '<h4>2 · Format</h4><div class=\"pick\">' + fm.map(function (f) { return '<button data-act=\"xp-format\" data-f=\"' + f[0] + '\" class=\"' + (x.format === f[0] ? \"on\" : \"\") + '\"><b>' + f[1] + \"</b><span>\" + f[2] + \"</span></button>\"; }).join(\"\") + \"</div>\" +\n \"<h4>Saved reports</h4>\" + hist + (x.key ? '<div class=\"hint\">Kept in your Redis at <span class=\"mono\">' + esc(x.key) + \"</span> (the newest 100).</div>\" : \"\") +\n '</div><div class=\"mf\"><span class=\"hint\" style=\"margin:0 auto 0 0\">' + (ready ? \"\" : \"Pick a period to export.\") + '</span><button class=\"btn ghost\" data-act=\"modal-close\">Cancel</button><button class=\"btn p\" data-act=\"xp-go\"' + (ready && !x.busy ? \"\" : \" disabled\") + \">\" + ICON.download + (x.busy ? \"Exporting…\" : \"Export\") + \"</button></div></div>\");\n }\n function doExport() {\n var x = state.xp;\n if (!x || x.busy || (x.mode === \"view\" ? !x.view : !x.period)) return;\n x.busy = true; drawExport();\n var body = x.mode === \"view\" ? { view: x.view, format: x.format } : { period: x.period, format: x.format };\n api(\"/api/reports\", { method: \"POST\", body: JSON.stringify(body) }).then(function (d) {\n deliver(d.report, x.format);\n log(\"Report saved in Redis at \" + (x.key || \"system-tracker:reports:\" + state.me.prefix) + \" (\" + d.meta.id + \").\");\n toast(x.format === \"pdf\" ? \"Report saved. Choose \\\"Save as PDF\\\" in the print dialog.\" : \"Report saved and downloaded.\");\n x.busy = false; x.period = null;\n return api(\"/api/reports\").then(function (l) { if (state.xp) { x.list = l.reports; drawExport(); } });\n }).catch(function (e) { x.busy = false; if (state.xp) drawExport(); toast(\"Could not export: \" + e.message); });\n }\n function deliver(rep, format) {\n var view = rep.kind === \"view\", day = String(rep.generatedAt).slice(0, 10);\n var name = \"system-tracker-\" + rep.prefix + \"-\" + (view ? String(rep.title).toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-|-$/g, \"\").slice(0, 60) || \"screen\" : rep.period) + \"-\" + day;\n if (format === \"pdf\") return view ? printView(rep) : printReport(rep);\n if (format === \"json\") return download(name + \".json\", JSON.stringify(rep, null, 2), \"application/json\");\n download(name + \".csv\", \"\\uFEFF\" + (view ? csvOfView(rep) : csvOf(rep)), \"text/csv;charset=utf-8\");\n }\n\n // ---------- exporting a screen ----------\n // What the current screen shows, as numbers, charts (time series) and tables, built from the data\n // already on the page. The server checks its shape and keeps a copy with the other reports.\n function rangeOf(ids, bucket, when) {\n return cap(when) + \", \" + PER[bucket] + (ids && ids.length ? \" · \" + fullTime(ids[0]) + \" – \" + fullTime(ids[ids.length - 1]) : \"\");\n }\n function headNums(d, first) {\n var b = d.chart.bucket, n = d.now, unit = BUCKET_NAME[b];\n return [\n { label: first + \" · \" + whenOf(d), value: n.total },\n { label: cap(SLOT_NOW[b]), value: n.current, note: n.previous !== null ? PREV_LABEL[b] + \": \" + n.previous : \"\" },\n { label: \"Average per \" + unit, value: Math.round(n.average * 10) / 10, note: \"complete \" + unit + \"s only\" },\n { label: \"Busiest \" + unit, value: n.peak.count, note: n.peak.id ? fullTime(n.peak.id) : \"\" }\n ];\n }\n function screenReport() {\n var r = route(), d = state.data, ready = d && state.dataFor === JSON.stringify(r);\n if (r.view === \"overview\") {\n if (!ready) return null;\n var rows = d.types.filter(function (x) { return !hiddenType(x.type); }), incoming = rows.filter(function (x) { return x.type !== \"OUT\"; }), tot = sum(incoming.map(function (x) { return x.total; })) || 1;\n return { screen: \"overview\", title: \"Overview\", range: rangeOf(d.chart.ids, d.chart.bucket, whenOf(d)), total: d.now.total, kpis: headNums(d, \"Requests\"),\n charts: [{ title: \"Traffic by type\", ids: d.chart.ids, series: incoming.length ? incoming.map(function (x) { return { label: x.type, values: x.chart }; }) : [{ label: \"All traffic\", values: d.chart.counts }] }],\n tables: [\n { title: \"By type\", columns: [\"Type\", cap(SLOT_NOW[d.chart.bucket]), cap(whenOf(d)), \"Share\"], rows: rows.map(function (x) { return [x.type, x.now, x.total, x.type === \"OUT\" ? \"outgoing\" + (x.own !== undefined ? \": \" + x.own + \" your backend, \" + x.thirdParty + \" third-party\" : \"\") : pct(x.total, tot)]; }) },\n { title: \"Top routes\", columns: [\"Type\", \"Method\", \"Route\", cap(SLOT_NOW[d.chart.bucket]), cap(whenOf(d))].concat(d.failures ? [\"5xx\"] : []).concat([\"Share\"]), rows: d.top.filter(function (x) { return !hiddenType(x.type); }).map(function (x) { return [x.type, x.action, x.name, x.now, x.total].concat(d.failures ? [x.errors || 0] : []).concat([x.type === \"OUT\" ? destText(x) : (x.share * 100).toFixed(1) + \"%\"]); }) }\n ] };\n }\n if (r.view === \"type\") {\n if (!ready) return null;\n var out = r.type === \"OUT\", k = headNums(d, r.type);\n if (d.failures) k.push({ label: \"5xx · \" + whenOf(d), value: d.now.errors, note: pct(d.now.errors || 0, d.now.total || 1) });\n return { screen: \"type\", title: r.type, range: rangeOf(d.chart.ids, d.chart.bucket, whenOf(d)), total: d.now.total, kpis: k,\n charts: [{ title: r.type + \" traffic\", ids: d.chart.ids, series: [{ label: r.type, values: d.chart.counts }] }],\n tables: [{ title: \"Routes\", columns: [\"Method\", \"Route\", cap(SLOT_NOW[d.chart.bucket]), cap(whenOf(d))].concat(d.failures ? [\"5xx\"] : []).concat(out ? [\"Destination\"] : []),\n rows: d.routes.map(function (x) { return [x.action, x.name, x.now, x.total].concat(d.failures ? [x.errors || 0] : []).concat(out ? [destText(x)] : []); }) }] };\n }\n if (r.view === \"route\") {\n if (!ready) return null;\n var c = d.chart, total = sum(c.counts), done = c.counts.slice(0, -1), peak = Math.max.apply(null, c.counts), errs = c.errors ? sum(c.errors) : null, when = RANGE_WHEN[state.range] || RANGE_WHEN[c.bucket];\n var kp = [{ label: \"Total in range\", value: total }, { label: \"Average\", value: done.length ? Math.round(sum(done) / done.length * 10) / 10 : 0, note: PER[c.bucket] }, { label: \"Peak\", value: peak, note: fullTime(c.ids[c.counts.indexOf(peak)]) }, { label: \"Lowest\", value: done.length ? Math.min.apply(null, done) : 0, note: \"complete buckets only\" }];\n if (errs !== null) kp.push({ label: \"Failure rate\", value: pct(errs, total), note: errs + \" responses with 5xx\" });\n var ser = [{ label: \"Requests\", values: c.counts }];\n if (c.errors) ser.push({ label: \"5xx\", values: c.errors });\n return { screen: \"route\", title: r.type + \" \" + r.action + \" \" + r.name, range: rangeOf(c.ids, c.bucket, when), total: total, kpis: kp,\n charts: [{ title: CHART_TITLE[c.bucket], ids: c.ids, series: ser }],\n tables: [\n { title: \"Now, by bucket\", columns: [\"Bucket\", \"Now\", \"Kept for\"], rows: d.tiles.map(function (t) { return [SLOT_LABEL[t.bucket] + \" (\" + SLOT_NOW[t.bucket] + \")\", t.current, expiry(t.expiry)]; }) },\n { title: \"Buckets, newest first\", columns: [\"Time (IST)\", \"Requests\"].concat(c.errors ? [\"5xx\"] : []), rows: c.ids.slice().reverse().map(function (id, i) { var j = c.ids.length - 1 - i; return [fullTime(id), c.counts[j]].concat(c.errors ? [c.errors[j]] : []); }) }\n ] };\n }\n if (r.view === \"board\" || r.view === \"template\") {\n var b = boardFor(r);\n if (!b) return null;\n var charts = [], tables = [];\n b.widgets.forEach(function (w) {\n var wd = state.wdata[w.id];\n if (!wd) return;\n if (w.chart === \"insights\") { tables.push({ title: w.title, columns: [\"Severity\", \"Problem\", \"Value\"], rows: (wd.items || []).map(function (it) { return [it.severity, it.title, it.value]; }) }); return; }\n var metric = w.metric || \"count\";\n if (w.chart === \"donut\" || w.chart === \"table\") {\n tables.push({ title: w.title + \" · \" + RANGE_WHEN[w.range], columns: [\"Name\", \"Requests\"].concat(wd.failures ? [\"5xx\"] : []), rows: (wd.breakdown || []).map(function (x) { return [x.label, x.total].concat(wd.failures ? [x.errors || 0] : []); }).sort(function (a, c2) { return c2[1] - a[1]; }) });\n return;\n }\n charts.push({ title: w.title, unit: metric === \"rate\" ? \"%\" : \"\", ids: wd.series[0].ids, series: wd.series.map(function (sr) { return { label: sr.label, values: metricOf(sr, metric).map(function (v) { return Math.round(v * 100) / 100; }) }; }) });\n });\n if (!charts.length && !tables.length && b.widgets.length) return null;\n return { screen: r.view, title: (r.view === \"template\" ? \"Template: \" : r.landing ? \"Overview: \" : \"Board: \") + b.name, range: \"Each chart has its own range\", kpis: [], charts: charts, tables: tables };\n }\n if (r.view === \"settings\") {\n var st = state.storage, cfg = state.cfg, tb = [];\n if (!st && !cfg) return null;\n var kps = [];\n if (st) {\n kps.push({ label: \"Redis used\", value: bytes(st.redis.used), note: st.redis.limit ? \"of \" + limitText(st.redis) : \"\" }, { label: \"Redis keys\", value: st.redis.keys });\n if (st.tracker) {\n kps.push({ label: \"Tracker\", value: st.tracker.bytes !== null ? \"≈\" + bytes(st.tracker.bytes) : \"—\", note: st.tracker.keys + \" keys\" }, { label: \"Other\", value: st.other && st.other.bytes !== null ? \"≈\" + bytes(st.other.bytes) : \"—\", note: \"other apps and Redis overhead\" });\n tb.push({ title: \"Storage in Redis (counted \" + agoText(st.tracker.at) + \")\", columns: [\"Keys\", \"Count\", \"Size (bytes, approx.)\", \"Kept for\"], rows: ST_GROUPS.filter(function (g) { return st.tracker.groups[g[0]].keys || st.tracker.expiry[g[0]] !== undefined; }).map(function (g) { var x = st.tracker.groups[g[0]], kept = st.tracker.expiry[g[0]]; return [g[1], x.keys, x.bytes, kept ? expiry(kept) : \"\"]; }) });\n }\n }\n if (cfg) {\n var rs = cfg.resolved;\n tb.push({ title: \"What's tracked\", columns: [\"Type\", \"Tracked\"], rows: Object.keys(rs.track).map(function (t) { return [t, rs.track[t] ? \"yes\" : \"no\"]; }) });\n tb.push({ title: \"Time buckets\", columns: [\"Bucket\", \"On\", \"Kept for\"], rows: BUCKETS.map(function (b2) { return [SLOT_LABEL[b2], rs.timeConfig[b2].enable ? \"yes\" : \"no\", expiry(rs.timeConfig[b2].expiry_in_sec)]; }) });\n }\n return { screen: \"settings\", title: \"Settings and storage\", range: \"\", kpis: kps, charts: [], tables: tb };\n }\n return null;\n }\n function csvOfView(r) {\n var out = [], row = function () { out.push([].slice.call(arguments).map(cell).join(\",\")); }, line = function (a) { out.push(a.map(cell).join(\",\")); };\n row(\"system-tracker export\"); row(\"Screen\", r.title); if (r.range) row(\"Range\", r.range + \" (IST)\"); row(\"Prefix\", r.prefix); row(\"Generated\", new Date(r.generatedAt).toLocaleString());\n if (r.kpis.length) { row(); row(\"Numbers\"); row(\"Name\", \"Value\", \"Note\"); r.kpis.forEach(function (k) { row(k.label, k.value, k.note); }); }\n r.charts.forEach(function (c) {\n row(); row(c.title + (c.unit ? \" (\" + c.unit + \")\" : \"\")); line([\"Time (IST)\"].concat(c.series.map(function (s) { return s.label; })));\n c.ids.forEach(function (id, i) { line([fullTime(id)].concat(c.series.map(function (s) { return s.values[i]; }))); });\n });\n r.tables.forEach(function (t) { row(); row(t.title); line(t.columns); t.rows.forEach(line); });\n return out.join(\"\\r\\n\") + \"\\r\\n\";\n }\n function printView(r) {\n var el = document.getElementById(\"print\");\n if (!el) { el = document.createElement(\"div\"); el.id = \"print\"; document.body.appendChild(el); }\n var val = function (v) { return typeof v === \"number\" ? fmt(v) : v === null || v === undefined ? \"—\" : esc(v); };\n var charts = r.charts.map(function (c) {\n var n = c.ids.length, W = 680, H = 160, totals = c.ids.map(function (_, i) { return sum(c.series.map(function (s) { return s.values[i] || 0; })); });\n // series on top of each other, except a route's 5xx, which are part of its requests\n var stacked = !(c.series.length === 2 && c.series[1].label === \"5xx\"), mx = Math.max.apply(null, (stacked ? totals : c.series[0].values).concat([1])), st = W / Math.max(n, 1), svg = \"\";\n c.ids.forEach(function (_, i) {\n var base = 0;\n c.series.forEach(function (s, j) {\n var v = s.values[i] || 0, h = v / mx * (H - 20), y = H - 16 - (stacked ? base : 0) - h;\n if (h > 0) svg += '<rect x=\"' + (i * st + st * 0.15).toFixed(1) + '\" y=\"' + y.toFixed(1) + '\" width=\"' + Math.max(1, st * 0.7).toFixed(1) + '\" height=\"' + h.toFixed(1) + '\" fill=\"' + (s.label === \"5xx\" ? \"#d4433b\" : PALETTE[j % PALETTE.length]) + '\"/>';\n if (stacked) base += h;\n });\n });\n if (n) [0, Math.floor((n - 1) / 2), n - 1].forEach(function (i) { svg += '<text x=\"' + (i * st + st / 2).toFixed(1) + '\" y=\"' + (H - 3) + '\" font-size=\"9\" text-anchor=\"middle\" fill=\"#555\">' + esc(axisLabel(c.ids[i])) + \"</text>\"; });\n var legend = c.series.length > 1 ? '<div class=\"sub\">' + c.series.map(function (s, j) { return '<span style=\"display:inline-block;width:9px;height:9px;margin:0 4px 0 10px;background:' + (s.label === \"5xx\" ? \"#d4433b\" : PALETTE[j % PALETTE.length]) + '\"></span>' + esc(s.label); }).join(\"\") + \"</div>\" : \"\";\n return \"<h2>\" + esc(c.title) + (c.unit ? \" (\" + esc(c.unit) + \")\" : \"\") + \"</h2>\" + legend + '<svg width=\"100%\" viewBox=\"0 0 ' + W + \" \" + H + '\">' + svg + \"</svg>\";\n }).join(\"\");\n var tables = r.tables.map(function (t) {\n return \"<h2>\" + esc(t.title) + \"</h2><table><tr>\" + t.columns.map(function (c, i) { return \"<th\" + (i ? \"\" : ' class=\"l\"') + \">\" + esc(c) + \"</th>\"; }).join(\"\") + \"</tr>\" +\n t.rows.map(function (rw) { return \"<tr>\" + rw.map(function (v, i) { return \"<td\" + (typeof v === \"number\" ? \"\" : ' class=\"l\"') + \">\" + val(v) + \"</td>\"; }).join(\"\") + \"</tr>\"; }).join(\"\") + \"</table>\";\n }).join(\"\");\n el.innerHTML = \"<h1>\" + esc(r.title) + '</h1><div class=\"sub\">' + (r.range ? esc(r.range) + \" (IST) · \" : \"\") + \"prefix \" + esc(r.prefix) + \" · generated \" + esc(new Date(r.generatedAt).toLocaleString()) + \"</div>\" +\n (r.kpis.length ? '<div class=\"kp\" style=\"flex-wrap:wrap\">' + r.kpis.map(function (k) { return \"<div>\" + esc(k.label) + \"<b>\" + val(k.value) + \"</b>\" + (k.note ? '<span class=\"sub\">' + esc(k.note) + \"</span>\" : \"\") + \"</div>\"; }).join(\"\") + \"</div>\" : \"\") + charts + tables;\n var done = function () { el.innerHTML = \"\"; removeEventListener(\"afterprint\", done); };\n addEventListener(\"afterprint\", done);\n setTimeout(function () { window.print(); }, 50);\n }\n function download(name, text, type) {\n var a = document.createElement(\"a\"), url = URL.createObjectURL(new Blob([text], { type: type }));\n a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();\n setTimeout(function () { URL.revokeObjectURL(url); }, 1000);\n }\n // route names come from requests; a leading = + - @ would run as a formula in a spreadsheet\n function cell(v) {\n var s = v === null || v === undefined ? \"\" : String(v);\n if (/^[=+\\-@\\t\\r]/.test(s)) s = \"'\" + s;\n return /[\",\\r\\n]/.test(s) ? '\"' + s.replace(/\"/g, '\"\"') + '\"' : s;\n }\n function csvOf(r) {\n var out = [], row = function () { out.push([].slice.call(arguments).map(cell).join(\",\")); };\n row(\"system-tracker report\"); row(\"Prefix\", r.prefix); row(\"Period\", r.label, r.range + \" (IST)\"); row(\"Generated\", new Date(r.generatedAt).toLocaleString());\n row(\"Requests\", r.total); if (r.errors !== null) row(\"5xx\", r.errors, pct(r.errors, r.total)); if (r.previous) row(r.previous.label, r.previous.total);\n if (r.chart) { row(); row(\"Requests over time\"); row(\"Time (IST)\", \"Requests\"); r.chart.ids.forEach(function (id, i) { row(fullTime(id), r.chart.counts[i]); }); }\n row(); row(\"By type\"); row(\"Type\", \"Requests\", \"5xx\"); r.types.forEach(function (t) { row(t.type, t.total, t.errors); });\n row(); row(r.routes.length < r.routeCount ? \"Routes (the busiest \" + r.routes.length + \" of \" + r.routeCount + \")\" : \"Routes\"); row(\"Type\", \"Method\", \"Route\", \"Requests\", \"5xx\", \"5xx rate\");\n r.routes.forEach(function (x) { row(x.type, x.action, x.name, x.total, x.errors, x.errors !== null ? pct(x.errors, x.total || 1) : \"\"); });\n return out.join(\"\\r\\n\") + \"\\r\\n\";\n }\n function printReport(r) {\n var el = document.getElementById(\"print\");\n if (!el) { el = document.createElement(\"div\"); el.id = \"print\"; document.body.appendChild(el); }\n var bars = \"\";\n if (r.chart && r.chart.counts.length) {\n var n = r.chart.counts.length, W = 680, H = 160, mx = Math.max.apply(null, r.chart.counts) || 1, st = W / n;\n r.chart.counts.forEach(function (v, i) { var h = v / mx * (H - 20); bars += '<rect x=\"' + (i * st + st * 0.15).toFixed(1) + '\" y=\"' + (H - 16 - h).toFixed(1) + '\" width=\"' + Math.max(1, st * 0.7).toFixed(1) + '\" height=\"' + h.toFixed(1) + '\" fill=\"#4c8ef5\"/>'; });\n [0, Math.floor((n - 1) / 2), n - 1].forEach(function (i) { bars += '<text x=\"' + (i * st + st / 2).toFixed(1) + '\" y=\"' + (H - 3) + '\" font-size=\"9\" text-anchor=\"middle\" fill=\"#555\">' + esc(axisLabel(r.chart.ids[i])) + \"</text>\"; });\n bars = '<h2>Requests over time</h2><svg width=\"100%\" viewBox=\"0 0 ' + W + \" \" + H + '\">' + bars + \"</svg>\";\n }\n el.innerHTML = '<h1>system-tracker report: ' + esc(r.label) + '</h1><div class=\"sub\">' + esc(r.range) + \" (IST) · prefix \" + esc(r.prefix) + \" · generated \" + esc(new Date(r.generatedAt).toLocaleString()) + \"</div>\" +\n '<div class=\"kp\"><div>Requests<b>' + fmt(r.total) + \"</b></div>\" + (r.errors !== null ? \"<div>5xx<b>\" + fmt(r.errors) + \" (\" + pct(r.errors, r.total) + \")</b></div>\" : \"\") + (r.previous ? \"<div>\" + esc(r.previous.label) + \"<b>\" + fmt(r.previous.total) + \"</b></div>\" : \"\") + \"<div>Routes<b>\" + fmt(r.routeCount) + \"</b></div></div>\" + bars +\n '<h2>By type</h2><table><tr><th>Type</th><th>Requests</th><th>Share</th><th>5xx</th></tr>' + r.types.map(function (t) { return \"<tr><td>\" + esc(t.type) + \"</td><td>\" + fmt(t.total) + \"</td><td>\" + pct(t.total, r.total || 1) + \"</td><td>\" + (t.errors === null ? \"—\" : fmt(t.errors)) + \"</td></tr>\"; }).join(\"\") + \"</table>\" +\n \"<h2>Routes\" + (r.routes.length < r.routeCount ? \" (the busiest \" + r.routes.length + \" of \" + fmt(r.routeCount) + \")\" : \"\") + '</h2><table><tr><th>Type</th><th class=\"l\">Route</th><th>Requests</th><th>5xx</th></tr>' + r.routes.map(function (x) { return \"<tr><td>\" + esc(x.type) + '</td><td class=\"l\">' + esc(x.action + \" \" + x.name) + \"</td><td>\" + fmt(x.total) + \"</td><td>\" + (x.errors === null ? \"—\" : fmt(x.errors)) + \"</td></tr>\"; }).join(\"\") + \"</table>\";\n var done = function () { el.innerHTML = \"\"; removeEventListener(\"afterprint\", done); };\n addEventListener(\"afterprint\", done);\n setTimeout(function () { window.print(); }, 50);\n }\n\n // Reset: what (everything, a type, a route) and when (all time or a period), checked first with a dry run.\n function openReset() {\n var types = trackedTypes();\n state.rs = { what: \"all\", type: types[0] || \"API\", action: \"\", name: \"\", period: \"all\", check: null, busy: false, routes: null, q: \"\", types: types };\n drawReset();\n }\n function drawReset() {\n var x = state.rs, whats = [[\"all\", \"Everything\", \"Every counter of this app\"], [\"type\", \"One type\", \"API, WEB, RPC…\"], [\"route\", \"One route\", \"A single endpoint\"]];\n var whens = [[\"all\", \"All time\"]].concat(Object.keys(PERIOD_NAMES).map(function (k) { return [k, PERIOD_NAMES[k]]; }));\n var src = \"\";\n if (x.what === \"type\") src = '<div class=\"typechips\">' + x.types.map(function (t) { return '<button data-act=\"rs-type\" data-t=\"' + esc(t) + '\" class=\"' + (x.type === t ? \"on\" : \"\") + '\"><i style=\"background:' + color(t) + '\"></i>' + esc(t) + \"</button>\"; }).join(\"\") + \"</div>\";\n if (x.what === \"route\") src = '<div class=\"row2\" style=\"margin-top:10px\"><select id=\"rs-rtype\">' + x.types.map(function (t) { return '<option value=\"' + esc(t) + '\"' + (x.type === t ? \" selected\" : \"\") + \">\" + esc(t) + \"</option>\"; }).join(\"\") + '</select><input class=\"inp\" id=\"rs-search\" placeholder=\"Search routes…\" style=\"flex:1\" value=\"' + esc(x.q) + '\"></div><div class=\"rlist\" id=\"rs-list\"><div class=\"empty\">Loading routes…</div></div>';\n var c = x.check, sumText = !c ? \"Checking what this deletes…\" : c.error ? '<span class=\"bad\">' + esc(c.error) + \"</span>\" : c.deleted + c.lowered ? \"This deletes <b>\" + fmt(c.deleted) + \"</b> counters\" + (c.lowered ? \" and lowers <b>\" + fmt(c.lowered) + \"</b> totals that include them\" : \"\") + \".\" : \"Nothing to delete for this choice.\";\n var ready = c && !c.error && c.deleted + c.lowered > 0 && !x.busy && (x.what !== \"route\" || x.name);\n modal('<div class=\"modal sm\" style=\"width:min(640px,100%)\"><div class=\"mh\"><b>Reset data</b><button class=\"ibtn\" data-act=\"modal-close\" title=\"Close\">✕</button></div><div class=\"mb\">' +\n '<div class=\"danger-box\"><b>These details will be cleared from tracking.</b> They won\\'t be accessible any more and are lost for good. Only system-tracker\\'s own counters under <span class=\"mono\">' + esc(state.me.prefix) + \"</span> are deleted; your boards and saved reports stay.</div>\" +\n '<h4>1 · What</h4><div class=\"pick\">' + whats.map(function (w) { return '<button data-act=\"rs-what\" data-w=\"' + w[0] + '\" class=\"' + (x.what === w[0] ? \"on\" : \"\") + '\"><b>' + w[1] + \"</b><span>\" + w[2] + \"</span></button>\"; }).join(\"\") + \"</div>\" + src +\n '<h4>2 · When</h4><div class=\"seg\" style=\"display:inline-flex;flex-wrap:wrap\">' + whens.map(function (w) { return '<button data-act=\"rs-when\" data-p=\"' + w[0] + '\" class=\"' + (x.period === w[0] ? \"on\" : \"\") + '\">' + w[1] + \"</button>\"; }).join(\"\") + \"</div>\" +\n '<div class=\"hint\" id=\"rs-sum\" style=\"margin-top:14px\">' + sumText + \"</div>\" +\n '</div><div class=\"mf\"><span class=\"hint\" style=\"margin:0 auto 0 0\">Are you sure? This can\\'t be undone.</span><button class=\"btn ghost\" data-act=\"modal-close\">Cancel</button><button class=\"btn d\" data-act=\"rs-go\"' + (ready ? \"\" : \" disabled\") + \">\" + ICON.trash + (x.busy ? \"Deleting…\" : \"Yes, delete\") + \"</button></div></div>\");\n if (x.what === \"route\") showResetRoutes();\n }\n function resetBody(dry) { var x = state.rs; return JSON.stringify({ what: x.what, type: x.type, action: x.action, name: x.name, period: x.period, dryRun: dry }); }\n var rsTimer;\n function checkReset() {\n var x = state.rs;\n x.check = null; drawReset();\n if (x.what === \"route\" && !x.name) { x.check = { deleted: 0, lowered: 0, error: \"Pick a route from the list.\" }; return drawReset(); }\n clearTimeout(rsTimer);\n rsTimer = setTimeout(function () {\n var want = resetBody(true);\n api(\"/api/reset\", { method: \"POST\", body: want }).then(function (d) { if (state.rs && resetBody(true) === want) { state.rs.check = d; drawReset(); } })\n .catch(function (e) { if (state.rs) { state.rs.check = { deleted: 0, lowered: 0, error: e.message }; drawReset(); } });\n }, 200);\n }\n // routes seen this year (or today, when years are off), for the chosen type\n function showResetRoutes() {\n var x = state.rs, el = document.getElementById(\"rs-list\");\n if (!el) return;\n if (!x.routes) {\n x.routes = [];\n api(\"/api/report?period=year\").catch(function () { return api(\"/api/report?period=today\"); }).then(function (d) { if (state.rs) { x.routes = d.routes; x.loaded = true; showResetRoutes(); } }).catch(function () { x.loaded = true; showResetRoutes(); });\n return;\n }\n if (!x.loaded) return;\n var q = x.q.toLowerCase(), rows = x.routes.filter(function (r) { return r.type === x.type && (!q || (r.action + \" \" + r.name).toLowerCase().indexOf(q) !== -1); }).slice(0, 80);\n el.innerHTML = rows.length ? rows.map(function (r) { var on = r.action === x.action && r.name === x.name; return '<button data-act=\"rs-route\" data-a=\"' + esc(r.action) + '\" data-n=\"' + esc(r.name) + '\" class=\"' + (on ? \"on\" : \"\") + '\">' + method(r.action) + '<span class=\"mono trunc\">' + esc(r.name) + '</span><span class=\"tnum\">' + short(r.total) + \"</span></button>\"; }).join(\"\") : '<div class=\"empty\">No ' + esc(x.type) + \" routes counted this year.</div>\";\n }\n function doReset() {\n var x = state.rs;\n if (!x || x.busy) return;\n x.busy = true; drawReset();\n api(\"/api/reset\", { method: \"POST\", body: resetBody(false) }).then(function (d) {\n log(\"Reset: deleted \" + d.deleted + \" counters, lowered \" + d.lowered + \" totals.\");\n toast(\"Deleted \" + fmt(d.deleted) + \" counters.\");\n closeModal(); state.wdata = {}; state.pdata = null; state.pAt = 0; state.side.at = 0;\n load(true); loadSide();\n }).catch(function (e) { x.busy = false; if (state.rs) drawReset(); toast(\"Could not reset: \" + e.message); });\n }\n\n function match(it, f) { return f === \"all\" || it.severity === f || it.category === f; }\n function median(a) { var x = a.slice().sort(function (p, q) { return p - q; }); return x.length ? (x[(x.length - 1) >> 1] + x[x.length >> 1]) / 2 : 0; }\n var UNIT_SHORT = { minute: \"min\", hour: \"h\", day: \"day\", month: \"mo\" };\n function winOf(d) { return d.window || { when: \"in the last 30 minutes\", unit: \"minute\", steps: 30 }; }\n function fillInsights(d) {\n var win = winOf(d);\n var span = \"last \" + win.steps + \" \" + win.unit + \"s\";\n state.insOpen = state.insOpen || {};\n var wh = document.getElementById(\"ins-when\"); if (wh) wh.textContent = \"anything unusual \" + win.when;\n var gk = document.getElementById(\"ins-gk\"); if (gk) gk.textContent = span + \" · dashed line: the usual \" + win.unit;\n var items = d.items.filter(function (i) { return !(i.target.type && hiddenType(i.target.type)); });\n var sev = [\"critical\", \"warning\", \"info\"].map(function (k) { return { label: k[0].toUpperCase() + k.slice(1), value: items.filter(function (i) { return i.severity === k; }).length, color: k === \"critical\" ? \"#f0706a\" : k === \"warning\" ? \"#e3b341\" : \"#6ea8fe\" }; });\n if (items.length) donut(\"ins-sev\", sev, \"\", items.length === 1 ? \"issue\" : \"issues\");\n else document.getElementById(\"ins-sev\").innerHTML = '<div class=\"allclear\">' + ICON.check + \"All clear</div>\";\n var gmax = Math.max.apply(null, GROUPS.map(function (g) { return items.filter(function (i) { return i.category === g[0]; }).length; }).concat([1]));\n document.getElementById(\"ins-groups\").innerHTML = '<div class=\"gbars\">' + GROUPS.map(function (g) {\n var list = items.filter(function (i) { return i.category === g[0]; }), worst = list.some(function (i) { return i.severity === \"critical\"; }) ? \"critical\" : list.some(function (i) { return i.severity === \"warning\"; }) ? \"warning\" : \"info\";\n return '<button data-act=\"insf\" data-f=\"' + g[0] + '\" class=\"' + (state.insFilter === g[0] ? \"on\" : \"\") + '\" data-tip=\"' + esc(list.length ? list.map(function (i) { return \"• \" + i.title; }).join(\"\\n\") : \"Nothing in this group\") + '\"><span>' + g[1] + '</span><i style=\"width:' + (list.length / gmax * 100) + \"%;background:\" + (list.length ? SEV_COLOR[worst] : \"var(--track)\") + '\"></i><b>' + list.length + \"</b></button>\";\n }).join(\"\") + \"</div>\";\n if (d.global) {\n var done = d.global.counts.slice(0, -1);\n var usual = d.global.usual !== undefined ? d.global.usual : median(done.slice(-31, -1));\n plot(\"ins-g\", { kind: \"area\", ids: d.global.ids, h: 150, series: [{ label: \"All traffic\", color: \"var(--bar)\", values: d.global.counts }], ref: usual, refLabel: \"usual \" + fmt(usual) + \"/\" + UNIT_SHORT[win.unit] });\n }\n document.getElementById(\"chips\").innerHTML = CATS.map(function (c) {\n var n = items.filter(function (i) { return match(i, c[0]); }).length;\n return '<button data-act=\"insf\" data-f=\"' + c[0] + '\" class=\"' + (state.insFilter === c[0] ? \"on\" : \"\") + '\">' + c[1] + \" <b>\" + n + \"</b></button>\";\n }).join(\"\");\n var list = items.filter(function (i) { return match(i, state.insFilter); });\n var up = document.getElementById(\"updated\");\n if (up) up.textContent = \"checked \" + d.checked + \" routes · IST \" + state.updated;\n paint(\"ins\", function () { insList(d, items, list); });\n }\n function insList(d, items, list) {\n var win = winOf(d), just = state.insJust; state.insJust = null; // only a card you just opened slides in\n document.getElementById(\"ins\").innerHTML = list.length ? list.map(function (it) {\n var i = d.items.indexOf(it), key = insKey(it), open = !!state.insOpen[key], col = SEV_COLOR[it.severity], t = it.target;\n var where = t.view === \"route\" ? \"Open \" + t.action + \" \" + t.name : t.view === \"type\" ? \"Open \" + t.type : \"Open the overview\";\n return '<div class=\"ins ' + it.severity + (open ? \" open\" + (key === just ? \" opening\" : \"\") : \"\") + '\"><button class=\"ins-h\" data-act=\"ins-toggle\" data-k=\"' + esc(key) + '\" data-tip=\"' + esc(it.detail + (open ? \"\" : \"\\n\\nClick to see the chart and details.\")) + '\"><span class=\"bar\"></span><span style=\"min-width:0\"><span class=\"tag\">' + it.severity + \" · \" + CAT_LABEL[it.category] + (it.from ? \" · \" + esc(it.from) : \"\") + '</span><div class=\"t trunc\">' + esc(it.title) + '</div><div class=\"d trunc\">' + esc(it.detail) + \"</div></span><span>\" + (it.series ? spark(170, 34, it.series.counts, col, it.series.ids, \"Calls\") : \"\") + '</span><span class=\"val tnum\">' + esc(it.value) + '</span><span class=\"more\">' + (open ? \"Hide\" : \"Details\") + CHEVRON + \"</span></button>\" +\n '<div class=\"ins-b\">' + (open ? '<div><div class=\"ctitle\">' + (it.series ? \"Last \" + it.series.ids.length + \" \" + (it.unit || win.unit) + \"s\" : \"\") + (it.baseline !== undefined ? \" · dashed line: the usual \" + (it.unit || win.unit) : \"\") + (it.series && it.series.errors && sum(it.series.errors) ? \" · 5xx in red\" : \"\") + '</div><div id=\"ic-' + i + '\"></div></div>' +\n '<div class=\"side\"><div class=\"facts\">' + (it.facts || []).map(function (f) { return '<div class=\"fact\"><div class=\"l\">' + esc(f.label) + '</div><div class=\"v tnum\">' + esc(f.value) + \"</div></div>\"; }).join(\"\") + \"</div>\" +\n \"<div><h5>What to check</h5><ul>\" + (ADVICE[it.category] || []).map(function (a) { return \"<li>\" + esc(a) + \"</li>\"; }).join(\"\") + \"</ul></div>\" +\n '<div class=\"row2\"><button class=\"btn p\" data-ins=\"' + i + '\">' + esc(where) + ' →</button><button class=\"btn\" data-act=\"ins-watch\" data-i=\"' + i + '\" data-tip=\"Adds a chart for this to your Watchlist board, so you can keep an eye on it\">' + ICON.plus + \"Watchlist</button></div></div>\" : \"\") + \"</div></div>\";\n }).join(\"\") : '<div class=\"card\"><div class=\"allclear\">' + ICON.check + (items.length ? \"Nothing in this group.\" : \"All clear: nothing unusual \" + esc(win.when) + \".\") + \"</div></div>\";\n list.forEach(function (it) {\n var i = d.items.indexOf(it);\n if (!state.insOpen[insKey(it)]) return;\n if (it.series) plot(\"ic-\" + i, { kind: \"bars\", ids: it.series.ids, h: 220, series: [{ label: it.target.view === \"route\" ? it.target.action + \" \" + it.target.name : \"Calls\", color: \"var(--bar)\", values: it.series.counts, errors: it.series.errors }], ref: it.baseline, refLabel: it.baseline !== undefined ? \"usual \" + fmt(it.baseline) + \"/\" + UNIT_SHORT[it.unit || win.unit] : \"\" });\n else { var el = document.getElementById(\"ic-\" + i); if (el) el.innerHTML = '<div class=\"empty\">No minute-by-minute chart for this one. Open it to see its time slots.</div>'; }\n });\n }\n // Adds a chart for an insight to the Watchlist board, creating the board the first time.\n function shownInsights() {\n var d = route().view === \"insights\" ? viewData() : null;\n return d ? d.items : state.side.insights ? state.side.insights.items : [];\n }\n function watchInsight(i) {\n var it = shownInsights()[i], t = it && it.target;\n if (!it) return;\n var b = state.boards.boards.filter(function (x) { return x.name === \"Watchlist\"; })[0];\n if (!b) { b = { id: uid(), name: \"Watchlist\", widgets: [] }; state.boards.boards.push(b); }\n var src = t.view === \"route\" ? { kind: \"route\", type: t.type, action: t.action, name: t.name } : t.view === \"type\" ? { kind: \"type\", type: t.type } : { kind: \"global\" };\n var title = (t.view === \"route\" ? t.action + \" \" + t.name : t.view === \"type\" ? t.type : \"All traffic\") + (it.category === \"errors\" ? \", 5xx \" : \", \") + PER[t.range];\n if (b.widgets.some(function (w) { return w.title === title; })) return toast('\"' + title + '\" is already on your Watchlist.');\n b.widgets.push({ id: uid(), title: title, chart: \"bars\", range: t.range, size: \"M\", metric: it.category === \"errors\" && src.kind === \"route\" ? \"errors\" : \"count\", series: [src] });\n saveBoards('Added \"' + title + '\" to your Watchlist board.');\n }\n function openInsight(i) {\n var it = shownInsights()[i];\n if (!it) return;\n state.range = it.target.range;\n location.hash = it.target.view === \"route\" ? routeHref(it.target.type, it.target.action, it.target.name) : it.target.view === \"type\" ? typeHref(it.target.type) : \"#/overview\";\n if (it.target.view === \"overview\") load(true);\n }\n\n // ---------- settings ----------\n var EXPIRY = [[3600, \"1 hour\"], [6 * 3600, \"6 hours\"], [86400, \"1 day\"], [7 * 86400, \"7 days\"], [30 * 86400, \"30 days\"], [90 * 86400, \"90 days\"], [180 * 86400, \"180 days\"], [365 * 86400, \"1 year\"], [5 * 365 * 86400, \"5 years\"], [10 * 365 * 86400, \"10 years\"]];\n function settingsShell() {\n return '<div class=\"crumbs\"><b>Settings</b><span class=\"meta\"></span>' + exportBtn() + '</div><div id=\"setnote\"></div><div class=\"card\" id=\"storage\"></div><div class=\"set-grid\"><div id=\"setform\"></div><div id=\"setcode\"></div></div>';\n }\n function tg(path, on, disabled) { return '<label class=\"tgl\"><input type=\"checkbox\" data-set=\"' + path + '\"' + (on ? \" checked\" : \"\") + (disabled ? \" disabled\" : \"\") + \"><span></span></label>\"; }\n function row(path, title, sub, control) { return '<div class=\"set-row' + (changes().indexOf(path) !== -1 ? \" changed\" : \"\") + '\"><div class=\"txt\"><b>' + title + \"</b><span>\" + sub + \"</span></div>\" + control + \"</div>\"; }\n function fillSettings() {\n if (!state.cfg) return;\n var d = draft(), n = changes();\n document.getElementById(\"setnote\").innerHTML = n.length ?\n '<div class=\"notice\"><div><b>Preview only: ' + n.length + (n.length === 1 ? \" change\" : \" changes\") + \".</b> This changes only what you see in this dashboard (types you turned off are hidden). Your app keeps tracking with its current settings. To apply them, update <span class=\\\"mono\\\">statis.init()</span> with the highlighted lines and restart your app.</div><button class=\\\"btn\\\" data-act=\\\"set-reset\\\" style=\\\"margin-left:auto\\\">Discard changes</button></div>\" :\n '<div class=\"notice info\"><div><b>These are your app\\'s current settings.</b> Change anything to preview it here and get the <span class=\"mono\">statis.init()</span> code to apply it. Nothing changes in your app until you update that code.</div></div>';\n var cs = state.cfg.resolved.customTypes;\n var types = Object.keys(d.track).map(function (t) { return row(\"track.\" + t, '<span class=\"tp mono\"><i style=\"background:' + color(t) + '\"></i>' + t + \"</span>\", esc(TYPE_INFO[t] || \"\"), tg(\"track.\" + t, d.track[t])); }).join(\"\");\n var buckets = BUCKETS.map(function (b) {\n var tc = d.timeConfig[b], opts = EXPIRY.slice();\n if (!opts.some(function (o) { return o[0] === tc.expiry_in_sec; })) opts.push([tc.expiry_in_sec, expiry(tc.expiry_in_sec)]);\n var changed = changes().indexOf(\"timeConfig.\" + b + \".enable\") !== -1 || changes().indexOf(\"timeConfig.\" + b + \".expiry_in_sec\") !== -1;\n return '<div class=\"set-row' + (changed ? \" changed\" : \"\") + '\"><div class=\"txt\"><b>' + SLOT_LABEL[b] + \"</b><span>\" + CHART_TITLE[b] + \" · key ends in :\" + b + ':{id}</span></div><select data-set=\"timeConfig.' + b + '.expiry_in_sec\"' + (tc.enable ? \"\" : \" disabled\") + \">\" + opts.map(function (o) { return '<option value=\"' + o[0] + '\"' + (o[0] === tc.expiry_in_sec ? \" selected\" : \"\") + \">keep \" + o[1] + \"</option>\"; }).join(\"\") + \"</select>\" + tg(\"timeConfig.\" + b + \".enable\", tc.enable) + \"</div>\";\n }).join(\"\");\n document.getElementById(\"setform\").innerHTML =\n '<div class=\"card\"><div class=\"ch\"><b>What to track</b><span class=\"k\">track</span></div>' + types + (cs.length ? '<div class=\"set-row\"><div class=\"txt\"><b>Custom types</b><span>' + cs.map(esc).join(\", \") + \" · set with the types and operations options</span></div></div>\" : \"\") + \"</div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Totals and failures</b><span class=\"k\">global · failures</span></div>' +\n row(\"global.total\", \"Overall total\", \"One {prefix}:global counter for every request\", tg(\"global.total\", d.global.total)) +\n row(\"global.perType\", \"Per-type totals\", \"A {prefix}:{TYPE}:global counter for each type\", tg(\"global.perType\", d.global.perType)) +\n row(\"failures\", \"Failures (5xx)\", \"An extra :5xx counter for responses with status 500 or higher\", tg(\"failures\", d.failures)) + \"</div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Skip requests</b><span class=\"k\">ignore</span></div>' +\n row(\"ignore.bots\", \"Bots and crawlers\", \"Skip search engines and crawlers\", tg(\"ignore.bots\", d.ignore.bots)) +\n row(\"ignore.methods\", \"Methods\", \"Requests with these methods aren't counted\", '<span class=\"row2\">' + [\"OPTIONS\", \"HEAD\"].map(function (m) { return '<label style=\"display:flex;gap:6px;align-items:center;font-size:13px\"><input type=\"checkbox\" data-set=\"ignore.methods\" value=\"' + m + '\"' + (d.ignore.methods.indexOf(m) !== -1 ? \" checked\" : \"\") + \">\" + m + \"</label>\"; }).join(\"\") + \"</span>\") +\n '<div class=\"set-row' + (changes().indexOf(\"ignore.paths\") !== -1 ? \" changed\" : \"\") + '\" style=\"flex-wrap:wrap\"><div class=\"txt\"><b>Paths</b><span>End a path with * to skip everything below it, like /health or /admin/*</span></div><input class=\"inp mono\" id=\"addpath\" placeholder=\"/health\" style=\"width:180px\"><button class=\"btn\" data-act=\"addpath\">Add</button>' +\n '<div class=\"paths\" style=\"width:100%;padding:8px 0 0\">' + (d.ignore.paths.length ? d.ignore.paths.map(function (p, i) { return '<span class=\"mono\">' + esc(p) + '<button class=\"ibtn sm\" data-act=\"delpath\" data-i=\"' + i + '\" title=\"Remove\">✕</button></span>'; }).join(\"\") : '<span class=\"muted\" style=\"border:0;background:none\">No paths skipped</span>') + \"</div></div></div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Time buckets</b><span class=\"k\">timeConfig</span></div>' + buckets + \"</div>\" +\n '<div class=\"card\"><div class=\"ch\"><b>Limits</b><span class=\"k\">limits</span></div>' +\n row(\"limits.maxNamesPerType\", \"Names per type\", \"More names than this are counted as &lt;other&gt;\", '<input class=\"inp tnum\" type=\"number\" min=\"1\" max=\"100000\" data-set=\"limits.maxNamesPerType\" value=\"' + d.limits.maxNamesPerType + '\" style=\"width:110px\">') +\n row(\"limits.collapseAfter\", \"Collapse a URL part after\", \"A part with more distinct values becomes &lt;slug&gt;\", '<input class=\"inp tnum\" type=\"number\" min=\"1\" max=\"100000\" data-set=\"limits.collapseAfter\" value=\"' + d.limits.collapseAfter + '\" style=\"width:110px\">') + \"</div>\";\n fillCode();\n }\n function setPath(d, path, value) {\n var p = path.split(\".\"), o = d;\n for (var i = 0; i < p.length - 1; i++) o = o[p[i]];\n o[p[p.length - 1]] = value;\n }\n function saveDraft(d) {\n store.set(draftKey(), d);\n if (!changes().length) store.del(draftKey());\n fillSettings();\n app.querySelector(\".top\").outerHTML = topBar().replace(/<div class=\"banner\">[\\s\\S]*$/, \"\");\n }\n\n // The init() call as written (secrets come masked from the server), with the preview applied.\n var ORDER = [\"redis\", \"prefix\", \"timeConfig\", \"ignore\", \"track\", \"global\", \"failures\", \"types\", \"operations\", \"limits\", \"ownDomains\", \"name\", \"enabled\", \"dashboard\"];\n function applyDraft(opts, d, r, def) {\n var o = clone(opts);\n function put(obj, key, val, dflt) { if (JSON.stringify(val) === JSON.stringify(dflt)) delete obj[key]; else obj[key] = val; }\n function child(key) { if (!o[key] || typeof o[key] !== \"object\" || Array.isArray(o[key])) o[key] = {}; return o[key]; }\n function tidy(key) { if (o[key] && typeof o[key] === \"object\" && !Object.keys(o[key]).length) delete o[key]; }\n Object.keys(d.track).forEach(function (t) { if (d.track[t] !== r.track[t]) put(child(\"track\"), t, d.track[t], def.track[t]); });\n tidy(\"track\");\n [\"total\", \"perType\"].forEach(function (k) { if (d.global[k] !== r.global[k]) put(child(\"global\"), k, d.global[k], true); });\n tidy(\"global\");\n if (d.failures !== r.failures) put(o, \"failures\", d.failures, false);\n if (d.ignore.bots !== r.ignore.bots) put(child(\"ignore\"), \"bots\", d.ignore.bots, false);\n if (d.ignore.methods.join() !== r.ignore.methods.join()) put(child(\"ignore\"), \"methods\", d.ignore.methods, def.ignore.methods);\n if (d.ignore.paths.join(\"\\n\") !== r.ignore.paths.join(\"\\n\")) put(child(\"ignore\"), \"paths\", d.ignore.paths, []);\n tidy(\"ignore\");\n BUCKETS.forEach(function (b) {\n var a = d.timeConfig[b], c = r.timeConfig[b], df = def.timeConfig[b];\n if (a.enable === c.enable && a.expiry_in_sec === c.expiry_in_sec) return;\n var tc = child(\"timeConfig\"), v = {};\n if (a.enable !== df.enable) v.enable = a.enable;\n if (a.enable && a.expiry_in_sec !== df.expiry_in_sec) v.expiry_in_sec = a.expiry_in_sec;\n put(tc, b, v, {});\n });\n tidy(\"timeConfig\");\n [\"maxNamesPerType\", \"collapseAfter\"].forEach(function (k) { if (d.limits[k] !== r.limits[k]) put(child(\"limits\"), k, d.limits[k], def.limits[k]); });\n tidy(\"limits\");\n // keep the user's order; a new key goes before the first existing key that follows it in ORDER\n var keys = Object.keys(opts);\n ORDER.forEach(function (k) {\n if (keys.indexOf(k) !== -1 || !(k in o)) return;\n var at = keys.findIndex(function (x) { return ORDER.indexOf(x) > ORDER.indexOf(k); });\n if (at === -1) keys.push(k); else keys.splice(at, 0, k);\n });\n var out = {};\n keys.forEach(function (k) { if (k in o) out[k] = o[k]; });\n Object.keys(o).forEach(function (k) { if (!(k in out)) out[k] = o[k]; });\n return out;\n }\n function jsKey(k) { return /^[A-Za-z_$][\\w$]*$/.test(k) ? k : JSON.stringify(k); }\n function inline(v) {\n if (v && v.$mask) return '\"••••••\"';\n if (v && v.$code) return v.$code;\n if (Array.isArray(v)) return \"[\" + v.map(inline).join(\", \") + \"]\";\n if (v && typeof v === \"object\") { var ks = Object.keys(v); return ks.length ? \"{ \" + ks.map(function (k) { return jsKey(k) + \": \" + inline(v[k]); }).join(\", \") + \" }\" : \"{}\"; }\n return JSON.stringify(v);\n }\n function codeLines(v, indent, key, out, tail) {\n var pad = new Array(indent + 1).join(\" \"), head = pad + (key === null ? \"\" : jsKey(key) + \": \");\n var one = inline(v);\n if (!v || typeof v !== \"object\" || v.$mask || v.$code || (head + one).length <= 64) { out.push(head + one + tail); return; }\n var arr = Array.isArray(v);\n out.push(head + (arr ? \"[\" : \"{\"));\n (arr ? v : Object.keys(v)).forEach(function (x, i) { codeLines(arr ? x : v[x], indent + 1, arr ? null : x, out, \",\"); });\n out.push(pad + (arr ? \"]\" : \"}\") + tail);\n }\n function codeOf(opts) { var out = [\"statis.init({\"]; Object.keys(opts).forEach(function (k) { codeLines(opts[k], 1, k, out, \",\"); }); out.push(\"});\"); return out; }\n // Line diff (LCS): kept, added and removed lines, in order.\n function diff(a, b) {\n var n = a.length, m = b.length, dp = [];\n for (var i = 0; i <= n; i++) { dp.push(new Array(m + 1).fill(0)); }\n for (i = n - 1; i >= 0; i--) for (var j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);\n var out = []; i = 0; j = 0;\n while (i < n && j < m) {\n if (a[i] === b[j]) { out.push([\" \", b[j]]); i++; j++; }\n else if (dp[i + 1][j] >= dp[i][j + 1]) out.push([\"-\", a[i++]]);\n else out.push([\"+\", b[j++]]);\n }\n while (i < n) out.push([\"-\", a[i++]]);\n while (j < m) out.push([\"+\", b[j++]]);\n return out;\n }\n function hlCode(line) {\n return esc(line)\n .replace(/(&quot;(?:[^&]|&(?!quot;))*?&quot;)/g, '<span class=\"k-s\">$1</span>')\n .replace(/\\b(true|false|null)\\b/g, '<span class=\"k-k\">$1</span>')\n .replace(/^(\\s*)([A-Za-z_$][\\w$]*)(:)/, '$1<span class=\"k-p\">$2</span>$3')\n .replace(/(:\\s)(\\d[\\d_]*)/g, '$1<span class=\"k-n\">$2</span>')\n .replace(/(••••••)/g, '<span class=\"k-m\">$1</span>');\n }\n function fillCode() {\n var host = document.getElementById(\"setcode\");\n if (!host || !state.cfg) return;\n var c = state.cfg, d = draft(), before = codeOf(c.options), after = codeOf(applyDraft(c.options, d, c.resolved, c.defaults));\n var lines = diff(before, after), adds = lines.filter(function (l) { return l[0] === \"+\"; }).length, dels = lines.filter(function (l) { return l[0] === \"-\"; }).length, k = 0;\n state.codeText = after.join(\"\\n\");\n host.innerHTML = '<div class=\"card\" style=\"position:sticky;top:70px\"><div class=\"ch\"><b>Your statis.init()</b><span class=\"k\">' + (adds || dels ? '<span class=\"good\">+' + adds + '</span> <span class=\"bad\">−' + dels + \"</span> lines\" : \"no changes yet\") + '</span><span class=\"right\"><button class=\"btn\" data-act=\"copycode\">' + ICON.copy + \"Copy code</button></span></div>\" +\n '<div class=\"code mono\">' + lines.map(function (l) {\n var cls = l[0] === \"+\" ? \"add\" : l[0] === \"-\" ? \"del\" : \"\", g = l[0] === \"-\" ? \"\" : ++k;\n return '<div class=\"ln ' + cls + '\"><span class=\"g\">' + g + '</span><span class=\"s\">' + (l[0] === \" \" ? \"\" : l[0]) + \"</span><span>\" + hlCode(l[1]) + \"</span></div>\";\n }).join(\"\") + '</div><div class=\"hint\" style=\"padding:10px 16px 14px\">Hidden values (<span class=\"k-m\">••••••</span>) are your Redis and dashboard credentials. They never leave your server, so keep your real values when you copy this.</div></div>';\n }\n\n // ---------- boards ----------\n // Boards are saved in Redis (one key per prefix), so restarts and redeploys keep them.\n function boardById(id) { return state.boards.boards.filter(function (b) { return b.id === id; })[0]; }\n function saveBoards(msg) {\n var doc = { boards: state.boards.boards, defaultId: state.boards.defaultId };\n return api(\"/api/boards\", { method: \"PUT\", body: JSON.stringify(doc) }).then(function () {\n if (msg) { log(msg + \" Saved in Redis at \" + state.boards.key + \" (\" + doc.boards.length + (doc.boards.length === 1 ? \" board\" : \" boards\") + \").\"); toast(msg); }\n }).catch(function (e) { toast(\"Could not save: \" + e.message); });\n }\n function loadBoards() {\n return api(\"/api/boards\").then(function (doc) {\n state.boards = doc;\n log(\"Loaded \" + doc.boards.length + (doc.boards.length === 1 ? \" board\" : \" boards\") + \" from Redis (\" + doc.key + \").\");\n }).catch(function () {});\n }\n\n // ---------- board layout ----------\n // Charts sit on a 4-column grid, sized in width x height units (one row is 170px). Each kind of chart\n // has a smallest and largest size so nothing gets cramped: [min width, max width, min height, max height].\n var LIMITS = { number: [1, 2, 1, 2], line: [1, 4, 1, 3], area: [1, 4, 1, 3], bars: [1, 4, 1, 3], stack: [2, 4, 1, 3], donut: [2, 4, 2, 3], table: [2, 4, 2, 4], insights: [2, 4, 1, 3] };\n var LEGACY = { S: [1, 1], M: [2, 2], L: [4, 2] };\n var ROW_H = 170, GAP = 14;\n function dims(w) {\n var l = LIMITS[w.chart] || LIMITS.line, x = w.w, y = w.h;\n if (!x || !y) { var g = LEGACY[w.size] || [2, 2]; x = g[0]; y = w.chart === \"number\" ? 1 : g[1]; }\n return { w: Math.max(l[0], Math.min(l[1], x)), h: Math.max(l[2], Math.min(l[3], y)) };\n }\n\n // Default templates, one per role or use. They are built in code, so editing one always saves a copy.\n // Charts that need types the app doesn't track, or failures: true, are left out.\n var TPL_CACHE = {};\n function templates() {\n var f = !!(state.cfg && state.cfg.resolved.failures);\n function w(title, chart, range, size, series, metric) { return { title: title, chart: chart, range: range, w: size[0], h: size[1], metric: metric || \"count\", series: series }; }\n var G = { kind: \"global\" };\n function T(t) { return { kind: \"type\", type: t }; }\n function P(t, a, n) { return { kind: \"pattern\", type: t, action: a || \"*\", name: n || \"*\" }; }\n var TYPES = [\"API\", \"WEB\", \"GRAPHQL\", \"RPC\", \"WSS\"].map(T);\n var list = [\n { key: \"everyday\", name: \"Everyday overview\", role: \"Default\", about: \"The day at a glance: what's unusual, totals, the mix by type and the busiest routes.\", widgets: [\n w(\"Insights\", \"insights\", \"min\", [4, 1], [G]),\n w(\"Requests per second\", \"number\", \"sec\", [1, 1], [G]), w(\"This minute\", \"number\", \"min\", [1, 1], [G]), w(\"This hour\", \"number\", \"hour\", [1, 1], [G]), w(\"Today\", \"number\", \"day\", [1, 1], [G]),\n w(\"Traffic by type, last hour\", \"stack\", \"min\", [4, 2], TYPES),\n w(\"Mix by type, last 30 days\", \"donut\", \"day\", [2, 2], [G]), w(\"Busiest routes, last 24 hours\", \"table\", \"hour\", [2, 2], [P(\"*\")]),\n w(\"All traffic, last 30 days\", \"area\", \"day\", [4, 2], [G])] },\n { key: \"devops\", name: \"DevOps / SRE\", role: \"DevOps\", about: \"Live load, failures and failure rate, spikes and the routes that fail most.\", widgets: [\n w(\"Requests per second\", \"number\", \"sec\", [1, 1], [G]), w(\"5xx this minute\", \"number\", \"min\", [1, 1], [P(\"*\")], \"errors\"), w(\"Failure rate this hour\", \"number\", \"hour\", [1, 1], [P(\"*\")], \"rate\"), w(\"This minute\", \"number\", \"min\", [1, 1], [G]),\n w(\"Requests per second, last minute\", \"bars\", \"sec\", [4, 2], [G]),\n w(\"Insights\", \"insights\", \"min\", [2, 2], [G]), w(\"Failure rate, last hour\", \"line\", \"min\", [2, 2], [P(\"*\")], \"rate\"),\n w(\"Traffic by type per minute\", \"stack\", \"min\", [2, 2], TYPES), w(\"Routes with 5xx, last 24 hours\", \"table\", \"hour\", [2, 2], [P(\"*\")], \"errors\"),\n w(\"All traffic, last 24 hours\", \"area\", \"hour\", [4, 2], [G])] },\n { key: \"product\", name: \"Product owner\", role: \"Product\", about: \"Usage over days and months, the features people use most, and page views next to API calls.\", widgets: [\n w(\"Requests today\", \"number\", \"day\", [1, 1], [G]), w(\"Page views today\", \"number\", \"day\", [1, 1], [T(\"WEB\")]), w(\"API calls today\", \"number\", \"day\", [1, 1], [T(\"API\")]), w(\"This month\", \"number\", \"month\", [1, 1], [G]),\n w(\"Daily traffic, last 30 days\", \"area\", \"day\", [4, 2], [G]),\n w(\"Most used features, last 30 days\", \"donut\", \"day\", [2, 2], [T(\"API\")]), w(\"Most viewed pages, last 30 days\", \"table\", \"day\", [2, 2], [T(\"WEB\")]),\n w(\"Page views vs API calls, last 30 days\", \"line\", \"day\", [2, 2], [T(\"WEB\"), T(\"API\")]), w(\"Monthly traffic, last 12 months\", \"bars\", \"month\", [2, 2], [G])] },\n { key: \"developer\", name: \"Backend developer\", role: \"Developer\", about: \"Every API route with its 5xx, calls by HTTP method, and GraphQL and RPC operations.\", widgets: [\n w(\"API this minute\", \"number\", \"min\", [1, 1], [T(\"API\")]), w(\"API 5xx today\", \"number\", \"day\", [1, 1], [P(\"API\")], \"errors\"), w(\"GraphQL this minute\", \"number\", \"min\", [1, 1], [T(\"GRAPHQL\")]), w(\"RPC this minute\", \"number\", \"min\", [1, 1], [T(\"RPC\")]),\n w(\"API calls by method, last hour\", \"stack\", \"min\", [4, 2], [P(\"API\", \"GET\"), P(\"API\", \"POST\"), P(\"API\", \"PUT\"), P(\"API\", \"PATCH\"), P(\"API\", \"DELETE\")]),\n w(\"API routes, last 24 hours\", \"table\", \"hour\", [2, 3], [T(\"API\")]), w(\"Methods, last 24 hours\", \"donut\", \"hour\", [2, 2], [P(\"API\", \"GET\"), P(\"API\", \"POST\"), P(\"API\", \"PUT\"), P(\"API\", \"PATCH\"), P(\"API\", \"DELETE\")]),\n w(\"GraphQL operations, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"GRAPHQL\")]), w(\"RPC methods, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"RPC\")])] },\n { key: \"qa\", name: \"QA & testing\", role: \"QA\", about: \"Failures while you test: 5xx by minute and route, failure rate and anything new or unusual.\", widgets: [\n w(\"5xx this minute\", \"number\", \"min\", [1, 1], [P(\"*\")], \"errors\"), w(\"5xx today\", \"number\", \"day\", [1, 1], [P(\"*\")], \"errors\"), w(\"Failure rate today\", \"number\", \"day\", [1, 1], [P(\"*\")], \"rate\"), w(\"Requests this minute\", \"number\", \"min\", [1, 1], [G]),\n w(\"5xx per minute, last hour\", \"bars\", \"min\", [2, 2], [P(\"*\")], \"errors\"), w(\"Failure rate per hour, last 24 hours\", \"line\", \"hour\", [2, 2], [P(\"*\")], \"rate\"),\n w(\"Routes with 5xx, last 24 hours\", \"table\", \"hour\", [4, 2], [P(\"*\")], \"errors\"), w(\"Insights\", \"insights\", \"min\", [4, 1], [G])] },\n { key: \"api\", name: \"API health\", role: \"API team\", about: \"API calls, failures and failure rate, route by route.\", widgets: [\n w(\"API calls per minute\", \"line\", \"min\", [2, 2], [T(\"API\")]), w(\"API 5xx per minute\", \"bars\", \"min\", [2, 2], [P(\"API\")], \"errors\"),\n w(\"API routes, last 30 days\", \"table\", \"day\", [4, 2], [T(\"API\")]), w(\"API failure rate, last 24 hours\", \"line\", \"hour\", [2, 2], [P(\"API\")], \"rate\"),\n w(\"API routes, last 24 hours\", \"donut\", \"hour\", [2, 2], [T(\"API\")]), w(\"API calls, last 30 days\", \"area\", \"day\", [4, 2], [T(\"API\")])] },\n { key: \"live\", name: \"Live wall\", role: \"Operations\", about: \"For a big screen next to Live refresh: per-second traffic, this minute and what's unusual.\", widgets: [\n w(\"Requests per second\", \"bars\", \"sec\", [4, 2], [G]),\n w(\"This second\", \"number\", \"sec\", [1, 1], [G]), w(\"This minute\", \"number\", \"min\", [1, 1], [G]), w(\"API this minute\", \"number\", \"min\", [1, 1], [T(\"API\")]), w(\"Page views this minute\", \"number\", \"min\", [1, 1], [T(\"WEB\")]),\n w(\"Types per second\", \"stack\", \"sec\", [4, 2], TYPES), w(\"Insights\", \"insights\", \"min\", [4, 1], [G])] },\n { key: \"capacity\", name: \"Capacity planning\", role: \"Engineering lead\", about: \"Long-range growth: hours, days, months and years, and how the mix changes.\", widgets: [\n w(\"Today\", \"number\", \"day\", [1, 1], [G]), w(\"This month\", \"number\", \"month\", [1, 1], [G]), w(\"This year\", \"number\", \"year\", [1, 1], [G]), w(\"This hour\", \"number\", \"hour\", [1, 1], [G]),\n w(\"Requests per month, last 12 months\", \"area\", \"month\", [4, 2], [G]),\n w(\"Requests per day, last 30 days\", \"line\", \"day\", [2, 2], [G]), w(\"Requests per hour, last 24 hours\", \"bars\", \"hour\", [2, 2], [G]),\n w(\"Mix by type, last 12 months\", \"donut\", \"month\", [2, 2], [G]), w(\"Requests per year\", \"bars\", \"year\", [2, 2], [G])] },\n { key: \"web\", name: \"Web & pages\", role: \"Frontend\", about: \"Page views next to the API calls they trigger, top pages and the daily trend.\", widgets: [\n w(\"Page views this minute\", \"number\", \"min\", [1, 1], [T(\"WEB\")]), w(\"Page views today\", \"number\", \"day\", [1, 1], [T(\"WEB\")]), w(\"API calls this minute\", \"number\", \"min\", [1, 1], [T(\"API\")]), w(\"API calls today\", \"number\", \"day\", [1, 1], [T(\"API\")]),\n w(\"Page views vs API calls, last hour\", \"line\", \"min\", [4, 2], [T(\"WEB\"), T(\"API\")]),\n w(\"Top pages, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"WEB\")]), w(\"Pages, last 30 days\", \"donut\", \"day\", [2, 2], [T(\"WEB\")]),\n w(\"Page views, last 30 days\", \"area\", \"day\", [4, 2], [T(\"WEB\")])] },\n { key: \"integrations\", name: \"Integrations & realtime\", role: \"Platform\", about: \"Calls to other services by host, WebSocket and event streams, GraphQL and RPC together.\", widgets: [\n w(\"Outgoing this minute\", \"number\", \"min\", [1, 1], [T(\"OUT\")]), w(\"WebSocket this minute\", \"number\", \"min\", [1, 1], [T(\"WSS\")]), w(\"RPC this minute\", \"number\", \"min\", [1, 1], [T(\"RPC\")]), w(\"GraphQL this minute\", \"number\", \"min\", [1, 1], [T(\"GRAPHQL\")]),\n w(\"Realtime and RPC per minute\", \"stack\", \"min\", [4, 2], [T(\"WSS\"), T(\"SSE\"), T(\"GRPC\"), T(\"RPC\"), T(\"GRAPHQL\")]),\n w(\"Outgoing calls by host, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"OUT\")]), w(\"Outgoing calls per minute\", \"line\", \"min\", [2, 2], [T(\"OUT\")]),\n w(\"RPC methods, last 24 hours\", \"donut\", \"hour\", [2, 2], [T(\"RPC\")]), w(\"GraphQL operations, last 24 hours\", \"table\", \"hour\", [2, 2], [T(\"GRAPHQL\")])] }\n ];\n list.forEach(function (t) {\n var needs = {};\n t.widgets = t.widgets.map(function (x) {\n if (x.metric !== \"count\" && !f) { needs[\"failures: true\"] = 1; return null; }\n x.series = x.series.filter(function (s) { var ok = !s.type || s.type === \"*\" || tracked(s.type); if (!ok) needs[s.type] = 1; return ok; });\n return x.series.length ? x : null;\n }).filter(Boolean);\n t.needs = Object.keys(needs);\n t.available = t.widgets.length >= 3;\n });\n return list;\n }\n // A template as a board, built once per visit so its charts keep their ids while it's on screen.\n function tplBoard(key) {\n if (!TPL_CACHE[key]) {\n var t = templates().filter(function (x) { return x.key === key; })[0];\n if (!t) return null;\n TPL_CACHE[key] = { id: \"tpl-\" + key, name: t.name, template: t, widgets: t.widgets.map(function (x) { x = clone(x); x.id = \"tpl-\" + key + \"-\" + uid(); return x; }) };\n }\n return TPL_CACHE[key];\n }\n function copyOf(b, name) { var c = clone(b); c.id = uid(); c.name = name || b.name; delete c.template; c.widgets.forEach(function (x) { x.id = uid(); }); return c; }\n // The built-in overview as a board, for \"Customize\".\n function overviewBoard() {\n var G = { kind: \"global\" }, types = trackedTypes().filter(function (t) { return t !== \"STATIC\" && t !== \"OUT\"; }).slice(0, 6);\n function w(title, chart, range, size, series) { return { id: uid(), title: title, chart: chart, range: range, w: size[0], h: size[1], metric: \"count\", series: series }; }\n return { id: uid(), name: \"My overview\", widgets: [\n w(\"Insights\", \"insights\", \"min\", [4, 1], [G]),\n w(\"Requests per second\", \"number\", \"sec\", [1, 1], [G]), w(\"This minute\", \"number\", \"min\", [1, 1], [G]), w(\"This hour\", \"number\", \"hour\", [1, 1], [G]), w(\"Today\", \"number\", \"day\", [1, 1], [G]),\n w(\"Traffic by type\", \"stack\", \"min\", [4, 2], types.map(function (t) { return { kind: \"type\", type: t }; })),\n w(\"By type, last 24 hours\", \"table\", \"hour\", [2, 2], [G]), w(\"Top routes, last 24 hours\", \"table\", \"hour\", [2, 2], [{ kind: \"pattern\", type: \"*\", action: \"*\", name: \"*\" }])\n ] };\n }\n function miniPreview(widgets) { return '<div class=\"mini\">' + widgets.slice(0, 8).map(function (w) { var d = dims(w); return '<i class=\"' + (d.w >= 3 ? \"l\" : d.w === 2 ? \"m\" : \"\") + '\"></i>'; }).join(\"\") + \"</div>\"; }\n function boardsView() {\n var bs = state.boards.boards, def = state.boards.defaultId, d = state.draft;\n function card(href, body) { return '<a class=\"bcard\" href=\"' + href + '\" style=\"text-decoration:none;color:inherit\">' + body + \"</a>\"; }\n return '<div class=\"crumbs\"><b>Boards</b></div>' +\n '<div class=\"notice info\"><div><b>Your templates are saved in your Redis</b> at <span class=\"mono\">' + esc(state.boards.key || \"\") + '</span>, so restarts and redeploys keep them, and everyone using this dashboard sees the same ones. Default templates never change: editing one saves your own copy.</div></div>' +\n (d ? '<div class=\"notice\"><div><b>Unsaved changes</b> on \"' + esc(d.board.name) + '\".</div><a class=\"btn\" href=\"#/board/' + encodeURIComponent(d.board.id) + '\" style=\"margin-left:auto;text-decoration:none\">Continue editing</a></div>' : \"\") +\n '<div class=\"h2\" style=\"margin-top:22px\">Your templates</div><div class=\"boards\">' +\n (bs.length ? bs.map(function (b) {\n return card(\"#/board/\" + encodeURIComponent(b.id), '<span class=\"nm\">' + (b.id === def ? '<span class=\"star\" title=\"Opens at the dashboard URL\">' + ICON.star.replace(\"<svg\", '<svg width=\"15\" height=\"15\"') + \"</span>\" : \"\") + '<span class=\"trunc\">' + esc(b.name) + '</span><button class=\"ibtn sm rn\" data-act=\"b-rename\" data-id=\"' + esc(b.id) + '\" title=\"Rename\">' + ICON.edit + '</button></span><span class=\"muted\" style=\"font-size:12.5px\">' + b.widgets.length + (b.widgets.length === 1 ? \" chart\" : \" charts\") + (b.id === def ? \" · default\" : \"\") + \"</span>\" + miniPreview(b.widgets));\n }).join(\"\") : '<div class=\"muted\" style=\"padding:6px 0\">None yet. Open a default template below and use or customize it, start blank, or customize the Overview.</div>') +\n '<button class=\"bcard tpl\" data-act=\"newboard\" data-tpl=\"\"><span class=\"nm\">' + ICON.plus.replace(\"<svg\", '<svg width=\"16\" height=\"16\"') + 'Blank board</span><span class=\"muted\" style=\"font-size:12.5px\">Start empty and add your own charts.</span></button>' +\n '<button class=\"bcard tpl\" data-act=\"customize\"><span class=\"nm\">' + ICON.edit.replace(\"<svg\", '<svg width=\"15\" height=\"15\"') + 'Customize the Overview</span><span class=\"muted\" style=\"font-size:12.5px\">The Overview tab as a board you can rearrange. Saving makes it your default.</span>' + miniPreview(overviewBoard().widgets) + \"</button></div>\" +\n '<div class=\"h2\" style=\"margin-top:26px\">Default templates</div><div class=\"hint\" style=\"margin-top:4px\">Ready-made boards for different roles. Open one to see it with your live data, then use it as it is or customize it.</div><div class=\"boards\">' +\n templates().map(function (t) {\n var body = '<span class=\"role\">' + esc(t.role) + '</span><span class=\"nm\"><span class=\"trunc\">' + esc(t.name) + '</span></span><span class=\"muted\" style=\"font-size:12.5px\">' + esc(t.about) + \"</span>\" +\n (t.available ? '<span class=\"muted\" style=\"font-size:12px\">' + t.widgets.length + \" charts\" + (t.needs.length ? \" · some need \" + esc(t.needs.join(\", \")) : \"\") + \"</span>\" + miniPreview(t.widgets) : '<span class=\"bad\" style=\"font-size:12px\">Needs ' + esc(t.needs.join(\", \")) + \" in statis.init</span>\");\n return t.available ? card(\"#/template/\" + t.key, body) : '<div class=\"bcard off\" data-tip=\"Turn on ' + esc(t.needs.join(\", \")) + ' in statis.init to use this template\">' + body + \"</div>\";\n }).join(\"\") + \"</div>\";\n }\n function templateShell(b) {\n var t = b.template;\n return '<div class=\"crumbs\"><a href=\"#/boards\">Boards</a><span class=\"sep\">›</span><a href=\"#/boards\">Default templates</a><span class=\"sep\">›</span><b class=\"trunc\" style=\"max-width:50vw\">' + esc(b.name) + '</b><span class=\"meta mono\" id=\"updated\"></span>' + exportBtn() + \"</div>\" +\n '<div class=\"notice info\"><div><b>' + esc(t.role) + \" template, with your live data.</b> \" + esc(t.about) + \" Use it as it is, or customize it first; either way you get your own copy and this template stays the same.\" + (t.needs.length ? \" Charts that need \" + esc(t.needs.join(\", \")) + \" are left out.\" : \"\") + \"</div></div>\" +\n '<div class=\"row2\" style=\"margin-top:12px\"><button class=\"btn p\" data-act=\"tpl-use\">' + ICON.plus + 'Use this template</button><button class=\"btn\" data-act=\"tpl-customize\">' + ICON.edit + \"Customize</button></div>\" +\n '<div class=\"wgrid\" id=\"wgrid\">' + b.widgets.map(widgetCard).join(\"\") + \"</div>\";\n }\n\n // ---------- editing a board ----------\n // Changes while editing go to a draft copy; Save writes it to Redis and opens the board, Cancel drops it.\n function currentBoard(id) { return state.draft && state.draft.board.id === id ? state.draft.board : boardById(id); }\n function editingBoard(id) { return !!(state.draft && state.draft.board.id === id); }\n function startEdit(b, meta) {\n meta = meta || {};\n state.draft = { board: clone(b), isNew: !!meta.isNew, makeDefault: !!meta.makeDefault, from: meta.from || \"#/boards\", dirty: !!meta.isNew, template: meta.template || \"\" };\n }\n function markDirty() { if (!state.draft) return; state.draft.dirty = true; var s = document.getElementById(\"savebtn\"); if (s) s.disabled = false; }\n function saveEdit() {\n var d = state.draft, b = d.board, i = state.boards.boards.map(function (x) { return x.id; }).indexOf(b.id);\n if (i === -1) state.boards.boards.push(b); else state.boards.boards[i] = b;\n if (d.makeDefault) state.boards.defaultId = b.id;\n state.draft = null;\n saveBoards('Board \"' + b.name + '\" ' + (i === -1 ? \"created\" : \"saved\") + (d.makeDefault ? \" and set as your default\" : \"\") + \".\").then(function () {\n state.shellKey = \"\";\n var r = route(), to = d.makeDefault ? \"#/overview\" : \"#/board/\" + encodeURIComponent(b.id);\n if ((r.view === \"board\" && r.id === b.id && (r.landing || !d.makeDefault)) || location.hash === to) render(); else location.hash = to;\n });\n }\n function cancelEdit() {\n var d = state.draft;\n state.draft = null;\n log('Changes to \"' + d.board.name + '\" discarded.');\n if (d.isNew) location.hash = d.from; else { state.shellKey = \"\"; render(); }\n }\n function newBoard() {\n var b = { id: uid(), name: \"Board \" + (state.boards.boards.length + 1), widgets: [] };\n startEdit(b, { isNew: true });\n location.hash = \"#/board/\" + b.id;\n }\n function useTemplate(key, customize) {\n var t = tplBoard(key), b = copyOf(t, t.name);\n if (customize) { startEdit(b, { isNew: true, template: t.name, from: \"#/template/\" + key }); location.hash = \"#/board/\" + b.id; return; }\n state.boards.boards.push(b);\n saveBoards('Board \"' + b.name + '\" created from the ' + t.template.role + \" template.\").then(function () { location.hash = \"#/board/\" + b.id; });\n }\n function customizeOverview() {\n var b = overviewBoard();\n startEdit(b, { isNew: true, makeDefault: true, from: \"#/overview\", template: \"the Overview\" });\n location.hash = \"#/board/\" + b.id;\n }\n\n // Renaming saves at once. While the board is being edited, the new name goes into the draft\n // too, so Save board keeps it and Cancel doesn't bring back the old one.\n function renameBoard(id) {\n var b = boardById(id);\n if (!b) return;\n modal('<div class=\"modal sm\"><div class=\"mh\"><b>Rename board</b><button class=\"ibtn\" data-act=\"modal-close\" title=\"Close\">✕</button></div><div class=\"mb\">' +\n '<input class=\"inp\" id=\"rn-name\" maxlength=\"80\" value=\"' + esc(b.name) + '\" aria-label=\"Board name\" style=\"width:100%;font-size:15px;padding:8px 10px\"><div class=\"hint\" id=\"rn-err\" style=\"margin-top:6px\"></div></div>' +\n '<div class=\"mf\"><button class=\"btn ghost\" data-act=\"modal-close\">Cancel</button><button class=\"btn p\" data-act=\"rn-save\" data-id=\"' + esc(id) + '\">Save name</button></div></div>');\n var inp = document.getElementById(\"rn-name\");\n inp.focus(); inp.select();\n }\n function saveRename(id) {\n var b = boardById(id), v = document.getElementById(\"rn-name\").value.trim().slice(0, 80);\n if (!b) return closeModal();\n if (!v) { document.getElementById(\"rn-err\").textContent = \"Give the board a name.\"; return; }\n closeModal();\n if (v === b.name) return;\n var old = b.name;\n b.name = v;\n if (state.draft && state.draft.board.id === id) state.draft.board.name = v;\n saveBoards('Board \"' + old + '\" renamed to \"' + v + '\".').then(function () { state.shellKey = \"\"; render(); });\n }\n\n function boardShell(b) {\n var def = state.boards.defaultId === b.id, ed = editingBoard(b.id), d = state.draft, landing = route().landing;\n var pen = '<button class=\"ibtn sm\" data-act=\"b-rename\" data-id=\"' + esc(b.id) + '\" title=\"Rename this board\">' + ICON.edit + \"</button>\";\n var head = landing ? '<b>Overview</b><span class=\"sep\">·</span><span class=\"namewrap\"><span class=\"muted trunc\" style=\"max-width:50vw\">' + esc(b.name) + \"</span>\" + pen + \"</span>\" : '<a href=\"#/boards\">Boards</a><span class=\"sep\">›</span><span class=\"namewrap\"><b class=\"trunc\" style=\"max-width:60vw\">' + esc(b.name) + \"</b>\" + pen + \"</span>\";\n if (!ed) {\n return '<div class=\"crumbs\">' + head + '<span class=\"meta mono\" id=\"updated\"></span>' + (landing ? resetBtn() : \"\") + exportBtn() + \"</div>\" +\n '<div class=\"row2\" style=\"margin-top:10px\"><button class=\"btn p\" data-act=\"b-edit\">' + ICON.edit + 'Edit board</button>' +\n (def ? '<button class=\"btn on\" data-act=\"b-default\" title=\"This board is your Overview\">' + ICON.star + 'Default board</button><button class=\"btn\" data-act=\"b-undefault\" title=\"Show the built-in Overview again\">Reset to default Overview</button>'\n : '<button class=\"btn\" data-act=\"b-default\" title=\"Show this board on the Overview tab and at the dashboard URL\">' + ICON.star + \"Set as default</button>\") +\n '<span class=\"grow\"></span><button class=\"btn ghost\" data-act=\"b-dup\">' + ICON.copy + 'Duplicate</button><button class=\"btn ghost danger\" data-act=\"b-del\">' + ICON.trash + \"Delete</button></div>\" +\n (def ? '<div class=\"hint\">This board is your Overview: it opens on the Overview tab and at the dashboard URL.</div>' : \"\") +\n '<div class=\"wgrid\" id=\"wgrid\">' + b.widgets.map(widgetCard).join(\"\") + \"</div>\" +\n (b.widgets.length ? \"\" : '<div class=\"card\"><div class=\"empty\">This board is empty. <button class=\"link\" data-act=\"b-edit\">Edit it</button> to add charts.</div></div>');\n }\n return '<div class=\"crumbs\">' + (landing ? \"<b>Overview</b>\" : '<a href=\"#/boards\">Boards</a>') + '<span class=\"sep\">›</span><label class=\"namewrap\" title=\"Board name\"><input class=\"namein\" id=\"bname\" maxlength=\"80\" value=\"' + esc(b.name) + '\" aria-label=\"Board name\"><span class=\"ibtn sm\">' + ICON.edit + '</span></label><span class=\"meta mono\" id=\"updated\"></span></div>' +\n '<div class=\"notice info\"><div><b>Editing' + (d.isNew ? \" a new board\" + (d.template ? \" from \" + esc(d.template) : \"\") : \"\") + '.</b> Drag a chart to move it (the others make room and keep their sizes), drag any edge or corner to resize it (sizes snap to the grid, from 1×1 up), and use its buttons to edit, duplicate or remove it. Nothing is saved until you click Save' + (d.makeDefault ? \", which also makes this board your default\" : \"\") + \".</div></div>\" +\n '<div class=\"row2\" style=\"margin-top:12px\"><button class=\"btn\" data-act=\"w-add\">' + ICON.plus + 'Add chart</button><span class=\"grow\"></span><button class=\"btn ghost\" data-act=\"b-cancel\">Cancel</button><button class=\"btn p\" id=\"savebtn\" data-act=\"b-save\"' + (d.dirty ? \"\" : \" disabled\") + \">Save board</button></div>\" +\n '<div class=\"wgrid editing\" id=\"wgrid\">' + b.widgets.map(widgetCard).join(\"\") + \"</div>\" +\n (b.widgets.length ? \"\" : '<div class=\"card\"><div class=\"empty\">No charts yet. <button class=\"link\" data-act=\"w-add\">Add your first chart</button>.</div></div>');\n }\n function widgetCard(w) {\n var ed = state.draft && route().view === \"board\" && editingBoard(route().id), d = dims(w), id = esc(w.id);\n return '<div class=\"w\" data-w=\"' + id + '\" data-cw=\"' + d.w + '\" data-ch=\"' + d.h + '\"><div class=\"wh\"><b class=\"trunc\">' + esc(w.title) + \"</b>\" +\n (ed ? '<span class=\"tools\"><button class=\"ibtn sm\" data-act=\"w-edit\" data-w=\"' + id + '\" title=\"Edit\">' + ICON.edit + '</button><button class=\"ibtn sm\" data-act=\"w-dup\" data-w=\"' + id + '\" title=\"Duplicate\">' + ICON.copy + '</button><button class=\"ibtn sm\" data-act=\"w-del\" data-w=\"' + id + '\" title=\"Remove\">' + ICON.trash + \"</button></span>\" : \"\") +\n '</div><div class=\"wk mono trunc\">' + esc(sourceText(w)) + '</div><div class=\"wb\" id=\"wb-' + id + '\"><div class=\"empty\">Loading…</div></div>' +\n (ed ? '<span class=\"dims\">' + d.w + \"×\" + d.h + \"</span>\" + [\"n\", \"ne\", \"e\", \"se\", \"s\", \"sw\", \"w\", \"nw\"].map(function (k) { return '<span class=\"rsz\" data-dir=\"' + k + '\" title=\"Drag to resize\"></span>'; }).join(\"\") : \"\") + \"</div>\";\n }\n function sourceText(w) {\n if (w.chart === \"insights\") return \"found automatically · refreshed with the page\";\n return w.series.map(function (s) { return keyOf(s); }).join(\" · \") + \" · \" + CHART_TITLE[w.range].toLowerCase() + (w.metric === \"errors\" ? \" · 5xx\" : w.metric === \"rate\" ? \" · failure rate\" : \"\");\n }\n function keyOf(s) {\n var p = (state.me && state.me.prefix) || \"{prefix}\";\n if (s.kind === \"global\") return p + \":global\";\n if (s.kind === \"type\") return p + \":\" + s.type + \":global\";\n return p + \":\" + s.type + \":\" + s.action + \":\" + s.name;\n }\n function labelOf(s) { return s.kind === \"global\" ? \"All traffic\" : s.kind === \"type\" ? s.type : s.kind === \"route\" ? s.action + \" \" + s.name : s.type + \":\" + s.action + \":\" + s.name; }\n function queryWidget(w) {\n if (w.chart === \"insights\") return api(\"/api/insights\").then(function (d) { state.side.insights = d; paintBadge(); return d; });\n return api(\"/api/query?q=\" + encodeURIComponent(JSON.stringify({ range: w.range, series: w.series, breakdown: w.chart === \"donut\" || w.chart === \"table\" })));\n }\n function boardFor(r) { return r.view === \"template\" ? tplBoard(r.key) : r.view === \"board\" ? currentBoard(r.id) : null; }\n function loadBoard(r, seq, timed) {\n var b = boardFor(r);\n if (!b) return Promise.resolve();\n state.updated = istNow();\n var up = document.getElementById(\"updated\");\n return Promise.all(b.widgets.map(function (w) {\n return queryWidget(w).then(function (d) {\n if (seq !== state.seq) return;\n state.wdata[w.id] = d;\n polled = timed;\n try { drawWidget(w, d, \"wb-\" + w.id); } finally { polled = false; }\n })\n .catch(function (e) { var el = document.getElementById(\"wb-\" + w.id); if (el && seq === state.seq) el.innerHTML = '<div class=\"empty bad\">' + esc(e.message) + \"</div>\"; });\n })).then(function () { if (up) up.textContent = \"IST · updated \" + state.updated; });\n }\n function metricOf(s, metric) {\n if (metric === \"errors\") return s.errors || s.counts.map(function () { return 0; });\n if (metric === \"rate\") return s.counts.map(function (c, i) { return c ? (s.errors ? s.errors[i] : 0) / c * 100 : 0; });\n return s.counts;\n }\n // Where a chart's series leads when clicked, like the built-in views.\n function srcHref(src) {\n if (!src || src.kind === \"global\") return \"#/overview\";\n if (src.kind === \"route\") return routeHref(src.type, src.action, src.name);\n return src.type && src.type !== \"*\" ? typeHref(src.type) : \"#/overview\";\n }\n function drawWidget(w, d, hostId) {\n var host = document.getElementById(hostId);\n if (!host || hold(host, function () { drawWidget(w, d, hostId); })) return;\n var nav = hostId !== \"ed-prev\" && !(state.draft && route().view === \"board\" && editingBoard(route().id)); // the editor's preview and a board being edited don't navigate\n if (w.chart === \"insights\") {\n var all = d.items || [], fit = Math.max(1, Math.floor((host.clientHeight - 6) / 38));\n host.innerHTML = all.length ? '<div class=\"wins\">' + all.slice(0, fit).map(function (it) {\n return '<button data-ins=\"' + all.indexOf(it) + '\"><i style=\"background:' + SEV_COLOR[it.severity] + '\"></i><span class=\"trunc\">' + esc(it.title) + '</span><b class=\"tnum\">' + esc(it.value) + \"</b></button>\";\n }).join(\"\") + (all.length > fit ? '<a class=\"link\" href=\"#/insights\" style=\"font-size:12px;padding:2px 14px\">+' + (all.length - fit) + \" more in Insights</a>\" : \"\") + \"</div>\" : '<div class=\"allclear\">' + ICON.check + \"All clear: nothing unusual right now.</div>\";\n return;\n }\n var metric = w.metric || \"count\", unit = metric === \"rate\" ? \"%\" : \"\";\n var series = d.series.map(function (s, i) { return { label: d.series.length > 1 ? s.label : (metric === \"errors\" ? \"5xx\" : metric === \"rate\" ? \"Failure rate\" : s.label), color: w.series.length > 1 ? (w.series[i].kind === \"type\" ? color(w.series[i].type) : PALETTE[i % PALETTE.length]) : w.series[0].kind === \"type\" ? color(w.series[0].type) : \"var(--bar)\", values: metricOf(s, metric), errors: metric === \"count\" && w.chart === \"bars\" && d.series.length === 1 ? s.errors : null }; });\n if (w.chart === \"number\") {\n // the current bucket is still filling, so show the last full one next to it rather than a % change\n var s0 = series[0], prev = s0.values[s0.values.length - 2] || 0, now = last(s0.values);\n host.innerHTML = '<div class=\"num\"' + (nav ? ' data-go=\"' + esc(srcHref(w.series[0])) + '\" style=\"cursor:pointer\" data-tip=\"Click to open ' + esc(labelOf(w.series[0])) + '\"' : \"\") + '><div class=\"v tnum trunc\">' + valText(now, unit) + '</div><div class=\"s\"><span>' + SLOT_NOW[d.range] + \"</span><span>·</span><span class=\\\"trunc\\\">\" + PREV_LABEL[d.range] + \": \" + valText(prev, unit) + \"</span></div>\" +\n (series.length > 1 ? '<div class=\"rows\">' + series.slice(1).map(function (s) { return '<div><span class=\"trunc\">' + esc(s.label) + '</span><b class=\"tnum\">' + valText(last(s.values), unit) + \"</b></div>\"; }).join(\"\") + \"</div>\" : \"\") +\n '<div style=\"margin-top:6px\">' + spark(400, 28, s0.values, typeof s0.color === \"string\" && s0.color.indexOf(\"var\") === 0 ? \"#6ea8fe\" : s0.color, d.series[0].ids, s0.label, true) + \"</div></div>\";\n return;\n }\n if (w.chart === \"donut\" || w.chart === \"table\") {\n var items = (d.breakdown || []).filter(function (b) { return !(b.target.kind === \"type\" && hiddenType(b.target.type)); }).map(function (b, i) {\n return { label: b.label, total: b.total, errors: b.errors, value: metric === \"errors\" ? b.errors || 0 : b.total, color: b.target.kind === \"type\" ? color(b.target.type) : PALETTE[i % PALETTE.length], go: nav ? srcHref(b.target) : null };\n }).sort(function (a, b) { return b.value - a.value; });\n // one route on its own splits into calls with and without 5xx, so any chart can become a donut\n if (items.length === 1 && items[0].errors !== null && metric !== \"errors\") {\n var one = items[0];\n items = [{ label: \"Without 5xx\", total: one.total - one.errors, errors: null, value: one.total - one.errors, color: \"#3fb979\", go: one.go }, { label: \"5xx\", total: one.errors, errors: one.errors, value: one.errors, color: \"#f0706a\", go: one.go }];\n }\n if (w.chart === \"donut\") {\n var top = items.slice(0, 7), rest = sum(items.slice(7).map(function (x) { return x.value; }));\n if (rest) top.push({ label: \"Other\", value: rest, color: \"#6b7684\" });\n donut(hostId, top, metric === \"errors\" ? \"errors\" : \"\");\n return;\n }\n var mx = Math.max.apply(null, items.map(function (x) { return x.value; }).concat([1])), was = host.querySelector(\".tscroll\"), keep = was ? was.scrollTop : 0;\n host.innerHTML = items.length ? '<div class=\"tscroll\" style=\"height:100%;overflow-y:auto\"><table class=\"tbl fit\"><tr><th>Name</th><th style=\"width:100px\">' + (metric === \"errors\" ? \"5xx\" : \"Requests\") + '</th><th class=\"opt\" style=\"width:100px\"></th>' + (d.failures && metric !== \"errors\" ? '<th style=\"width:124px\">5xx</th>' : \"\") + \"</tr>\" + items.map(function (x) {\n return \"<tr\" + (x.go ? ' data-go=\"' + esc(x.go) + '\"' : \"\") + '><td class=\"name mono trunc\" title=\"' + esc(x.label) + '\">' + esc(x.label) + '</td><td class=\"tnum\">' + fmt(x.value) + '</td><td class=\"opt\"><span class=\"share\" style=\"width:80px\"><i style=\"width:' + (x.value / mx * 100).toFixed(1) + \"%;background:\" + x.color + '\"></i></span></td>' + (d.failures && metric !== \"errors\" ? '<td class=\"tnum' + (x.errors ? \" bad\" : \"\") + '\">' + (x.errors ? fmt(x.errors) + \" · \" + pct(x.errors, x.total) : \"0\") + \"</td>\" : \"\") + \"</tr>\";\n }).join(\"\") + \"</table></div>\" : '<div class=\"empty\">Nothing counted in this range yet.</div>';\n if (keep && host.firstChild) host.firstChild.scrollTop = keep; // a refresh keeps your place in a long table\n return;\n }\n plot(hostId, { kind: w.chart, ids: d.series[0].ids, h: Math.max(70, host.clientHeight - 10 - (series.length > 1 ? 30 : 0)), unit: unit, series: series,\n pick: nav ? function (i, sr) { state.range = w.range; location.hash = srcHref(w.series[sr.idx] || w.series[0]); load(true); } : null });\n }\n\n // ---------- chart editor ----------\n function editWidget(w) {\n var types = trackedTypes();\n state.ed = { w: w ? clone(w) : { id: uid(), title: \"\", chart: \"line\", range: \"min\", metric: \"count\", w: 2, h: 2, series: [{ kind: \"global\" }] }, at: 0, isNew: !w, routes: {}, match: null, previewSeq: 0, types: types };\n var dd = dims(state.ed.w); state.ed.w.w = dd.w; state.ed.w.h = dd.h; delete state.ed.w.size;\n drawEditor();\n previewWidget();\n }\n function edSeries() { return state.ed.w.series[state.ed.at]; }\n function failuresOn() { return state.cfg ? state.cfg.resolved.failures : false; }\n function canErrors() { return failuresOn() && state.ed.w.series.every(function (s) { return s.kind === \"route\" || s.kind === \"pattern\"; }); }\n // donuts and tables split types and patterns by route, global by type, several series against each\n // other, and a single route into calls with and without 5xx, so every source can use them\n function canBreakdown() { return true; }\n function autoTitle(w) {\n if (w.chart === \"insights\") return \"Insights\";\n var what = w.series.length > 1 ? w.series.map(labelOf).join(\" vs \") : labelOf(w.series[0]);\n var m = w.metric === \"errors\" ? \"5xx: \" : w.metric === \"rate\" ? \"Failure rate: \" : \"\";\n return m + what + (w.chart === \"number\" ? \", \" + SLOT_NOW[w.range] : \", \" + RANGE_WHEN[w.range]);\n }\n function drawEditor() {\n var e = state.ed, w = e.w, s = edSeries(), buckets = (state.cfg ? BUCKETS.filter(function (b) { return state.cfg.resolved.timeConfig[b].enable; }) : BUCKETS), ins = w.chart === \"insights\";\n var kinds = [[\"global\", \"All traffic\", \"Every request\"], [\"type\", \"A type\", \"API, WEB, RPC…\"], [\"route\", \"One route\", \"A single endpoint\"], [\"pattern\", \"A pattern\", \"Many, with * wildcards\"]];\n var srcEditor = \"\";\n if (s.kind === \"type\") {\n srcEditor = '<div class=\"typechips\">' + Object.keys(TC).concat(state.cfg ? state.cfg.resolved.customTypes : []).map(function (t) {\n var ok = e.types.indexOf(t) !== -1;\n return '<button data-act=\"ed-type\" data-t=\"' + esc(t) + '\" class=\"' + (s.type === t ? \"on\" : \"\") + '\"' + (ok ? \"\" : ' disabled title=\"' + esc(t) + ' isn\\'t tracked. Turn it on with track in statis.init first.\"') + '><i style=\"background:' + color(t) + '\"></i>' + esc(t) + \"</button>\";\n }).join(\"\") + '</div><div class=\"hint\">Crossed-out types aren\\'t tracked by your app, so there is nothing to chart. Turn them on with <span class=\"mono\">track</span> in <span class=\"mono\">statis.init()</span>.</div>';\n } else if (s.kind === \"route\") {\n srcEditor = '<div class=\"row2\" style=\"margin-top:10px\"><select id=\"ed-rtype\">' + e.types.map(function (t) { return '<option value=\"' + esc(t) + '\"' + (s.type === t ? \" selected\" : \"\") + \">\" + esc(t) + \"</option>\"; }).join(\"\") + '</select><input class=\"inp\" id=\"ed-rsearch\" placeholder=\"Search routes…\" style=\"flex:1\" value=\"' + esc(e.rsearch || \"\") + '\"></div><div class=\"rlist\" id=\"ed-rlist\"><div class=\"empty\">Loading routes…</div></div>';\n } else if (s.kind === \"pattern\") {\n srcEditor = '<div class=\"pat\"><label>Type<input class=\"inp mono\" id=\"ed-pt\" list=\"dl-types\" value=\"' + esc(s.type) + '\"></label><label>Method / action<input class=\"inp mono\" id=\"ed-pa\" list=\"dl-acts\" value=\"' + esc(s.action) + '\"></label><label>Name<input class=\"inp mono\" id=\"ed-pn\" value=\"' + esc(s.name) + '\" placeholder=\"api/users*\"></label></div>' +\n '<datalist id=\"dl-types\"><option value=\"*\">' + e.types.map(function (t) { return '<option value=\"' + esc(t) + '\">'; }).join(\"\") + '</datalist><datalist id=\"dl-acts\"><option value=\"*\"><option value=\"GET\"><option value=\"POST\"><option value=\"PUT\"><option value=\"PATCH\"><option value=\"DELETE\"><option value=\"QUERY\"><option value=\"MUTATION\"><option value=\"CALL\"><option value=\"MESSAGE\"><option value=\"CONNECT\"></datalist>' +\n '<div class=\"keyline\">Searches <code class=\"mono\" id=\"ed-key\">' + esc(keyOf(s)) + ':{bucket}:{id}</code> · <span id=\"ed-match\">checking…</span></div><div class=\"rlist\" id=\"ed-mlist\" style=\"max-height:140px\"></div>';\n } else {\n srcEditor = '<div class=\"hint\" style=\"margin-top:10px\">Every request your app receives: <span class=\"mono\">' + esc(keyOf(s)) + \"</span>. WebSocket messages and outgoing calls aren't part of it.</div>\";\n }\n var breakdownOk = canBreakdown(), errOk = canErrors(), l = LIMITS[w.chart] || LIMITS.line;\n var charts = [\"number\", \"line\", \"area\", \"bars\", \"stack\", \"donut\", \"table\", \"insights\"].map(function (c) {\n var ok = (c !== \"donut\" && c !== \"table\") || breakdownOk;\n return '<button data-act=\"ed-chart\" data-c=\"' + c + '\" class=\"' + (w.chart === c ? \"on\" : \"\") + '\"' + (ok ? \"\" : ' disabled title=\"A single route can\\'t be split up; pick a type, a pattern or several series\"') + \">\" + CHART_ICON[c] + \"<b>\" + CHART_NAME[c] + \"</b></button>\";\n }).join(\"\");\n var sizes = '<span class=\"muted\" style=\"font-size:12px\">Width</span><div class=\"seg\">' + [1, 2, 3, 4].map(function (n) { return '<button data-act=\"ed-w\" data-n=\"' + n + '\" class=\"' + (w.w === n ? \"on\" : \"\") + '\"' + (n < l[0] || n > l[1] ? \" disabled\" : \"\") + \">\" + n + \"</button>\"; }).join(\"\") + '</div><span class=\"muted\" style=\"font-size:12px\">Height</span><div class=\"seg\">' + [1, 2, 3, 4].map(function (n) { return '<button data-act=\"ed-h\" data-n=\"' + n + '\" class=\"' + (w.h === n ? \"on\" : \"\") + '\"' + (n < l[2] || n > l[3] ? \" disabled\" : \"\") + \">\" + n + \"</button>\"; }).join(\"\") + \"</div>\";\n var body = '<div class=\"ed\"><div>' +\n (ins ? '<h4><i>1</i>What to track</h4><div class=\"hint\" style=\"margin:0\">Insights shows the latest problems found for you; there is nothing to pick.</div>' :\n '<h4><i>1</i>What to track</h4><div class=\"serieses\">' + w.series.map(function (x, i) { return '<button data-act=\"ed-at\" data-i=\"' + i + '\" class=\"' + (i === e.at ? \"on\" : \"\") + '\"><i style=\"background:' + (w.series.length > 1 ? PALETTE[i % PALETTE.length] : \"var(--bar)\") + '\"></i><span class=\"mono trunc\">' + esc(labelOf(x)) + \"</span>\" + (w.series.length > 1 ? '<span data-act=\"ed-del\" data-i=\"' + i + '\" title=\"Remove\" style=\"margin-left:4px;color:var(--ink3)\">✕</span>' : \"\") + \"</button>\"; }).join(\"\") +\n (w.series.length < 6 ? '<button data-act=\"ed-add\" title=\"Compare with another series\">' + ICON.plus.replace(\"<svg\", '<svg width=\"12\" height=\"12\"') + \"Compare</button>\" : \"\") + \"</div>\" +\n '<div class=\"kinds\">' + kinds.map(function (k) { return '<button data-act=\"ed-kind\" data-k=\"' + k[0] + '\" class=\"' + (s.kind === k[0] ? \"on\" : \"\") + '\"><b>' + k[1] + \"</b><span>\" + k[2] + \"</span></button>\"; }).join(\"\") + \"</div>\" + srcEditor) +\n '<h4><i>2</i>Show as</h4><div class=\"charts-pick\">' + charts + \"</div>\" +\n (ins ? \"\" : '<h4><i>3</i>Measure</h4><div class=\"row2\"><div class=\"seg\">' + [[\"count\", \"Requests\"], [\"errors\", \"5xx\"], [\"rate\", \"Failure rate\"]].map(function (m) { var ok = m[0] === \"count\" || errOk; return '<button data-act=\"ed-metric\" data-m=\"' + m[0] + '\" class=\"' + (w.metric === m[0] ? \"on\" : \"\") + '\"' + (ok ? \"\" : \" disabled\") + \">\" + m[1] + \"</button>\"; }).join(\"\") + \"</div>\" + (errOk ? \"\" : '<span class=\"hint\" style=\"margin:0\">' + (failuresOn() ? \"5xx needs routes or patterns\" : \"5xx needs failures: true in init\") + \"</span>\") + \"</div>\" +\n '<h4><i>4</i>Time range</h4><div class=\"seg\" style=\"display:inline-flex\">' + buckets.map(function (b) { return '<button data-act=\"ed-range\" data-r=\"' + b + '\" class=\"' + (w.range === b ? \"on\" : \"\") + '\">' + RANGE_LABEL[b] + \"</button>\"; }).join(\"\") + \"</div>\") +\n '<h4><i>' + (ins ? 3 : 5) + '</i>Size on the grid</h4><div class=\"row2\">' + sizes + '<span class=\"hint\" style=\"margin:0\">' + esc(CHART_NAME[w.chart]) + \": \" + l[0] + \"×\" + l[2] + \" to \" + l[1] + \"×\" + l[3] + \"</span></div>\" +\n '<h4><i>' + (ins ? 4 : 6) + '</i>Title</h4><input class=\"inp\" id=\"ed-title\" style=\"width:100%\" placeholder=\"' + esc(autoTitle(w)) + '\" value=\"' + esc(w.title) + '\">' +\n '</div><div><div class=\"preview\"><div class=\"hint\" style=\"margin:0 0 8px\">Live preview · ' + w.w + \"×\" + w.h + '</div><div class=\"w\" style=\"height:' + (w.h * ROW_H + (w.h - 1) * GAP) + \"px;max-width:\" + (w.w >= 2 ? \"100%\" : \"300px\") + '\"><div class=\"wh\"><b class=\"trunc\">' + esc(w.title || autoTitle(w)) + '</b></div><div class=\"wk mono trunc\">' + esc(sourceText(w)) + '</div><div class=\"wb\" id=\"ed-prev\"><div class=\"empty\">Loading…</div></div></div></div></div></div>';\n modal('<div class=\"modal\"><div class=\"mh\"><b>' + (e.isNew ? \"Add a chart\" : \"Edit chart\") + '</b><button class=\"ibtn\" data-act=\"ed-cancel\" title=\"Close\">✕</button></div><div class=\"mb\">' + body + '</div><div class=\"mf\"><span class=\"hint\" style=\"margin:0 auto 0 0\">The board is saved when you click Save board.</span><button class=\"btn ghost\" data-act=\"ed-cancel\">Cancel</button><button class=\"btn p\" data-act=\"ed-save\">' + (e.isNew ? \"Add to board\" : \"Update chart\") + \"</button></div></div>\");\n if (!ins && s.kind === \"route\") loadRouteList();\n if (!ins && s.kind === \"pattern\") loadMatches();\n }\n function loadRouteList() {\n var e = state.ed, s = edSeries(), t = document.getElementById(\"ed-rtype\") ? document.getElementById(\"ed-rtype\").value : s.type;\n var show = function (list) {\n var q = (e.rsearch || \"\").toLowerCase(), el = document.getElementById(\"ed-rlist\");\n if (!el) return;\n var rows = list.filter(function (x) { return !q || (x.action + \" \" + x.name).toLowerCase().indexOf(q) !== -1; }).slice(0, 80);\n el.innerHTML = rows.length ? rows.map(function (x) { var on = s.kind === \"route\" && s.type === t && s.action === x.action && s.name === x.name; return '<button data-act=\"ed-route\" data-t=\"' + esc(t) + '\" data-a=\"' + esc(x.action) + '\" data-n=\"' + esc(x.name) + '\" class=\"' + (on ? \"on\" : \"\") + '\">' + method(x.action) + '<span class=\"mono trunc\">' + esc(x.name) + '</span><span class=\"tnum\">' + short(x.day) + \" today</span></button>\"; }).join(\"\") : '<div class=\"empty\">No routes counted today for ' + esc(t) + \".</div>\";\n };\n if (e.routes[t]) return show(e.routes[t]);\n api(\"/api/type?type=\" + encodeURIComponent(t)).then(function (d) { e.routes[t] = d.routes; show(d.routes); }).catch(function (er) { var el = document.getElementById(\"ed-rlist\"); if (el) el.innerHTML = '<div class=\"empty bad\">' + esc(er.message) + \"</div>\"; });\n }\n var matchTimer;\n function loadMatches() {\n clearTimeout(matchTimer);\n matchTimer = setTimeout(function () {\n var s = edSeries(), m = document.getElementById(\"ed-match\"), list = document.getElementById(\"ed-mlist\");\n if (!m) return;\n api(\"/api/match?type=\" + encodeURIComponent(s.type) + \"&action=\" + encodeURIComponent(s.action) + \"&name=\" + encodeURIComponent(s.name)).then(function (d) {\n m.innerHTML = '<b class=\"' + (d.routes.length ? \"good\" : \"bad\") + '\">' + (d.routes.length ? \"matches \" + d.routes.length + (d.routes.length === 1 ? \" route\" : \" routes\") + \" today\" : \"matches nothing today\") + \"</b>\";\n list.innerHTML = d.routes.slice(0, 30).map(function (x) { return '<button disabled style=\"cursor:default\"><span class=\"tp mono\" style=\"margin-right:6px\"><i style=\"background:' + color(x.type) + '\"></i>' + esc(x.type) + \"</span>\" + method(x.action) + '<span class=\"mono trunc\">' + esc(x.name) + '</span><span class=\"tnum\">' + short(x.day) + \"</span></button>\"; }).join(\"\");\n }).catch(function (er) { m.innerHTML = '<b class=\"bad\">' + esc(er.message) + \"</b>\"; list.innerHTML = \"\"; });\n }, 250);\n }\n var prevTimer;\n function previewWidget() {\n clearTimeout(prevTimer);\n prevTimer = setTimeout(function () {\n var e = state.ed;\n if (!e) return;\n var w = e.w, seq = ++e.previewSeq;\n if (unpicked(w)) { var el = document.getElementById(\"ed-prev\"); if (el) el.innerHTML = '<div class=\"empty\">Pick a route from the list.</div>'; return; }\n queryWidget(w).then(function (d) { if (state.ed && seq === state.ed.previewSeq) drawWidget(w, d, \"ed-prev\"); })\n .catch(function (er) { var el = document.getElementById(\"ed-prev\"); if (el) el.innerHTML = '<div class=\"empty bad\">' + esc(er.message) + \"</div>\"; });\n }, 200);\n }\n function edChanged(redraw) {\n var w = state.ed.w;\n if (!canBreakdown() && (w.chart === \"donut\" || w.chart === \"table\")) w.chart = \"line\";\n if (!canErrors()) w.metric = \"count\";\n var d = dims(w); w.w = d.w; w.h = d.h;\n if (redraw) drawEditor(); else { var b = document.querySelector(\".preview .wh b\"); if (b) b.textContent = w.title || autoTitle(w); var k = document.querySelector(\".preview .wk\"); if (k) k.textContent = sourceText(w); }\n previewWidget();\n }\n function unpicked(w) { return w.chart !== \"insights\" && w.series.some(function (s) { return s.kind === \"route\" && !s.name; }); }\n function saveWidget() {\n var e = state.ed, r = route(), b = currentBoard(r.id);\n if (!b) return;\n if (unpicked(e.w)) return toast(\"Pick a route for each series first.\");\n if (!editingBoard(b.id)) { startEdit(b); b = state.draft.board; }\n if (!e.w.title) e.w.title = autoTitle(e.w);\n var i = b.widgets.map(function (x) { return x.id; }).indexOf(e.w.id);\n if (i === -1) b.widgets.push(e.w); else b.widgets[i] = e.w;\n delete state.wdata[e.w.id];\n closeModal();\n markDirty();\n state.shellKey = \"\"; render();\n }\n\n // ---------- resizing charts (edit mode) ----------\n var rs = null;\n document.addEventListener(\"mousedown\", function (ev) {\n var h = ev.target.closest && ev.target.closest(\".rsz\");\n if (!h || !state.draft) return;\n ev.preventDefault();\n var card = h.closest(\".w\"), grid = document.getElementById(\"wgrid\"), cs = getComputedStyle(grid);\n var cols = cs.gridTemplateColumns.split(\" \").length, gap = parseFloat(cs.columnGap) || GAP;\n var w = state.draft.board.widgets.filter(function (x) { return x.id === card.getAttribute(\"data-w\"); })[0];\n if (!w) return;\n var r = card.getBoundingClientRect();\n rs = { card: card, w: w, cols: cols, gap: gap, colW: (grid.clientWidth - gap * (cols - 1)) / cols, dir: h.getAttribute(\"data-dir\") || \"se\", x0: ev.clientX, y0: ev.clientY, w0: r.width, h0: r.height, size: dims(w) };\n card.classList.add(\"resizing\");\n });\n document.addEventListener(\"mousemove\", function (ev) {\n if (!rs) return;\n edgeScroll(ev);\n var l = LIMITS[rs.w.chart] || LIMITS.line;\n // left and top handles grow the chart when dragged outwards, like the right and bottom ones\n var dx = ev.clientX - rs.x0, dy = ev.clientY - rs.y0, dir = rs.dir;\n var pw = rs.w0 + (dir.indexOf(\"e\") !== -1 ? dx : dir.indexOf(\"w\") !== -1 ? -dx : 0), ph = rs.h0 + (dir.indexOf(\"s\") !== -1 ? dy : dir.indexOf(\"n\") !== -1 ? -dy : 0);\n var W = Math.round((pw + rs.gap) / (rs.colW + rs.gap)), H = Math.round((ph + rs.gap) / (ROW_H + rs.gap));\n W = Math.max(l[0], Math.min(l[1], rs.cols, W)); H = Math.max(l[2], Math.min(l[3], H));\n if (W === rs.size.w && H === rs.size.h) return;\n rs.size = { w: W, h: H };\n rs.card.setAttribute(\"data-cw\", W); rs.card.setAttribute(\"data-ch\", H);\n rs.card.querySelector(\".dims\").textContent = W + \"×\" + H;\n });\n document.addEventListener(\"mouseup\", function () {\n if (!rs) return;\n var x = rs; rs = null; edgeScroll(null);\n x.card.classList.remove(\"resizing\");\n if (x.size.w === dims(x.w).w && x.size.h === dims(x.w).h) return;\n x.w.w = x.size.w; x.w.h = x.size.h; delete x.w.size;\n markDirty();\n if (state.wdata[x.w.id]) drawWidget(x.w, state.wdata[x.w.id], \"wb-\" + x.w.id);\n });\n\n // ---------- modal ----------\n function modal(html) { closeModal(true); var d = document.createElement(\"div\"); d.className = \"modal-back\"; d.id = \"modal\"; d.innerHTML = html; document.body.appendChild(d); }\n function closeModal(keep) { var m = document.getElementById(\"modal\"); if (m) m.remove(); if (!keep) { state.ed = null; state.xp = null; state.rs = null; } }\n\n function showError() {\n var main = document.querySelector(\"main\");\n if (!main) return;\n var e = document.getElementById(\"err\");\n if (!e) { e = document.createElement(\"div\"); e.id = \"err\"; e.className = \"err\"; main.insertBefore(e, main.children[1] || null); }\n e.textContent = state.error;\n }\n\n function loginView() {\n return '<form class=\"login\" id=\"login\"><div style=\"display:flex;align-items:center;gap:12px\"><span class=\"mark\" style=\"width:30px;height:30px;border-radius:8px\"></span><div><div style=\"font-size:19px;font-weight:600\">Sign in</div><div style=\"font-size:13px;color:var(--ink2)\">system-tracker dashboard</div></div></div>' +\n '<label for=\"u\">Username</label><input class=\"in\" id=\"u\" name=\"username\" autocomplete=\"username\" required>' +\n '<label for=\"pw\">Password</label><input class=\"in\" id=\"pw\" name=\"password\" type=\"password\" autocomplete=\"current-password\" required>' +\n '<div class=\"bad\" id=\"lerr\" style=\"font-size:13px;margin-top:12px;min-height:18px\"></div>' +\n '<button class=\"btn p\" type=\"submit\">Sign in</button></form>';\n }\n\n function render() {\n var me = state.me;\n hideHover();\n if (!me) { app.innerHTML = topBar(); return; }\n if (me.required && !me.signedIn) {\n state.shellKey = \"\";\n app.innerHTML = topBar() + loginView();\n var u = document.getElementById(\"u\");\n if (u) u.focus();\n return;\n }\n var r = route(), cb = boardFor(r);\n var key = JSON.stringify(r) + \"|\" + (cb ? JSON.stringify(cb) + editingBoard(cb.id) : \"\");\n if (key !== state.shellKey) {\n state.shellKey = key; state.first = true;\n if (r.view !== \"type\") state.filter = \"\";\n var body;\n if (r.view === \"insights\") body = insightsShell();\n else if (r.view === \"settings\") body = settingsShell();\n else if (r.view === \"boards\") body = boardsView();\n else if (r.view === \"template\") body = cb ? templateShell(cb) : '<div class=\"card\"><div class=\"empty\">There is no such template. <a class=\"link\" href=\"#/boards\">See all templates</a>.</div></div>';\n else if (r.view === \"board\") body = cb ? boardShell(cb) : '<div class=\"card\"><div class=\"empty\">That board doesn\\'t exist any more. <a class=\"link\" href=\"#/boards\">See all boards</a>.</div></div>';\n else body = shell(r);\n app.innerHTML = topBar() + \"<main>\" + body + \"</main>\";\n var s = document.getElementById(\"sort\");\n if (s) s.value = state.sort;\n state.data = null;\n if (r.view === \"settings\") { fillSettings(); fillStorage(); }\n } else {\n app.querySelector(\".top\").outerHTML = topBar().replace(/<div class=\"banner\">[\\s\\S]*$/, \"\");\n }\n if (state.data) fill(r, state.data);\n if (cb) b2(r);\n load(true);\n }\n function b2(r) { var b = boardFor(r); b.widgets.forEach(function (w) { if (state.wdata[w.id]) drawWidget(w, state.wdata[w.id], \"wb-\" + w.id); }); }\n\n // ---------- events ----------\n document.addEventListener(\"click\", function (ev) {\n var t = ev.target.closest ? ev.target : ev.target.parentElement;\n if (t.classList && t.classList.contains(\"modal-back\")) { closeModal(); return; }\n if (t.closest(\".wgrid.editing\") && !t.closest(\"[data-act]\")) return;\n var hit = t.closest(\".chart[data-chart]\");\n if (hit && CH[hit.getAttribute(\"data-chart\")] && CH[hit.getAttribute(\"data-chart\")].pick) {\n var m = CH[hit.getAttribute(\"data-chart\")], g = m.geo, rr = hit.querySelector(\"svg\").getBoundingClientRect(), idx = Math.floor((ev.clientX - rr.left - g.padL) / g.step);\n if (idx >= 0 && idx < g.n) {\n var my = ev.clientY - rr.top, base = 0, hitS = null, vis = m.series.filter(function (s) { return !s.off; });\n if (m.kind === \"stack\") vis.forEach(function (s) { var v = s.values[idx] || 0; if (my <= g.Y(base) && my >= g.Y(base + v)) hitS = s; base += v; });\n else vis.forEach(function (s) { if (!hitS || Math.abs(g.Y(s.values[idx] || 0) - my) < Math.abs(g.Y(hitS.values[idx] || 0) - my)) hitS = s; });\n if (hitS) return m.pick(idx, hitS);\n }\n }\n var ins = t.closest(\"[data-ins]\");\n if (ins) return openInsight(+ins.getAttribute(\"data-ins\"));\n var go = t.closest(\"[data-go]\");\n if (go && !t.closest(\"[data-act]\")) { closeModal(); location.hash = go.getAttribute(\"data-go\"); return; }\n var rb = t.closest(\"[data-range]\");\n if (rb) { state.range = rb.getAttribute(\"data-range\"); load(true); return; }\n var act = t.closest(\"[data-act]\");\n if (!act) return;\n var a = act.getAttribute(\"data-act\"), r = route(), b = r.view === \"board\" ? currentBoard(r.id) : null, e = state.ed;\n var wid = act.getAttribute(\"data-w\"), widget = b && wid ? b.widgets.filter(function (x) { return x.id === wid; })[0] : null;\n switch (a) {\n case \"reload\": load(true); if (!state.stPoll) loadStorage(); break;\n case \"recount\": loadStorage(true); break;\n case \"theme\":\n var light = document.documentElement.dataset.theme !== \"light\";\n document.documentElement.dataset.theme = light ? \"light\" : \"dark\";\n try { localStorage.setItem(\"system-tracker-theme\", light ? \"light\" : \"dark\"); } catch (x) {}\n render(); break;\n case \"logout\": api(\"/api/logout\", { method: \"POST\" }).then(function () { state.me.signedIn = false; state.storage = null; state.stAt = 0; render(); }); break;\n case \"legend\":\n var k = act.getAttribute(\"data-chart\") + \"|\" + act.getAttribute(\"data-label\");\n state.legendOff[k] = !state.legendOff[k];\n if (state.data) fill(route(), state.data); else if (b) b2(r); break;\n case \"insf\": state.insFilter = act.getAttribute(\"data-f\"); if (viewData()) steady(function () { fillInsights(viewData()); }); break;\n case \"ins-toggle\":\n state.insOpen = state.insOpen || {};\n var ik = act.getAttribute(\"data-k\"); state.insOpen[ik] = !state.insOpen[ik]; state.insJust = state.insOpen[ik] ? ik : null;\n hideHover(); if (viewData()) steady(function () { fillInsights(viewData()); }); break;\n case \"ins-watch\": watchInsight(+act.getAttribute(\"data-i\")); break;\n case \"ptop\": store.set(\"system-tracker-hide-top-routes\", !topHidden()); if (state.pdata) steady(function () { fillPeriod(state.pdata); }); break;\n case \"period\":\n state.period = act.getAttribute(\"data-p\"); store.set(\"system-tracker-period\", state.period);\n document.querySelectorAll(\"#ptabs button\").forEach(function (x) { x.classList.toggle(\"on\", x.getAttribute(\"data-p\") === state.period); });\n state.insOpen = {}; load(true); break;\n case \"modal-close\": closeModal(); break;\n case \"export\": openExport(\"period\"); break;\n case \"export-view\": openExport(\"view\"); break;\n case \"xp-period\": state.xp.period = act.getAttribute(\"data-p\"); drawExport(); break;\n case \"xp-format\": state.xp.format = act.getAttribute(\"data-f\"); drawExport(); break;\n case \"xp-go\": doExport(); break;\n case \"xp-get\":\n var fmtWanted = act.getAttribute(\"data-f\");\n api(\"/api/reports/one?id=\" + encodeURIComponent(act.getAttribute(\"data-id\"))).then(function (rep) { deliver(rep, fmtWanted); }).catch(function (er) { toast(er.message); }); break;\n case \"xp-del\":\n if (!window.confirm(\"Delete this saved report? This can't be undone.\")) break;\n api(\"/api/reports/one?id=\" + encodeURIComponent(act.getAttribute(\"data-id\")), { method: \"DELETE\" }).then(function () { return api(\"/api/reports\"); }).then(function (l) { if (state.xp) { state.xp.list = l.reports; drawExport(); } }).catch(function (er) { toast(er.message); }); break;\n case \"reset\": openReset(); checkReset(); break;\n case \"rs-what\": state.rs.what = act.getAttribute(\"data-w\"); state.rs.action = state.rs.name = \"\"; checkReset(); break;\n case \"rs-type\": state.rs.type = act.getAttribute(\"data-t\"); checkReset(); break;\n case \"rs-when\": state.rs.period = act.getAttribute(\"data-p\"); checkReset(); break;\n case \"rs-route\": state.rs.action = act.getAttribute(\"data-a\"); state.rs.name = act.getAttribute(\"data-n\"); checkReset(); break;\n case \"rs-go\": doReset(); break;\n case \"set-reset\": store.del(draftKey()); saveDraft(clone(state.cfg.resolved)); break;\n case \"copy-curl\":\n var kept = viewData(), list = kept && (act.getAttribute(\"data-src\") === \"f\" ? kept.failed : kept.samples), one = list && list[+act.getAttribute(\"data-i\")];\n if (!one) break;\n (navigator.clipboard ? navigator.clipboard.writeText(curlOf(one, state.curlMode)) : Promise.reject()).then(function () { toast(state.curlMode === \"structure\" ? \"Copied the curl structure. Fill in the :params and <values>.\" : \"Copied. Paste it in a terminal, or in Postman with Import → Raw text.\"); }, function () { toast(\"Select the command and copy it.\"); }); break;\n case \"copycode\":\n (navigator.clipboard ? navigator.clipboard.writeText(state.codeText) : Promise.reject()).then(function () { toast(\"Copied. Put your real credentials back where you see ••••••.\"); }, function () { toast(\"Select the code and copy it.\"); }); break;\n case \"addpath\":\n var inp = document.getElementById(\"addpath\"), v = inp.value.trim();\n if (v) { var d = draft(); if (d.ignore.paths.indexOf(v) === -1) d.ignore.paths.push(v); saveDraft(d); } break;\n case \"delpath\": var dd = draft(); dd.ignore.paths.splice(+act.getAttribute(\"data-i\"), 1); saveDraft(dd); break;\n case \"newboard\": newBoard(); break;\n case \"tpl-use\": useTemplate(r.key, false); break;\n case \"tpl-customize\": useTemplate(r.key, true); break;\n case \"customize\": customizeOverview(); break;\n case \"b-edit\": startEdit(b); state.shellKey = \"\"; render(); break;\n case \"b-rename\": ev.preventDefault(); renameBoard(act.getAttribute(\"data-id\")); break;\n case \"rn-save\": saveRename(act.getAttribute(\"data-id\")); break;\n case \"b-save\": saveEdit(); break;\n case \"b-cancel\": cancelEdit(); break;\n case \"b-default\":\n if (state.boards.defaultId === b.id) break;\n state.boards.defaultId = b.id;\n saveBoards('\"' + b.name + '\" is now your Overview.').then(function () { state.shellKey = \"\"; render(); }); break;\n case \"b-undefault\":\n state.boards.defaultId = null;\n saveBoards(\"The Overview tab shows the built-in Overview again.\").then(function () { state.shellKey = \"\"; render(); }); break;\n case \"b-dup\": var nb = clone(b); nb.id = uid(); nb.name = b.name + \" (copy)\"; nb.widgets.forEach(function (x) { x.id = uid(); }); state.boards.boards.push(nb); saveBoards('Board \"' + nb.name + '\" created.').then(function () { location.hash = \"#/board/\" + nb.id; }); break;\n case \"b-del\":\n if (!confirmDelete(b)) break;\n state.boards.boards = state.boards.boards.filter(function (x) { return x.id !== b.id; });\n if (state.boards.defaultId === b.id) state.boards.defaultId = null;\n saveBoards('Board \"' + b.name + '\" deleted.').then(function () { location.hash = \"#/boards\"; }); break;\n case \"w-add\": if (!editingBoard(b.id)) { startEdit(b); state.shellKey = \"\"; render(); } editWidget(null); break;\n case \"w-edit\": editWidget(widget); break;\n case \"w-dup\": var nw = clone(widget); nw.id = uid(); nw.title = widget.title + \" (copy)\"; b.widgets.splice(b.widgets.indexOf(widget) + 1, 0, nw); if (state.wdata[widget.id]) state.wdata[nw.id] = state.wdata[widget.id]; markDirty(); render(); break;\n case \"w-del\": b.widgets = b.widgets.filter(function (x) { return x !== widget; }); markDirty(); render(); break;\n case \"ed-cancel\": closeModal(); break;\n case \"ed-save\": saveWidget(); break;\n case \"ed-at\": if (ev.target.getAttribute(\"data-act\") === \"ed-del\") break; e.at = +act.getAttribute(\"data-i\"); edChanged(true); break;\n case \"ed-del\": e.w.series.splice(+act.getAttribute(\"data-i\"), 1); e.at = 0; edChanged(true); break;\n case \"ed-add\": e.w.series.push({ kind: \"type\", type: e.types[0] || \"API\" }); e.at = e.w.series.length - 1; edChanged(true); break;\n case \"ed-kind\":\n var kk = act.getAttribute(\"data-k\"), cur = edSeries(), T0 = cur.type && cur.type !== \"*\" && e.types.indexOf(cur.type) !== -1 ? cur.type : e.types[0] || \"API\";\n e.w.series[e.at] = kk === \"global\" ? { kind: \"global\" } : kk === \"type\" ? { kind: \"type\", type: T0 } : kk === \"route\" ? { kind: \"route\", type: T0, action: \"\", name: \"\" } : { kind: \"pattern\", type: T0, action: \"*\", name: \"*\" };\n edChanged(true); break;\n case \"ed-type\": edSeries().type = act.getAttribute(\"data-t\"); edChanged(true); break;\n case \"ed-route\": e.w.series[e.at] = { kind: \"route\", type: act.getAttribute(\"data-t\"), action: act.getAttribute(\"data-a\"), name: act.getAttribute(\"data-n\") }; edChanged(true); break;\n case \"ed-chart\": e.w.chart = act.getAttribute(\"data-c\"); edChanged(true); break;\n case \"ed-metric\": e.w.metric = act.getAttribute(\"data-m\"); edChanged(true); break;\n case \"ed-range\": e.w.range = act.getAttribute(\"data-r\"); edChanged(true); break;\n case \"ed-w\": e.w.w = +act.getAttribute(\"data-n\"); edChanged(true); break;\n case \"ed-h\": e.w.h = +act.getAttribute(\"data-n\"); edChanged(true); break;\n }\n });\n function confirmDelete(b) { return window.confirm('Delete the board \"' + b.name + '\"? This can\\'t be undone.'); }\n document.addEventListener(\"input\", function (ev) {\n var id = ev.target.id, e = state.ed;\n if (id === \"filter\") { state.filter = ev.target.value; if (viewData()) fillRoutes(route(), viewData()); }\n if (e && id === \"ed-title\") { e.w.title = ev.target.value; edChanged(false); }\n if (e && id === \"ed-rsearch\") { e.rsearch = ev.target.value; loadRouteList(); }\n if (state.rs && id === \"rs-search\") { state.rs.q = ev.target.value; showResetRoutes(); }\n if (e && (id === \"ed-pt\" || id === \"ed-pa\" || id === \"ed-pn\")) {\n var s = edSeries(), v = ev.target.value.trim() || \"*\";\n if (id === \"ed-pt\") s.type = v; else if (id === \"ed-pa\") s.action = v; else s.name = v;\n var k = document.getElementById(\"ed-key\"); if (k) k.textContent = keyOf(s) + \":{bucket}:{id}\";\n var chip = document.querySelector(\".serieses button.on span.mono\"); if (chip) chip.textContent = labelOf(s);\n loadMatches(); edChanged(false);\n }\n });\n document.addEventListener(\"change\", function (ev) {\n var t = ev.target, id = t.id;\n if (id === \"sort\") { state.sort = t.value; if (viewData()) fillRoutes(route(), viewData()); }\n if (id === \"curlmode\") { state.curlMode = t.value === \"sample\" ? \"sample\" : \"structure\"; store.set(\"system-tracker-curl-mode\", state.curlMode); if (viewData()) fillRequests(route(), viewData()); }\n if (id === \"refresh\") {\n state.refresh = Number(t.value);\n store.set(\"system-tracker-refresh\", state.refresh);\n t.parentElement.classList.toggle(\"off\", !state.refresh);\n if (state.refresh) load(true);\n }\n if (id === \"ed-rtype\") loadRouteList();\n if (id === \"rs-rtype\" && state.rs) { state.rs.type = t.value; state.rs.action = state.rs.name = \"\"; checkReset(); }\n if (id === \"bname\" && state.draft) {\n var v = t.value.trim();\n if (v && v !== state.draft.board.name) { state.draft.board.name = v.slice(0, 80); markDirty(); }\n }\n var path = t.getAttribute && t.getAttribute(\"data-set\");\n if (path && state.cfg) {\n var d = draft();\n if (path === \"ignore.methods\") {\n var ms = []; document.querySelectorAll('[data-set=\"ignore.methods\"]').forEach(function (c) { if (c.checked) ms.push(c.value); });\n d.ignore.methods = ms;\n } else if (t.type === \"checkbox\") setPath(d, path, t.checked);\n else setPath(d, path, Math.max(1, Math.round(Number(t.value)) || 1));\n saveDraft(d);\n }\n });\n document.addEventListener(\"keydown\", function (ev) {\n if (ev.key === \"Escape\" && document.getElementById(\"modal\")) closeModal();\n if (ev.key === \"Enter\" && ev.target.id === \"addpath\") { ev.preventDefault(); document.querySelector('[data-act=\"addpath\"]').click(); }\n if (ev.key === \"Enter\" && ev.target.id === \"bname\") ev.target.blur();\n if (ev.key === \"Enter\" && ev.target.id === \"rn-name\") { ev.preventDefault(); document.querySelector('[data-act=\"rn-save\"]').click(); }\n if (ev.key === \"/\" && !/INPUT|TEXTAREA|SELECT/.test(ev.target.tagName)) { var f = document.getElementById(\"filter\"); if (f) { ev.preventDefault(); f.focus(); } }\n });\n // ---------- moving charts (edit mode) ----------\n // Press on a chart and drag: the others make room as you go, and every chart keeps its size.\n var mv = null;\n document.addEventListener(\"mousedown\", function (ev) {\n var card = ev.target.closest && ev.target.closest(\".wgrid.editing .w[data-w]\");\n if (!card || rs || ev.button !== 0 || ev.target.closest(\"button, input, select, a, .rsz\")) return;\n ev.preventDefault();\n mv = { card: card, id: card.getAttribute(\"data-w\"), x0: ev.clientX, y0: ev.clientY, on: false, at: 0, last: \"\" };\n });\n function moveTo(ev) {\n var b = state.draft && state.draft.board, grid = document.getElementById(\"wgrid\");\n var under = document.elementFromPoint(ev.clientX, ev.clientY), t = under && under.closest && under.closest(\".wgrid .w[data-w]\");\n if (!b || !grid || !t || t === mv.card || Date.now() - mv.at < 140) return;\n var r = t.getBoundingClientRect(), after = ev.clientY > r.bottom - r.height / 3 || (ev.clientY >= r.top + r.height / 3 && ev.clientX > r.left + r.width / 2);\n var key = t.getAttribute(\"data-w\") + (after ? \">\" : \"<\");\n if (key === mv.last) return;\n mv.last = key; mv.at = Date.now();\n grid.insertBefore(mv.card, after ? t.nextSibling : t);\n var order = [].map.call(grid.querySelectorAll(\".w[data-w]\"), function (e) { return e.getAttribute(\"data-w\"); });\n b.widgets.sort(function (p, q) { return order.indexOf(p.id) - order.indexOf(q.id); });\n mv.moved = true;\n }\n document.addEventListener(\"mousemove\", function (ev) {\n if (!mv) return;\n if (!mv.on) {\n if (Math.abs(ev.clientX - mv.x0) + Math.abs(ev.clientY - mv.y0) < 6) return;\n mv.on = true; mv.card.classList.add(\"dragging\"); document.body.classList.add(\"moving\");\n }\n edgeScroll(ev);\n moveTo(ev);\n });\n document.addEventListener(\"mouseup\", function () {\n if (!mv) return;\n var m = mv; mv = null; edgeScroll(null);\n m.card.classList.remove(\"dragging\"); document.body.classList.remove(\"moving\");\n if (m.moved) markDirty();\n });\n // While moving or resizing near the top or bottom of the window, the page scrolls.\n var edge = { dy: 0, timer: 0 };\n function edgeScroll(ev) {\n edge.dy = !ev ? 0 : ev.clientY > innerHeight - 60 ? 14 : ev.clientY < 60 ? -14 : 0;\n if (edge.dy && !edge.timer) edge.timer = setInterval(function () { if (!edge.dy) { clearInterval(edge.timer); edge.timer = 0; return; } scrollBy(0, edge.dy); }, 16);\n }\n document.addEventListener(\"submit\", function (ev) {\n if (ev.target.id !== \"login\") return;\n ev.preventDefault();\n var btn = ev.target.querySelector(\"button\"), err = document.getElementById(\"lerr\");\n btn.disabled = true; err.textContent = \"\";\n fetch(BASE + \"/api/login\", { method: \"POST\", credentials: \"same-origin\", headers: { \"content-type\": \"application/json\" }, body: JSON.stringify({ username: document.getElementById(\"u\").value, password: document.getElementById(\"pw\").value }) })\n .then(function (res) {\n if (!res.ok) return res.json().catch(function () { return {}; }).then(function (b) { err.textContent = (res.status === 429 && b.error) || \"Wrong username or password\"; btn.disabled = false; });\n return start();\n })\n .catch(function () { err.textContent = \"Could not reach the server\"; btn.disabled = false; });\n });\n window.addEventListener(\"hashchange\", render);\n var resizeTimer;\n window.addEventListener(\"resize\", function () {\n clearTimeout(resizeTimer);\n resizeTimer = setTimeout(function () { var r = route(); if (state.data) fill(r, state.data); if (boardFor(r)) b2(r); }, 150);\n });\n\n function start() {\n return fetch(BASE + \"/api/me\", { credentials: \"same-origin\" }).then(function (res) { return res.json(); }).then(function (me) {\n state.me = me;\n if (!me.signedIn) return render();\n return Promise.all([api(\"/api/config\").then(function (c) { state.cfg = c; }).catch(function () {}), loadBoards()]).then(function () {\n render();\n if (route().view !== \"overview\" && route().view !== \"insights\") loadSide();\n });\n }).catch(function () { app.innerHTML = topBar() + '<main><div class=\"err\">Could not reach the dashboard API.</div></main>'; });\n }\n start();\n})();\n</script>\n</body>\n</html>\n";