@openhoo/hoopilot 1.1.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 +9 -1
- package/dist/cli.js +970 -27
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +989 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +30 -1
- package/dist/index.d.ts +30 -1
- package/dist/index.js +988 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1668,6 +1668,812 @@ function randomId2() {
|
|
|
1668
1668
|
return crypto.randomUUID().replaceAll("-", "");
|
|
1669
1669
|
}
|
|
1670
1670
|
|
|
1671
|
+
// src/dashboard.ts
|
|
1672
|
+
var DASHBOARD_HTML = `<!doctype html>
|
|
1673
|
+
<html lang="en">
|
|
1674
|
+
<head>
|
|
1675
|
+
<meta charset="utf-8" />
|
|
1676
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1677
|
+
<meta name="color-scheme" content="dark light" />
|
|
1678
|
+
<title>hoopilot · dashboard</title>
|
|
1679
|
+
<style>
|
|
1680
|
+
:root {
|
|
1681
|
+
--bg-0:#0b0e14; --bg-1:#11151c; --bg-2:#171c25; --bg-3:#1f2630;
|
|
1682
|
+
--border:#262d38; --border-strong:#37404d;
|
|
1683
|
+
--text-0:#e6edf3; --text-1:#9aa7b4; --text-2:#5e6b78; --text-dim:#3a434e; --text-inv:#0b0e14;
|
|
1684
|
+
--accent:#4ea1ff; --accent-2:#56d4dd; --accent-soft:rgba(78,161,255,.14);
|
|
1685
|
+
--amber:#f5b042;
|
|
1686
|
+
--ok:#3fb950; --warn:#d8a13a; --danger:#f0556a; --info:#a371f7; --cache:#7c8cff;
|
|
1687
|
+
--spark:#4ea1ff; --spark-fill:color-mix(in srgb, var(--accent) 14%, transparent);
|
|
1688
|
+
--grid-line:rgba(255,255,255,.05);
|
|
1689
|
+
--flash:color-mix(in srgb, var(--accent) 22%, transparent);
|
|
1690
|
+
--flash-up:color-mix(in srgb, var(--ok) 22%, transparent);
|
|
1691
|
+
--flash-down:color-mix(in srgb, var(--danger) 22%, transparent);
|
|
1692
|
+
--c1:#4ea1ff; --c2:#3fb950; --c3:#d8a13a; --c4:#a371f7; --c5:#56d4dd; --c6:#f0556a;
|
|
1693
|
+
--mono: ui-monospace, "SF Mono", "Cascadia Code", "JetBrains Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
|
1694
|
+
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, system-ui, sans-serif;
|
|
1695
|
+
}
|
|
1696
|
+
@media (prefers-color-scheme: light) {
|
|
1697
|
+
:root:not([data-theme="dark"]) {
|
|
1698
|
+
--bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
|
|
1699
|
+
--border:#d0d7de; --border-strong:#b6bec8;
|
|
1700
|
+
--text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
|
|
1701
|
+
--accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
|
|
1702
|
+
--amber:#b5730a;
|
|
1703
|
+
--ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
|
|
1704
|
+
--spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
|
|
1705
|
+
--grid-line:rgba(0,0,0,.06);
|
|
1706
|
+
--flash:color-mix(in srgb, var(--accent) 16%, transparent);
|
|
1707
|
+
--flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
|
|
1708
|
+
--flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
|
|
1709
|
+
--c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
[data-theme="light"] {
|
|
1713
|
+
--bg-0:#f6f8fa; --bg-1:#ffffff; --bg-2:#f0f3f6; --bg-3:#e9edf1;
|
|
1714
|
+
--border:#d0d7de; --border-strong:#b6bec8;
|
|
1715
|
+
--text-0:#1f2328; --text-1:#5a6570; --text-2:#8a96a3; --text-dim:#bcc2c9; --text-inv:#ffffff;
|
|
1716
|
+
--accent:#0969da; --accent-2:#0a7ea4; --accent-soft:rgba(9,105,218,.12);
|
|
1717
|
+
--amber:#b5730a;
|
|
1718
|
+
--ok:#1a7f37; --warn:#9a6700; --danger:#cf222e; --info:#8250df; --cache:#5563e0;
|
|
1719
|
+
--spark:#0969da; --spark-fill:color-mix(in srgb, var(--accent) 12%, transparent);
|
|
1720
|
+
--grid-line:rgba(0,0,0,.06);
|
|
1721
|
+
--flash:color-mix(in srgb, var(--accent) 16%, transparent);
|
|
1722
|
+
--flash-up:color-mix(in srgb, var(--ok) 16%, transparent);
|
|
1723
|
+
--flash-down:color-mix(in srgb, var(--danger) 16%, transparent);
|
|
1724
|
+
--c1:#0969da; --c2:#1a7f37; --c3:#9a6700; --c4:#8250df; --c5:#0a7ea4; --c6:#cf222e;
|
|
1725
|
+
}
|
|
1726
|
+
* { box-sizing: border-box; }
|
|
1727
|
+
html, body { margin:0; padding:0; }
|
|
1728
|
+
body {
|
|
1729
|
+
background: var(--bg-0); color: var(--text-0); font-family: var(--sans);
|
|
1730
|
+
font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased;
|
|
1731
|
+
}
|
|
1732
|
+
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
|
|
1733
|
+
.num { font-family: var(--mono); font-variant-numeric: tabular-nums slashed-zero; }
|
|
1734
|
+
.shell { max-width: 1280px; margin: 0 auto; padding: 0 24px 28px; }
|
|
1735
|
+
@media (min-width: 1080px) { .shell { border-left:1px solid var(--border); border-right:1px solid var(--border); } }
|
|
1736
|
+
@media (max-width: 680px) { .shell { padding: 0 12px 24px; } }
|
|
1737
|
+
|
|
1738
|
+
/* header */
|
|
1739
|
+
header.bar {
|
|
1740
|
+
position: sticky; top: 0; z-index: 20; background: var(--bg-1);
|
|
1741
|
+
border-bottom: 1px solid var(--border); height: 48px;
|
|
1742
|
+
}
|
|
1743
|
+
.bar-in { max-width:1280px; margin:0 auto; height:48px; padding:0 24px; display:flex; align-items:center; gap:12px; }
|
|
1744
|
+
@media (max-width:680px){ .bar-in{ padding:0 12px; gap:8px; } }
|
|
1745
|
+
.wordmark { font-family: var(--mono); font-weight:700; font-size:14px; color:var(--text-0); letter-spacing:-.01em; }
|
|
1746
|
+
.caret { display:inline-block; width:7px; height:15px; background:var(--amber); margin-left:3px; vertical-align:-2px; animation: blink 1.1s steps(1) infinite; }
|
|
1747
|
+
.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; }
|
|
1748
|
+
.chip.plan-pro { background:var(--accent-soft); color:var(--accent); }
|
|
1749
|
+
.chip.plan-business { background:color-mix(in srgb, var(--info) 16%, transparent); color:var(--info); }
|
|
1750
|
+
.chip.plan-free, .chip.plan-offline { background:var(--bg-3); color:var(--text-2); }
|
|
1751
|
+
.spacer { flex:1; }
|
|
1752
|
+
.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); }
|
|
1753
|
+
.dot { width:7px; height:7px; border-radius:50%; background:var(--text-2); flex:none; }
|
|
1754
|
+
.pill.live .dot { background:var(--ok); }
|
|
1755
|
+
.pill.paused .dot { background:var(--text-2); }
|
|
1756
|
+
.pill.reconnect { color:var(--warn); } .pill.reconnect .dot { background:var(--warn); }
|
|
1757
|
+
.pill.authkey { color:var(--warn); } .pill.authkey .dot { background:var(--warn); }
|
|
1758
|
+
.heartbeat { animation: hb .5s ease-out; }
|
|
1759
|
+
.updated { font-family:var(--mono); font-size:11px; color:var(--text-2); white-space:nowrap; }
|
|
1760
|
+
.updated.warn { color:var(--warn); } .updated.danger { color:var(--danger); }
|
|
1761
|
+
.seg { display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden; }
|
|
1762
|
+
.seg button { background:transparent; color:var(--text-1); border:0; font-family:var(--mono); font-size:11px; padding:3px 8px; cursor:pointer; }
|
|
1763
|
+
.seg button + button { border-left:1px solid var(--border); }
|
|
1764
|
+
.seg button.active { background:var(--accent); color:var(--text-inv); }
|
|
1765
|
+
.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; }
|
|
1766
|
+
.iconbtn:hover { background:var(--bg-3); }
|
|
1767
|
+
button:focus-visible, input:focus-visible, .seg button:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
|
|
1768
|
+
#scanbar { position:absolute; left:0; bottom:-1px; height:1px; width:100%; overflow:hidden; }
|
|
1769
|
+
#scanbar::after { content:""; position:absolute; left:0; top:0; height:1px; width:40%;
|
|
1770
|
+
background:linear-gradient(90deg, transparent, var(--accent), transparent);
|
|
1771
|
+
animation: scan var(--scan-ms, 4000ms) linear infinite; }
|
|
1772
|
+
header.bar.paused #scanbar::after, header.bar.frozen #scanbar::after { animation-play-state:paused; opacity:.35; }
|
|
1773
|
+
|
|
1774
|
+
/* disconnect banner */
|
|
1775
|
+
#banner { display:none; margin-top:10px; padding:7px 12px; border-radius:5px; font-family:var(--mono); font-size:12px;
|
|
1776
|
+
background:color-mix(in srgb, var(--danger) 16%, transparent); color:var(--danger); border:1px solid color-mix(in srgb, var(--danger) 40%, transparent); }
|
|
1777
|
+
#banner.ok { background:color-mix(in srgb, var(--ok) 16%, transparent); color:var(--ok); border-color:color-mix(in srgb, var(--ok) 40%, transparent); }
|
|
1778
|
+
#banner.show { display:block; }
|
|
1779
|
+
|
|
1780
|
+
/* hero strip */
|
|
1781
|
+
.hero { display:grid; grid-template-columns:repeat(4,1fr); margin:18px 0 16px; }
|
|
1782
|
+
.vital { padding:6px 18px; }
|
|
1783
|
+
.vital + .vital { border-left:1px solid var(--border); }
|
|
1784
|
+
.vital .eyebrow { font-size:10px; font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-1); }
|
|
1785
|
+
.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); }
|
|
1786
|
+
.vital .vsub { font-size:11px; color:var(--text-2); min-height:14px; }
|
|
1787
|
+
.vital .vspark { display:block; width:100%; height:24px; margin-top:4px; }
|
|
1788
|
+
.vital.active { }
|
|
1789
|
+
.vital.active .eyebrow { color:var(--accent); }
|
|
1790
|
+
@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; } }
|
|
1791
|
+
@media (max-width:600px){ .hero{ grid-template-columns:1fr; } .vital + .vital{ border-left:0; border-top:1px solid var(--border); } }
|
|
1792
|
+
|
|
1793
|
+
/* grid + panels */
|
|
1794
|
+
.grid { display:grid; grid-template-columns:repeat(12,1fr); gap:12px; }
|
|
1795
|
+
.panel { position:relative; background:var(--bg-1); border:1px solid var(--border); border-radius:4px; padding:16px 12px 12px; min-width:0; }
|
|
1796
|
+
.panel > .ptitle { position:absolute; top:-8px; left:10px; padding:0 6px; background:var(--bg-1);
|
|
1797
|
+
font-family:var(--mono); font-size:11px; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
|
|
1798
|
+
.span5{ grid-column:span 5; } .span3{ grid-column:span 3; } .span4{ grid-column:span 4; }
|
|
1799
|
+
.span7{ grid-column:span 7; } .span8{ grid-column:span 8; }
|
|
1800
|
+
@media (max-width:1079px){ .grid{ grid-template-columns:repeat(6,1fr); }
|
|
1801
|
+
.span5,.span7,.span8{ grid-column:span 6; } .span3{ grid-column:span 3; } .span4{ grid-column:span 6; } }
|
|
1802
|
+
@media (max-width:680px){ .grid{ grid-template-columns:1fr; }
|
|
1803
|
+
.span3,.span4,.span5,.span7,.span8{ grid-column:span 1; } }
|
|
1804
|
+
|
|
1805
|
+
.headline { font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-weight:600; font-size:22px; line-height:1.1; }
|
|
1806
|
+
.cap { font-size:11px; color:var(--text-2); }
|
|
1807
|
+
.stack-bar { display:flex; height:8px; border-radius:4px; overflow:hidden; background:var(--bg-3); margin:8px 0; }
|
|
1808
|
+
.stack-bar i { display:block; height:100%; }
|
|
1809
|
+
.stack-bar.empty { outline:1px dashed var(--border); background:transparent; }
|
|
1810
|
+
|
|
1811
|
+
table.tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-variant-numeric:tabular-nums slashed-zero; font-size:12px; }
|
|
1812
|
+
.scrollx { overflow-x:auto; }
|
|
1813
|
+
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; }
|
|
1814
|
+
table.tbl th.l { text-align:left; }
|
|
1815
|
+
table.tbl td { padding:3px 6px; text-align:right; white-space:nowrap; border-bottom:1px solid color-mix(in srgb, var(--border) 55%, transparent); }
|
|
1816
|
+
table.tbl td.l { text-align:left; max-width:160px; overflow:hidden; text-overflow:ellipsis; }
|
|
1817
|
+
table.tbl tr:hover td { background:var(--bg-2); }
|
|
1818
|
+
table.tbl tr.total td { border-top:1px solid var(--border-strong); border-bottom:0; font-weight:600; color:var(--text-0); }
|
|
1819
|
+
.minibar { display:inline-block; height:6px; border-radius:3px; background:var(--accent); vertical-align:middle; min-width:1px; }
|
|
1820
|
+
.ghost td { color:var(--text-2); text-align:center; }
|
|
1821
|
+
.reasoning { color:var(--info); } .cached { color:var(--cache); }
|
|
1822
|
+
|
|
1823
|
+
.legend { display:flex; flex-wrap:wrap; gap:4px 14px; margin-top:8px; }
|
|
1824
|
+
.legend .li { display:flex; align-items:center; gap:6px; font-family:var(--mono); font-size:11px; color:var(--text-1); }
|
|
1825
|
+
.legend .sw { width:8px; height:8px; border-radius:2px; flex:none; }
|
|
1826
|
+
|
|
1827
|
+
.lat-trio { display:flex; gap:18px; align-items:baseline; }
|
|
1828
|
+
.lat-trio .b { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
|
|
1829
|
+
.lat-trio .b small { display:block; font-size:10px; font-weight:600; text-transform:uppercase; color:var(--text-2); letter-spacing:.05em; }
|
|
1830
|
+
.lat-p95 { color:var(--info); }
|
|
1831
|
+
.lat-track { position:relative; height:22px; margin-top:10px; }
|
|
1832
|
+
.lat-track .line { position:absolute; top:11px; left:0; right:0; height:1px; background:var(--border); }
|
|
1833
|
+
.lat-track .tick { position:absolute; top:5px; width:2px; height:12px; border-radius:1px; }
|
|
1834
|
+
.lat-track .tick.p50 { background:var(--accent); } .lat-track .tick.p95 { background:var(--info); }
|
|
1835
|
+
.lat-track .tlab { position:absolute; top:-2px; font-family:var(--mono); font-size:9px; color:var(--text-2); transform:translateX(-50%); }
|
|
1836
|
+
details.routes { margin-top:10px; } details.routes summary { cursor:pointer; font-size:11px; color:var(--text-2); font-family:var(--mono); }
|
|
1837
|
+
|
|
1838
|
+
.qrow { margin:10px 0; } .qrow .qhead { display:flex; justify-content:space-between; align-items:baseline; font-size:12px; }
|
|
1839
|
+
.qrow .qname { color:var(--text-1); } .qrow .qval { font-family:var(--mono); font-variant-numeric:tabular-nums; color:var(--text-0); }
|
|
1840
|
+
.qbar { position:relative; height:8px; border-radius:4px; background:var(--bg-3); margin-top:5px; overflow:hidden; }
|
|
1841
|
+
.qbar i { position:absolute; left:0; top:0; height:100%; border-radius:4px; }
|
|
1842
|
+
.qbar.over i.ext { background:repeating-linear-gradient(45deg, var(--danger), var(--danger) 3px, transparent 3px, transparent 6px); }
|
|
1843
|
+
.inf { font-family:var(--mono); font-size:12px; color:var(--ok); }
|
|
1844
|
+
.emptybox { border:1px solid var(--border); border-radius:5px; padding:14px; text-align:center; color:var(--text-2); }
|
|
1845
|
+
.emptybox .keyglyph { font-size:20px; color:var(--text-1); }
|
|
1846
|
+
.emptybox h4 { margin:8px 0 4px; font-family:var(--sans); font-size:13px; color:var(--text-1); font-weight:600; }
|
|
1847
|
+
.emptybox .errline { font-family:var(--mono); font-size:11px; color:var(--text-2); word-break:break-word; margin:4px 0; }
|
|
1848
|
+
.prompt { font-family:var(--mono); font-size:12px; color:var(--text-1); }
|
|
1849
|
+
|
|
1850
|
+
.upblocks { display:flex; gap:18px; }
|
|
1851
|
+
.upblk { } .upblk .v { font-family:var(--mono); font-variant-numeric:tabular-nums; font-size:20px; font-weight:600; }
|
|
1852
|
+
.upblk .k { font-size:10px; text-transform:uppercase; letter-spacing:.05em; color:var(--text-2); }
|
|
1853
|
+
.upblk.err.hot { color:var(--danger); }
|
|
1854
|
+
.rate { font-family:var(--mono); font-size:12px; } .rate.warn{ color:var(--warn);} .rate.danger{ color:var(--danger);} .rate.ok{ color:var(--ok); }
|
|
1855
|
+
#up-spark, #thru-svg { display:block; width:100%; }
|
|
1856
|
+
#up-spark { height:30px; margin-top:8px; }
|
|
1857
|
+
#thru-svg { height:88px; margin-top:6px; }
|
|
1858
|
+
.flag { font-family:var(--mono); font-size:10px; color:var(--text-2); }
|
|
1859
|
+
|
|
1860
|
+
footer.foot { margin-top:14px; padding-top:10px; border-top:1px solid var(--border); display:flex; flex-wrap:wrap; gap:4px 14px;
|
|
1861
|
+
font-family:var(--mono); font-size:11px; color:var(--text-2); }
|
|
1862
|
+
footer.foot .end { margin-left:auto; }
|
|
1863
|
+
@media (max-width:680px){ footer.foot .end{ margin-left:0; } }
|
|
1864
|
+
|
|
1865
|
+
.skel { color:var(--text-dim); }
|
|
1866
|
+
.flash { animation: flash .6s ease-out; } .flash-up { animation: flashup .6s ease-out; } .flash-down { animation: flashdown .6s ease-out; }
|
|
1867
|
+
|
|
1868
|
+
/* auth takeover */
|
|
1869
|
+
#auth { display:none; }
|
|
1870
|
+
#auth.show { display:flex; justify-content:center; padding:64px 16px; }
|
|
1871
|
+
.authcard { width:100%; max-width:420px; background:var(--bg-1); border:1px solid var(--border); border-radius:6px; padding:22px 18px; position:relative; }
|
|
1872
|
+
.authcard h3 { margin:0 0 10px; font-family:var(--mono); font-size:12px; letter-spacing:.1em; text-transform:uppercase; color:var(--text-1); }
|
|
1873
|
+
.authcard p { font-size:12px; color:var(--text-2); margin:0 0 14px; }
|
|
1874
|
+
.authcard .row { display:flex; gap:8px; }
|
|
1875
|
+
.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; }
|
|
1876
|
+
.authcard input.bad { border-color:var(--danger); }
|
|
1877
|
+
.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; }
|
|
1878
|
+
.authcard .err { color:var(--danger); font-family:var(--mono); font-size:11px; min-height:14px; margin-top:8px; }
|
|
1879
|
+
.authcard .clear { position:absolute; top:14px; right:16px; font-size:11px; color:var(--text-2); cursor:pointer; }
|
|
1880
|
+
.dim { opacity:.45; filter:grayscale(.4); transition:opacity .2s, filter .2s; }
|
|
1881
|
+
|
|
1882
|
+
@keyframes blink { 50% { opacity:0; } }
|
|
1883
|
+
@keyframes scan { 0%{ transform:translateX(-100%);} 100%{ transform:translateX(350%);} }
|
|
1884
|
+
@keyframes hb { 0%{ transform:scale(1);} 35%{ transform:scale(1.7);} 100%{ transform:scale(1);} }
|
|
1885
|
+
@keyframes flash { from{ background:var(--flash);} to{ background:transparent;} }
|
|
1886
|
+
@keyframes flashup { from{ background:var(--flash-up);} to{ background:transparent;} }
|
|
1887
|
+
@keyframes flashdown { from{ background:var(--flash-down);} to{ background:transparent;} }
|
|
1888
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1889
|
+
.caret { animation:none; } #scanbar::after { animation:none; opacity:.3; }
|
|
1890
|
+
.heartbeat { animation:none; }
|
|
1891
|
+
.flash, .flash-up, .flash-down { animation:none; box-shadow: inset 2px 0 0 var(--accent); }
|
|
1892
|
+
}
|
|
1893
|
+
</style>
|
|
1894
|
+
</head>
|
|
1895
|
+
<body>
|
|
1896
|
+
<header class="bar" id="bar">
|
|
1897
|
+
<div class="bar-in">
|
|
1898
|
+
<span class="wordmark">hoopilot<span class="caret" aria-hidden="true"></span></span>
|
|
1899
|
+
<span class="chip" id="version-chip">v···</span>
|
|
1900
|
+
<span class="chip plan-offline" id="plan-chip">— offline</span>
|
|
1901
|
+
<span class="spacer"></span>
|
|
1902
|
+
<span class="pill" id="conn-pill" aria-live="polite"><span class="dot" id="conn-dot"></span><span id="conn-text">connecting</span></span>
|
|
1903
|
+
<span class="updated" id="updated"></span>
|
|
1904
|
+
<span class="seg" id="seg" role="group" aria-label="Refresh interval">
|
|
1905
|
+
<button data-ms="2000">2s</button><button data-ms="4000" class="active">4s</button><button data-ms="10000">10s</button>
|
|
1906
|
+
</span>
|
|
1907
|
+
<button class="iconbtn" id="btn-pause" title="Pause / resume" aria-label="Pause or resume">❚❚</button>
|
|
1908
|
+
<button class="iconbtn" id="btn-theme" title="Theme: auto / dark / light" aria-label="Cycle theme">A</button>
|
|
1909
|
+
</div>
|
|
1910
|
+
<div id="scanbar" aria-hidden="true"></div>
|
|
1911
|
+
</header>
|
|
1912
|
+
|
|
1913
|
+
<div class="shell">
|
|
1914
|
+
<div id="banner" role="status" aria-live="polite"></div>
|
|
1915
|
+
|
|
1916
|
+
<section id="content">
|
|
1917
|
+
<section class="hero" aria-label="Vitals">
|
|
1918
|
+
<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>
|
|
1919
|
+
<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>
|
|
1920
|
+
<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>
|
|
1921
|
+
<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>
|
|
1922
|
+
</section>
|
|
1923
|
+
|
|
1924
|
+
<section class="grid">
|
|
1925
|
+
<div class="panel span5"><span class="ptitle">┤ Proxy · requests ┠</span>
|
|
1926
|
+
<div class="headline"><span id="req-total" class="skel">···</span> <span class="cap">requests</span></div>
|
|
1927
|
+
<div class="stack-bar empty" id="route-sharebar"></div>
|
|
1928
|
+
<div class="stack-bar empty" id="status-healthbar"></div>
|
|
1929
|
+
<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>
|
|
1930
|
+
</div>
|
|
1931
|
+
|
|
1932
|
+
<div class="panel span3"><span class="ptitle">┤ Status ┠</span>
|
|
1933
|
+
<div class="headline"><span id="error-rate" class="skel">···</span> <span class="cap">err rate</span></div>
|
|
1934
|
+
<div class="stack-bar empty" id="status-bar"></div>
|
|
1935
|
+
<div class="legend" id="status-legend"></div>
|
|
1936
|
+
</div>
|
|
1937
|
+
|
|
1938
|
+
<div class="panel span4"><span class="ptitle">┤ Latency · ms ┠</span>
|
|
1939
|
+
<div class="lat-trio">
|
|
1940
|
+
<div class="b"><small>p50</small><span id="lat-p50" class="skel">·</span></div>
|
|
1941
|
+
<div class="b lat-p95"><small>p95</small><span id="lat-p95" class="skel">·</span></div>
|
|
1942
|
+
<div class="b"><small>avg</small><span id="lat-avg" class="skel">·</span></div>
|
|
1943
|
+
<div class="b"><small>obs</small><span id="lat-count" class="skel">·</span></div>
|
|
1944
|
+
</div>
|
|
1945
|
+
<div class="lat-track" id="lat-track"><div class="line"></div></div>
|
|
1946
|
+
<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>
|
|
1947
|
+
</div>
|
|
1948
|
+
|
|
1949
|
+
<div class="panel span7"><span class="ptitle">┤ Tokens · by model ┠</span>
|
|
1950
|
+
<div class="headline"><span id="tok-total" class="skel">···</span> <span class="cap">tokens · <span id="tok-cache">cache ·%</span></span></div>
|
|
1951
|
+
<div class="stack-bar empty" id="tok-mixbar"></div>
|
|
1952
|
+
<div class="legend" id="tok-legend"></div>
|
|
1953
|
+
<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>
|
|
1954
|
+
</div>
|
|
1955
|
+
|
|
1956
|
+
<div class="panel span5"><span class="ptitle">┤ Copilot · quota ┠</span>
|
|
1957
|
+
<div id="copilot-body"><div class="emptybox skel">loading…</div></div>
|
|
1958
|
+
</div>
|
|
1959
|
+
|
|
1960
|
+
<div class="panel span4"><span class="ptitle">┤ Upstream · copilot edge ┠</span>
|
|
1961
|
+
<div class="upblocks">
|
|
1962
|
+
<div class="upblk"><div class="v" id="up-total">·</div><div class="k">calls</div></div>
|
|
1963
|
+
<div class="upblk err" id="up-errblk"><div class="v" id="up-errors">·</div><div class="k">errors</div></div>
|
|
1964
|
+
<div class="upblk"><div class="v rate" id="up-rate">·</div><div class="k">err rate</div></div>
|
|
1965
|
+
</div>
|
|
1966
|
+
<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>
|
|
1967
|
+
<div class="flag" id="up-flag"></div>
|
|
1968
|
+
</div>
|
|
1969
|
+
|
|
1970
|
+
<div class="panel span8"><span class="ptitle">┤ Throughput ┠</span>
|
|
1971
|
+
<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>
|
|
1972
|
+
<svg id="thru-svg" viewBox="0 0 320 88" preserveAspectRatio="none" aria-hidden="true">
|
|
1973
|
+
<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>
|
|
1974
|
+
<line class="grid" x1="0" y1="22" x2="320" y2="22" stroke="var(--grid-line)"/>
|
|
1975
|
+
<line class="grid" x1="0" y1="44" x2="320" y2="44" stroke="var(--grid-line)"/>
|
|
1976
|
+
<line class="grid" x1="0" y1="66" x2="320" y2="66" stroke="var(--grid-line)"/>
|
|
1977
|
+
<path id="thru-tok-area" fill="url(#thrugrad)" stroke="none"/>
|
|
1978
|
+
<path id="thru-tok-line" fill="none" stroke="var(--accent)" stroke-width="1.5" vector-effect="non-scaling-stroke"/>
|
|
1979
|
+
<path id="thru-req-line" fill="none" stroke="var(--accent-2)" stroke-width="1.2" vector-effect="non-scaling-stroke" opacity="0.9"/>
|
|
1980
|
+
</svg>
|
|
1981
|
+
</div>
|
|
1982
|
+
</section>
|
|
1983
|
+
</section>
|
|
1984
|
+
|
|
1985
|
+
<section id="auth" aria-live="polite">
|
|
1986
|
+
<div class="authcard">
|
|
1987
|
+
<span class="clear" id="auth-clear" style="display:none">clear key</span>
|
|
1988
|
+
<h3>┤ Auth required ┠</h3>
|
|
1989
|
+
<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>
|
|
1990
|
+
<div class="row"><input id="auth-input" type="password" placeholder="x-api-key" autocomplete="off" spellcheck="false" /><button id="auth-connect">connect</button></div>
|
|
1991
|
+
<div class="err" id="auth-err"></div>
|
|
1992
|
+
</div>
|
|
1993
|
+
</section>
|
|
1994
|
+
|
|
1995
|
+
<footer class="foot">
|
|
1996
|
+
<span id="foot-started">started ·</span>
|
|
1997
|
+
<span id="foot-uptime">uptime ·</span>
|
|
1998
|
+
<span id="foot-total">· req</span>
|
|
1999
|
+
<span id="foot-tokens">· tokens</span>
|
|
2000
|
+
<span id="foot-upstream">upstream ·</span>
|
|
2001
|
+
<span class="end" id="foot-cadence"></span>
|
|
2002
|
+
</footer>
|
|
2003
|
+
</div>
|
|
2004
|
+
|
|
2005
|
+
<script>
|
|
2006
|
+
(function(){
|
|
2007
|
+
"use strict";
|
|
2008
|
+
var byId = function(id){ return document.getElementById(id); };
|
|
2009
|
+
var CAP = 60;
|
|
2010
|
+
|
|
2011
|
+
// ---- persistent state ----
|
|
2012
|
+
var LS = window.localStorage;
|
|
2013
|
+
var apiKey = "";
|
|
2014
|
+
try { apiKey = LS.getItem("hoopilot.apiKey") || ""; } catch (e) { apiKey = ""; }
|
|
2015
|
+
var theme = "auto";
|
|
2016
|
+
try { theme = LS.getItem("hoopilot.theme") || "auto"; } catch (e) { theme = "auto"; }
|
|
2017
|
+
var intervalMs = 4000;
|
|
2018
|
+
try { var sv = parseInt(LS.getItem("hoopilot.intervalMs") || "", 10); if (sv === 2000 || sv === 4000 || sv === 10000) intervalMs = sv; } catch (e) {}
|
|
2019
|
+
|
|
2020
|
+
// ---- runtime state ----
|
|
2021
|
+
var paused = false;
|
|
2022
|
+
var timer = null;
|
|
2023
|
+
var inflightFetch = null;
|
|
2024
|
+
var lastSuccessAt = 0;
|
|
2025
|
+
var prevSample = null; // { t, reqTotal, tokTotal, upTotal, startedAt }
|
|
2026
|
+
var lastRender = {}; // for change-flash
|
|
2027
|
+
var backoffMs = 0;
|
|
2028
|
+
var lastUptime = null; // seconds; ticked locally between polls
|
|
2029
|
+
var hist = { req:[], tok:[], inflight:[], up:[] };
|
|
2030
|
+
|
|
2031
|
+
// ---- formatting helpers ----
|
|
2032
|
+
function humanInt(n){
|
|
2033
|
+
if (n === null || n === undefined || !isFinite(n)) return "0";
|
|
2034
|
+
var a = Math.abs(n);
|
|
2035
|
+
if (a >= 1000000) return (n/1000000).toFixed(a >= 10000000 ? 0 : 1) + "M";
|
|
2036
|
+
if (a >= 1000) return (n/1000).toFixed(a >= 10000 ? 0 : 1) + "k";
|
|
2037
|
+
return String(Math.round(n));
|
|
2038
|
+
}
|
|
2039
|
+
function rate(n){
|
|
2040
|
+
if (n === null || n === undefined || !isFinite(n)) return "0";
|
|
2041
|
+
if (n >= 100) return String(Math.round(n));
|
|
2042
|
+
if (n >= 10) return n.toFixed(1);
|
|
2043
|
+
return n.toFixed(2);
|
|
2044
|
+
}
|
|
2045
|
+
function pct(n){ if (!isFinite(n)) return "0%"; return (n >= 10 ? Math.round(n) : Math.round(n*10)/10) + "%"; }
|
|
2046
|
+
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 + ""; }
|
|
2047
|
+
function pad2(n){ return (n < 10 ? "0" : "") + n; }
|
|
2048
|
+
function fmtUptime(sec){
|
|
2049
|
+
sec = Math.max(0, Math.floor(sec));
|
|
2050
|
+
var d = Math.floor(sec/86400); sec -= d*86400;
|
|
2051
|
+
var h = Math.floor(sec/3600); sec -= h*3600;
|
|
2052
|
+
var m = Math.floor(sec/60); var s = sec - m*60;
|
|
2053
|
+
if (d > 0) return d + "d " + pad2(h) + ":" + pad2(m);
|
|
2054
|
+
if (h > 0) return h + ":" + pad2(m) + ":" + pad2(s);
|
|
2055
|
+
return m + ":" + pad2(s);
|
|
2056
|
+
}
|
|
2057
|
+
function titleize(key){
|
|
2058
|
+
var map = { premium_interactions:"Premium requests", chat:"Chat", completions:"Completions", code_review:"Code review" };
|
|
2059
|
+
if (map[key]) return map[key];
|
|
2060
|
+
return key.split("_").map(function(w){ return w ? w.charAt(0).toUpperCase() + w.slice(1) : w; }).join(" ");
|
|
2061
|
+
}
|
|
2062
|
+
function relTime(iso){
|
|
2063
|
+
var t = Date.parse(iso); if (!isFinite(t)) return iso || "";
|
|
2064
|
+
var s = Math.max(0, Math.round((Date.now() - t)/1000));
|
|
2065
|
+
return fmtUptime(s) + " ago";
|
|
2066
|
+
}
|
|
2067
|
+
function clearEl(el){ while (el && el.firstChild) el.removeChild(el.firstChild); }
|
|
2068
|
+
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; }
|
|
2069
|
+
|
|
2070
|
+
// Set numeric text and flash on discrete change.
|
|
2071
|
+
function setNum(id, value, kind){
|
|
2072
|
+
var el = byId(id); if (!el) return;
|
|
2073
|
+
el.classList.remove("skel");
|
|
2074
|
+
var s = String(value);
|
|
2075
|
+
if (el.textContent !== s){
|
|
2076
|
+
el.textContent = s;
|
|
2077
|
+
var prev = lastRender[id];
|
|
2078
|
+
if (prev !== undefined){
|
|
2079
|
+
var cls = "flash";
|
|
2080
|
+
if (kind === "delta" && typeof value === "number" && typeof prev === "number"){
|
|
2081
|
+
cls = value > prev ? "flash-up" : (value < prev ? "flash-down" : null);
|
|
2082
|
+
}
|
|
2083
|
+
if (cls){ el.classList.remove("flash","flash-up","flash-down"); void el.offsetWidth; el.classList.add(cls); }
|
|
2084
|
+
}
|
|
2085
|
+
lastRender[id] = value;
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
function setText(id, s){ var el = byId(id); if (el){ el.classList.remove("skel"); el.textContent = s; } }
|
|
2089
|
+
|
|
2090
|
+
// ---- sparkline rendering ----
|
|
2091
|
+
function pushHist(arr, v){ arr.push(v); if (arr.length > CAP) arr.shift(); }
|
|
2092
|
+
function buildSpark(values, w, h){
|
|
2093
|
+
var pts = []; for (var i=0;i<values.length;i++){ if (isFinite(values[i])) pts.push({ i:i, v:values[i] }); }
|
|
2094
|
+
if (pts.length < 2) return null;
|
|
2095
|
+
var min = Infinity, max = -Infinity;
|
|
2096
|
+
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; } }
|
|
2097
|
+
var flat = (max - min) <= 0;
|
|
2098
|
+
var pad = flat ? 1 : (max - min) * 0.05; var lo = min - pad, hi = max + pad; var span = hi - lo; if (span <= 0) span = 1;
|
|
2099
|
+
var n = values.length;
|
|
2100
|
+
var line = "", lastX = 0, lastY = 0, started = false;
|
|
2101
|
+
for (var k=0;k<n;k++){
|
|
2102
|
+
var val = values[k]; if (!isFinite(val)) continue;
|
|
2103
|
+
var x = (n === 1) ? w : (k * (w/(n-1)));
|
|
2104
|
+
var norm = flat ? 0.5 : (val - lo)/span;
|
|
2105
|
+
var y = h - norm*(h-2) - 1;
|
|
2106
|
+
line += (started ? " L" : "M") + x.toFixed(2) + "," + y.toFixed(2);
|
|
2107
|
+
lastX = x; lastY = y; started = true;
|
|
2108
|
+
}
|
|
2109
|
+
var area = line + " L" + lastX.toFixed(2) + "," + h + " L0," + h + " Z";
|
|
2110
|
+
return { line:line, area:area, lastX:lastX, lastY:lastY };
|
|
2111
|
+
}
|
|
2112
|
+
function drawSpark(svgId, values){
|
|
2113
|
+
var svg = byId(svgId); if (!svg) return;
|
|
2114
|
+
var vb = svg.viewBox.baseVal; var w = vb.width || 200, h = vb.height || 24;
|
|
2115
|
+
var sp = buildSpark(values, w, h);
|
|
2116
|
+
var line = svg.querySelector(".line"), area = svg.querySelector(".area"), dot = svg.querySelector("circle");
|
|
2117
|
+
if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); if (dot) dot.style.display = "none"; return; }
|
|
2118
|
+
if (line) line.setAttribute("d", sp.line);
|
|
2119
|
+
if (area) area.setAttribute("d", sp.area);
|
|
2120
|
+
if (dot){ dot.setAttribute("cx", sp.lastX.toFixed(2)); dot.setAttribute("cy", sp.lastY.toFixed(2)); dot.style.display = ""; }
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// ---- theme ----
|
|
2124
|
+
function applyTheme(){
|
|
2125
|
+
var root = document.documentElement;
|
|
2126
|
+
if (theme === "dark") root.setAttribute("data-theme","dark");
|
|
2127
|
+
else if (theme === "light") root.setAttribute("data-theme","light");
|
|
2128
|
+
else root.removeAttribute("data-theme");
|
|
2129
|
+
byId("btn-theme").textContent = theme === "dark" ? "D" : (theme === "light" ? "L" : "A");
|
|
2130
|
+
}
|
|
2131
|
+
byId("btn-theme").addEventListener("click", function(){
|
|
2132
|
+
theme = theme === "auto" ? "dark" : (theme === "dark" ? "light" : "auto");
|
|
2133
|
+
try { LS.setItem("hoopilot.theme", theme); } catch (e) {}
|
|
2134
|
+
applyTheme();
|
|
2135
|
+
});
|
|
2136
|
+
|
|
2137
|
+
// ---- interval + pause ----
|
|
2138
|
+
function setActiveSeg(){
|
|
2139
|
+
var btns = byId("seg").querySelectorAll("button");
|
|
2140
|
+
for (var i=0;i<btns.length;i++){ btns[i].classList.toggle("active", parseInt(btns[i].getAttribute("data-ms"),10) === intervalMs); }
|
|
2141
|
+
document.documentElement.style.setProperty("--scan-ms", intervalMs + "ms");
|
|
2142
|
+
}
|
|
2143
|
+
byId("seg").addEventListener("click", function(ev){
|
|
2144
|
+
var b = ev.target.closest ? ev.target.closest("button") : null; if (!b) return;
|
|
2145
|
+
intervalMs = parseInt(b.getAttribute("data-ms"),10) || 4000;
|
|
2146
|
+
try { LS.setItem("hoopilot.intervalMs", String(intervalMs)); } catch (e) {}
|
|
2147
|
+
setActiveSeg();
|
|
2148
|
+
if (!paused){ schedule(0); }
|
|
2149
|
+
});
|
|
2150
|
+
byId("btn-pause").addEventListener("click", function(){
|
|
2151
|
+
paused = !paused;
|
|
2152
|
+
byId("btn-pause").innerHTML = paused ? "▶" : "❚❚";
|
|
2153
|
+
byId("bar").classList.toggle("paused", paused);
|
|
2154
|
+
if (paused){ if (timer){ clearTimeout(timer); timer = null; } setPill("paused","PAUSED",false); }
|
|
2155
|
+
else { setPill("live","LIVE",false); schedule(0); }
|
|
2156
|
+
});
|
|
2157
|
+
|
|
2158
|
+
// ---- connection pill / banner ----
|
|
2159
|
+
function setPill(kind, text, beat){
|
|
2160
|
+
var pill = byId("conn-pill"); var dot = byId("conn-dot");
|
|
2161
|
+
pill.className = "pill " + kind;
|
|
2162
|
+
byId("conn-text").textContent = text;
|
|
2163
|
+
if (beat && dot){ dot.classList.remove("heartbeat"); void dot.offsetWidth; dot.classList.add("heartbeat"); }
|
|
2164
|
+
}
|
|
2165
|
+
function showBanner(text, ok){
|
|
2166
|
+
var b = byId("banner"); b.textContent = text; b.className = "banner show" + (ok ? " ok" : ""); b.classList.add("show");
|
|
2167
|
+
if (ok){ setTimeout(function(){ b.classList.remove("show"); }, 2000); }
|
|
2168
|
+
}
|
|
2169
|
+
function hideBanner(){ byId("banner").classList.remove("show"); }
|
|
2170
|
+
function setDimmed(on){ byId("content").classList.toggle("dim", on); }
|
|
2171
|
+
|
|
2172
|
+
// ---- auth takeover ----
|
|
2173
|
+
function showAuth(rejected){
|
|
2174
|
+
byId("content").style.display = "none";
|
|
2175
|
+
byId("auth").classList.add("show");
|
|
2176
|
+
setPill("authkey","API KEY",false);
|
|
2177
|
+
byId("auth-err").textContent = rejected ? "key rejected" : "";
|
|
2178
|
+
byId("auth-input").classList.toggle("bad", !!rejected);
|
|
2179
|
+
byId("auth-clear").style.display = apiKey ? "" : "none";
|
|
2180
|
+
byId("auth-input").focus();
|
|
2181
|
+
}
|
|
2182
|
+
function hideAuth(){ byId("auth").classList.remove("show"); byId("content").style.display = ""; }
|
|
2183
|
+
byId("auth-connect").addEventListener("click", function(){
|
|
2184
|
+
var v = byId("auth-input").value.trim(); if (!v) return;
|
|
2185
|
+
apiKey = v; try { LS.setItem("hoopilot.apiKey", apiKey); } catch (e) {}
|
|
2186
|
+
hideAuth(); schedule(0);
|
|
2187
|
+
});
|
|
2188
|
+
byId("auth-input").addEventListener("keydown", function(ev){ if (ev.key === "Enter") byId("auth-connect").click(); });
|
|
2189
|
+
byId("auth-clear").addEventListener("click", function(){
|
|
2190
|
+
apiKey = ""; try { LS.removeItem("hoopilot.apiKey"); } catch (e) {}
|
|
2191
|
+
byId("auth-input").value = ""; byId("auth-clear").style.display = "none"; byId("auth-input").focus();
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
// ---- the poll loop (setTimeout-chained, never setInterval) ----
|
|
2195
|
+
var pollGen = 0;
|
|
2196
|
+
function schedule(delay){
|
|
2197
|
+
if (timer){ clearTimeout(timer); }
|
|
2198
|
+
if (paused) return;
|
|
2199
|
+
timer = setTimeout(poll, delay === undefined ? intervalMs : delay);
|
|
2200
|
+
}
|
|
2201
|
+
function poll(){
|
|
2202
|
+
if (paused) return;
|
|
2203
|
+
// A new poll supersedes any in-flight one. Bump the generation so the old
|
|
2204
|
+
// request's settled handlers (including its abort rejection) become no-ops
|
|
2205
|
+
// and never flash a false "disconnected".
|
|
2206
|
+
pollGen += 1; var myGen = pollGen;
|
|
2207
|
+
if (inflightFetch){ try { inflightFetch.abort(); } catch (e) {} }
|
|
2208
|
+
var ctrl = new AbortController(); inflightFetch = ctrl;
|
|
2209
|
+
var to = setTimeout(function(){ try { ctrl.abort(); } catch (e) {} }, 3000);
|
|
2210
|
+
var headers = { "accept":"application/json" };
|
|
2211
|
+
if (apiKey) headers["x-api-key"] = apiKey;
|
|
2212
|
+
fetch("/v1/usage", { headers: headers, signal: ctrl.signal, cache:"no-store" }).then(function(res){
|
|
2213
|
+
clearTimeout(to);
|
|
2214
|
+
if (myGen !== pollGen) return null;
|
|
2215
|
+
if (res.status === 401 || res.status === 403){ inflightFetch = null; showAuth(!!apiKey); return null; }
|
|
2216
|
+
if (!res.ok) throw new Error("HTTP " + res.status);
|
|
2217
|
+
return res.json();
|
|
2218
|
+
}).then(function(data){
|
|
2219
|
+
if (myGen !== pollGen || data === null || paused) return;
|
|
2220
|
+
inflightFetch = null;
|
|
2221
|
+
onData(data);
|
|
2222
|
+
backoffMs = 0; lastSuccessAt = Date.now();
|
|
2223
|
+
hideAuth(); setDimmed(false); hideBanner();
|
|
2224
|
+
setPill("live","LIVE",true);
|
|
2225
|
+
byId("bar").classList.remove("frozen");
|
|
2226
|
+
schedule(intervalMs);
|
|
2227
|
+
}).catch(function(err){
|
|
2228
|
+
clearTimeout(to);
|
|
2229
|
+
if (myGen !== pollGen || paused) return;
|
|
2230
|
+
inflightFetch = null;
|
|
2231
|
+
onDisconnect(err);
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
function onDisconnect(err){
|
|
2235
|
+
setPill("reconnect","RECONNECTING",false);
|
|
2236
|
+
setDimmed(true);
|
|
2237
|
+
byId("bar").classList.add("frozen");
|
|
2238
|
+
backoffMs = backoffMs ? Math.min(Math.round(backoffMs * 1.5), 30000) : intervalMs;
|
|
2239
|
+
showBanner("Disconnected (" + (err && err.message ? err.message : "no response") + ") \\u2014 retrying in " + Math.round(backoffMs/1000) + "s", false);
|
|
2240
|
+
schedule(backoffMs);
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
// ---- main render ----
|
|
2244
|
+
function onData(usage){
|
|
2245
|
+
var proxy = usage.proxy || {};
|
|
2246
|
+
var now = Date.now();
|
|
2247
|
+
|
|
2248
|
+
setText("version-chip", "v" + (usage.version || "?"));
|
|
2249
|
+
|
|
2250
|
+
// rates
|
|
2251
|
+
var reqTotal = (proxy.requests && proxy.requests.total) || 0;
|
|
2252
|
+
var tokTotal = (proxy.tokens && proxy.tokens.total) || 0;
|
|
2253
|
+
var upTotal = (proxy.upstream && proxy.upstream.total) || 0;
|
|
2254
|
+
var startedAt = proxy.startedAt || "";
|
|
2255
|
+
var reqPerSec = NaN, tokPerSec = NaN, upDelta = 0, restarted = false;
|
|
2256
|
+
if (prevSample){
|
|
2257
|
+
var dt = (now - prevSample.t)/1000;
|
|
2258
|
+
if (prevSample.startedAt && startedAt && prevSample.startedAt !== startedAt) restarted = true;
|
|
2259
|
+
if (reqTotal < prevSample.reqTotal || tokTotal < prevSample.tokTotal) restarted = true;
|
|
2260
|
+
if (restarted){ reqPerSec = 0; tokPerSec = 0; upDelta = 0; }
|
|
2261
|
+
else if (dt > 0 && isFinite(dt)){
|
|
2262
|
+
reqPerSec = Math.max(0, (reqTotal - prevSample.reqTotal)/dt);
|
|
2263
|
+
tokPerSec = Math.max(0, (tokTotal - prevSample.tokTotal)/dt);
|
|
2264
|
+
upDelta = Math.max(0, upTotal - prevSample.upTotal);
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
prevSample = { t:now, reqTotal:reqTotal, tokTotal:tokTotal, upTotal:upTotal, startedAt:startedAt };
|
|
2268
|
+
|
|
2269
|
+
// hero vitals
|
|
2270
|
+
if (isFinite(reqPerSec)){ pushHist(hist.req, reqPerSec); setNum("req-num", rate(reqPerSec)); } else setText("req-num","\\u2014");
|
|
2271
|
+
if (isFinite(tokPerSec)){ pushHist(hist.tok, tokPerSec); setNum("tok-num", humanInt(tokPerSec)); } else setText("tok-num","\\u2014");
|
|
2272
|
+
var inflight = proxy.inFlight || 0;
|
|
2273
|
+
pushHist(hist.inflight, inflight); setNum("inflight-num", String(inflight), "delta");
|
|
2274
|
+
byId("v-inflight").classList.toggle("active", inflight > 0);
|
|
2275
|
+
setText("uptime-num", fmtUptime(proxy.uptimeSeconds || 0));
|
|
2276
|
+
|
|
2277
|
+
setText("req-sub", hist.req.length ? ("avg " + rate(avg(hist.req)) + "/s") : "warming up");
|
|
2278
|
+
setText("tok-sub", hist.tok.length ? ("peak " + humanInt(Math.max.apply(null, hist.tok)) + "/s") : "warming up");
|
|
2279
|
+
setText("inflight-sub", inflight + " now");
|
|
2280
|
+
setText("uptime-sub", startedAt ? ("since " + relTime(startedAt)) : "");
|
|
2281
|
+
|
|
2282
|
+
drawSpark("req-spark", hist.req);
|
|
2283
|
+
drawSpark("tok-spark", hist.tok);
|
|
2284
|
+
drawSpark("inflight-spark", hist.inflight);
|
|
2285
|
+
|
|
2286
|
+
renderRequests(proxy);
|
|
2287
|
+
renderStatus(proxy);
|
|
2288
|
+
renderLatency(proxy.latency || {});
|
|
2289
|
+
renderTokens(proxy.tokens || {});
|
|
2290
|
+
renderCopilot(usage);
|
|
2291
|
+
renderUpstream(proxy.upstream || {}, upDelta, restarted);
|
|
2292
|
+
renderThroughput();
|
|
2293
|
+
renderFooter(usage, proxy);
|
|
2294
|
+
|
|
2295
|
+
setNum("req-total", humanInt(reqTotal));
|
|
2296
|
+
setNum("tok-total", humanInt(tokTotal));
|
|
2297
|
+
lastUptime = proxy.uptimeSeconds || 0;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
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; }
|
|
2301
|
+
|
|
2302
|
+
var ROUTE_COLORS = ["var(--c1)","var(--c2)","var(--c3)","var(--c4)","var(--c5)","var(--c6)"];
|
|
2303
|
+
function renderRequests(proxy){
|
|
2304
|
+
var byRoute = (proxy.requests && proxy.requests.byRoute) || {};
|
|
2305
|
+
var total = (proxy.requests && proxy.requests.total) || 0;
|
|
2306
|
+
var rows = Object.keys(byRoute).map(function(k){ return { k:k, v:byRoute[k] }; }).sort(function(a,b){ return b.v - a.v; });
|
|
2307
|
+
var share = byId("route-sharebar"); clearEl(share); share.className = "stack-bar" + (total ? "" : " empty");
|
|
2308
|
+
var body = byId("routes-body"); clearEl(body);
|
|
2309
|
+
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; }
|
|
2310
|
+
rows.forEach(function(r, idx){
|
|
2311
|
+
var p = total ? (r.v/total*100) : 0;
|
|
2312
|
+
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);
|
|
2313
|
+
var tr = mk("tr");
|
|
2314
|
+
var name = mk("td","l", r.k); name.title = r.k; tr.appendChild(name);
|
|
2315
|
+
tr.appendChild(mk("td",null, humanInt(r.v)));
|
|
2316
|
+
tr.appendChild(mk("td",null, pct(p)));
|
|
2317
|
+
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);
|
|
2318
|
+
body.appendChild(tr);
|
|
2319
|
+
});
|
|
2320
|
+
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);
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
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"; }
|
|
2324
|
+
function statusColor(cls){ return cls === "ok" ? "var(--ok)" : cls === "info" ? "var(--info)" : cls === "warn" ? "var(--warn)" : cls === "danger" ? "var(--danger)" : "var(--text-2)"; }
|
|
2325
|
+
function renderStatus(proxy){
|
|
2326
|
+
var byStatus = (proxy.requests && proxy.requests.byStatus) || {};
|
|
2327
|
+
var total = 0, errs = 0; var groups = { ok:0, info:0, warn:0, danger:0, muted:0 };
|
|
2328
|
+
var codes = Object.keys(byStatus).map(function(k){ return { k:k, v:byStatus[k] }; }).sort(function(a,b){ return b.v - a.v; });
|
|
2329
|
+
codes.forEach(function(c){ total += c.v; var cls = statusClass(c.k); groups[cls] += c.v; if (cls === "warn" || cls === "danger") errs += c.v; });
|
|
2330
|
+
var bar = byId("status-bar"); clearEl(bar); bar.className = "stack-bar" + (total ? "" : " empty");
|
|
2331
|
+
["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); } });
|
|
2332
|
+
var leg = byId("status-legend"); clearEl(leg);
|
|
2333
|
+
if (!codes.length){ leg.appendChild(mk("span","li","no requests yet")); }
|
|
2334
|
+
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); });
|
|
2335
|
+
var er = total ? (errs/total*100) : 0;
|
|
2336
|
+
setNum("error-rate", pct(er));
|
|
2337
|
+
var el = byId("error-rate"); el.style.color = er > 5 ? "var(--danger)" : er > 1 ? "var(--warn)" : "var(--ok)";
|
|
2338
|
+
}
|
|
2339
|
+
|
|
2340
|
+
function renderLatency(lat){
|
|
2341
|
+
setText("lat-p50", fmtMs(lat.p50Ms)); setText("lat-avg", fmtMs(lat.avgMs)); setText("lat-count", humanInt(lat.count || 0));
|
|
2342
|
+
var p95 = byId("lat-p95"); p95.classList.remove("skel"); p95.textContent = fmtMs(lat.p95Ms);
|
|
2343
|
+
p95.style.color = (lat.p50Ms > 0 && lat.p95Ms > 2*lat.p50Ms) ? "var(--warn)" : "var(--info)";
|
|
2344
|
+
// track: position p50 and p95 across 0..(p95*1.15)
|
|
2345
|
+
var track = byId("lat-track"); var old = track.querySelectorAll(".tick,.tlab"); for (var i=0;i<old.length;i++) old[i].remove();
|
|
2346
|
+
var maxv = Math.max(lat.p95Ms || 0, lat.avgMs || 0, 1) * 1.15;
|
|
2347
|
+
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); }
|
|
2348
|
+
place(lat.p50Ms, "p50"); place(lat.p95Ms, "p95");
|
|
2349
|
+
var lr = byId("lat-routes"); clearEl(lr);
|
|
2350
|
+
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); });
|
|
2351
|
+
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); });
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2354
|
+
function renderTokens(tok){
|
|
2355
|
+
var prompt = tok.prompt||0, completion = tok.completion||0, reasoning = tok.reasoning||0, cached = tok.cached||0;
|
|
2356
|
+
var sum = prompt + completion + reasoning;
|
|
2357
|
+
var bar = byId("tok-mixbar"); clearEl(bar); bar.className = "stack-bar" + (sum ? "" : " empty");
|
|
2358
|
+
var parts = [ ["prompt", prompt, "var(--text-1)"], ["completion", completion, "var(--accent)"], ["reasoning", reasoning, "var(--info)"] ];
|
|
2359
|
+
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); } });
|
|
2360
|
+
var leg = byId("tok-legend"); clearEl(leg);
|
|
2361
|
+
var legParts = parts.concat([["cached", cached, "var(--cache)"]]);
|
|
2362
|
+
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); });
|
|
2363
|
+
var cacheRate = prompt ? (cached/prompt*100) : 0; setText("tok-cache", "cache " + pct(cacheRate));
|
|
2364
|
+
var body = byId("tok-body"); clearEl(body);
|
|
2365
|
+
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); });
|
|
2366
|
+
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; }
|
|
2367
|
+
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);
|
|
2368
|
+
tr.appendChild(mk("td",null, humanInt(m.prompt||0))); tr.appendChild(mk("td",null, humanInt(m.completion||0)));
|
|
2369
|
+
tr.appendChild(mk("td","reasoning", humanInt(m.reasoning||0))); tr.appendChild(mk("td","cached", humanInt(m.cached||0)));
|
|
2370
|
+
tr.appendChild(mk("td",null, humanInt(m.total||0))); tr.appendChild(mk("td",null, humanInt(m.requests||0))); body.appendChild(tr); });
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
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"; }
|
|
2374
|
+
function renderCopilot(usage){
|
|
2375
|
+
var box = byId("copilot-body"); clearEl(box);
|
|
2376
|
+
var cp = usage.copilot; var planChip = byId("plan-chip");
|
|
2377
|
+
if (!cp){
|
|
2378
|
+
planChip.className = "chip plan-offline"; planChip.textContent = "\\u2014 offline";
|
|
2379
|
+
var eb = mk("div","emptybox"); eb.appendChild(mk("div","keyglyph","\\u26bf"));
|
|
2380
|
+
eb.appendChild(mk("h4",null,"Copilot not connected"));
|
|
2381
|
+
if (usage.copilot_error) eb.appendChild(mk("div","errline", usage.copilot_error));
|
|
2382
|
+
eb.appendChild(mk("div","prompt","$ hoopilot login"));
|
|
2383
|
+
box.appendChild(eb); return;
|
|
2384
|
+
}
|
|
2385
|
+
planChip.className = "chip " + planClass(cp.plan); planChip.textContent = cp.plan || "copilot";
|
|
2386
|
+
var head = mk("div","cap");
|
|
2387
|
+
var bits = [];
|
|
2388
|
+
if (cp.accessTypeSku) bits.push(cp.accessTypeSku);
|
|
2389
|
+
if (cp.chatEnabled !== undefined) bits.push(cp.chatEnabled ? "chat on" : "chat off");
|
|
2390
|
+
if (cp.quotaResetDate) bits.push("resets " + cp.quotaResetDate);
|
|
2391
|
+
head.textContent = bits.join(" \\u00b7 "); box.appendChild(head);
|
|
2392
|
+
var quotas = cp.quotas || {}; var keys = Object.keys(quotas);
|
|
2393
|
+
if (!keys.length){ box.appendChild(mk("div","cap","No metered quotas reported.")); return; }
|
|
2394
|
+
var order = { premium_interactions:0, chat:1, completions:2 };
|
|
2395
|
+
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); });
|
|
2396
|
+
keys.forEach(function(k){
|
|
2397
|
+
var q = quotas[k]; var row = mk("div","qrow");
|
|
2398
|
+
var hd = mk("div","qhead"); hd.appendChild(mk("span","qname", titleize(k)));
|
|
2399
|
+
if (q.unlimited){ hd.appendChild(mk("span","inf","\\u221e unlimited")); row.appendChild(hd); box.appendChild(row); return; }
|
|
2400
|
+
var ent = q.entitlement, rem = q.remaining, used = q.used;
|
|
2401
|
+
var usedPct = (q.percentRemaining !== undefined) ? (100 - q.percentRemaining) : ((ent && used !== undefined) ? (used/ent*100) : 0);
|
|
2402
|
+
usedPct = Math.max(0, Math.min(100, usedPct));
|
|
2403
|
+
var valTxt = (used !== undefined && ent !== undefined) ? (humanInt(used) + " / " + humanInt(ent)) : (rem !== undefined ? (humanInt(rem) + " left") : pct(100-usedPct) + " left");
|
|
2404
|
+
hd.appendChild(mk("span","qval", valTxt)); row.appendChild(hd);
|
|
2405
|
+
var bar = mk("div","qbar"); var fill = mk("i"); fill.style.width = usedPct + "%";
|
|
2406
|
+
fill.style.background = usedPct > 85 ? "var(--danger)" : usedPct > 60 ? "var(--warn)" : "var(--ok)"; bar.appendChild(fill);
|
|
2407
|
+
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); }
|
|
2408
|
+
row.appendChild(bar);
|
|
2409
|
+
if (q.overageCount){ var ov = mk("div","flag", humanInt(q.overageCount) + " overage" + (q.tokenBasedBilling ? " \\u00b7 token billing" : "")); row.appendChild(ov); }
|
|
2410
|
+
box.appendChild(row);
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
function renderUpstream(up, delta, restarted){
|
|
2415
|
+
setNum("up-total", humanInt(up.total||0));
|
|
2416
|
+
setNum("up-errors", humanInt(up.errors||0), "delta");
|
|
2417
|
+
var er = up.total ? (up.errors/up.total*100) : 0;
|
|
2418
|
+
var rt = byId("up-rate"); rt.textContent = pct(er); rt.className = "v rate " + (er > 5 ? "danger" : er > 1 ? "warn" : "ok");
|
|
2419
|
+
byId("up-errblk").classList.toggle("hot", (up.errors||0) > 0);
|
|
2420
|
+
pushHist(hist.up, delta||0); drawSpark("up-spark", hist.up);
|
|
2421
|
+
byId("up-flag").textContent = restarted ? "\\u21bb restarted" : "";
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
function renderThroughput(){
|
|
2425
|
+
drawDual("thru-tok-line","thru-tok-area", hist.tok, true);
|
|
2426
|
+
drawDual("thru-req-line", null, hist.req, false);
|
|
2427
|
+
setText("thru-tok", hist.tok.length ? rate(hist.tok[hist.tok.length-1]) : "\\u2014");
|
|
2428
|
+
setText("thru-req", hist.req.length ? rate(hist.req[hist.req.length-1]) : "\\u2014");
|
|
2429
|
+
var peakTok = hist.tok.length ? Math.max.apply(null, hist.tok) : 0;
|
|
2430
|
+
setText("thru-peak", "peak " + humanInt(peakTok) + " tok/s");
|
|
2431
|
+
}
|
|
2432
|
+
function drawDual(lineId, areaId, values, withArea){
|
|
2433
|
+
var svg = byId("thru-svg"); var vb = svg.viewBox.baseVal; var w = vb.width, h = vb.height;
|
|
2434
|
+
var sp = buildSpark(values, w, h);
|
|
2435
|
+
var line = byId(lineId); var area = areaId ? byId(areaId) : null;
|
|
2436
|
+
if (!sp){ if (line) line.setAttribute("d",""); if (area) area.setAttribute("d",""); return; }
|
|
2437
|
+
if (line) line.setAttribute("d", sp.line);
|
|
2438
|
+
if (area && withArea) area.setAttribute("d", sp.area);
|
|
2439
|
+
}
|
|
2440
|
+
|
|
2441
|
+
function renderFooter(usage, proxy){
|
|
2442
|
+
setText("foot-started", proxy.startedAt ? ("started " + new Date(proxy.startedAt).toLocaleString()) : "started \\u2014");
|
|
2443
|
+
setText("foot-uptime", "uptime " + fmtUptime(proxy.uptimeSeconds||0));
|
|
2444
|
+
setText("foot-total", humanInt((proxy.requests && proxy.requests.total)||0) + " req");
|
|
2445
|
+
setText("foot-tokens", humanInt((proxy.tokens && proxy.tokens.total)||0) + " tokens");
|
|
2446
|
+
var up = proxy.upstream || {}; setText("foot-upstream", "upstream " + humanInt(up.total||0) + " / " + humanInt(up.errors||0) + " err");
|
|
2447
|
+
setText("foot-cadence", "polling /v1/usage every " + Math.round(intervalMs/1000) + "s \\u00b7 GET /dashboard");
|
|
2448
|
+
}
|
|
2449
|
+
|
|
2450
|
+
// ---- 1s freshness + uptime ticker (independent of the poll loop) ----
|
|
2451
|
+
setInterval(function(){
|
|
2452
|
+
if (lastSuccessAt){
|
|
2453
|
+
var ago = Math.round((Date.now() - lastSuccessAt)/1000);
|
|
2454
|
+
var u = byId("updated"); u.textContent = "updated " + ago + "s ago";
|
|
2455
|
+
// Staleness only matters while polling; a deliberate pause is not "stale".
|
|
2456
|
+
u.className = "updated" + (paused ? "" : ago > intervalMs/1000*4 ? " danger" : ago > intervalMs/1000*2 ? " warn" : "");
|
|
2457
|
+
}
|
|
2458
|
+
// Tick uptime locally between polls so the seconds advance smoothly; each
|
|
2459
|
+
// successful poll re-seeds lastUptime from the authoritative server value.
|
|
2460
|
+
if (!paused && lastUptime !== null){
|
|
2461
|
+
lastUptime += 1;
|
|
2462
|
+
byId("uptime-num").textContent = fmtUptime(lastUptime);
|
|
2463
|
+
var fu = byId("foot-uptime"); if (fu) fu.textContent = "uptime " + fmtUptime(lastUptime);
|
|
2464
|
+
}
|
|
2465
|
+
}, 1000);
|
|
2466
|
+
|
|
2467
|
+
// ---- boot ----
|
|
2468
|
+
applyTheme(); setActiveSeg();
|
|
2469
|
+
setPill("","CONNECTING",false);
|
|
2470
|
+
poll();
|
|
2471
|
+
})();
|
|
2472
|
+
</script>
|
|
2473
|
+
</body>
|
|
2474
|
+
</html>
|
|
2475
|
+
`;
|
|
2476
|
+
|
|
1671
2477
|
// src/metrics.ts
|
|
1672
2478
|
var PROMETHEUS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8";
|
|
1673
2479
|
var DURATION_BUCKETS_SECONDS = [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60];
|
|
@@ -1689,6 +2495,7 @@ var MetricsRegistry = class {
|
|
|
1689
2495
|
#upstream = /* @__PURE__ */ new Map();
|
|
1690
2496
|
#copilotQuota;
|
|
1691
2497
|
#githubRateLimit = /* @__PURE__ */ new Map();
|
|
2498
|
+
#extraction = { extracted: 0, missing: 0 };
|
|
1692
2499
|
constructor(options = {}) {
|
|
1693
2500
|
this.#startedAtMs = (options.now ?? Date.now)();
|
|
1694
2501
|
}
|
|
@@ -1705,6 +2512,19 @@ var MetricsRegistry = class {
|
|
|
1705
2512
|
this.#requests.set(key, (this.#requests.get(key) ?? 0) + 1);
|
|
1706
2513
|
this.#observeDuration(observation.route, observation.durationMs / 1e3);
|
|
1707
2514
|
}
|
|
2515
|
+
/**
|
|
2516
|
+
* Record whether one upstream completion reported token usage. `missing`
|
|
2517
|
+
* counts responses that carried no usage object — most often streamed Chat
|
|
2518
|
+
* Completions sent without `stream_options: {"include_usage": true}` — so a
|
|
2519
|
+
* rising miss rate flags clients whose token usage is going unaccounted.
|
|
2520
|
+
*/
|
|
2521
|
+
recordTokenExtraction(extracted) {
|
|
2522
|
+
if (extracted) {
|
|
2523
|
+
this.#extraction.extracted += 1;
|
|
2524
|
+
} else {
|
|
2525
|
+
this.#extraction.missing += 1;
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
1708
2528
|
/** Accumulate token counts for a model from one upstream completion. */
|
|
1709
2529
|
recordTokens(model, usage) {
|
|
1710
2530
|
const name = this.#modelLabel(model);
|
|
@@ -1810,13 +2630,45 @@ var MetricsRegistry = class {
|
|
|
1810
2630
|
return {
|
|
1811
2631
|
githubRateLimit,
|
|
1812
2632
|
inFlight: this.#inFlight,
|
|
2633
|
+
latency: this.#latencySnapshot(),
|
|
1813
2634
|
requests: { byRoute, byStatus, total: requestsTotal },
|
|
1814
2635
|
startedAt: new Date(this.#startedAtMs).toISOString(),
|
|
1815
|
-
tokens: { byModel, ...tokenTotals },
|
|
2636
|
+
tokens: { byModel, extraction: { ...this.#extraction }, ...tokenTotals },
|
|
1816
2637
|
upstream: { errors: upstreamErrors, total: upstreamTotal },
|
|
1817
2638
|
uptimeSeconds: Math.max(0, Math.round((now() - this.#startedAtMs) / 1e3))
|
|
1818
2639
|
};
|
|
1819
2640
|
}
|
|
2641
|
+
// Summarize the duration histogram into a JSON latency view: per-route count and
|
|
2642
|
+
// exact average, plus overall average and estimated p50/p95. The percentiles come
|
|
2643
|
+
// from the buckets aggregated across routes, so they share /metrics' resolution.
|
|
2644
|
+
#latencySnapshot() {
|
|
2645
|
+
const byRoute = {};
|
|
2646
|
+
const aggregateBuckets = new Array(DURATION_BUCKETS_SECONDS.length).fill(0);
|
|
2647
|
+
let totalCount = 0;
|
|
2648
|
+
let totalSum = 0;
|
|
2649
|
+
for (const [route, entry] of this.#durations) {
|
|
2650
|
+
byRoute[route] = {
|
|
2651
|
+
avgMs: entry.count > 0 ? round2(entry.sum / entry.count * 1e3) : 0,
|
|
2652
|
+
count: entry.count
|
|
2653
|
+
};
|
|
2654
|
+
totalCount += entry.count;
|
|
2655
|
+
totalSum += entry.sum;
|
|
2656
|
+
for (let i = 0; i < aggregateBuckets.length; i += 1) {
|
|
2657
|
+
aggregateBuckets[i] = (aggregateBuckets[i] ?? 0) + (entry.buckets[i] ?? 0);
|
|
2658
|
+
}
|
|
2659
|
+
}
|
|
2660
|
+
return {
|
|
2661
|
+
avgMs: totalCount > 0 ? round2(totalSum / totalCount * 1e3) : 0,
|
|
2662
|
+
byRoute,
|
|
2663
|
+
count: totalCount,
|
|
2664
|
+
p50Ms: round2(
|
|
2665
|
+
quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.5) * 1e3
|
|
2666
|
+
),
|
|
2667
|
+
p95Ms: round2(
|
|
2668
|
+
quantileFromBuckets(aggregateBuckets, DURATION_BUCKETS_SECONDS, totalCount, 0.95) * 1e3
|
|
2669
|
+
)
|
|
2670
|
+
};
|
|
2671
|
+
}
|
|
1820
2672
|
/** Render the Prometheus text exposition format (version 0.0.4). */
|
|
1821
2673
|
renderPrometheus(now = Date.now) {
|
|
1822
2674
|
const lines = [];
|
|
@@ -1862,6 +2714,16 @@ var MetricsRegistry = class {
|
|
|
1862
2714
|
for (const [model, totals] of this.#tokens) {
|
|
1863
2715
|
lines.push(`hoopilot_model_requests_total${labels({ model })} ${totals.requests}`);
|
|
1864
2716
|
}
|
|
2717
|
+
lines.push(
|
|
2718
|
+
"# HELP hoopilot_token_extraction_total Completions by whether upstream reported token usage."
|
|
2719
|
+
);
|
|
2720
|
+
lines.push("# TYPE hoopilot_token_extraction_total counter");
|
|
2721
|
+
lines.push(
|
|
2722
|
+
`hoopilot_token_extraction_total${labels({ outcome: "extracted" })} ${this.#extraction.extracted}`
|
|
2723
|
+
);
|
|
2724
|
+
lines.push(
|
|
2725
|
+
`hoopilot_token_extraction_total${labels({ outcome: "missing" })} ${this.#extraction.missing}`
|
|
2726
|
+
);
|
|
1865
2727
|
lines.push("# HELP hoopilot_request_duration_seconds Request duration by route.");
|
|
1866
2728
|
lines.push("# TYPE hoopilot_request_duration_seconds histogram");
|
|
1867
2729
|
for (const [route, entry] of this.#durations) {
|
|
@@ -2017,23 +2879,25 @@ var MetricsRegistry = class {
|
|
|
2017
2879
|
}
|
|
2018
2880
|
}
|
|
2019
2881
|
};
|
|
2020
|
-
function observeResponseUsage(response, fallbackModel, onUsage, signal) {
|
|
2882
|
+
function observeResponseUsage(response, fallbackModel, onUsage, signal, onOutcome) {
|
|
2021
2883
|
const body = response.body;
|
|
2022
2884
|
if (!body) {
|
|
2023
2885
|
return response;
|
|
2024
2886
|
}
|
|
2025
2887
|
const [clientBranch, observerBranch] = body.tee();
|
|
2026
2888
|
const isSse = response.headers.get("content-type")?.includes("text/event-stream") ?? false;
|
|
2027
|
-
void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal).catch(
|
|
2028
|
-
|
|
2889
|
+
void consumeUsage(observerBranch, isSse, fallbackModel, onUsage, signal, onOutcome).catch(
|
|
2890
|
+
() => {
|
|
2891
|
+
}
|
|
2892
|
+
);
|
|
2029
2893
|
return new Response(clientBranch, {
|
|
2030
2894
|
headers: response.headers,
|
|
2031
2895
|
status: response.status,
|
|
2032
2896
|
statusText: response.statusText
|
|
2033
2897
|
});
|
|
2034
2898
|
}
|
|
2035
|
-
function recordResponseTextUsage(text, isSse, fallbackModel, onUsage) {
|
|
2036
|
-
const accumulator = createUsageAccumulator(fallbackModel, onUsage);
|
|
2899
|
+
function recordResponseTextUsage(text, isSse, fallbackModel, onUsage, onOutcome) {
|
|
2900
|
+
const accumulator = createUsageAccumulator(fallbackModel, onUsage, onOutcome);
|
|
2037
2901
|
if (isSse) {
|
|
2038
2902
|
for (const line of text.split(/\r?\n/)) {
|
|
2039
2903
|
considerSseLine(line, accumulator.consider);
|
|
@@ -2046,7 +2910,7 @@ function recordResponseTextUsage(text, isSse, fallbackModel, onUsage) {
|
|
|
2046
2910
|
}
|
|
2047
2911
|
accumulator.finish();
|
|
2048
2912
|
}
|
|
2049
|
-
async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
|
|
2913
|
+
async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal, onOutcome) {
|
|
2050
2914
|
const reader = stream.getReader();
|
|
2051
2915
|
const onAbort = () => {
|
|
2052
2916
|
reader.cancel().catch(() => {
|
|
@@ -2059,7 +2923,12 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
|
|
|
2059
2923
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2060
2924
|
}
|
|
2061
2925
|
const decoder = new TextDecoder();
|
|
2062
|
-
const
|
|
2926
|
+
const guardedOutcome = onOutcome ? (extracted) => {
|
|
2927
|
+
if (!signal?.aborted) {
|
|
2928
|
+
onOutcome(extracted);
|
|
2929
|
+
}
|
|
2930
|
+
} : void 0;
|
|
2931
|
+
const accumulator = createUsageAccumulator(fallbackModel, onUsage, guardedOutcome);
|
|
2063
2932
|
let buffer = "";
|
|
2064
2933
|
let bufferedBytes = 0;
|
|
2065
2934
|
let overflowed = false;
|
|
@@ -2107,7 +2976,7 @@ async function consumeUsage(stream, isSse, fallbackModel, onUsage, signal) {
|
|
|
2107
2976
|
}
|
|
2108
2977
|
accumulator.finish();
|
|
2109
2978
|
}
|
|
2110
|
-
function createUsageAccumulator(fallbackModel, onUsage) {
|
|
2979
|
+
function createUsageAccumulator(fallbackModel, onUsage, onOutcome) {
|
|
2111
2980
|
let model = fallbackModel;
|
|
2112
2981
|
let usage;
|
|
2113
2982
|
return {
|
|
@@ -2126,6 +2995,7 @@ function createUsageAccumulator(fallbackModel, onUsage) {
|
|
|
2126
2995
|
if (usage) {
|
|
2127
2996
|
onUsage(model, usage);
|
|
2128
2997
|
}
|
|
2998
|
+
onOutcome?.(usage !== void 0);
|
|
2129
2999
|
}
|
|
2130
3000
|
};
|
|
2131
3001
|
}
|
|
@@ -2156,6 +3026,26 @@ function modelText(value) {
|
|
|
2156
3026
|
function nonNegative(value) {
|
|
2157
3027
|
return Number.isFinite(value) && value > 0 ? value : 0;
|
|
2158
3028
|
}
|
|
3029
|
+
function round2(value) {
|
|
3030
|
+
return Math.round(value * 100) / 100;
|
|
3031
|
+
}
|
|
3032
|
+
function quantileFromBuckets(bucketCounts, bounds, count, q) {
|
|
3033
|
+
if (count <= 0) {
|
|
3034
|
+
return 0;
|
|
3035
|
+
}
|
|
3036
|
+
const rank = q * count;
|
|
3037
|
+
let cumulative = 0;
|
|
3038
|
+
for (let i = 0; i < bounds.length; i += 1) {
|
|
3039
|
+
const inBucket = bucketCounts[i] ?? 0;
|
|
3040
|
+
if (inBucket > 0 && cumulative + inBucket >= rank) {
|
|
3041
|
+
const lower = i === 0 ? 0 : bounds[i - 1] ?? 0;
|
|
3042
|
+
const upper = bounds[i] ?? lower;
|
|
3043
|
+
return lower + (upper - lower) * ((rank - cumulative) / inBucket);
|
|
3044
|
+
}
|
|
3045
|
+
cumulative += inBucket;
|
|
3046
|
+
}
|
|
3047
|
+
return bounds[bounds.length - 1] ?? 0;
|
|
3048
|
+
}
|
|
2159
3049
|
function cleanLabel(value) {
|
|
2160
3050
|
let result = "";
|
|
2161
3051
|
for (const char of value) {
|
|
@@ -2254,6 +3144,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
2254
3144
|
const metrics = options.metrics ?? new MetricsRegistry();
|
|
2255
3145
|
const readUsage = createUsageReader(client, metrics);
|
|
2256
3146
|
const recordTokens = (model, usage) => metrics.recordTokens(model, usage);
|
|
3147
|
+
const recordExtraction = (extracted) => metrics.recordTokenExtraction(extracted);
|
|
2257
3148
|
const streamingProxyMode = resolveStreamingProxyMode(options);
|
|
2258
3149
|
const bufferProxyBodies = shouldBufferProxyBodies(streamingProxyMode);
|
|
2259
3150
|
return async (request) => {
|
|
@@ -2293,6 +3184,9 @@ function createHoopilotHandler(options = {}) {
|
|
|
2293
3184
|
if (request.method === "OPTIONS") {
|
|
2294
3185
|
return finish(new Response(null, { headers: corsHeaders() }));
|
|
2295
3186
|
}
|
|
3187
|
+
if (request.method === "GET" && apiPath === "/dashboard") {
|
|
3188
|
+
return finish(dashboardResponse());
|
|
3189
|
+
}
|
|
2296
3190
|
if (!isAuthorized(request, apiKey)) {
|
|
2297
3191
|
requestLogger.warn({ event: "http.request.unauthorized" }, "invalid hoopilot api key");
|
|
2298
3192
|
return finish(jsonError(401, "invalid_api_key", "Invalid or missing Hoopilot API key."));
|
|
@@ -2319,6 +3213,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
2319
3213
|
client,
|
|
2320
3214
|
metrics,
|
|
2321
3215
|
recordTokens,
|
|
3216
|
+
recordExtraction,
|
|
2322
3217
|
request,
|
|
2323
3218
|
requestLogger,
|
|
2324
3219
|
bufferProxyBodies
|
|
@@ -2334,6 +3229,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
2334
3229
|
client,
|
|
2335
3230
|
metrics,
|
|
2336
3231
|
recordTokens,
|
|
3232
|
+
recordExtraction,
|
|
2337
3233
|
request,
|
|
2338
3234
|
requestLogger,
|
|
2339
3235
|
bufferProxyBodies
|
|
@@ -2346,6 +3242,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
2346
3242
|
client,
|
|
2347
3243
|
metrics,
|
|
2348
3244
|
recordTokens,
|
|
3245
|
+
recordExtraction,
|
|
2349
3246
|
request,
|
|
2350
3247
|
requestLogger,
|
|
2351
3248
|
bufferProxyBodies
|
|
@@ -2354,7 +3251,14 @@ function createHoopilotHandler(options = {}) {
|
|
|
2354
3251
|
}
|
|
2355
3252
|
if (request.method === "POST" && apiPath === "/v1/responses/compact") {
|
|
2356
3253
|
return finish(
|
|
2357
|
-
await handleResponsesCompact(
|
|
3254
|
+
await handleResponsesCompact(
|
|
3255
|
+
client,
|
|
3256
|
+
metrics,
|
|
3257
|
+
recordTokens,
|
|
3258
|
+
recordExtraction,
|
|
3259
|
+
request,
|
|
3260
|
+
requestLogger
|
|
3261
|
+
)
|
|
2358
3262
|
);
|
|
2359
3263
|
}
|
|
2360
3264
|
if (request.method === "POST" && apiPath === "/v1/responses") {
|
|
@@ -2363,6 +3267,7 @@ function createHoopilotHandler(options = {}) {
|
|
|
2363
3267
|
client,
|
|
2364
3268
|
metrics,
|
|
2365
3269
|
recordTokens,
|
|
3270
|
+
recordExtraction,
|
|
2366
3271
|
request,
|
|
2367
3272
|
requestLogger,
|
|
2368
3273
|
bufferProxyBodies
|
|
@@ -2439,7 +3344,7 @@ function startHoopilotServer(options = {}) {
|
|
|
2439
3344
|
url: `http://${urlHost(host)}:${server.port}`
|
|
2440
3345
|
};
|
|
2441
3346
|
}
|
|
2442
|
-
async function handleAnthropicMessages(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
|
|
3347
|
+
async function handleAnthropicMessages(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
|
|
2443
3348
|
const anthropicRequest = await readJson(request);
|
|
2444
3349
|
const responsesRequest = anthropicMessagesToResponsesRequest(anthropicRequest);
|
|
2445
3350
|
const upstream = await client.responses(JSON.stringify(responsesRequest), request.signal);
|
|
@@ -2452,12 +3357,18 @@ async function handleAnthropicMessages(client, metrics, recordTokens, request, l
|
|
|
2452
3357
|
if (isStreamingResponse(upstream) && upstream.body) {
|
|
2453
3358
|
if (bufferProxyBodies) {
|
|
2454
3359
|
const text = await upstream.text();
|
|
2455
|
-
recordResponseTextUsage(text, true, model, recordTokens);
|
|
3360
|
+
recordResponseTextUsage(text, true, model, recordTokens, recordExtraction);
|
|
2456
3361
|
return proxyResponse(
|
|
2457
3362
|
responseFromText(upstream, responsesSseTextToAnthropicSseText(text, { model }))
|
|
2458
3363
|
);
|
|
2459
3364
|
}
|
|
2460
|
-
const observed = observeResponseUsage(
|
|
3365
|
+
const observed = observeResponseUsage(
|
|
3366
|
+
upstream,
|
|
3367
|
+
model,
|
|
3368
|
+
recordTokens,
|
|
3369
|
+
request.signal,
|
|
3370
|
+
recordExtraction
|
|
3371
|
+
);
|
|
2461
3372
|
if (!observed.body) {
|
|
2462
3373
|
return proxyResponse(observed);
|
|
2463
3374
|
}
|
|
@@ -2475,6 +3386,7 @@ async function handleAnthropicMessages(client, metrics, recordTokens, request, l
|
|
|
2475
3386
|
const responseModel = typeof body.model === "string" ? body.model.trim() : "";
|
|
2476
3387
|
recordTokens(responseModel || model, usage);
|
|
2477
3388
|
}
|
|
3389
|
+
recordExtraction(usage !== void 0);
|
|
2478
3390
|
return jsonResponse(responsesResponseToAnthropicMessage(body, model));
|
|
2479
3391
|
}
|
|
2480
3392
|
function handleAnthropicCountTokens(body) {
|
|
@@ -2500,7 +3412,7 @@ async function handleModels(client, metrics, signal, logger) {
|
|
|
2500
3412
|
logUpstreamSuccess(logger, "/models", upstream.status);
|
|
2501
3413
|
return jsonResponse(normalizeModelsResponse(await upstream.json()));
|
|
2502
3414
|
}
|
|
2503
|
-
async function handleChatCompletions(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
|
|
3415
|
+
async function handleChatCompletions(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
|
|
2504
3416
|
const chatRequest = normalizeChatCompletionRequest(await readJson(request));
|
|
2505
3417
|
const upstream = await client.chatCompletions(chatRequest, request.signal);
|
|
2506
3418
|
metrics.recordUpstream("/chat/completions", upstream.ok);
|
|
@@ -2515,11 +3427,12 @@ async function handleChatCompletions(client, metrics, recordTokens, request, log
|
|
|
2515
3427
|
model,
|
|
2516
3428
|
recordTokens,
|
|
2517
3429
|
request.signal,
|
|
2518
|
-
bufferProxyBodies
|
|
3430
|
+
bufferProxyBodies,
|
|
3431
|
+
recordExtraction
|
|
2519
3432
|
)
|
|
2520
3433
|
);
|
|
2521
3434
|
}
|
|
2522
|
-
async function handleCompletions(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
|
|
3435
|
+
async function handleCompletions(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
|
|
2523
3436
|
const body = await readJson(request);
|
|
2524
3437
|
const upstream = await client.chatCompletions(
|
|
2525
3438
|
completionsRequestToChatCompletion(body),
|
|
@@ -2534,7 +3447,7 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
|
|
|
2534
3447
|
if (isStreamingResponse(upstream) && upstream.body) {
|
|
2535
3448
|
if (bufferProxyBodies) {
|
|
2536
3449
|
const upstreamText = await upstream.text();
|
|
2537
|
-
recordResponseTextUsage(upstreamText, true, model, recordTokens);
|
|
3450
|
+
recordResponseTextUsage(upstreamText, true, model, recordTokens, recordExtraction);
|
|
2538
3451
|
const text = completionSseTextFromChatSseText(upstreamText);
|
|
2539
3452
|
return proxyResponse(responseFromText(upstream, text));
|
|
2540
3453
|
}
|
|
@@ -2547,7 +3460,8 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
|
|
|
2547
3460
|
}),
|
|
2548
3461
|
model,
|
|
2549
3462
|
recordTokens,
|
|
2550
|
-
request.signal
|
|
3463
|
+
request.signal,
|
|
3464
|
+
recordExtraction
|
|
2551
3465
|
)
|
|
2552
3466
|
);
|
|
2553
3467
|
}
|
|
@@ -2557,9 +3471,10 @@ async function handleCompletions(client, metrics, recordTokens, request, logger,
|
|
|
2557
3471
|
const responseModel = typeof completion.model === "string" ? completion.model.trim() : "";
|
|
2558
3472
|
recordTokens(responseModel || model, usage);
|
|
2559
3473
|
}
|
|
3474
|
+
recordExtraction(usage !== void 0);
|
|
2560
3475
|
return jsonResponse(chatCompletionToCompletion(completion));
|
|
2561
3476
|
}
|
|
2562
|
-
async function handleResponses(client, metrics, recordTokens, request, logger, bufferProxyBodies) {
|
|
3477
|
+
async function handleResponses(client, metrics, recordTokens, recordExtraction, request, logger, bufferProxyBodies) {
|
|
2563
3478
|
const body = await readJsonText(request);
|
|
2564
3479
|
const upstream = await client.responses(body, request.signal);
|
|
2565
3480
|
metrics.recordUpstream("/responses", upstream.ok);
|
|
@@ -2574,11 +3489,12 @@ async function handleResponses(client, metrics, recordTokens, request, logger, b
|
|
|
2574
3489
|
model,
|
|
2575
3490
|
recordTokens,
|
|
2576
3491
|
request.signal,
|
|
2577
|
-
bufferProxyBodies
|
|
3492
|
+
bufferProxyBodies,
|
|
3493
|
+
recordExtraction
|
|
2578
3494
|
)
|
|
2579
3495
|
);
|
|
2580
3496
|
}
|
|
2581
|
-
async function handleResponsesCompact(client, metrics, recordTokens, request, logger) {
|
|
3497
|
+
async function handleResponsesCompact(client, metrics, recordTokens, recordExtraction, request, logger) {
|
|
2582
3498
|
const body = await readJson(request);
|
|
2583
3499
|
const upstream = await client.responses(
|
|
2584
3500
|
JSON.stringify({ ...body, stream: false }),
|
|
@@ -2591,17 +3507,23 @@ async function handleResponsesCompact(client, metrics, recordTokens, request, lo
|
|
|
2591
3507
|
logUpstreamSuccess(logger, "/responses", upstream.status);
|
|
2592
3508
|
const isSse = isStreamingResponse(upstream);
|
|
2593
3509
|
const text = await upstream.text();
|
|
2594
|
-
recordResponseTextUsage(
|
|
3510
|
+
recordResponseTextUsage(
|
|
3511
|
+
text,
|
|
3512
|
+
isSse,
|
|
3513
|
+
normalizeRequestedModel(body.model),
|
|
3514
|
+
recordTokens,
|
|
3515
|
+
recordExtraction
|
|
3516
|
+
);
|
|
2595
3517
|
return jsonResponse(responsesCompactionResult(text, isSse));
|
|
2596
3518
|
}
|
|
2597
|
-
async function responseWithObservedUsage(response, fallbackModel, recordTokens, signal, bufferBody) {
|
|
3519
|
+
async function responseWithObservedUsage(response, fallbackModel, recordTokens, signal, bufferBody, recordExtraction) {
|
|
2598
3520
|
const isSse = isStreamingResponse(response);
|
|
2599
3521
|
if (bufferBody && response.body) {
|
|
2600
3522
|
const text = await response.text();
|
|
2601
|
-
recordResponseTextUsage(text, isSse, fallbackModel, recordTokens);
|
|
3523
|
+
recordResponseTextUsage(text, isSse, fallbackModel, recordTokens, recordExtraction);
|
|
2602
3524
|
return responseFromText(response, text);
|
|
2603
3525
|
}
|
|
2604
|
-
return observeResponseUsage(response, fallbackModel, recordTokens, signal);
|
|
3526
|
+
return observeResponseUsage(response, fallbackModel, recordTokens, signal, recordExtraction);
|
|
2605
3527
|
}
|
|
2606
3528
|
function responseFromText(source, text) {
|
|
2607
3529
|
return new Response(text, {
|
|
@@ -2957,6 +3879,9 @@ function routeFor(method, path) {
|
|
|
2957
3879
|
if (method === "GET" && (path === "/" || path === "/healthz")) {
|
|
2958
3880
|
return "health";
|
|
2959
3881
|
}
|
|
3882
|
+
if (method === "GET" && path === "/dashboard") {
|
|
3883
|
+
return "dashboard";
|
|
3884
|
+
}
|
|
2960
3885
|
if (method === "GET" && path === "/metrics") {
|
|
2961
3886
|
return "metrics";
|
|
2962
3887
|
}
|
|
@@ -3011,10 +3936,28 @@ function metricsResponse(metrics) {
|
|
|
3011
3936
|
status: 200
|
|
3012
3937
|
});
|
|
3013
3938
|
}
|
|
3939
|
+
function dashboardResponse() {
|
|
3940
|
+
return new Response(DASHBOARD_HTML, {
|
|
3941
|
+
headers: {
|
|
3942
|
+
...corsHeaders(),
|
|
3943
|
+
"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'",
|
|
3944
|
+
"content-type": "text/html; charset=utf-8",
|
|
3945
|
+
"referrer-policy": "no-referrer",
|
|
3946
|
+
"x-content-type-options": "nosniff",
|
|
3947
|
+
"x-frame-options": "DENY"
|
|
3948
|
+
},
|
|
3949
|
+
status: 200
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3014
3952
|
async function handleUsage(metrics, readUsage, signal) {
|
|
3015
3953
|
const { copilot, error } = await readUsage(signal);
|
|
3016
3954
|
const proxy = metrics.snapshot();
|
|
3017
|
-
const body = {
|
|
3955
|
+
const body = {
|
|
3956
|
+
copilot: copilot ?? null,
|
|
3957
|
+
object: "usage",
|
|
3958
|
+
proxy,
|
|
3959
|
+
version: await getVersion()
|
|
3960
|
+
};
|
|
3018
3961
|
if (error) {
|
|
3019
3962
|
body.copilot_error = error;
|
|
3020
3963
|
}
|
|
@@ -4068,7 +5011,7 @@ Commands:
|
|
|
4068
5011
|
|
|
4069
5012
|
While the server runs, GET /metrics exposes Prometheus metrics (request counts,
|
|
4070
5013
|
token usage, latency) and GET /v1/usage returns those metrics plus live Copilot
|
|
4071
|
-
quota as JSON.
|
|
5014
|
+
quota as JSON. Open GET /dashboard in a browser for a live usage and status view.
|
|
4072
5015
|
|
|
4073
5016
|
Options:
|
|
4074
5017
|
-p, --port <port> Port to listen on. Default: 4141
|