papercliped 1.1.0-beta.2 → 2.2.0
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.
- package/.claude-plugin/marketplace.json +14 -11
- package/.claude-plugin/plugin.json +17 -8
- package/.mcp.json +8 -14
- package/CHANGELOG.md +70 -1
- package/LICENSE +21 -21
- package/README.md +80 -80
- package/commands/paperclip-report.md +5 -5
- package/commands/paperclip-status.md +4 -4
- package/commands/paperclip-sync.md +5 -5
- package/dist/accounts/alias.js +1 -1
- package/dist/accounts/username.js +1 -1
- package/dist/cli.js +11 -11
- package/dist/http.js +17 -3
- package/dist/manage/routes.js +60 -82
- package/dist/oauth/pages.js +32 -18
- package/dist/oauth/paperclip-login.js +38 -0
- package/dist/oauth/pg-store.js +0 -13
- package/dist/oauth/provider.js +83 -64
- package/dist/oauth/scopes.js +19 -16
- package/dist/openapi.js +19 -2
- package/dist/public-api/health.js +46 -0
- package/dist/public-api/openapi.js +51 -0
- package/dist/public-api/routes.js +251 -0
- package/dist/public-api/types.js +14 -0
- package/dist/server.js +4 -0
- package/dist/site/landing.js +3 -3
- package/dist/site/markdown.js +2 -1
- package/dist/site/site.js +16 -4
- package/dist/telemetry/recorder.js +1 -1
- package/dist/tools.js +48 -2
- package/dist/web/csp.js +16 -0
- package/dist/web/hosts.js +23 -0
- package/dist/web/routes.js +132 -0
- package/docs/ARCHITECTURE.md +74 -73
- package/docs/COMMUNITY.md +86 -0
- package/docs/DEPLOY-RENDER.md +3 -3
- package/docs/DOMAINS.md +74 -0
- package/docs/LAUNCH.md +50 -34
- package/docs/RELEASING.md +37 -0
- package/docs/SECURITY.md +82 -65
- package/docs/SITEMAP.md +92 -0
- package/docs/V3-SUBDOMAINS.md +106 -0
- package/docs/chatgpt.md +20 -20
- package/docs/least-privilege.sql +23 -23
- package/docs/legal/PRIVACY.md +37 -37
- package/docs/legal/TERMS.md +11 -11
- package/docs/oauth.md +61 -62
- package/docs/supabase-schema.sql +8 -1
- package/migrations/001_init.sql +84 -84
- package/migrations/002_audit.sql +41 -41
- package/migrations/003_accounts.sql +66 -66
- package/migrations/004_privacy_panel.sql +57 -57
- package/migrations/006_two_levels.sql +5 -0
- package/package.json +10 -14
- package/site/docs/chatgpt.md +1 -1
- package/site/docs/cli.md +27 -0
- package/site/docs/connect-your-paperclip.md +120 -0
- package/site/docs/environment.md +64 -0
- package/site/docs/faq.md +31 -0
- package/site/docs/getting-started.md +11 -5
- package/site/docs/limits-and-errors.md +32 -0
- package/site/docs/manage-api.md +33 -0
- package/site/docs/manage.md +12 -21
- package/site/docs/other-ai-apps.md +28 -0
- package/site/docs/paperclip-plugin.md +36 -0
- package/site/docs/permissions.md +5 -5
- package/site/docs/public-api.md +62 -0
- package/site/docs/security.md +2 -2
- package/site/docs/self-hosting.md +43 -0
- package/site/docs/signup.md +3 -3
- package/site/docs/tools.md +47 -0
- package/site/docs/troubleshooting.md +2 -2
- package/site/docs/what-is-papercliped.md +37 -0
- package/site/privacy.md +5 -3
- package/site/terms.md +1 -1
- package/skills/paperclip/SKILL.md +23 -23
- package/dist/demo.js +0 -144
- package/dist/manage/page.js +0 -170
- package/dist/panel/config.js +0 -61
- package/dist/panel/main.js +0 -90
- package/dist/panel/page.js +0 -553
- package/dist/panel/routes.js +0 -284
- package/dist/panel/server.js +0 -37
- package/docs/panel-role.sql +0 -26
package/dist/panel/page.js
DELETED
|
@@ -1,553 +0,0 @@
|
|
|
1
|
-
import { FAVICON_LINK } from "../site/favicon.js";
|
|
2
|
-
/**
|
|
3
|
-
* Operator dashboard (served at /admin). One self-contained page: no external scripts, fonts or images, so the CSP can be
|
|
4
|
-
* `default-src 'none'` + a per-response script nonce. All data is drawn with DOM/SVG APIs and `textContent` — client names,
|
|
5
|
-
* tenant hosts and tool names are untrusted strings and are never interpolated into HTML.
|
|
6
|
-
*
|
|
7
|
-
* Design follows the dataviz method: one axis per chart (throughput and latency are separate charts, never dual-axis), colour
|
|
8
|
-
* assigned by job (categorical blue/orange for Paperclip-vs-bridge time, a one-hue ramp for ordered percentiles, reserved
|
|
9
|
-
* status colours that always ship with an icon + label), thin marks with 2px surface gaps, hairline solid gridlines, a legend
|
|
10
|
-
* for ≥2 series, a crosshair tooltip, and a "view as table" for every chart.
|
|
11
|
-
*/
|
|
12
|
-
import { esc } from "../oauth/pages.js";
|
|
13
|
-
const CSS = String.raw `
|
|
14
|
-
:root{color-scheme:light;--surface:#fcfcfb;--card:#ffffff;--ink:#0b0b0b;--ink2:#52514e;--ink3:#7a7974;--hair:#e7e6e2;--hair2:#d8d7d2;
|
|
15
|
-
--s1:#2a78d6;--s2:#eb6834;--r300:#6da7ec;--r450:#2a78d6;--r600:#184f95;--neutral:#b8b7b1;
|
|
16
|
-
--good:#0ca30c;--warn:#fab219;--serious:#ec835a;--crit:#d03b3b;--goodink:#08700a;--warnink:#8a5b00;--critink:#b02a2a;--wash:#f4f3ef}
|
|
17
|
-
@media (prefers-color-scheme:dark){:root{color-scheme:dark;--surface:#1a1a19;--card:#212120;--ink:#ffffff;--ink2:#c3c2b7;--ink3:#8f8e86;--hair:#2e2e2c;--hair2:#3a3a37;
|
|
18
|
-
--s1:#3987e5;--s2:#d95926;--r300:#6da7ec;--r450:#3987e5;--r600:#2a78d6;--neutral:#6b6a65;--goodink:#4cc24c;--warnink:#fab219;--critink:#ff7b7b;--wash:#262624}}
|
|
19
|
-
*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
|
|
20
|
-
body{margin:0;background:var(--surface);color:var(--ink);font:14px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
|
|
21
|
-
.wrap{max-width:1240px;margin:0 auto;padding:16px}
|
|
22
|
-
header{display:flex;flex-wrap:wrap;gap:12px 20px;align-items:center;justify-content:space-between;margin-bottom:14px}
|
|
23
|
-
h1{font-size:16px;font-weight:600;margin:0;letter-spacing:.01em}h1 small{font-weight:400;color:var(--ink2);margin-left:8px}
|
|
24
|
-
.ctl{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
25
|
-
.seg{display:inline-flex;border:1px solid var(--hair2);border-radius:8px;overflow:hidden}
|
|
26
|
-
.seg button{border:0;background:transparent;color:var(--ink2);padding:6px 10px;font:inherit;cursor:pointer;border-right:1px solid var(--hair2)}
|
|
27
|
-
.seg button:last-child{border-right:0}.seg button:hover{background:var(--wash)}
|
|
28
|
-
.seg button[aria-pressed=true]{background:var(--wash);color:var(--ink);font-weight:600}
|
|
29
|
-
button.plain,.signout button{border:1px solid var(--hair2);background:transparent;color:var(--ink2);border-radius:8px;padding:6px 10px;font:inherit;cursor:pointer}
|
|
30
|
-
button.plain:hover,.signout button:hover{background:var(--wash)}
|
|
31
|
-
button:focus-visible,summary:focus-visible,th[tabindex]:focus-visible,.hit:focus-visible{outline:2px solid var(--s1);outline-offset:2px}
|
|
32
|
-
.signout{margin:0}
|
|
33
|
-
.pill{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--hair2);border-radius:999px;padding:3px 10px;font-weight:600}
|
|
34
|
-
.pill .ic{font-weight:700}.pill.healthy{color:var(--goodink)}.pill.degraded{color:var(--warnink)}.pill.critical{color:var(--critink)}.pill.idle{color:var(--ink2)}
|
|
35
|
-
.live{display:inline-flex;align-items:center;gap:6px;color:var(--ink2)}
|
|
36
|
-
.dot{width:8px;height:8px;border-radius:50%;background:var(--good)}.dot.stale{background:var(--warn)}.dot.off{background:var(--crit)}.dot.paused{background:var(--neutral)}
|
|
37
|
-
@media (prefers-reduced-motion:no-preference){.dot:not(.stale):not(.off):not(.paused){animation:pulse 2s ease-in-out infinite}
|
|
38
|
-
.flash{animation:flash 1.6s ease-out}}
|
|
39
|
-
@keyframes pulse{50%{opacity:.35}}@keyframes flash{from{background:var(--wash)}to{background:transparent}}
|
|
40
|
-
.banner{display:none;margin:0 0 12px;padding:8px 12px;border:1px solid var(--hair2);border-radius:8px;background:var(--wash);color:var(--ink)}
|
|
41
|
-
.banner.show{display:block}.banner b{color:var(--critink)}
|
|
42
|
-
main{transition:opacity .15s}main.loading{opacity:.6}
|
|
43
|
-
.reasons{margin:0 0 12px;color:var(--ink2)}
|
|
44
|
-
.grid{display:grid;gap:12px}
|
|
45
|
-
.tiles{grid-template-columns:repeat(auto-fit,minmax(160px,1fr));margin-bottom:12px}
|
|
46
|
-
.hero{grid-column:1/-1}@media (min-width:900px){.hero{grid-column:span 2}}
|
|
47
|
-
.card{background:var(--card);border:1px solid var(--hair);border-radius:12px;padding:14px 16px;min-width:0}
|
|
48
|
-
.card h2{font-size:13px;font-weight:600;margin:0 0 2px;color:var(--ink)}.card .sub{color:var(--ink2);font-size:12px;margin:0 0 8px}
|
|
49
|
-
.tile .lbl{color:var(--ink2);font-size:12px}.tile .val{font-size:24px;font-weight:600;line-height:1.2;margin-top:2px}
|
|
50
|
-
.tile .note{color:var(--ink2);font-size:12px;margin-top:2px}.tile .val .ic{font-size:16px;margin-right:4px}
|
|
51
|
-
.tile.hero .val{font-size:52px;line-height:1.05}.tile.hero .note{font-size:13px}
|
|
52
|
-
.good{color:var(--goodink)}.warn{color:var(--warnink)}.bad{color:var(--critink)}
|
|
53
|
-
.two{grid-template-columns:1fr}@media (min-width:900px){.two{grid-template-columns:1fr 1fr}}
|
|
54
|
-
.legend{display:flex;flex-wrap:wrap;gap:4px 14px;margin:0 0 6px;color:var(--ink2);font-size:12px}
|
|
55
|
-
.legend span{display:inline-flex;align-items:center;gap:6px}.legend i{display:inline-block;width:10px;height:10px;border-radius:2px}.legend i.ln{height:2px;width:14px;border-radius:0}
|
|
56
|
-
.chart{position:relative}.chart svg{display:block;width:100%;height:auto;overflow:visible}
|
|
57
|
-
.chart text{fill:var(--ink3);font:11px system-ui,sans-serif}
|
|
58
|
-
.tip{position:absolute;pointer-events:none;background:var(--card);border:1px solid var(--hair2);border-radius:8px;padding:8px 10px;font-size:12px;box-shadow:0 4px 18px rgba(0,0,0,.14);z-index:5;min-width:140px;display:none}
|
|
59
|
-
.tip .th{color:var(--ink2);margin-bottom:4px}.tip .row{display:flex;align-items:center;gap:8px;margin-top:2px}
|
|
60
|
-
.tip .row i{display:inline-block;width:12px;height:2px}.tip .row b{font-weight:600;color:var(--ink);min-width:3.2em;text-align:right;font-variant-numeric:tabular-nums}.tip .row span{color:var(--ink2)}
|
|
61
|
-
details.tv{margin-top:6px}details.tv summary{cursor:pointer;color:var(--ink2);font-size:12px}
|
|
62
|
-
.tw{max-height:240px;overflow:auto;margin-top:6px}
|
|
63
|
-
table{border-collapse:collapse;width:100%;font-size:12.5px}
|
|
64
|
-
th,td{padding:5px 6px;text-align:left;border-bottom:1px solid var(--hair);white-space:nowrap}
|
|
65
|
-
td.txt{max-width:190px;overflow:hidden;text-overflow:ellipsis}
|
|
66
|
-
.tail{max-height:420px;overflow:auto}
|
|
67
|
-
th{font-weight:600;color:var(--ink2);font-size:12px;position:sticky;top:0;background:var(--card)}
|
|
68
|
-
th.n,td.n{text-align:right;font-variant-numeric:tabular-nums}th[tabindex]{cursor:pointer}th[aria-sort=ascending]::after{content:" ▲";font-size:9px}th[aria-sort=descending]::after{content:" ▼";font-size:9px}
|
|
69
|
-
.tbl{overflow-x:auto}
|
|
70
|
-
.chip{display:inline-flex;align-items:center;gap:4px;font-weight:600}.chip.ok{color:var(--goodink)}.chip.rej{color:var(--ink2)}.chip.fault{color:var(--critink)}.chip.slow{color:var(--warnink)}
|
|
71
|
-
.split{display:inline-flex;height:6px;width:44px;border-radius:3px;overflow:hidden;gap:2px;vertical-align:middle;background:var(--hair)}.split i{display:block;height:100%}
|
|
72
|
-
.empty{color:var(--ink2);padding:18px 4px}
|
|
73
|
-
h2.sect{font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--ink2);margin:22px 2px 10px}
|
|
74
|
-
ul.logfeed{list-style:none;margin:0;padding:0;font:12.5px/1.7 ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
75
|
-
ul.logfeed li{padding:1px 4px;border-radius:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
76
|
-
ul.logfeed .t{color:var(--ink3)}ul.logfeed .u{font-weight:600}ul.logfeed .d{color:var(--ink3)}
|
|
77
|
-
ul.logfeed .k-joined{color:var(--goodink)}ul.logfeed .k-left{color:var(--ink2)}ul.logfeed .k-failed{color:var(--critink)}ul.logfeed .k-updated{color:var(--ink)}
|
|
78
|
-
footer{color:var(--ink3);font-size:12px;margin:14px 0 4px}footer b{color:var(--ink2);font-weight:600}
|
|
79
|
-
.ln{display:block}.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
|
|
80
|
-
.err{background:var(--wash);border:1px solid var(--hair2);padding:8px 10px;border-radius:8px;color:var(--critink);margin:10px 0}
|
|
81
|
-
form.login{max-width:360px;margin:12vh auto;background:var(--card);border:1px solid var(--hair);border-radius:12px;padding:20px}
|
|
82
|
-
form.login input{width:100%;padding:8px 10px;margin:8px 0 12px;border:1px solid var(--hair2);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit}
|
|
83
|
-
form.login button{border:1px solid var(--ink);background:var(--ink);color:var(--surface);border-radius:8px;padding:8px 14px;font:inherit;cursor:pointer;width:100%}
|
|
84
|
-
`;
|
|
85
|
-
export function loginPage(_nonce, error) {
|
|
86
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Papercliped panel — sign in</title>${FAVICON_LINK}<style>${CSS}</style></head><body>
|
|
87
|
-
<form class="login" method="post" action="/login"><h1>Papercliped panel</h1><p style="color:var(--ink2);margin:6px 0 0">Operator sign-in</p>
|
|
88
|
-
${error ? `<div class="err" role="alert">${esc(error)}</div>` : ""}
|
|
89
|
-
<label for="t" style="display:block;margin-top:12px">Admin token</label><input id="t" name="token" type="password" autocomplete="off" required autofocus><button type="submit">Sign in</button></form></body></html>`;
|
|
90
|
-
}
|
|
91
|
-
export function panelPage(nonce, o) {
|
|
92
|
-
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Papercliped panel — live</title>${FAVICON_LINK}<style>${CSS}</style></head>
|
|
93
|
-
<body data-slow="${Number(o.slowMs) || 1500}"><div class="wrap">
|
|
94
|
-
<header><h1>Papercliped panel<small id="where"></small></h1>
|
|
95
|
-
<div class="ctl"><span id="pill" class="pill idle"><span class="ic">•</span><span>Loading…</span></span>
|
|
96
|
-
<span class="live"><span id="dot" class="dot stale"></span><span id="age">connecting…</span></span>
|
|
97
|
-
<div class="seg" id="win" role="group" aria-label="Time window"></div>
|
|
98
|
-
<button class="plain" id="pause" type="button" aria-pressed="false">Pause</button>
|
|
99
|
-
<form class="signout" method="post" action="/logout"><button type="submit">Sign out</button></form></div></header>
|
|
100
|
-
<div id="banner" class="banner" role="status"></div>
|
|
101
|
-
<main id="main">
|
|
102
|
-
<p id="reasons" class="reasons"></p>
|
|
103
|
-
<section class="grid tiles" id="tiles" aria-label="Key numbers"></section>
|
|
104
|
-
<section class="grid two">
|
|
105
|
-
<div class="card"><h2>Throughput</h2><p class="sub">Tool calls per bucket, split by outcome</p><div id="c-thr"></div></div>
|
|
106
|
-
<div class="card"><h2>Latency</h2><p class="sub">End-to-end time inside the bridge, per bucket</p><div id="c-lat"></div></div>
|
|
107
|
-
<div class="card"><h2>Where the time goes</h2><p class="sub">Average per call: waiting on the user's Paperclip vs. the bridge itself</p><div id="c-split"></div></div>
|
|
108
|
-
<div class="card"><h2>Latency distribution</h2><p class="sub">How many calls finished within each time band (axis shows each band's upper bound, in ms unless marked s)</p><div id="c-hist"></div></div>
|
|
109
|
-
</section>
|
|
110
|
-
<h2 class="sect" id="community-h">Community</h2>
|
|
111
|
-
<section class="grid tiles" id="ctiles" aria-label="Community numbers"></section>
|
|
112
|
-
<section class="grid two">
|
|
113
|
-
<div class="card"><h2>Connections over time</h2><p class="sub">Clients that finished connecting vs. connection attempts that failed</p><div id="c-conn"></div></div>
|
|
114
|
-
<div class="card"><h2>Community log</h2><p class="sub">Newest first. <span id="feed-note"></span></p><div class="tbl tail" id="t-feed"></div></div>
|
|
115
|
-
<div class="card"><h2>Why connections fail</h2><p class="sub">Failed or abandoned sign-ins in this window</p><div class="tbl" id="t-fail"></div></div>
|
|
116
|
-
<div class="card"><h2>Most active users</h2><p class="sub">By tool calls in this window</p><div class="tbl" id="t-users"></div></div>
|
|
117
|
-
</section>
|
|
118
|
-
<h2 class="sect">Performance detail</h2>
|
|
119
|
-
<section class="grid two">
|
|
120
|
-
<div class="card"><h2>Tools</h2><p class="sub">Click a column to sort. "Wait" = time waiting on the user's Paperclip</p><div class="tbl" id="t-tools"></div></div>
|
|
121
|
-
<div class="card"><h2>Tenants — slowest Paperclip instances</h2><p class="sub">Sorted by "Wait p95": time spent waiting on that tenant's Paperclip</p><div class="tbl" id="t-inst"></div></div>
|
|
122
|
-
<div class="card"><h2>Errors by class</h2><p class="sub">Caller mistakes are not system faults</p><div class="tbl" id="t-err"></div></div>
|
|
123
|
-
<div class="card"><h2>OAuth & MCP endpoints</h2><p class="sub">HTTP requests handled by the bridge</p><div class="tbl" id="t-http"></div></div>
|
|
124
|
-
</section>
|
|
125
|
-
<section class="grid two" style="margin-top:12px">
|
|
126
|
-
<div class="card"><h2>Live tail</h2><p class="sub">Newest calls first (✎ = changes something). Arguments and results are never recorded.</p><div class="tbl tail" id="t-live"></div></div>
|
|
127
|
-
<div class="card"><h2>Slowest calls in window</h2><p class="sub">Where to look first</p><div class="tbl" id="t-slow"></div></div>
|
|
128
|
-
</section>
|
|
129
|
-
</main>
|
|
130
|
-
<footer id="foot"></footer></div>
|
|
131
|
-
<script nonce="${nonce}">${String.raw `
|
|
132
|
-
(function () {
|
|
133
|
-
'use strict';
|
|
134
|
-
var SLOW = Number(document.body.getAttribute('data-slow')) || 1500;
|
|
135
|
-
var WINDOWS = ['5m', '15m', '1h', '6h', '24h', '7d'];
|
|
136
|
-
var WIN_MS = { '5m': 300000, '15m': 900000, '1h': 3600000, '6h': 21600000, '24h': 86400000, '7d': 604800000 };
|
|
137
|
-
var state = { win: '15m', paused: false, data: null, lastOk: 0, err: null, events: [], lastId: 0, sort: {}, open: {}, hovering: false, feed: [], lastFeedId: 0, cname: 'cliped' };
|
|
138
|
-
var hash = (location.hash || '').replace('#', '');
|
|
139
|
-
if (WINDOWS.indexOf(hash) >= 0) state.win = hash;
|
|
140
|
-
|
|
141
|
-
function $(id) { return document.getElementById(id); }
|
|
142
|
-
function el(tag, props, kids) {
|
|
143
|
-
var e = document.createElement(tag);
|
|
144
|
-
if (props) for (var k in props) { if (k === 'class') e.className = props[k]; else if (k === 'text') e.textContent = props[k]; else e.setAttribute(k, props[k]); }
|
|
145
|
-
(kids || []).forEach(function (c) { if (c != null) e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
|
|
146
|
-
return e;
|
|
147
|
-
}
|
|
148
|
-
var NS = 'http://www.w3.org/2000/svg';
|
|
149
|
-
function sv(tag, props) { var e = document.createElementNS(NS, tag); for (var k in props) e.setAttribute(k, props[k]); return e; }
|
|
150
|
-
function fmtMs(v) { if (v == null || isNaN(v)) return '—'; if (v >= 1000) return (v / 1000).toFixed(v >= 10000 ? 1 : 2) + ' s'; if (v >= 100) return Math.round(v) + ' ms'; return (Math.round(v * 10) / 10) + ' ms'; }
|
|
151
|
-
function fmtInt(n) { return Math.round(n).toLocaleString(); }
|
|
152
|
-
function fmtPct(v) { return (v >= 10 ? v.toFixed(0) : v.toFixed(1)) + '%'; }
|
|
153
|
-
function pad(n) { return (n < 10 ? '0' : '') + n; }
|
|
154
|
-
function fmtTime(t, sec) { var d = new Date(t); return pad(d.getHours()) + ':' + pad(d.getMinutes()) + (sec ? ':' + pad(d.getSeconds()) : ''); }
|
|
155
|
-
function fmtDay(t) { var d = new Date(t); return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + fmtTime(t); }
|
|
156
|
-
function niceStep(v) { if (v <= 0) return 1; var p = Math.pow(10, Math.floor(Math.log(v) / Math.LN10)), f = v / p; return (f <= 1 ? 1 : f <= 2 ? 2 : f <= 2.5 ? 2.5 : f <= 5 ? 5 : 10) * p; }
|
|
157
|
-
function short(n) { return String(n).replace(/^paperclip_/, ''); }
|
|
158
|
-
var ALIAS = /^[A-Z][a-z]{1,2}[0-9]{2}$/;
|
|
159
|
-
function nameCell(n) { if (n && ALIAS.test(n)) return el('span', { title: 'Anonymous user (shown as an alias)' }, [el('span', { 'aria-hidden': 'true', text: '◌ ' }), n]); return n || '—'; }
|
|
160
|
-
function cls(name) { return 'fill:var(' + name + ')'; }
|
|
161
|
-
|
|
162
|
-
// ───────── status chips (colour never carries meaning alone: icon + label) ─────────
|
|
163
|
-
function chip(kind, label) { return el('span', { class: 'chip ' + kind }, [el('span', { 'aria-hidden': 'true', text: kind === 'ok' ? '✓' : kind === 'fault' ? '✕' : kind === 'slow' ? '▲' : '!' }), label]); }
|
|
164
|
-
var SHORTCLS = { upstream_5xx: 'server error', upstream_unreachable: 'unreachable', upstream_4xx: 'rejected', internal: 'bridge error', invalid_input: 'bad input', insufficient_scope: 'no scope', read_only: 'read-only', rate_limited: 'throttled', unauthorized: 'unauthorized' };
|
|
165
|
-
function outcome(ev) {
|
|
166
|
-
if (ev.ok) return chip('ok', ev.status && ev.kind === 'http' ? String(ev.status) : 'ok');
|
|
167
|
-
var fault = ev.errorClass === 'upstream_5xx' || ev.errorClass === 'upstream_unreachable' || ev.errorClass === 'internal';
|
|
168
|
-
return chip(fault ? 'fault' : 'rej', (ev.status || '') + ' ' + (SHORTCLS[ev.errorClass] || ev.errorClass || 'error'));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ───────── charts ─────────
|
|
172
|
-
function drawChart(host, cfg) {
|
|
173
|
-
host.textContent = '';
|
|
174
|
-
var W = Math.max(280, host.clientWidth || 560), H = 214, m = { l: 48, r: 14, t: 10, b: 24 };
|
|
175
|
-
var iw = W - m.l - m.r, ih = H - m.t - m.b;
|
|
176
|
-
var base, n, slotLabel;
|
|
177
|
-
if (cfg.cats) { n = cfg.cats.length; base = 0; } else {
|
|
178
|
-
base = Math.floor(cfg.from / cfg.bucketMs) * cfg.bucketMs; n = Math.max(1, Math.ceil((cfg.to - base) / cfg.bucketMs));
|
|
179
|
-
}
|
|
180
|
-
var slots = new Array(n);
|
|
181
|
-
if (cfg.cats) { for (var c = 0; c < n; c++) slots[c] = cfg.rows[c]; } else {
|
|
182
|
-
cfg.rows.forEach(function (b) { var i = Math.round((b.t - base) / cfg.bucketMs); if (i >= 0 && i < n) slots[i] = b; });
|
|
183
|
-
}
|
|
184
|
-
var ymax = 0;
|
|
185
|
-
for (var i = 0; i < n; i++) {
|
|
186
|
-
var s = slots[i]; if (!s) continue;
|
|
187
|
-
if (cfg.type === 'bars') { var tot = 0; cfg.series.forEach(function (se) { tot += se.get(s) || 0; }); ymax = Math.max(ymax, tot); }
|
|
188
|
-
else cfg.series.forEach(function (se) { var v = se.get(s); if (v != null) ymax = Math.max(ymax, v); });
|
|
189
|
-
}
|
|
190
|
-
if (cfg.refLine) ymax = Math.max(ymax, cfg.refLine.value * 1.1);
|
|
191
|
-
var step = niceStep(ymax / 4); if (cfg.intTicks && step < 1) step = 1; var nTicks = Math.max(2, Math.ceil(ymax / step - 1e-9)); ymax = step * nTicks;
|
|
192
|
-
var yS = function (v) { return m.t + ih - (v / ymax) * ih; };
|
|
193
|
-
|
|
194
|
-
// legend (≥2 series)
|
|
195
|
-
if (cfg.series.length > 1) {
|
|
196
|
-
var lg = el('div', { class: 'legend' });
|
|
197
|
-
cfg.series.forEach(function (se) { var k = el('i', { style: 'background:var(' + se.color + ')', class: cfg.type === 'lines' ? 'ln' : '' }); lg.appendChild(el('span', null, [k, se.label])); });
|
|
198
|
-
host.appendChild(lg);
|
|
199
|
-
}
|
|
200
|
-
var wrap = el('div', { class: 'chart' });
|
|
201
|
-
var svg = sv('svg', { viewBox: '0 0 ' + W + ' ' + H, role: 'img', 'aria-label': cfg.title });
|
|
202
|
-
// grid + y ticks (hairline, solid)
|
|
203
|
-
for (var t = 0; t <= nTicks; t++) {
|
|
204
|
-
var v = step * t, y = yS(v);
|
|
205
|
-
svg.appendChild(sv('line', { x1: m.l, x2: W - m.r, y1: y, y2: y, style: 'stroke:var(--hair);stroke-width:1' }));
|
|
206
|
-
var tx = sv('text', { x: m.l - 6, y: y + 3.5, 'text-anchor': 'end' }); tx.textContent = cfg.yFmt(v); svg.appendChild(tx);
|
|
207
|
-
}
|
|
208
|
-
// x ticks
|
|
209
|
-
var slotW = iw / n;
|
|
210
|
-
var ticks = cfg.cats ? n : Math.min(6, n);
|
|
211
|
-
for (var k2 = 0; k2 < ticks; k2++) {
|
|
212
|
-
var idx = cfg.cats ? k2 : Math.round((k2 / Math.max(1, ticks - 1)) * (n - 1));
|
|
213
|
-
var lab = cfg.cats ? cfg.cats[idx] : fmtTime(base + idx * cfg.bucketMs, cfg.bucketMs < 60000);
|
|
214
|
-
var xt = sv('text', { x: m.l + idx * slotW + slotW / 2, y: H - 6, 'text-anchor': 'middle' }); xt.textContent = lab; svg.appendChild(xt);
|
|
215
|
-
}
|
|
216
|
-
// reference line (threshold) — only drawn when it is within range
|
|
217
|
-
if (cfg.refLine && cfg.refLine.value <= ymax) {
|
|
218
|
-
var ry = yS(cfg.refLine.value);
|
|
219
|
-
svg.appendChild(sv('line', { x1: m.l, x2: W - m.r, y1: ry, y2: ry, style: 'stroke:var(--warn);stroke-width:1;stroke-dasharray:4 3' }));
|
|
220
|
-
var rt = sv('text', { x: W - m.r, y: ry - 4, 'text-anchor': 'end' }); rt.textContent = cfg.refLine.label; svg.appendChild(rt);
|
|
221
|
-
}
|
|
222
|
-
// marks
|
|
223
|
-
var bw = Math.max(1, Math.min(24, slotW - 2));
|
|
224
|
-
if (cfg.type === 'bars') {
|
|
225
|
-
for (var j = 0; j < n; j++) {
|
|
226
|
-
var sj = slots[j]; if (!sj) continue;
|
|
227
|
-
var x = m.l + j * slotW + (slotW - bw) / 2, acc = 0, drawn = 0;
|
|
228
|
-
cfg.series.forEach(function (se, si) {
|
|
229
|
-
var val = se.get(sj) || 0; if (val <= 0) return;
|
|
230
|
-
var h = Math.max(1, (val / ymax) * ih), top = m.t + ih - acc - h, gap = drawn > 0 && h > 3 ? 2 : 0;
|
|
231
|
-
var isTop = cfg.series.slice(si + 1).every(function (o) { return !(o.get(sj) > 0); });
|
|
232
|
-
var r = isTop && bw >= 6 ? Math.min(4, h) : 0, hh = h - gap;
|
|
233
|
-
var d = r ? 'M' + x + ',' + (top + hh) + 'V' + (top + r) + 'Q' + x + ',' + top + ' ' + (x + r) + ',' + top + 'H' + (x + bw - r) + 'Q' + (x + bw) + ',' + top + ' ' + (x + bw) + ',' + (top + r) + 'V' + (top + hh) + 'Z'
|
|
234
|
-
: 'M' + x + ',' + top + 'H' + (x + bw) + 'V' + (top + hh) + 'H' + x + 'Z';
|
|
235
|
-
svg.appendChild(sv('path', { d: d, style: 'fill:var(' + se.color + ')' }));
|
|
236
|
-
acc += h; drawn++;
|
|
237
|
-
});
|
|
238
|
-
if (cfg.valueLabels && sj) { var vt = sv('text', { x: x + bw / 2, y: m.t + ih - acc - 4, 'text-anchor': 'middle' }); vt.textContent = cfg.yFmt(cfg.series[0].get(sj) || 0); if ((cfg.series[0].get(sj) || 0) > 0) svg.appendChild(vt); }
|
|
239
|
-
}
|
|
240
|
-
} else {
|
|
241
|
-
cfg.series.forEach(function (se) {
|
|
242
|
-
var d = '', pen = false, last = null;
|
|
243
|
-
for (var q = 0; q < n; q++) {
|
|
244
|
-
var sq = slots[q], vq = sq ? se.get(sq) : null;
|
|
245
|
-
if (vq == null) { pen = false; continue; }
|
|
246
|
-
var px = m.l + q * slotW + slotW / 2, py = yS(vq);
|
|
247
|
-
d += (pen ? 'L' : 'M') + px.toFixed(1) + ',' + py.toFixed(1); pen = true; last = [px, py];
|
|
248
|
-
}
|
|
249
|
-
if (d) svg.appendChild(sv('path', { d: d, fill: 'none', style: 'stroke:var(' + se.color + ');stroke-width:2;stroke-linejoin:round;stroke-linecap:round' }));
|
|
250
|
-
if (last) svg.appendChild(sv('circle', { cx: last[0], cy: last[1], r: 4, style: 'fill:var(' + se.color + ');stroke:var(--card);stroke-width:2' }));
|
|
251
|
-
});
|
|
252
|
-
}
|
|
253
|
-
// hover layer: crosshair + one tooltip listing every series
|
|
254
|
-
var cross = sv('line', { y1: m.t, y2: m.t + ih, style: 'stroke:var(--hair2);stroke-width:1;display:none' }); svg.appendChild(cross);
|
|
255
|
-
var hov = cfg.series.map(function (se) { var c2 = sv('circle', { r: 4, style: 'fill:var(' + se.color + ');stroke:var(--card);stroke-width:2;display:none' }); svg.appendChild(c2); return c2; });
|
|
256
|
-
var hit = sv('rect', { x: m.l, y: m.t, width: iw, height: ih, fill: 'transparent', tabindex: '0', class: 'hit', 'aria-label': cfg.title + ' — use arrow keys to read values' });
|
|
257
|
-
svg.appendChild(hit);
|
|
258
|
-
var tip = el('div', { class: 'tip', role: 'status' });
|
|
259
|
-
var cur = -1;
|
|
260
|
-
function show(ix) {
|
|
261
|
-
ix = Math.max(0, Math.min(n - 1, ix)); cur = ix;
|
|
262
|
-
var sl = slots[ix], cx = m.l + ix * slotW + slotW / 2;
|
|
263
|
-
cross.setAttribute('x1', cx); cross.setAttribute('x2', cx); cross.style.display = '';
|
|
264
|
-
tip.textContent = '';
|
|
265
|
-
tip.appendChild(el('div', { class: 'th', text: cfg.cats ? (cfg.catsLong || cfg.cats)[ix] : fmtDay(base + ix * cfg.bucketMs) }));
|
|
266
|
-
if (!sl) { tip.appendChild(el('div', { class: 'row', text: 'no data' })); hov.forEach(function (h) { h.style.display = 'none'; }); }
|
|
267
|
-
cfg.series.forEach(function (se, si) {
|
|
268
|
-
var vv = sl ? se.get(sl) : null;
|
|
269
|
-
if (cfg.type === 'lines') { if (vv == null) hov[si].style.display = 'none'; else { hov[si].setAttribute('cx', cx); hov[si].setAttribute('cy', yS(vv)); hov[si].style.display = ''; } }
|
|
270
|
-
if (sl) tip.appendChild(el('div', { class: 'row' }, [el('i', { style: 'background:var(' + se.color + ')' }), el('b', { text: vv == null ? '—' : cfg.valFmt(vv) }), el('span', { text: se.label })]));
|
|
271
|
-
});
|
|
272
|
-
tip.style.display = 'block';
|
|
273
|
-
var tw = tip.offsetWidth || 160, left = (cx / W) * wrap.clientWidth + 12;
|
|
274
|
-
if (left + tw > wrap.clientWidth) left = (cx / W) * wrap.clientWidth - tw - 12;
|
|
275
|
-
tip.style.left = Math.max(0, left) + 'px'; tip.style.top = '8px';
|
|
276
|
-
}
|
|
277
|
-
function hide() { cross.style.display = 'none'; hov.forEach(function (h) { h.style.display = 'none'; }); tip.style.display = 'none'; }
|
|
278
|
-
hit.addEventListener('pointermove', function (e) { var r = svg.getBoundingClientRect(); show(Math.floor(((e.clientX - r.left) * (W / r.width) - m.l) / slotW)); });
|
|
279
|
-
hit.addEventListener('pointerenter', function () { state.hovering = true; });
|
|
280
|
-
hit.addEventListener('pointerleave', function () { state.hovering = false; hide(); }); hit.addEventListener('blur', hide);
|
|
281
|
-
hit.addEventListener('focus', function () { show(cur < 0 ? n - 1 : cur); });
|
|
282
|
-
hit.addEventListener('keydown', function (e) { if (e.key === 'ArrowLeft') { show((cur < 0 ? n : cur) - 1); e.preventDefault(); } else if (e.key === 'ArrowRight') { show((cur < 0 ? -1 : cur) + 1); e.preventDefault(); } else if (e.key === 'Escape') hide(); });
|
|
283
|
-
wrap.appendChild(svg); wrap.appendChild(tip); host.appendChild(wrap);
|
|
284
|
-
// table view (every value reachable without hovering)
|
|
285
|
-
var det = el('details', { class: 'tv' }); if (state.open[host.id]) det.open = true; det.addEventListener('toggle', function () { state.open[host.id] = det.open; });
|
|
286
|
-
var tw2 = el('div', { class: 'tw' }), tb = el('table'), hd = el('tr');
|
|
287
|
-
hd.appendChild(el('th', { text: cfg.cats ? 'Band' : 'Time' }));
|
|
288
|
-
cfg.series.forEach(function (se) { hd.appendChild(el('th', { class: 'n', text: se.label })); });
|
|
289
|
-
tb.appendChild(el('thead', null, [hd]));
|
|
290
|
-
var body = el('tbody');
|
|
291
|
-
for (var z = 0; z < n; z++) {
|
|
292
|
-
var sz = slots[z]; if (!sz && !cfg.cats) continue;
|
|
293
|
-
var tr = el('tr'); tr.appendChild(el('td', { text: cfg.cats ? (cfg.catsLong || cfg.cats)[z] : fmtDay(base + z * cfg.bucketMs) }));
|
|
294
|
-
cfg.series.forEach(function (se) { var vz = sz ? se.get(sz) : null; tr.appendChild(el('td', { class: 'n', text: vz == null ? '—' : cfg.valFmt(vz) })); });
|
|
295
|
-
body.appendChild(tr);
|
|
296
|
-
}
|
|
297
|
-
tb.appendChild(body); tw2.appendChild(tb); det.appendChild(el('summary', { text: 'View as table' })); det.appendChild(tw2); host.appendChild(det);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// ───────── sortable tables ─────────
|
|
301
|
-
function table(host, id, cols, rows, defSort, empty) {
|
|
302
|
-
host.textContent = '';
|
|
303
|
-
if (!rows.length) { host.appendChild(el('div', { class: 'empty', text: empty || 'Nothing in this window.' })); return; }
|
|
304
|
-
var st = state.sort[id] || (state.sort[id] = { key: defSort.key, dir: defSort.dir || -1 });
|
|
305
|
-
var sorted = rows.slice().sort(function (a, b) { var x = a[st.key], y = b[st.key]; if (x == null) x = -Infinity; if (y == null) y = -Infinity; return (x < y ? -1 : x > y ? 1 : 0) * st.dir; });
|
|
306
|
-
var tb = el('table'), hr = el('tr');
|
|
307
|
-
cols.forEach(function (c) {
|
|
308
|
-
var th = el('th', { class: c.num ? 'n' : '', text: c.label, tabindex: '0', scope: 'col' });
|
|
309
|
-
if (st.key === c.key) th.setAttribute('aria-sort', st.dir > 0 ? 'ascending' : 'descending');
|
|
310
|
-
var go = function () { if (st.key === c.key) st.dir = -st.dir; else { st.key = c.key; st.dir = c.num ? -1 : 1; } table(host, id, cols, rows, defSort, empty); };
|
|
311
|
-
th.addEventListener('click', go); th.addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
|
|
312
|
-
hr.appendChild(th);
|
|
313
|
-
});
|
|
314
|
-
tb.appendChild(el('thead', null, [hr]));
|
|
315
|
-
var body = el('tbody');
|
|
316
|
-
sorted.forEach(function (r) { var tr = el('tr'); if (r._id && r._new) tr.className = 'flash'; cols.forEach(function (c) { var td = el('td', { class: c.num ? 'n' : 'txt' }); var out = c.cell ? c.cell(r) : String(r[c.key] == null ? '—' : r[c.key]); if (!c.num && typeof out === 'string') td.title = out; td.appendChild(typeof out === 'string' ? document.createTextNode(out) : out); tr.appendChild(td); }); body.appendChild(tr); });
|
|
317
|
-
tb.appendChild(body); host.appendChild(tb);
|
|
318
|
-
}
|
|
319
|
-
function msCell(k) { return function (r) { var v = r[k]; if (v == null) return '—'; return v >= SLOW ? el('span', { class: 'chip slow', title: 'Slower than ' + fmtMs(SLOW) }, [el('span', { 'aria-hidden': 'true', text: '▲' }), fmtMs(v)]) : fmtMs(v); }; }
|
|
320
|
-
|
|
321
|
-
// ───────── tiles ─────────
|
|
322
|
-
function tile(label, value, note, extra) {
|
|
323
|
-
var t = el('div', { class: 'card tile' + (extra && extra.hero ? ' hero' : '') });
|
|
324
|
-
t.appendChild(el('div', { class: 'lbl', text: label }));
|
|
325
|
-
var v = el('div', { class: 'val' + (extra && extra.cls ? ' ' + extra.cls : '') });
|
|
326
|
-
if (extra && extra.icon) v.appendChild(el('span', { class: 'ic', 'aria-hidden': 'true', text: extra.icon }));
|
|
327
|
-
v.appendChild(document.createTextNode(value)); t.appendChild(v);
|
|
328
|
-
if (note) t.appendChild(el('div', { class: 'note', text: note }));
|
|
329
|
-
if (extra && extra.spark) t.appendChild(extra.spark);
|
|
330
|
-
return t;
|
|
331
|
-
}
|
|
332
|
-
function spark(values) {
|
|
333
|
-
var vs = values.slice(-12); var ok = vs.filter(function (x) { return x != null; }); if (ok.length < 2) return document.createTextNode('');
|
|
334
|
-
var W = 120, H = 26, mx = Math.max.apply(null, ok) || 1, d = '';
|
|
335
|
-
vs.forEach(function (v, i) { if (v == null) return; d += (d ? 'L' : 'M') + (i / (vs.length - 1) * (W - 6) + 3).toFixed(1) + ',' + (H - 4 - (v / mx) * (H - 8)).toFixed(1); });
|
|
336
|
-
var s = sv('svg', { viewBox: '0 0 ' + W + ' ' + H, width: W, height: H, 'aria-hidden': 'true' });
|
|
337
|
-
s.appendChild(sv('path', { d: d, fill: 'none', style: 'stroke:var(--neutral);stroke-width:2;stroke-linejoin:round' }));
|
|
338
|
-
var lastI = vs.length - 1; if (vs[lastI] != null) s.appendChild(sv('circle', { cx: (W - 3), cy: (H - 4 - (vs[lastI] / mx) * (H - 8)), r: 3.5, style: 'fill:var(--s1);stroke:var(--card);stroke-width:2' }));
|
|
339
|
-
return s;
|
|
340
|
-
}
|
|
341
|
-
function delta(cur, prev, higherIsWorse, unit) {
|
|
342
|
-
if (!prev || prev <= 0) return '';
|
|
343
|
-
var ch = (cur - prev) / prev * 100; if (Math.abs(ch) < 1) return 'flat vs previous ' + state.win;
|
|
344
|
-
return (ch > 0 ? '▲ ' : '▼ ') + Math.abs(ch).toFixed(0) + '% ' + (unit === 'lat' ? (ch > 0 ? 'slower' : 'faster') : (ch > 0 ? 'more' : 'fewer')) + ' vs previous ' + state.win;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// ───────── render ─────────
|
|
348
|
-
var PLAIN = { invalid_input: 'Caller mistake (bad input)', insufficient_scope: 'Caller lacked the scope', read_only: 'Blocked by read-only mode', upstream_4xx: 'Paperclip rejected the request (4xx)', upstream_5xx: 'Paperclip server error (5xx)', upstream_unreachable: 'Paperclip unreachable / timed out', rate_limited: 'Rate limited', unauthorized: 'Missing or invalid token', internal: 'Bridge internal error' };
|
|
349
|
-
var FAULTS = { upstream_5xx: 1, upstream_unreachable: 1, internal: 1 };
|
|
350
|
-
|
|
351
|
-
function render() {
|
|
352
|
-
var d = state.data; if (!d) return;
|
|
353
|
-
var t = d.totals, p = d.previous, sys = d.system, mins = (d.to - d.from) / 60000;
|
|
354
|
-
// header
|
|
355
|
-
var pill = $('pill'), h = d.health, icons = { healthy: '✓', degraded: '▲', critical: '✕', idle: '•' }, names = { healthy: 'Healthy', degraded: 'Degraded', critical: 'Critical', idle: 'Idle' };
|
|
356
|
-
pill.className = 'pill ' + h.state; pill.textContent = ''; pill.appendChild(el('span', { class: 'ic', 'aria-hidden': 'true', text: icons[h.state] })); pill.appendChild(el('span', { text: names[h.state] }));
|
|
357
|
-
$('reasons').textContent = h.reasons.length ? (h.state === 'idle' ? h.reasons[0] : 'Why: ' + h.reasons.join(' · ')) : '';
|
|
358
|
-
var nodes = sys.nodes || [], lead = nodes[0] || null;
|
|
359
|
-
$('where').textContent = sys.mode + ' · ' + (nodes.length ? nodes.length + ' bridge node' + (nodes.length > 1 ? 's' : '') : 'no bridge reporting');
|
|
360
|
-
// tiles
|
|
361
|
-
var tiles = $('tiles'); tiles.textContent = '';
|
|
362
|
-
var faultRate = t.count ? t.faults / t.count * 100 : 0, rejRate = t.count ? (t.errors - t.faults) / t.count * 100 : 0;
|
|
363
|
-
tiles.appendChild(tile('p95 latency', t.count ? fmtMs(t.p95) : '—', t.count ? 'p50 ' + fmtMs(t.p50) + ' · p99 ' + fmtMs(t.p99) + (p.count ? ' · ' + delta(t.p95, p.p95, true, 'lat') : '') : 'No calls in this window', { hero: true, cls: t.p95 >= SLOW * 3 ? 'bad' : t.p95 >= SLOW ? 'warn' : '', icon: t.p95 >= SLOW * 3 ? '✕' : t.p95 >= SLOW ? '▲' : '' }));
|
|
364
|
-
tiles.appendChild(tile('Calls', fmtInt(t.count), (t.count / Math.max(mins, 1 / 60)).toFixed(t.count / mins >= 10 ? 0 : 1) + ' per minute' + (p.count ? ' · ' + delta(t.count, p.count, false, 'n') : '')));
|
|
365
|
-
tiles.appendChild(tile('System faults', t.count ? fmtPct(faultRate) : '—', fmtInt(t.faults) + ' of ' + fmtInt(t.count) + ' calls', { cls: faultRate >= 10 ? 'bad' : faultRate >= 2 ? 'warn' : t.count ? 'good' : '', icon: faultRate >= 10 ? '✕' : faultRate >= 2 ? '▲' : t.count ? '✓' : '' }));
|
|
366
|
-
tiles.appendChild(tile('Rejected', t.count ? fmtPct(rejRate) : '—', 'Caller mistakes: bad input, missing scope, read-only'));
|
|
367
|
-
tiles.appendChild(tile('Paperclip wait p95', t.count ? fmtMs(t.upstreamP95) : '—', 'Time spent waiting on users\' Paperclips'));
|
|
368
|
-
tiles.appendChild(tile('Bridge overhead p95', t.count ? fmtMs(t.bridgeP95) : '—', 'Everything that isn\'t waiting on Paperclip', { cls: t.bridgeP95 >= SLOW ? 'warn' : '', icon: t.bridgeP95 >= SLOW ? '▲' : '' }));
|
|
369
|
-
var silent = sys.bridgeSilentMs, down = silent == null || silent > sys.bridgeSilentAfterMs;
|
|
370
|
-
tiles.appendChild(tile('Bridge', silent == null ? 'never reported' : down ? 'not reporting' : 'reporting', silent == null ? 'No bridge has written a health sample yet' : down ? 'Last heard ' + Math.round(silent / 1000) + ' s ago — asleep, down, or cannot reach the database' : nodes.length + ' node' + (nodes.length > 1 ? 's' : '') + ' · last sample ' + Math.round(silent / 1000) + ' s ago' + (lead ? ' · v' + lead.version : ''), { cls: down ? 'bad' : 'good', icon: down ? '✕' : '✓' }));
|
|
371
|
-
var ping = lead ? lead.dbPingMs : null;
|
|
372
|
-
tiles.appendChild(tile('Bridge → database', ping == null ? (lead ? 'failing' : '—') : fmtMs(ping), 'Round-trip measured by the bridge' + (sys.panelPingMs != null ? ' · panel ' + fmtMs(sys.panelPingMs) : ''), { cls: lead && ping == null ? 'bad' : ping != null && ping >= 250 ? 'warn' : '', icon: lead && ping == null ? '✕' : ping != null && ping >= 250 ? '▲' : '', spark: spark(sys.recentPing || []) }));
|
|
373
|
-
var lag = lead ? lead.loopLagP99Ms : null;
|
|
374
|
-
tiles.appendChild(tile('Event-loop lag p99', lag == null ? '—' : fmtMs(lag), lead ? 'Bridge process · memory ' + Math.round(lead.rssMb) + ' MB' : 'No data', { cls: lag != null && lag >= 100 ? 'warn' : '', icon: lag != null && lag >= 100 ? '▲' : '' }));
|
|
375
|
-
tiles.appendChild(tile('Live connections', sys.liveGrants == null ? '—' : fmtInt(sys.liveGrants), 'Active OAuth grants'));
|
|
376
|
-
// charts
|
|
377
|
-
var common = { from: d.from, to: d.to, bucketMs: d.bucketMs };
|
|
378
|
-
if (state.hovering) { /* keep the chart under the pointer stable while it is being read */ }
|
|
379
|
-
else if (!t.count) { ['c-thr', 'c-lat', 'c-split', 'c-hist'].forEach(function (id) { var e = $(id); e.textContent = ''; e.appendChild(el('div', { class: 'empty', text: 'No tool calls in this window yet.' })); }); }
|
|
380
|
-
else {
|
|
381
|
-
drawChart($('c-thr'), Object.assign({ title: 'Throughput', type: 'bars', rows: d.series, intTicks: true, yFmt: fmtInt, valFmt: fmtInt, series: [
|
|
382
|
-
{ label: 'Succeeded', color: '--s1', get: function (b) { return b.count - b.errors; } },
|
|
383
|
-
{ label: 'Rejected (caller)', color: '--neutral', get: function (b) { return b.errors - b.faults; } },
|
|
384
|
-
{ label: 'System faults ✕', color: '--crit', get: function (b) { return b.faults; } }] }, common));
|
|
385
|
-
drawChart($('c-lat'), Object.assign({ title: 'Latency percentiles', type: 'lines', rows: d.series, yFmt: fmtMs, valFmt: fmtMs, refLine: { value: SLOW, label: 'slow ' + fmtMs(SLOW) }, series: [
|
|
386
|
-
{ label: 'p50', color: '--r300', get: function (b) { return b.p50; } },
|
|
387
|
-
{ label: 'p95', color: '--r450', get: function (b) { return b.p95; } },
|
|
388
|
-
{ label: 'p99', color: '--r600', get: function (b) { return b.p99; } }] }, common));
|
|
389
|
-
drawChart($('c-split'), Object.assign({ title: 'Latency split', type: 'bars', rows: d.series, yFmt: fmtMs, valFmt: fmtMs, series: [
|
|
390
|
-
{ label: 'Waiting on Paperclip', color: '--s1', get: function (b) { return b.avgUpstream; } },
|
|
391
|
-
{ label: 'Bridge', color: '--s2', get: function (b) { return b.avgBridge; } }] }, common));
|
|
392
|
-
var cats = ['≤25', '≤50', '≤100', '≤200', '≤400', '≤800', '≤1.6s', '≤3.2s', '>3.2s'], catsLong = ['under 25 ms', '25–50 ms', '50–100 ms', '100–200 ms', '200–400 ms', '400–800 ms', '0.8–1.6 s', '1.6–3.2 s', '3.2 s or more'];
|
|
393
|
-
drawChart($('c-hist'), { title: 'Latency distribution', type: 'bars', cats: cats, catsLong: catsLong, rows: d.histogram.map(function (n) { return { n: n }; }), valueLabels: true, intTicks: true, yFmt: fmtInt, valFmt: fmtInt, series: [{ label: 'Calls', color: '--s1', get: function (b) { return b.n; } }] });
|
|
394
|
-
}
|
|
395
|
-
renderCommunity();
|
|
396
|
-
// tables
|
|
397
|
-
var num = function (k, f) { return function (r) { return r[k] == null ? '—' : f(r[k]); }; };
|
|
398
|
-
table($('t-tools'), 'tools', [
|
|
399
|
-
{ key: 'key', label: 'Tool', cell: function (r) { return short(r.key); } }, { key: 'count', label: 'Calls', num: 1, cell: num('count', fmtInt) },
|
|
400
|
-
{ key: 'faultPct', label: 'Faults', num: 1, cell: function (r) { return r.faults ? chip('fault', fmtPct(r.faultPct)) : '0%'; } },
|
|
401
|
-
{ key: 'rejPct', label: 'Rejected', num: 1, cell: function (r) { return fmtPct(r.rejPct); } },
|
|
402
|
-
{ key: 'p50', label: 'p50', num: 1, cell: msCell('p50') }, { key: 'p95', label: 'p95', num: 1, cell: msCell('p95') }, { key: 'upstreamP95', label: 'Wait p95', num: 1, cell: msCell('upstreamP95') }],
|
|
403
|
-
d.byTool.map(function (r) { return Object.assign({ faultPct: r.faults / r.count * 100, rejPct: (r.errors - r.faults) / r.count * 100 }, r); }), { key: 'count' }, 'No tool calls yet.');
|
|
404
|
-
table($('t-inst'), 'inst', [
|
|
405
|
-
{ key: 'key', label: 'Tenant host' }, { key: 'count', label: 'Calls', num: 1, cell: num('count', fmtInt) },
|
|
406
|
-
{ key: 'faults', label: 'Faults', num: 1, cell: function (r) { return r.faults ? chip('fault', fmtInt(r.faults)) : '0'; } },
|
|
407
|
-
{ key: 'p95', label: 'p95', num: 1, cell: msCell('p95') }, { key: 'upstreamP95', label: 'Wait p95', num: 1, cell: msCell('upstreamP95') }],
|
|
408
|
-
d.byInstance, { key: 'upstreamP95' }, 'No tenant traffic yet (single-instance mode has no tenant host).');
|
|
409
|
-
var totalErr = d.byError.reduce(function (a, r) { return a + r.count; }, 0);
|
|
410
|
-
table($('t-err'), 'err', [
|
|
411
|
-
{ key: 'key', label: 'Class', cell: function (r) { return el('span', null, [FAULTS[r.key] ? chip('fault', PLAIN[r.key] || r.key) : chip('rej', PLAIN[r.key] || r.key)]); } },
|
|
412
|
-
{ key: 'count', label: 'Count', num: 1, cell: num('count', fmtInt) }, { key: 'share', label: 'Share', num: 1, cell: function (r) { return fmtPct(r.share); } }],
|
|
413
|
-
d.byError.map(function (r) { return Object.assign({ share: totalErr ? r.count / totalErr * 100 : 0 }, r); }), { key: 'count' }, 'No errors in this window.');
|
|
414
|
-
table($('t-http'), 'http', [
|
|
415
|
-
{ key: 'key', label: 'Endpoint' }, { key: 'count', label: 'Requests', num: 1, cell: num('count', fmtInt) },
|
|
416
|
-
{ key: 'errors', label: 'Errors', num: 1, cell: function (r) { return r.errors ? chip('rej', fmtInt(r.errors)) : '0'; } },
|
|
417
|
-
{ key: 'p50', label: 'p50', num: 1, cell: msCell('p50') }, { key: 'p95', label: 'p95', num: 1, cell: msCell('p95') }],
|
|
418
|
-
d.http.byRoute, { key: 'count' }, 'No OAuth or MCP requests yet.');
|
|
419
|
-
var slowRows = d.slowest.map(function (e) { return Object.assign({ at2: fmtTime(e.at, true) }, e); });
|
|
420
|
-
table($('t-slow'), 'slow', [
|
|
421
|
-
{ key: 'at', label: 'Time', cell: function (r) { return fmtTime(r.at, true); } }, { key: 'name', label: 'Tool', cell: function (r) { return short(r.name); } },
|
|
422
|
-
{ key: 'totalMs', label: 'Total', num: 1, cell: msCell('totalMs') }, { key: 'upstreamMs', label: 'Wait', num: 1, cell: function (r) { return r.upstreamMs == null ? '—' : fmtMs(r.upstreamMs); } },
|
|
423
|
-
{ key: 'ok', label: 'Result', cell: outcome }, { key: 'instance', label: 'Tenant', cell: function (r) { return r.instance || '—'; } }], slowRows, { key: 'totalMs' }, 'No calls in this window.');
|
|
424
|
-
// footer
|
|
425
|
-
$('foot').textContent = '';
|
|
426
|
-
$('foot').appendChild(document.createTextNode('Times are in your local time zone. '));
|
|
427
|
-
$('foot').appendChild(el('b', { text: sys.persistent ? "History is read from the bridge's Postgres database." : 'This panel is showing in-memory demo data.' }));
|
|
428
|
-
$('foot').appendChild(document.createTextNode(' This panel is a separate program with a read-only database role: it cannot see Paperclip credentials, secret keys or the real usernames of anonymous users (they appear as aliases like Ann02).'));
|
|
429
|
-
renderStatus();
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
var FAILWHY = { 'connect_failed:denied': 'Cancelled by the user', 'connect_failed:expired': 'Paperclip approval expired or was cancelled', 'connect_failed:unreachable': 'Paperclip unreachable or not a Paperclip', 'connect_failed:invalid_instance': 'Invalid or blocked Paperclip address', 'connect_failed:too_many_attempts': 'Too many addresses tried', 'login_failed:bad_credentials': 'Wrong username or secret key', 'login_failed:rate_limited': 'Locked out by the rate limit' };
|
|
434
|
-
function renderCommunity() {
|
|
435
|
-
var d = state.data; if (!d || !d.community) return;
|
|
436
|
-
var c = d.community, k = c.counts || {}, started = k.started || 0, completed = k.completed || 0;
|
|
437
|
-
state.cname = c.name || 'cliped';
|
|
438
|
-
var ct = $('ctiles'); ct.textContent = '';
|
|
439
|
-
var rate = c.successRate, rateTxt = rate == null ? '—' : fmtPct(rate * 100), poor = started >= 5 && rate != null;
|
|
440
|
-
ct.appendChild(tile('Users', fmtInt(c.totalUsers), 'All accounts ever created'));
|
|
441
|
-
ct.appendChild(tile('Active users', fmtInt(c.activeUsers), 'Made tool calls in this window'));
|
|
442
|
-
ct.appendChild(tile('New users', fmtInt(k.joined || 0), fmtInt(k.login || 0) + ' sign-ins · ' + fmtInt(k.updated || 0) + ' updates'));
|
|
443
|
-
ct.appendChild(tile('Connection success', rateTxt, started ? fmtInt(completed) + ' of ' + fmtInt(started) + ' sign-ins completed' : 'No sign-ins started yet', { cls: !poor ? '' : rate >= 0.8 ? 'good' : rate >= 0.5 ? 'warn' : 'bad', icon: !poor ? '' : rate >= 0.8 ? '✓' : rate >= 0.5 ? '▲' : '✕' }));
|
|
444
|
-
var failN = 0; Object.keys(c.failures || {}).forEach(function (x) { failN += c.failures[x]; });
|
|
445
|
-
ct.appendChild(tile('Failed sign-ins', fmtInt(failN), 'Wrong keys, lockouts, unreachable Paperclips, cancels', { cls: failN > 0 && started >= 5 && failN / Math.max(started, 1) > 0.3 ? 'warn' : '', icon: failN > 0 && started >= 5 && failN / Math.max(started, 1) > 0.3 ? '▲' : '' }));
|
|
446
|
-
ct.appendChild(tile('Left', fmtInt(k.left || 0), 'Disconnected, expired for inactivity, or deleted'));
|
|
447
|
-
if (!state.hovering) {
|
|
448
|
-
var any = (c.series || []).length;
|
|
449
|
-
if (!any) { var e = $('c-conn'); e.textContent = ''; e.appendChild(el('div', { class: 'empty', text: 'No sign-ins in this window yet.' })); }
|
|
450
|
-
else drawChart($('c-conn'), { title: 'Connections', type: 'bars', rows: c.series, from: d.from, to: d.to, bucketMs: d.bucketMs, intTicks: true, yFmt: fmtInt, valFmt: fmtInt, series: [
|
|
451
|
-
{ label: 'Connected', color: '--s1', get: function (b) { return b.completed; } },
|
|
452
|
-
{ label: 'Failed ✕', color: '--crit', get: function (b) { return b.failed; } }] });
|
|
453
|
-
}
|
|
454
|
-
var fr = Object.keys(c.failures || {}).map(function (key) { return { key: key, why: FAILWHY[key] || key, count: c.failures[key] }; });
|
|
455
|
-
table($('t-fail'), 'fail', [{ key: 'why', label: 'Reason' }, { key: 'count', label: 'Count', num: 1, cell: function (r) { return fmtInt(r.count); } }], fr, { key: 'count' }, 'No failed sign-ins in this window.');
|
|
456
|
-
table($('t-users'), 'users', [
|
|
457
|
-
{ key: 'key', label: 'User', cell: function (r) { return nameCell(r.key); } }, { key: 'count', label: 'Calls', num: 1, cell: function (r) { return fmtInt(r.count); } },
|
|
458
|
-
{ key: 'faults', label: 'Faults', num: 1, cell: function (r) { return r.faults ? chip('fault', fmtInt(r.faults)) : '0'; } },
|
|
459
|
-
{ key: 'p95', label: 'p95', num: 1, cell: msCell('p95') }], c.byUser || [], { key: 'count' }, 'No user activity in this window yet.');
|
|
460
|
-
}
|
|
461
|
-
var VERB = { joined: 'joined', left: 'left', updated: 'updated', login: 'signed in', login_failed: 'failed to sign in', connect_failed: 'could not connect' };
|
|
462
|
-
function feedLine(e) {
|
|
463
|
-
var li = el('li', { class: e._new ? 'flash' : '' });
|
|
464
|
-
var cls = e.kind === 'joined' ? 'k-joined' : e.kind === 'left' ? 'k-left' : (e.kind === 'login_failed' || e.kind === 'connect_failed') ? 'k-failed' : 'k-updated';
|
|
465
|
-
var icon = e.kind === 'joined' ? '+' : e.kind === 'left' ? '−' : e.kind === 'updated' ? '↻' : e.kind === 'login' ? '→' : '✕';
|
|
466
|
-
var verb = (VERB[e.kind] || e.kind) + ((e.kind === 'joined' || e.kind === 'left' || e.kind === 'updated') ? ' ' + state.cname : '');
|
|
467
|
-
li.appendChild(el('span', { class: 't', text: fmtTime(e.at, true) + ' ' }));
|
|
468
|
-
li.appendChild(el('span', { class: cls, 'aria-hidden': 'true', text: icon + ' ' }));
|
|
469
|
-
var u = el('span', { class: 'u' }); u.appendChild(e.username ? nameCell(e.username) : document.createTextNode('(visitor)')); li.appendChild(u);
|
|
470
|
-
li.appendChild(document.createTextNode(' - '));
|
|
471
|
-
li.appendChild(el('span', { class: cls, text: verb }));
|
|
472
|
-
if (e.detail && e.kind !== 'joined') li.appendChild(el('span', { class: 'd', text: ' (' + String(e.detail).replace(/_/g, ' ') + ')' }));
|
|
473
|
-
return li;
|
|
474
|
-
}
|
|
475
|
-
function renderFeed() {
|
|
476
|
-
var host = $('t-feed'); host.textContent = '';
|
|
477
|
-
if (!state.feed.length) { host.appendChild(el('div', { class: 'empty', text: 'Waiting for the first user event…' })); return; }
|
|
478
|
-
var ul = el('ul', { class: 'logfeed' }); state.feed.forEach(function (e) { ul.appendChild(feedLine(e)); }); host.appendChild(ul);
|
|
479
|
-
state.feed.forEach(function (e) { e._new = false; });
|
|
480
|
-
}
|
|
481
|
-
async function loadFeed() {
|
|
482
|
-
clearTimeout(feedTimer);
|
|
483
|
-
try {
|
|
484
|
-
var r = await fetch('/api/community/events?limit=40&after=' + state.lastFeedId, { credentials: 'same-origin', cache: 'no-store' });
|
|
485
|
-
if (r.status === 401) { location.reload(); return; }
|
|
486
|
-
if (r.ok) {
|
|
487
|
-
var j = await r.json(); state.cname = j.name || state.cname;
|
|
488
|
-
if (j.events.length) { var first = state.lastFeedId === 0; j.events.forEach(function (e) { e._new = !first; }); state.feed = j.events.concat(state.feed).slice(0, 40); state.lastFeedId = Math.max(state.lastFeedId, j.events[0].id || 0); renderFeed(); }
|
|
489
|
-
else if (!state.feed.length) renderFeed();
|
|
490
|
-
}
|
|
491
|
-
} catch (e) { /* the summary loop reports connectivity */ }
|
|
492
|
-
finally { if (!state.paused && !document.hidden) feedTimer = setTimeout(loadFeed, 2500); }
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
function renderLive() {
|
|
496
|
-
var rows = state.events.map(function (e) { return Object.assign({ _id: e.id, _new: e._new }, e); });
|
|
497
|
-
table($('t-live'), 'live', [
|
|
498
|
-
{ key: 'at', label: 'Time', cell: function (r) { return fmtTime(r.at, true); } },
|
|
499
|
-
{ key: 'name', label: 'Tool', cell: function (r) { return short(r.name) + (r.mutation ? ' ✎' : ''); } },
|
|
500
|
-
{ key: 'ok', label: 'Result', cell: outcome },
|
|
501
|
-
{ key: 'totalMs', label: 'Took', num: 1, cell: function (r) { var tot = r.totalMs || 0, up = r.upstreamMs || 0; var w = el('span', null, [msCell('totalMs')(r)]); if (r.kind === 'tool' && tot > 0) { var sp = el('span', { class: 'split', title: 'Paperclip ' + fmtMs(up) + ' · bridge ' + fmtMs(tot - up), style: 'margin-left:8px' }); sp.appendChild(el('i', { style: 'width:' + Math.round(up / tot * 100) + '%;background:var(--s1)' })); sp.appendChild(el('i', { style: 'width:' + Math.round((tot - up) / tot * 100) + '%;background:var(--s2)' })); w.appendChild(sp); } return w; } },
|
|
502
|
-
{ key: 'instance', label: 'Tenant', cell: function (r) { return r.instance || '—'; } }],
|
|
503
|
-
rows, { key: 'at' }, 'Waiting for the first call…');
|
|
504
|
-
state.events.forEach(function (e) { e._new = false; });
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function renderStatus() {
|
|
508
|
-
var age = state.lastOk ? Math.round((Date.now() - state.lastOk) / 1000) : null, dot = $('dot'), bn = $('banner');
|
|
509
|
-
if (state.paused) { dot.className = 'dot paused'; $('age').textContent = 'paused'; }
|
|
510
|
-
else if (state.err) { dot.className = 'dot off'; $('age').textContent = 'reconnecting…'; }
|
|
511
|
-
else if (age == null) { dot.className = 'dot stale'; $('age').textContent = 'connecting…'; }
|
|
512
|
-
else { dot.className = 'dot' + (age > 20 ? ' stale' : ''); $('age').textContent = age < 3 ? 'live' : 'updated ' + age + 's ago'; }
|
|
513
|
-
if (state.err) { bn.className = 'banner show'; bn.textContent = ''; bn.appendChild(el('b', { text: 'Cannot reach the bridge: ' })); bn.appendChild(document.createTextNode(state.err + ' — showing the last data. Retrying.')); } else bn.className = 'banner';
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// ───────── data loop ─────────
|
|
517
|
-
var timer, evTimer, feedTimer, main = $('main');
|
|
518
|
-
async function load() {
|
|
519
|
-
clearTimeout(timer); main.classList.add('loading');
|
|
520
|
-
try {
|
|
521
|
-
var r = await fetch('/api/summary?window=' + state.win, { credentials: 'same-origin', cache: 'no-store' });
|
|
522
|
-
if (r.status === 401) { location.reload(); return; }
|
|
523
|
-
if (!r.ok) { var j = await r.json().catch(function () { return {}; }); throw new Error(j.error || 'HTTP ' + r.status); }
|
|
524
|
-
state.data = await r.json(); state.lastOk = Date.now(); state.err = null; render();
|
|
525
|
-
} catch (e) { state.err = e.message; renderStatus(); }
|
|
526
|
-
finally { main.classList.remove('loading'); if (!state.paused && !document.hidden) timer = setTimeout(load, WIN_MS[state.win] <= 3600000 ? 3000 : 10000); }
|
|
527
|
-
}
|
|
528
|
-
async function loadEvents() {
|
|
529
|
-
clearTimeout(evTimer);
|
|
530
|
-
try {
|
|
531
|
-
var r = await fetch('/api/events?limit=40&after=' + state.lastId, { credentials: 'same-origin', cache: 'no-store' });
|
|
532
|
-
if (r.status === 401) { location.reload(); return; }
|
|
533
|
-
if (r.ok) {
|
|
534
|
-
var j = await r.json();
|
|
535
|
-
if (j.events.length) { var first = state.lastId === 0; j.events.forEach(function (e) { e._new = !first; }); state.events = j.events.concat(state.events).slice(0, 40); state.lastId = Math.max(state.lastId, j.events[0].id || 0); renderLive(); }
|
|
536
|
-
else if (!state.events.length) renderLive();
|
|
537
|
-
}
|
|
538
|
-
} catch (e) { /* the summary loop reports connectivity */ }
|
|
539
|
-
finally { if (!state.paused && !document.hidden) evTimer = setTimeout(loadEvents, 2000); }
|
|
540
|
-
}
|
|
541
|
-
function kick() { clearTimeout(timer); clearTimeout(evTimer); clearTimeout(feedTimer); load(); loadEvents(); loadFeed(); }
|
|
542
|
-
|
|
543
|
-
// controls
|
|
544
|
-
var seg = $('win');
|
|
545
|
-
WINDOWS.forEach(function (w) { var b = el('button', { type: 'button', 'aria-pressed': String(w === state.win), text: w }); b.addEventListener('click', function () { state.win = w; location.hash = w; Array.prototype.forEach.call(seg.children, function (c) { c.setAttribute('aria-pressed', String(c.textContent === w)); }); state.sort = {}; kick(); }); seg.appendChild(b); });
|
|
546
|
-
$('pause').addEventListener('click', function () { state.paused = !state.paused; this.setAttribute('aria-pressed', String(state.paused)); this.textContent = state.paused ? 'Resume' : 'Pause'; if (state.paused) { clearTimeout(timer); clearTimeout(evTimer); clearTimeout(feedTimer); renderStatus(); } else kick(); });
|
|
547
|
-
document.addEventListener('visibilitychange', function () { if (document.hidden) { clearTimeout(timer); clearTimeout(evTimer); clearTimeout(feedTimer); } else if (!state.paused) kick(); });
|
|
548
|
-
setInterval(renderStatus, 1000);
|
|
549
|
-
var rz; window.addEventListener('resize', function () { cancelAnimationFrame(rz); rz = requestAnimationFrame(function () { if (state.data) render(); }); });
|
|
550
|
-
kick();
|
|
551
|
-
})();
|
|
552
|
-
`}</script></body></html>`;
|
|
553
|
-
}
|