@openhoo/hoopilot 1.2.0 → 1.3.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/README.md +8 -0
- package/dist/cli.js +884 -2
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +903 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +18 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +902 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2355,6 +2355,7 @@ var MetricsRegistry = class {
|
|
|
2355
2355
|
return {
|
|
2356
2356
|
githubRateLimit,
|
|
2357
2357
|
inFlight: this.#inFlight,
|
|
2358
|
+
latency: this.#latencySnapshot(),
|
|
2358
2359
|
requests: { byRoute, byStatus, total: requestsTotal },
|
|
2359
2360
|
startedAt: new Date(this.#startedAtMs).toISOString(),
|
|
2360
2361
|
tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
|
|
@@ -2362,6 +2363,37 @@ var MetricsRegistry = class {
|
|
|
2362
2363
|
uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
|
|
2363
2364
|
};
|
|
2364
2365
|
}
|
|
2366
|
+
// Summarize the duration histogram into a JSON latency view: per-route count and
|
|
2367
|
+
// exact average, plus overall average and estimated p50/p95. The percentiles come
|
|
2368
|
+
// from the buckets aggregated across routes, so they share /metrics' resolution.
|
|
2369
|
+
#latencySnapshot() {
|
|
2370
|
+
const byRoute = {};
|
|
2371
|
+
const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
|
|
2372
|
+
let totalCount = 0;
|
|
2373
|
+
let totalSum = 0;
|
|
2374
|
+
for (const [route, entry] of this.#durations) {
|
|
2375
|
+
byRoute[route] = {
|
|
2376
|
+
avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
|
|
2377
|
+
count: entry.count
|
|
2378
|
+
};
|
|
2379
|
+
totalCount += entry.count;
|
|
2380
|
+
totalSum += entry.sum;
|
|
2381
|
+
for (let i = 0; i < aggregateBuckets.length; i += 1) {
|
|
2382
|
+
aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
return {
|
|
2386
|
+
avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
|
|
2387
|
+
byRoute,
|
|
2388
|
+
count: totalCount,
|
|
2389
|
+
p50Ms: round2(
|
|
2390
|
+
quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
|
|
2391
|
+
),
|
|
2392
|
+
p95Ms: round2(
|
|
2393
|
+
quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
|
|
2394
|
+
)
|
|
2395
|
+
};
|
|
2396
|
+
}
|
|
2365
2397
|
/** Render the Prometheus text exposition format (version 0.0.4). */
|
|
2366
2398
|
renderPrometheus(now = Date.now) {
|
|
2367
2399
|
const lines = [];
|
|
@@ -2719,6 +2751,26 @@ function modelText(value) {
|
|
|
2719
2751
|
function nonNegative(value) {
|
|
2720
2752
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
2721
2753
|
}
|
|
2754
|
+
function round2(value) {
|
|
2755
|
+
return Math.round(value * 100) / 100;
|
|
2756
|
+
}
|
|
2757
|
+
function quantileFromBuckets(bucketCounts, bounds, count, q) {
|
|
2758
|
+
if (count <= 0) {
|
|
2759
|
+
return 0;
|
|
2760
|
+
}
|
|
2761
|
+
const rank = q * count;
|
|
2762
|
+
let cumulative = 0;
|
|
2763
|
+
for (let i = 0; i < bounds.length; i += 1) {
|
|
2764
|
+
const inBucket = bucketCounts[i] ?? 0;
|
|
2765
|
+
if (inBucket > 0 && cumulative + inBucket >= rank) {
|
|
2766
|
+
const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
|
|
2767
|
+
const upper = bounds[i] ?? lower;
|
|
2768
|
+
return lower + (upper - lower) * ((rank - cumulative) / inBucket);
|
|
2769
|
+
}
|
|
2770
|
+
cumulative += inBucket;
|
|
2771
|
+
}
|
|
2772
|
+
return bounds[bounds.length - 1] ?? 0;
|
|
2773
|
+
}
|
|
2722
2774
|
function cleanLabel(value) {
|
|
2723
2775
|
let result = "";
|
|
2724
2776
|
for (const char of value) {
|
|
@@ -2768,9 +2820,834 @@ function formatNumber(value) {
|
|
|
2768
2820
|
return Number.isInteger(value) ? value.toString() : String(value);
|
|
2769
2821
|
}
|
|
2770
2822
|
|
|
2823
|
+
// src/dashboard.ts
|
|
2824
|
+
var DASHBOARD_HTML = `<!doctype html>
|
|
2825
|
+
<html lang="en">
|
|
2826
|
+
<head>
|
|
2827
|
+
<meta charset="utf-8" />
|
|
2828
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2829
|
+
<meta name="color-scheme" content="dark light" />
|
|
2830
|
+
<title>hoopilot · dashboard</title>
|
|
2831
|
+
<style>
|
|
2832
|
+
:root {
|
|
2833
|
+
--bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
|
|
2834
|
+
--border:#262d38; --border-strong:#37404d;
|
|
2835
|
+
--text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
|
|
2836
|
+
--accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
|
|
2837
|
+
--amber:#f5b042;
|
|
2838
|
+
--ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
|
|
2839
|
+
--spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
|
|
2840
|
+
--grid-line:rgba(255,255,255,.05);
|
|
2841
|
+
--flash:color-mix(in srgb, var(--accent) 22%, transparent);
|
|
2842
|
+
--flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
|
|
2843
|
+
--flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
|
|
2844
|
+
--c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
|
|
2845
|
+
--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
|
2846
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
|
|
2847
|
+
}
|
|
2848
|
+
@media (prefers-color-scheme: light) {
|
|
2849
|
+
:root:not([data-theme="dark"]) {
|
|
2850
|
+
--bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
|
|
2851
|
+
--border:#d0d7de; --border-strong:#b6bec8;
|
|
2852
|
+
--text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
|
|
2853
|
+
--accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
|
|
2854
|
+
--amber:#b5730a;
|
|
2855
|
+
--ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
|
|
2856
|
+
--spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
|
|
2857
|
+
--grid-line:rgba(0,0,0,.06);
|
|
2858
|
+
--flash:color-mix(in srgb, var(--accent) 16%, transparent);
|
|
2859
|
+
--flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
|
|
2860
|
+
--flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
|
|
2861
|
+
--c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
[data-theme="light"] {
|
|
2865
|
+
--bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
|
|
2866
|
+
--border:#d0d7de; --border-strong:#b6bec8;
|
|
2867
|
+
--text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
|
|
2868
|
+
--accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
|
|
2869
|
+
--amber:#b5730a;
|
|
2870
|
+
--ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
|
|
2871
|
+
--spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
|
|
2872
|
+
--grid-line:rgba(0,0,0,.06);
|
|
2873
|
+
--flash:color-mix(in srgb, var(--accent) 16%, transparent);
|
|
2874
|
+
--flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
|
|
2875
|
+
--flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
|
|
2876
|
+
--c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
|
|
2877
|
+
}
|
|
2878
|
+
* { box-sizing: border-box; }
|
|
2879
|
+
html, body { margin:0; padding:0; }
|
|
2880
|
+
body {
|
|
2881
|
+
background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
|
|
2882
|
+
font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
|
|
2883
|
+
}
|
|
2884
|
+
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
|
|
2885
|
+
.num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
|
|
2886
|
+
.shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
|
|
2887
|
+
@media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
|
|
2888
|
+
@media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
|
|
2889
|
+
|
|
2890
|
+
/* header */
|
|
2891
|
+
header.bar {
|
|
2892
|
+
position: sticky; top: 0; z-index: 20; background: var(--bg-1);
|
|
2893
|
+
border-bottom: 1px solid var(--border); height: 48px;
|
|
2894
|
+
}
|
|
2895
|
+
.bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
|
|
2896
|
+
@media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
|
|
2897
|
+
.wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
|
|
2898
|
+
.caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
|
|
2899
|
+
.chip { font-family: var(--mono); font-size:11px; padding:2px 7px; border-radius:10px; background:var(--bg-3); color:var(--text-1); white-space:nowrap; }
|
|
2900
|
+
.chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
|
|
2901
|
+
.chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
|
|
2902
|
+
.chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
|
|
2903
|
+
.spacer { flex:1; }
|
|
2904
|
+
.pill { display:inline-flex; align-items:center; gap:6px; font-size:11px; font-family:var(--mono); padding:3px 9px; border-radius:11px; background:var(--bg-3); color:var(--text-1); }
|
|
2905
|
+
.dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
|
|
2906
|
+
.pill.live .dot { background:var(--ok); }
|
|
2907
|
+
.pill.paused .dot { background:var(--text-2); }
|
|
2908
|
+
.pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
|
|
2909
|
+
.pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
|
|
2910
|
+
.heartbeat { animation: hb .5s ease-out; }
|
|
2911
|
+
.updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
|
|
2912
|
+
.updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
|
|
2913
|
+
.seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
|
|
2914
|
+
.seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
|
|
2915
|
+
.seg button + button { border-left:1px solid var(--border); }
|
|
2916
|
+
.seg button.active { background:var(--accent); color:var(--text-inv); }
|
|
2917
|
+
.iconbtn { background:transparent; border:1px solid var(--border); border-radius:6px; color:var(--text-1); cursor:pointer; font-size:13px; line-height:1; padding:4px 7px; min-width:30px; }
|
|
2918
|
+
.iconbtn:hover { background:var(--bg-3); }
|
|
2919
|
+
button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
|
|
2920
|
+
#scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
|
|
2921
|
+
#scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
|
|
2922
|
+
background:linear-gradient(90deg, transparent, var(--accent), transparent);
|
|
2923
|
+
animation: scan var(--scan-ms, 4000ms) linear infinite; }
|
|
2924
|
+
header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
|
|
2925
|
+
|
|
2926
|
+
/* disconnect banner */
|
|
2927
|
+
#banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
|
|
2928
|
+
background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
|
|
2929
|
+
#banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
|
|
2930
|
+
#banner.show { display:block; }
|
|
2931
|
+
|
|
2932
|
+
/* hero strip */
|
|
2933
|
+
.hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
|
|
2934
|
+
.vital { padding:6px 18px; }
|
|
2935
|
+
.vital + .vital { border-left:1px solid var(--border); }
|
|
2936
|
+
.vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
|
|
2937
|
+
.vital .vnum { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:clamp(2rem,5vw,3.25rem); line-height:1.02; letter-spacing:-.02em; color:var(--text-0); }
|
|
2938
|
+
.vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
|
|
2939
|
+
.vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
|
|
2940
|
+
.vital.active { }
|
|
2941
|
+
.vital.active .eyebrow { color:var(--accent); }
|
|
2942
|
+
@media (max-width:1079px){ .hero{ grid-template-columns:repeat(2,1fr); } .vital:nth-child(3){ border-left:0; } .vital:nth-child(n+3){ border-top:1px solid var(--border); padding-top:12px; } }
|
|
2943
|
+
@media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
|
|
2944
|
+
|
|
2945
|
+
/* grid + panels */
|
|
2946
|
+
.grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
|
|
2947
|
+
.panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
|
|
2948
|
+
.panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
|
|
2949
|
+
font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
|
|
2950
|
+
.span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
|
|
2951
|
+
.span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
|
|
2952
|
+
@media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
|
|
2953
|
+
.span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
|
|
2954
|
+
@media (max-width:680px){ .grid{ grid-template-columns:1fr; }
|
|
2955
|
+
.span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
|
|
2956
|
+
|
|
2957
|
+
.headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
|
|
2958
|
+
.cap { font-size:11px; color:var(--text-2); }
|
|
2959
|
+
.stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
|
|
2960
|
+
.stack-bar i { display:block; height:100%; }
|
|
2961
|
+
.stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
|
|
2962
|
+
|
|
2963
|
+
table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
|
|
2964
|
+
.scrollx { overflow-x:auto; }
|
|
2965
|
+
table.tbl th { font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); text-align:right; padding:4px 6px; border-bottom:1px solid var(--border); white-space:nowrap; }
|
|
2966
|
+
table.tbl th.l { text-align:left; }
|
|
2967
|
+
table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
|
|
2968
|
+
table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
|
|
2969
|
+
table.tbl tr:hover td { background:var(--bg-2); }
|
|
2970
|
+
table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
|
|
2971
|
+
.minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
|
|
2972
|
+
.ghost td { color:var(--text-2); text-align:center; }
|
|
2973
|
+
.reasoning { color:var(--info); } .cached { color:var(--cache); }
|
|
2974
|
+
|
|
2975
|
+
.legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
|
|
2976
|
+
.legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
|
|
2977
|
+
.legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
|
|
2978
|
+
|
|
2979
|
+
.lat-trio { display:flex; gap:18px; align-items:baseline; }
|
|
2980
|
+
.lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
|
|
2981
|
+
.lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
|
|
2982
|
+
.lat-p95 { color:var(--info); }
|
|
2983
|
+
.lat-track { position:relative; height:22px; margin-top:10px; }
|
|
2984
|
+
.lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
|
|
2985
|
+
.lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
|
|
2986
|
+
.lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
|
|
2987
|
+
.lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
|
|
2988
|
+
details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
|
|
2989
|
+
|
|
2990
|
+
.qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
|
|
2991
|
+
.qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
|
|
2992
|
+
.qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
|
|
2993
|
+
.qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
|
|
2994
|
+
.qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
|
|
2995
|
+
.inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
|
|
2996
|
+
.emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
|
|
2997
|
+
.emptybox .keyglyph { font-size:20px; color:var(--text-1); }
|
|
2998
|
+
.emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
|
|
2999
|
+
.emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
|
|
3000
|
+
.prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
|
|
3001
|
+
|
|
3002
|
+
.upblocks { display:flex; gap:18px; }
|
|
3003
|
+
.upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
|
|
3004
|
+
.upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
|
|
3005
|
+
.upblk.err.hot { color:var(--danger); }
|
|
3006
|
+
.rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
|
|
3007
|
+
#up-spark, #thru-svg { display:block; width:100%; }
|
|
3008
|
+
#up-spark { height:30px; margin-top:8px; }
|
|
3009
|
+
#thru-svg { height:88px; margin-top:6px; }
|
|
3010
|
+
.flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
|
|
3011
|
+
|
|
3012
|
+
footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
|
|
3013
|
+
font-family:var(--mono); font-size:11px; color:var(--text-2); }
|
|
3014
|
+
footer.foot .end { margin-left:auto; }
|
|
3015
|
+
@media (max-width:680px){ footer.foot .end{ margin-left:0; } }
|
|
3016
|
+
|
|
3017
|
+
.skel { color:var(--text-dim); }
|
|
3018
|
+
.flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
|
|
3019
|
+
|
|
3020
|
+
/* auth takeover */
|
|
3021
|
+
#auth { display:none; }
|
|
3022
|
+
#auth.show { display:flex; justify-content:center; padding:64px 16px; }
|
|
3023
|
+
.authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
|
|
3024
|
+
.authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
|
|
3025
|
+
.authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
|
|
3026
|
+
.authcard .row { display:flex; gap:8px; }
|
|
3027
|
+
.authcard input { flex:1; background:var(--bg-0); border:1px solid var(--border); border-radius:5px; color:var(--text-0); font-family:var(--mono); font-size:13px; padding:8px 10px; }
|
|
3028
|
+
.authcard input.bad { border-color:var(--danger); }
|
|
3029
|
+
.authcard button { background:var(--accent); color:var(--text-inv); border:0; border-radius:5px; font-family:var(--mono); font-size:12px; padding:0 14px; cursor:pointer; }
|
|
3030
|
+
.authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
|
|
3031
|
+
.authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
|
|
3032
|
+
.dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
|
|
3033
|
+
|
|
3034
|
+
@keyframes blink { 50% { opacity:0; } }
|
|
3035
|
+
@keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
|
|
3036
|
+
@keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
|
|
3037
|
+
@keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
|
|
3038
|
+
@keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
|
|
3039
|
+
@keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
|
|
3040
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3041
|
+
.caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
|
|
3042
|
+
.heartbeat { animation:none; }
|
|
3043
|
+
.flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
|
|
3044
|
+
}
|
|
3045
|
+
</style>
|
|
3046
|
+
</head>
|
|
3047
|
+
<body>
|
|
3048
|
+
<header class="bar" id="bar">
|
|
3049
|
+
<div class="bar-in">
|
|
3050
|
+
<span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
|
|
3051
|
+
<span class="chip" id="version-chip">v···</span>
|
|
3052
|
+
<span class="chip plan-offline" id="plan-chip">— offline</span>
|
|
3053
|
+
<span class="spacer"></span>
|
|
3054
|
+
<span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
|
|
3055
|
+
<span class="updated" id="updated"></span>
|
|
3056
|
+
<span class="seg" id="seg" role="group" aria-label="Refresh interval">
|
|
3057
|
+
<button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
|
|
3058
|
+
</span>
|
|
3059
|
+
<button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">❚❚</button>
|
|
3060
|
+
<button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
|
|
3061
|
+
</div>
|
|
3062
|
+
<div id="scanbar" aria-hidden="true"></div>
|
|
3063
|
+
</header>
|
|
3064
|
+
|
|
3065
|
+
<div class="shell">
|
|
3066
|
+
<div id="banner" role="status" aria-live="polite"></div>
|
|
3067
|
+
|
|
3068
|
+
<section id="content">
|
|
3069
|
+
<section class="hero" aria-label="Vitals">
|
|
3070
|
+
<div class="vital" id="v-req"><div class="eyebrow">Req / s</div><div class="vnum skel" id="req-num">···</div><div class="vsub" id="req-sub"></div><svg class="vspark" id="req-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--ok)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--ok)" style="display:none"/></svg></div>
|
|
3071
|
+
<div class="vital" id="v-tok"><div class="eyebrow">Tokens / s</div><div class="vnum skel" id="tok-num">···</div><div class="vsub" id="tok-sub"></div><svg class="vspark" id="tok-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--accent)" style="display:none"/></svg></div>
|
|
3072
|
+
<div class="vital" id="v-inflight"><div class="eyebrow">In‑flight</div><div class="vnum skel" id="inflight-num">···</div><div class="vsub" id="inflight-sub"></div><svg class="vspark" id="inflight-spark" viewBox="0 0 200 24" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--accent-2)" stroke-width="1.5" vector-effect="non-scaling-stroke"/><circle r="1.6" fill="var(--accent-2)" style="display:none"/></svg></div>
|
|
3073
|
+
<div class="vital" id="v-uptime"><div class="eyebrow">Uptime</div><div class="vnum skel" id="uptime-num">···</div><div class="vsub" id="uptime-sub"></div></div>
|
|
3074
|
+
</section>
|
|
3075
|
+
|
|
3076
|
+
<section class="grid">
|
|
3077
|
+
<div class="panel span5"><span class="ptitle">┤ Proxy · requests ┠</span>
|
|
3078
|
+
<div class="headline"><span id="req-total" class="skel">···</span> <span class="cap">requests</span></div>
|
|
3079
|
+
<div class="stack-bar empty" id="route-sharebar"></div>
|
|
3080
|
+
<div class="stack-bar empty" id="status-healthbar"></div>
|
|
3081
|
+
<div class="scrollx"><table class="tbl"><thead><tr><th class="l">Route</th><th>Count</th><th>%</th><th style="width:60px"> </th></tr></thead><tbody id="routes-body"><tr class="ghost"><td colspan="4">loading…</td></tr></tbody></table></div>
|
|
3082
|
+
</div>
|
|
3083
|
+
|
|
3084
|
+
<div class="panel span3"><span class="ptitle">┤ Status ┠</span>
|
|
3085
|
+
<div class="headline"><span id="error-rate" class="skel">···</span> <span class="cap">err rate</span></div>
|
|
3086
|
+
<div class="stack-bar empty" id="status-bar"></div>
|
|
3087
|
+
<div class="legend" id="status-legend"></div>
|
|
3088
|
+
</div>
|
|
3089
|
+
|
|
3090
|
+
<div class="panel span4"><span class="ptitle">┤ Latency · ms ┠</span>
|
|
3091
|
+
<div class="lat-trio">
|
|
3092
|
+
<div class="b"><small>p50</small><span id="lat-p50" class="skel">·</span></div>
|
|
3093
|
+
<div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">·</span></div>
|
|
3094
|
+
<div class="b"><small>avg</small><span id="lat-avg" class="skel">·</span></div>
|
|
3095
|
+
<div class="b"><small>obs</small><span id="lat-count" class="skel">·</span></div>
|
|
3096
|
+
</div>
|
|
3097
|
+
<div class="lat-track" id="lat-track"><div class="line"></div></div>
|
|
3098
|
+
<details class="routes"><summary>by route</summary><div class="scrollx"><table class="tbl"><thead><tr><th class="l">Route</th><th>avg ms</th><th>count</th></tr></thead><tbody id="lat-routes"></tbody></table></div></details>
|
|
3099
|
+
</div>
|
|
3100
|
+
|
|
3101
|
+
<div class="panel span7"><span class="ptitle">┤ Tokens · by model ┠</span>
|
|
3102
|
+
<div class="headline"><span id="tok-total" class="skel">···</span> <span class="cap">tokens · <span id="tok-cache">cache ·%</span></span></div>
|
|
3103
|
+
<div class="stack-bar empty" id="tok-mixbar"></div>
|
|
3104
|
+
<div class="legend" id="tok-legend"></div>
|
|
3105
|
+
<div class="scrollx" style="margin-top:8px"><table class="tbl"><thead><tr><th class="l">Model</th><th>prompt</th><th>compl</th><th>reason</th><th>cached</th><th>total</th><th>reqs</th></tr></thead><tbody id="tok-body"><tr class="ghost"><td colspan="7">no token usage yet</td></tr></tbody></table></div>
|
|
3106
|
+
</div>
|
|
3107
|
+
|
|
3108
|
+
<div class="panel span5"><span class="ptitle">┤ Copilot · quota ┠</span>
|
|
3109
|
+
<div id="copilot-body"><div class="emptybox skel">loading…</div></div>
|
|
3110
|
+
</div>
|
|
3111
|
+
|
|
3112
|
+
<div class="panel span4"><span class="ptitle">┤ Upstream · copilot edge ┠</span>
|
|
3113
|
+
<div class="upblocks">
|
|
3114
|
+
<div class="upblk"><div class="v" id="up-total">·</div><div class="k">calls</div></div>
|
|
3115
|
+
<div class="upblk err" id="up-errblk"><div class="v" id="up-errors">·</div><div class="k">errors</div></div>
|
|
3116
|
+
<div class="upblk"><div class="v rate" id="up-rate">·</div><div class="k">err rate</div></div>
|
|
3117
|
+
</div>
|
|
3118
|
+
<svg id="up-spark" viewBox="0 0 320 30" preserveAspectRatio="none" aria-hidden="true"><path class="area" fill="var(--spark-fill)" stroke="none"/><path class="line" fill="none" stroke="var(--danger)" stroke-width="1.5" vector-effect="non-scaling-stroke"/></svg>
|
|
3119
|
+
<div class="flag" id="up-flag"></div>
|
|
3120
|
+
</div>
|
|
3121
|
+
|
|
3122
|
+
<div class="panel span8"><span class="ptitle">┤ Throughput ┠</span>
|
|
3123
|
+
<div class="cap"><span style="color:var(--accent)">■</span> tokens/s <span id="thru-tok" class="num"></span> <span style="color:var(--accent-2)">■</span> req/s <span id="thru-req" class="num"></span> <span class="end" id="thru-peak" style="float:right"></span></div>
|
|
3124
|
+
<svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
|
|
3125
|
+
<defs><linearGradient id="thrugrad" x1="0" y1="0" x2="0" y2="1"><stop offset="0%" stop-color="var(--accent)" stop-opacity="0.28"/><stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/></linearGradient></defs>
|
|
3126
|
+
<line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
|
|
3127
|
+
<line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
|
|
3128
|
+
<line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
|
|
3129
|
+
<path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
|
|
3130
|
+
<path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
|
|
3131
|
+
<path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
|
|
3132
|
+
</svg>
|
|
3133
|
+
</div>
|
|
3134
|
+
</section>
|
|
3135
|
+
</section>
|
|
3136
|
+
|
|
3137
|
+
<section id="auth" aria-live="polite">
|
|
3138
|
+
<div class="authcard">
|
|
3139
|
+
<span class="clear" id="auth-clear" style="display:none">clear key</span>
|
|
3140
|
+
<h3>┤ Auth required ┠</h3>
|
|
3141
|
+
<p>This hoopilot proxy requires an API key. It is stored locally in your browser and sent as <span class="mono">x-api-key</span>.</p>
|
|
3142
|
+
<div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
|
|
3143
|
+
<div class="err" id="auth-err"></div>
|
|
3144
|
+
</div>
|
|
3145
|
+
</section>
|
|
3146
|
+
|
|
3147
|
+
<footer class="foot">
|
|
3148
|
+
<span id="foot-started">started ·</span>
|
|
3149
|
+
<span id="foot-uptime">uptime ·</span>
|
|
3150
|
+
<span id="foot-total">· req</span>
|
|
3151
|
+
<span id="foot-tokens">· tokens</span>
|
|
3152
|
+
<span id="foot-upstream">upstream ·</span>
|
|
3153
|
+
<span class="end" id="foot-cadence"></span>
|
|
3154
|
+
</footer>
|
|
3155
|
+
</div>
|
|
3156
|
+
|
|
3157
|
+
<script>
|
|
3158
|
+
(function(){
|
|
3159
|
+
"use strict";
|
|
3160
|
+
var byId = function(id){ return document.getElementById(id); };
|
|
3161
|
+
var CAP = 60;
|
|
3162
|
+
|
|
3163
|
+
// ---- persistent state ----
|
|
3164
|
+
var LS = window.localStorage;
|
|
3165
|
+
var apiKey = "";
|
|
3166
|
+
try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
|
|
3167
|
+
var theme = "auto";
|
|
3168
|
+
try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
|
|
3169
|
+
var intervalMs = 4000;
|
|
3170
|
+
try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
|
|
3171
|
+
|
|
3172
|
+
// ---- runtime state ----
|
|
3173
|
+
var paused = false;
|
|
3174
|
+
var timer = null;
|
|
3175
|
+
var inflightFetch = null;
|
|
3176
|
+
var lastSuccessAt = 0;
|
|
3177
|
+
var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
|
|
3178
|
+
var lastRender = {}; // for change-flash
|
|
3179
|
+
var backoffMs = 0;
|
|
3180
|
+
var lastUptime = null; // seconds; ticked locally between polls
|
|
3181
|
+
var hist = { req:[], tok:[], inflight:[], up:[] };
|
|
3182
|
+
|
|
3183
|
+
// ---- formatting helpers ----
|
|
3184
|
+
function humanInt(n){
|
|
3185
|
+
if (n === null || n === undefined || !isFinite(n)) return "0";
|
|
3186
|
+
var a = Math.abs(n);
|
|
3187
|
+
if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
|
|
3188
|
+
if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
|
|
3189
|
+
return String(Math.round(n));
|
|
3190
|
+
}
|
|
3191
|
+
function rate(n){
|
|
3192
|
+
if (n === null || n === undefined || !isFinite(n)) return "0";
|
|
3193
|
+
if (n >= 100) return String(Math.round(n));
|
|
3194
|
+
if (n >= 10) return n.toFixed(1);
|
|
3195
|
+
return n.toFixed(2);
|
|
3196
|
+
}
|
|
3197
|
+
function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
|
|
3198
|
+
function fmtMs(n){ if (n === null || n === undefined || !isFinite(n) || n <= 0) return "0"; if (n >= 1000) return (n/1000).toFixed(2) + "s"; if (n >= 100) return String(Math.round(n)); return Math.round(n*10)/10 + ""; }
|
|
3199
|
+
function pad2(n){ return (n < 10 ? "0" : "") + n; }
|
|
3200
|
+
function fmtUptime(sec){
|
|
3201
|
+
sec = Math.max(0, Math.floor(sec));
|
|
3202
|
+
var d = Math.floor(sec/86400); sec -= d*86400;
|
|
3203
|
+
var h = Math.floor(sec/3600); sec -= h*3600;
|
|
3204
|
+
var m = Math.floor(sec/60); var s = sec - m*60;
|
|
3205
|
+
if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
|
|
3206
|
+
if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
|
|
3207
|
+
return m + ":" + pad2(s);
|
|
3208
|
+
}
|
|
3209
|
+
function titleize(key){
|
|
3210
|
+
var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
|
|
3211
|
+
if (map[key]) return map[key];
|
|
3212
|
+
return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
|
|
3213
|
+
}
|
|
3214
|
+
function relTime(iso){
|
|
3215
|
+
var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
|
|
3216
|
+
var s = Math.max(0, Math.round((Date.now() - t)/1000));
|
|
3217
|
+
return fmtUptime(s) + " ago";
|
|
3218
|
+
}
|
|
3219
|
+
function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
|
|
3220
|
+
function mk(tag, cls, txt){ var e = document.createElement(tag); if (cls) e.className = cls; if (txt !== undefined && txt !== null) e.textContent = txt; return e; }
|
|
3221
|
+
|
|
3222
|
+
// Set numeric text and flash on discrete change.
|
|
3223
|
+
function setNum(id, value, kind){
|
|
3224
|
+
var el = byId(id); if (!el) return;
|
|
3225
|
+
el.classList.remove("skel");
|
|
3226
|
+
var s = String(value);
|
|
3227
|
+
if (el.textContent !== s){
|
|
3228
|
+
el.textContent = s;
|
|
3229
|
+
var prev = lastRender[id];
|
|
3230
|
+
if (prev !== undefined){
|
|
3231
|
+
var cls = "flash";
|
|
3232
|
+
if (kind === "delta" && typeof value === "number" && typeof prev === "number"){
|
|
3233
|
+
cls = value > prev ? "flash-up" : (value < prev ? "flash-down" : null);
|
|
3234
|
+
}
|
|
3235
|
+
if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
|
|
3236
|
+
}
|
|
3237
|
+
lastRender[id] = value;
|
|
3238
|
+
}
|
|
3239
|
+
}
|
|
3240
|
+
function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
|
|
3241
|
+
|
|
3242
|
+
// ---- sparkline rendering ----
|
|
3243
|
+
function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
|
|
3244
|
+
function buildSpark(values, w, h){
|
|
3245
|
+
var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
|
|
3246
|
+
if (pts.length < 2) return null;
|
|
3247
|
+
var min = Infinity, max = -Infinity;
|
|
3248
|
+
for (var j=0;j<values.length;j++){ var v = values[j]; if (isFinite(v)){ if (v<min) min=v; if (v>max) max=v; } }
|
|
3249
|
+
var flat = (max - min) <= 0;
|
|
3250
|
+
var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
|
|
3251
|
+
var n = values.length;
|
|
3252
|
+
var line = "", lastX = 0, lastY = 0, started = false;
|
|
3253
|
+
for (var k=0;k<n;k++){
|
|
3254
|
+
var val = values[k]; if (!isFinite(val)) continue;
|
|
3255
|
+
var x = (n === 1) ? w : (k * (w/(n-1)));
|
|
3256
|
+
var norm = flat ? 0.5 : (val - lo)/span;
|
|
3257
|
+
var y = h - norm*(h-2) - 1;
|
|
3258
|
+
line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
|
|
3259
|
+
lastX = x; lastY = y; started = true;
|
|
3260
|
+
}
|
|
3261
|
+
var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
|
|
3262
|
+
return { line:line, area:area, lastX:lastX, lastY:lastY };
|
|
3263
|
+
}
|
|
3264
|
+
function drawSpark(svgId, values){
|
|
3265
|
+
var svg = byId(svgId); if (!svg) return;
|
|
3266
|
+
var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
|
|
3267
|
+
var sp = buildSpark(values, w, h);
|
|
3268
|
+
var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
|
|
3269
|
+
if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
|
|
3270
|
+
if (line) line.setAttribute("d", sp.line);
|
|
3271
|
+
if (area) area.setAttribute("d", sp.area);
|
|
3272
|
+
if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
|
|
3273
|
+
}
|
|
3274
|
+
|
|
3275
|
+
// ---- theme ----
|
|
3276
|
+
function applyTheme(){
|
|
3277
|
+
var root = document.documentElement;
|
|
3278
|
+
if (theme === "dark") root.setAttribute("data-theme","dark");
|
|
3279
|
+
else if (theme === "light") root.setAttribute("data-theme","light");
|
|
3280
|
+
else root.removeAttribute("data-theme");
|
|
3281
|
+
byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
|
|
3282
|
+
}
|
|
3283
|
+
byId("btn-theme").addEventListener("click", function(){
|
|
3284
|
+
theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
|
|
3285
|
+
try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
|
|
3286
|
+
applyTheme();
|
|
3287
|
+
});
|
|
3288
|
+
|
|
3289
|
+
// ---- interval + pause ----
|
|
3290
|
+
function setActiveSeg(){
|
|
3291
|
+
var btns = byId("seg").querySelectorAll("button");
|
|
3292
|
+
for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
|
|
3293
|
+
document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
|
|
3294
|
+
}
|
|
3295
|
+
byId("seg").addEventListener("click", function(ev){
|
|
3296
|
+
var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
|
|
3297
|
+
intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
|
|
3298
|
+
try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
|
|
3299
|
+
setActiveSeg();
|
|
3300
|
+
if (!paused){ schedule(0); }
|
|
3301
|
+
});
|
|
3302
|
+
byId("btn-pause").addEventListener("click", function(){
|
|
3303
|
+
paused = !paused;
|
|
3304
|
+
byId("btn-pause").innerHTML = paused ? "▶" : "❚❚";
|
|
3305
|
+
byId("bar").classList.toggle("paused", paused);
|
|
3306
|
+
if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
|
|
3307
|
+
else { setPill("live","LIVE",false); schedule(0); }
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3310
|
+
// ---- connection pill / banner ----
|
|
3311
|
+
function setPill(kind, text, beat){
|
|
3312
|
+
var pill = byId("conn-pill"); var dot = byId("conn-dot");
|
|
3313
|
+
pill.className = "pill " + kind;
|
|
3314
|
+
byId("conn-text").textContent = text;
|
|
3315
|
+
if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
|
|
3316
|
+
}
|
|
3317
|
+
function showBanner(text, ok){
|
|
3318
|
+
var b = byId("banner"); b.textContent = text; b.className = "banner show" + (ok ? " ok" : ""); b.classList.add("show");
|
|
3319
|
+
if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
|
|
3320
|
+
}
|
|
3321
|
+
function hideBanner(){ byId("banner").classList.remove("show"); }
|
|
3322
|
+
function setDimmed(on){ byId("content").classList.toggle("dim", on); }
|
|
3323
|
+
|
|
3324
|
+
// ---- auth takeover ----
|
|
3325
|
+
function showAuth(rejected){
|
|
3326
|
+
byId("content").style.display = "none";
|
|
3327
|
+
byId("auth").classList.add("show");
|
|
3328
|
+
setPill("authkey","API KEY",false);
|
|
3329
|
+
byId("auth-err").textContent = rejected ? "key rejected" : "";
|
|
3330
|
+
byId("auth-input").classList.toggle("bad", !!rejected);
|
|
3331
|
+
byId("auth-clear").style.display = apiKey ? "" : "none";
|
|
3332
|
+
byId("auth-input").focus();
|
|
3333
|
+
}
|
|
3334
|
+
function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
|
|
3335
|
+
byId("auth-connect").addEventListener("click", function(){
|
|
3336
|
+
var v = byId("auth-input").value.trim(); if (!v) return;
|
|
3337
|
+
apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
|
|
3338
|
+
hideAuth(); schedule(0);
|
|
3339
|
+
});
|
|
3340
|
+
byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
|
|
3341
|
+
byId("auth-clear").addEventListener("click", function(){
|
|
3342
|
+
apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
|
|
3343
|
+
byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
|
|
3344
|
+
});
|
|
3345
|
+
|
|
3346
|
+
// ---- the poll loop (setTimeout-chained, never setInterval) ----
|
|
3347
|
+
var pollGen = 0;
|
|
3348
|
+
function schedule(delay){
|
|
3349
|
+
if (timer){ clearTimeout(timer); }
|
|
3350
|
+
if (paused) return;
|
|
3351
|
+
timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
|
|
3352
|
+
}
|
|
3353
|
+
function poll(){
|
|
3354
|
+
if (paused) return;
|
|
3355
|
+
// A new poll supersedes any in-flight one. Bump the generation so the old
|
|
3356
|
+
// request's settled handlers (including its abort rejection) become no-ops
|
|
3357
|
+
// and never flash a false "disconnected".
|
|
3358
|
+
pollGen += 1; var myGen = pollGen;
|
|
3359
|
+
if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
|
|
3360
|
+
var ctrl = new AbortController(); inflightFetch = ctrl;
|
|
3361
|
+
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
|
|
3362
|
+
var headers = { "accept":"application/json" };
|
|
3363
|
+
if (apiKey) headers["x-api-key"] = apiKey;
|
|
3364
|
+
fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
3365
|
+
clearTimeout(to);
|
|
3366
|
+
if (myGen !== pollGen) return null;
|
|
3367
|
+
if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
|
|
3368
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
3369
|
+
return res.json();
|
|
3370
|
+
}).then(function(data){
|
|
3371
|
+
if (myGen !== pollGen || data === null || paused) return;
|
|
3372
|
+
inflightFetch = null;
|
|
3373
|
+
onData(data);
|
|
3374
|
+
backoffMs = 0; lastSuccessAt = Date.now();
|
|
3375
|
+
hideAuth(); setDimmed(false); hideBanner();
|
|
3376
|
+
setPill("live","LIVE",true);
|
|
3377
|
+
byId("bar").classList.remove("frozen");
|
|
3378
|
+
schedule(intervalMs);
|
|
3379
|
+
}).catch(function(err){
|
|
3380
|
+
clearTimeout(to);
|
|
3381
|
+
if (myGen !== pollGen || paused) return;
|
|
3382
|
+
inflightFetch = null;
|
|
3383
|
+
onDisconnect(err);
|
|
3384
|
+
});
|
|
3385
|
+
}
|
|
3386
|
+
function onDisconnect(err){
|
|
3387
|
+
setPill("reconnect","RECONNECTING",false);
|
|
3388
|
+
setDimmed(true);
|
|
3389
|
+
byId("bar").classList.add("frozen");
|
|
3390
|
+
backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
|
|
3391
|
+
showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
|
|
3392
|
+
schedule(backoffMs);
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
// ---- main render ----
|
|
3396
|
+
function onData(usage){
|
|
3397
|
+
var proxy = usage.proxy || {};
|
|
3398
|
+
var now = Date.now();
|
|
3399
|
+
|
|
3400
|
+
setText("version-chip", "v" + (usage.version || "?"));
|
|
3401
|
+
|
|
3402
|
+
// rates
|
|
3403
|
+
var reqTotal = (proxy.requests && proxy.requests.total) || 0;
|
|
3404
|
+
var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
|
|
3405
|
+
var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
|
|
3406
|
+
var startedAt = proxy.startedAt || "";
|
|
3407
|
+
var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
|
|
3408
|
+
if (prevSample){
|
|
3409
|
+
var dt = (now - prevSample.t)/1000;
|
|
3410
|
+
if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
|
|
3411
|
+
if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
|
|
3412
|
+
if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
|
|
3413
|
+
else if (dt > 0 && isFinite(dt)){
|
|
3414
|
+
reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
|
|
3415
|
+
tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
|
|
3416
|
+
upDelta = Math.max(0, upTotal - prevSample.upTotal);
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
|
|
3420
|
+
|
|
3421
|
+
// hero vitals
|
|
3422
|
+
if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
|
|
3423
|
+
if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
|
|
3424
|
+
var inflight = proxy.inFlight || 0;
|
|
3425
|
+
pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta");
|
|
3426
|
+
byId("v-inflight").classList.toggle("active", inflight > 0);
|
|
3427
|
+
setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
|
|
3428
|
+
|
|
3429
|
+
setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
|
|
3430
|
+
setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
|
|
3431
|
+
setText("inflight-sub", inflight + " now");
|
|
3432
|
+
setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
|
|
3433
|
+
|
|
3434
|
+
drawSpark("req-spark", hist.req);
|
|
3435
|
+
drawSpark("tok-spark", hist.tok);
|
|
3436
|
+
drawSpark("inflight-spark", hist.inflight);
|
|
3437
|
+
|
|
3438
|
+
renderRequests(proxy);
|
|
3439
|
+
renderStatus(proxy);
|
|
3440
|
+
renderLatency(proxy.latency || {});
|
|
3441
|
+
renderTokens(proxy.tokens || {});
|
|
3442
|
+
renderCopilot(usage);
|
|
3443
|
+
renderUpstream(proxy.upstream || {}, upDelta, restarted);
|
|
3444
|
+
renderThroughput();
|
|
3445
|
+
renderFooter(usage, proxy);
|
|
3446
|
+
|
|
3447
|
+
setNum("req-total", humanInt(reqTotal));
|
|
3448
|
+
setNum("tok-total", humanInt(tokTotal));
|
|
3449
|
+
lastUptime = proxy.uptimeSeconds || 0;
|
|
3450
|
+
}
|
|
3451
|
+
|
|
3452
|
+
function avg(arr){ if (!arr.length) return 0; var s = 0; for (var i=0;i<arr.length;i++) s += arr[i]; return s/arr.length; }
|
|
3453
|
+
|
|
3454
|
+
var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
|
|
3455
|
+
function renderRequests(proxy){
|
|
3456
|
+
var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
|
|
3457
|
+
var total = (proxy.requests && proxy.requests.total) || 0;
|
|
3458
|
+
var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
|
|
3459
|
+
var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
|
|
3460
|
+
var body = byId("routes-body"); clearEl(body);
|
|
3461
|
+
if (!rows.length){ var tr = mk("tr","ghost"); var td = mk("td",null,"no requests yet"); td.colSpan = 4; tr.appendChild(td); body.appendChild(tr); return; }
|
|
3462
|
+
rows.forEach(function(r, idx){
|
|
3463
|
+
var p = total ? (r.v/total*100) : 0;
|
|
3464
|
+
var seg = mk("i"); seg.style.width = p + "%"; seg.style.background = ROUTE_COLORS[idx % ROUTE_COLORS.length]; seg.title = r.k + " " + pct(p); share.appendChild(seg);
|
|
3465
|
+
var tr = mk("tr");
|
|
3466
|
+
var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
|
|
3467
|
+
tr.appendChild(mk("td",null, humanInt(r.v)));
|
|
3468
|
+
tr.appendChild(mk("td",null, pct(p)));
|
|
3469
|
+
var btd = mk("td"); var bar = mk("span","minibar"); bar.style.width = Math.max(2, p) + "%"; bar.style.background = ROUTE_COLORS[idx % ROUTE_COLORS.length]; btd.appendChild(bar); tr.appendChild(btd);
|
|
3470
|
+
body.appendChild(tr);
|
|
3471
|
+
});
|
|
3472
|
+
var tot = mk("tr","total"); tot.appendChild(mk("td","l","total")); tot.appendChild(mk("td",null, humanInt(total))); tot.appendChild(mk("td",null,"100%")); tot.appendChild(mk("td")); body.appendChild(tot);
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
function statusClass(code){ var c = String(code).charAt(0); if (c === "2") return "ok"; if (c === "3") return "info"; if (c === "4") return "warn"; if (c === "5") return "danger"; return "muted"; }
|
|
3476
|
+
function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
|
|
3477
|
+
function renderStatus(proxy){
|
|
3478
|
+
var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
|
|
3479
|
+
var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
|
|
3480
|
+
var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
|
|
3481
|
+
codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
|
|
3482
|
+
var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
|
|
3483
|
+
["ok","info","warn","danger","muted"].forEach(function(cls){ if (groups[cls] > 0){ var seg = mk("i"); seg.style.width = (groups[cls]/total*100) + "%"; seg.style.background = statusColor(cls); bar.appendChild(seg); } });
|
|
3484
|
+
var leg = byId("status-legend"); clearEl(leg);
|
|
3485
|
+
if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
|
|
3486
|
+
codes.forEach(function(c){ var li = mk("span","li"); var sw = mk("span","sw"); sw.style.background = statusColor(statusClass(c.k)); li.appendChild(sw); li.appendChild(mk("span",null, c.k + " " + humanInt(c.v))); leg.appendChild(li); });
|
|
3487
|
+
var er = total ? (errs/total*100) : 0;
|
|
3488
|
+
setNum("error-rate", pct(er));
|
|
3489
|
+
var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
function renderLatency(lat){
|
|
3493
|
+
setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
|
|
3494
|
+
var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
|
|
3495
|
+
p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
|
|
3496
|
+
// track: position p50 and p95 across 0..(p95*1.15)
|
|
3497
|
+
var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
|
|
3498
|
+
var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
|
|
3499
|
+
function place(v, cls){ if (!isFinite(v) || v <= 0) return; var x = Math.min(100, v/maxv*100); var t = mk("div","tick " + cls); t.style.left = x + "%"; track.appendChild(t); var lab = mk("div","tlab", fmtMs(v)); lab.style.left = x + "%"; track.appendChild(lab); }
|
|
3500
|
+
place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
|
|
3501
|
+
var lr = byId("lat-routes"); clearEl(lr);
|
|
3502
|
+
var byRoute = lat.byRoute || {}; var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return (b.v.avgMs||0) - (a.v.avgMs||0); });
|
|
3503
|
+
rows.forEach(function(r){ var tr = mk("tr"); var n = mk("td","l", r.k); n.title = r.k; tr.appendChild(n); tr.appendChild(mk("td",null, fmtMs(r.v.avgMs))); tr.appendChild(mk("td",null, humanInt(r.v.count||0))); lr.appendChild(tr); });
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
function renderTokens(tok){
|
|
3507
|
+
var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
|
|
3508
|
+
var sum = prompt + completion + reasoning;
|
|
3509
|
+
var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
|
|
3510
|
+
var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
|
|
3511
|
+
parts.forEach(function(p){ if (sum && p[1] > 0){ var seg = mk("i"); seg.style.width = (p[1]/sum*100) + "%"; seg.style.background = p[2]; seg.title = p[0]; bar.appendChild(seg); } });
|
|
3512
|
+
var leg = byId("tok-legend"); clearEl(leg);
|
|
3513
|
+
var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
|
|
3514
|
+
legParts.forEach(function(p){ var li = mk("span","li"); var sw = mk("span","sw"); sw.style.background = p[2]; li.appendChild(sw); var den = (p[0] === "cached") ? prompt : sum; var sh = den ? " " + pct(p[1]/den*100) : ""; li.appendChild(mk("span",null, p[0] + " " + humanInt(p[1]) + sh)); leg.appendChild(li); });
|
|
3515
|
+
var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
|
|
3516
|
+
var body = byId("tok-body"); clearEl(body);
|
|
3517
|
+
var byModel = tok.byModel || {}; var rows = Object.keys(byModel).map(function(k){ return { k:k, v:byModel[k] }; }).sort(function(a,b){ return (b.v.total||0) - (a.v.total||0); });
|
|
3518
|
+
if (!rows.length){ var tr = mk("tr","ghost"); var td = mk("td",null,"no token usage yet"); td.colSpan = 7; tr.appendChild(td); body.appendChild(tr); return; }
|
|
3519
|
+
rows.forEach(function(r){ var m = r.v; var tr = mk("tr"); var n = mk("td","l", r.k); n.title = r.k; tr.appendChild(n);
|
|
3520
|
+
tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
|
|
3521
|
+
tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
|
|
3522
|
+
tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
|
|
3523
|
+
}
|
|
3524
|
+
|
|
3525
|
+
function planClass(plan){ if (!plan) return "plan-offline"; if (plan.indexOf("pro") >= 0) return "plan-pro"; if (plan.indexOf("business") >= 0 || plan.indexOf("enterprise") >= 0) return "plan-business"; return "plan-free"; }
|
|
3526
|
+
function renderCopilot(usage){
|
|
3527
|
+
var box = byId("copilot-body"); clearEl(box);
|
|
3528
|
+
var cp = usage.copilot; var planChip = byId("plan-chip");
|
|
3529
|
+
if (!cp){
|
|
3530
|
+
planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
|
|
3531
|
+
var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
|
|
3532
|
+
eb.appendChild(mk("h4",null,"Copilot not connected"));
|
|
3533
|
+
if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
|
|
3534
|
+
eb.appendChild(mk("div","prompt","$ hoopilot login"));
|
|
3535
|
+
box.appendChild(eb); return;
|
|
3536
|
+
}
|
|
3537
|
+
planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
|
|
3538
|
+
var head = mk("div","cap");
|
|
3539
|
+
var bits = [];
|
|
3540
|
+
if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
|
|
3541
|
+
if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
|
|
3542
|
+
if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
|
|
3543
|
+
head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
|
|
3544
|
+
var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
|
|
3545
|
+
if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
|
|
3546
|
+
var order = { premium_interactions:0, chat:1, completions:2 };
|
|
3547
|
+
keys.sort(function(a,b){ var ra = order[a]===undefined?9:order[a], rb = order[b]===undefined?9:order[b]; return ra-rb || a.localeCompare(b); });
|
|
3548
|
+
keys.forEach(function(k){
|
|
3549
|
+
var q = quotas[k]; var row = mk("div","qrow");
|
|
3550
|
+
var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
|
|
3551
|
+
if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
|
|
3552
|
+
var ent = q.entitlement, rem = q.remaining, used = q.used;
|
|
3553
|
+
var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
|
|
3554
|
+
usedPct = Math.max(0, Math.min(100, usedPct));
|
|
3555
|
+
var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
|
|
3556
|
+
hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
|
|
3557
|
+
var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
|
|
3558
|
+
fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
|
|
3559
|
+
if (q.overageCount && q.overagePermitted){ bar.classList.add("over"); var ext = mk("i","ext"); ext.style.left = "100%"; ext.style.width = "8%"; bar.appendChild(ext); }
|
|
3560
|
+
row.appendChild(bar);
|
|
3561
|
+
if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
|
|
3562
|
+
box.appendChild(row);
|
|
3563
|
+
});
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
function renderUpstream(up, delta, restarted){
|
|
3567
|
+
setNum("up-total", humanInt(up.total||0));
|
|
3568
|
+
setNum("up-errors", humanInt(up.errors||0), "delta");
|
|
3569
|
+
var er = up.total ? (up.errors/up.total*100) : 0;
|
|
3570
|
+
var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
|
|
3571
|
+
byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
|
|
3572
|
+
pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
|
|
3573
|
+
byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
function renderThroughput(){
|
|
3577
|
+
drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
|
|
3578
|
+
drawDual("thru-req-line", null, hist.req, false);
|
|
3579
|
+
setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
|
|
3580
|
+
setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
|
|
3581
|
+
var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
|
|
3582
|
+
setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
|
|
3583
|
+
}
|
|
3584
|
+
function drawDual(lineId, areaId, values, withArea){
|
|
3585
|
+
var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
|
|
3586
|
+
var sp = buildSpark(values, w, h);
|
|
3587
|
+
var line = byId(lineId); var area = areaId ? byId(areaId) : null;
|
|
3588
|
+
if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
|
|
3589
|
+
if (line) line.setAttribute("d", sp.line);
|
|
3590
|
+
if (area && withArea) area.setAttribute("d", sp.area);
|
|
3591
|
+
}
|
|
3592
|
+
|
|
3593
|
+
function renderFooter(usage, proxy){
|
|
3594
|
+
setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
|
|
3595
|
+
setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
|
|
3596
|
+
setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
|
|
3597
|
+
setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
|
|
3598
|
+
var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
|
|
3599
|
+
setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
// ---- 1s freshness + uptime ticker (independent of the poll loop) ----
|
|
3603
|
+
setInterval(function(){
|
|
3604
|
+
if (lastSuccessAt){
|
|
3605
|
+
var ago = Math.round((Date.now() - lastSuccessAt)/1000);
|
|
3606
|
+
var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
|
|
3607
|
+
// Staleness only matters while polling; a deliberate pause is not "stale".
|
|
3608
|
+
u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
|
|
3609
|
+
}
|
|
3610
|
+
// Tick uptime locally between polls so the seconds advance smoothly; each
|
|
3611
|
+
// successful poll re-seeds lastUptime from the authoritative server value.
|
|
3612
|
+
if (!paused && lastUptime !== null){
|
|
3613
|
+
lastUptime += 1;
|
|
3614
|
+
byId("uptime-num").textContent = fmtUptime(lastUptime);
|
|
3615
|
+
var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
|
|
3616
|
+
}
|
|
3617
|
+
}, 1000);
|
|
3618
|
+
|
|
3619
|
+
// ---- boot ----
|
|
3620
|
+
applyTheme(); setActiveSeg();
|
|
3621
|
+
setPill("","CONNECTING",false);
|
|
3622
|
+
poll();
|
|
3623
|
+
})();
|
|
3624
|
+
</script>
|
|
3625
|
+
</body>
|
|
3626
|
+
</html>
|
|
3627
|
+
`;
|
|
3628
|
+
|
|
2771
3629
|
// src/version.ts
|
|
2772
3630
|
var BAKED_VERSION = typeof HOOPILOT_VERSION !== "undefined" ? HOOPILOT_VERSION : void 0;
|
|
2773
3631
|
var IS_STANDALONE_BINARY = BAKED_VERSION !== void 0;
|
|
3632
|
+
var cachedVersion;
|
|
3633
|
+
async function getVersion() {
|
|
3634
|
+
if (cachedVersion !== void 0) {
|
|
3635
|
+
return cachedVersion;
|
|
3636
|
+
}
|
|
3637
|
+
let resolved;
|
|
3638
|
+
if (BAKED_VERSION) {
|
|
3639
|
+
resolved = BAKED_VERSION;
|
|
3640
|
+
} else {
|
|
3641
|
+
try {
|
|
3642
|
+
const manifest = await Bun.file(new URL("../package.json", import.meta.url)).json();
|
|
3643
|
+
resolved = typeof manifest.version === "string" ? manifest.version : "0.0.0";
|
|
3644
|
+
} catch {
|
|
3645
|
+
resolved = "0.0.0";
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
cachedVersion = resolved;
|
|
3649
|
+
return resolved;
|
|
3650
|
+
}
|
|
2774
3651
|
|
|
2775
3652
|
// src/server.ts
|
|
2776
3653
|
var DEFAULT_HOST = "127.0.0.1";
|
|
@@ -2837,6 +3714,9 @@ function createHoopilotHandler(options = {}) {
|
|
|
2837
3714
|
if (request.method === "OPTIONS") {
|
|
2838
3715
|
return finish(new Response(null, { headers: corsHeaders() }));
|
|
2839
3716
|
}
|
|
3717
|
+
if (request.method === "GET" && apiPath === "/dashboard") {
|
|
3718
|
+
return finish(dashboardResponse());
|
|
3719
|
+
}
|
|
2840
3720
|
if (!isAuthorized(request, apiKey)) {
|
|
2841
3721
|
requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
|
|
2842
3722
|
return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
|
|
@@ -3529,6 +4409,9 @@ function routeFor(method, path) {
|
|
|
3529
4409
|
if (method === "GET" && (path === "/" || path === "/healthz")) {
|
|
3530
4410
|
return "health";
|
|
3531
4411
|
}
|
|
4412
|
+
if (method === "GET" && path === "/dashboard") {
|
|
4413
|
+
return "dashboard";
|
|
4414
|
+
}
|
|
3532
4415
|
if (method === "GET" && path === "/metrics") {
|
|
3533
4416
|
return "metrics";
|
|
3534
4417
|
}
|
|
@@ -3583,10 +4466,28 @@ function metricsResponse(metrics) {
|
|
|
3583
4466
|
status: 200
|
|
3584
4467
|
});
|
|
3585
4468
|
}
|
|
4469
|
+
function dashboardResponse() {
|
|
4470
|
+
return new Response(DASHBOARD_HTML, {
|
|
4471
|
+
headers: {
|
|
4472
|
+
...corsHeaders(),
|
|
4473
|
+
"content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
4474
|
+
"content-type": "text/html; charset=utf-8",
|
|
4475
|
+
"referrer-policy": "no-referrer",
|
|
4476
|
+
"x-content-type-options": "nosniff",
|
|
4477
|
+
"x-frame-options": "DENY"
|
|
4478
|
+
},
|
|
4479
|
+
status: 200
|
|
4480
|
+
});
|
|
4481
|
+
}
|
|
3586
4482
|
async function handleUsage(metrics, readUsage, signal) {
|
|
3587
4483
|
const { copilot, error } = await readUsage(signal);
|
|
3588
4484
|
const proxy = metrics.snapshot();
|
|
3589
|
-
const body = {
|
|
4485
|
+
const body = {
|
|
4486
|
+
copilot: copilot ?? null,
|
|
4487
|
+
object: "usage",
|
|
4488
|
+
proxy,
|
|
4489
|
+
version: await getVersion()
|
|
4490
|
+
};
|
|
3590
4491
|
if (error) {
|
|
3591
4492
|
body.copilot_error = error;
|
|
3592
4493
|
}
|