@convex-dev/ai-budget 0.0.2-alpha.0 → 0.0.2-alpha.10

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.
@@ -0,0 +1 @@
1
+ export declare const DASHBOARD_HTML: string;
@@ -0,0 +1,223 @@
1
+ // Self-contained admin dashboard served by the component over HTTP (see
2
+ // AIBudget.registerRoutes). Plain HTML/CSS/JS — no build step, no framework —
3
+ // so it ships inside the published package as a string. `__API_BASE__` and
4
+ // `__TOKEN__` are substituted at serve time; the page talks only to the
5
+ // component's own JSON API under API_BASE.
6
+ export const DASHBOARD_HTML = String.raw `<!doctype html>
7
+ <html lang="en">
8
+ <head>
9
+ <meta charset="utf-8" />
10
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
11
+ <title>AI Budget</title>
12
+ <style>
13
+ :root {
14
+ --bg: #0f1115; --panel: #171a21; --border: #262b36; --muted: #8b93a7;
15
+ --fg: #e6e9ef; --accent: #6ea8fe; --accent2: #4ade80; --danger: #f87171;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body { margin: 0; font: 14px/1.5 system-ui, sans-serif; background: var(--bg); color: var(--fg); }
19
+ header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
20
+ header h1 { font-size: 16px; margin: 0; }
21
+ header .sub { color: var(--muted); font-size: 12px; }
22
+ nav { display: flex; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); }
23
+ nav button { background: transparent; color: var(--muted); border: 1px solid transparent; padding: 6px 12px; border-radius: 6px; cursor: pointer; font: inherit; }
24
+ nav button.active { background: var(--panel); color: var(--fg); border-color: var(--border); }
25
+ main { padding: 20px; }
26
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
27
+ th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
28
+ th { color: var(--muted); font-weight: 600; }
29
+ td.mono, .mono { font-variant-numeric: tabular-nums; font-family: ui-monospace, monospace; }
30
+ input, select { background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 4px 6px; font: inherit; width: 90px; }
31
+ button.act { background: var(--panel); color: var(--fg); border: 1px solid var(--border); border-radius: 5px; padding: 3px 8px; cursor: pointer; font-size: 12px; }
32
+ button.act:hover { border-color: var(--accent); }
33
+ .row { display: flex; gap: 10px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
34
+ .pill { font-size: 11px; padding: 1px 7px; border-radius: 10px; border: 1px solid var(--border); color: var(--muted); }
35
+ .ok { color: var(--accent2); } .bad { color: var(--danger); }
36
+ .bars { display: flex; align-items: flex-end; gap: 3px; height: 120px; padding-top: 10px; }
37
+ .bar { background: var(--accent); border-radius: 2px 2px 0 0; min-width: 8px; flex: 1; position: relative; }
38
+ .bar span { position: absolute; bottom: -18px; left: 0; right: 0; text-align: center; font-size: 9px; color: var(--muted); }
39
+ .muted { color: var(--muted); } h2 { font-size: 14px; margin: 18px 0 8px; }
40
+ .err { color: var(--danger); padding: 10px; }
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <header>
45
+ <h1>☂️ AI Budget</h1>
46
+ <span class="sub">component admin dashboard</span>
47
+ <span id="total" class="sub" style="margin-left:auto"></span>
48
+ </header>
49
+ <nav>
50
+ <button data-tab="buckets" class="active">Buckets</button>
51
+ <button data-tab="requests">Requests</button>
52
+ <button data-tab="usage">Usage</button>
53
+ <button data-tab="settings">Settings</button>
54
+ </nav>
55
+ <main id="main"></main>
56
+ <script>
57
+ // Injected as JSON literals by registerRoutes (no surrounding quotes here).
58
+ const API = __API_BASE__;
59
+ const TOKEN = __TOKEN__;
60
+ // If we were opened with ?token=… (needed for the initial navigation, which
61
+ // can't set headers), drop it from the address bar so it doesn't linger in
62
+ // history; API calls below use the Authorization header instead.
63
+ try {
64
+ const u = new URL(location.href);
65
+ if (u.searchParams.has("token")) { u.searchParams.delete("token"); history.replaceState(null, "", u.toString()); }
66
+ } catch (e) {}
67
+ const H = TOKEN ? { Authorization: "Bearer " + TOKEN } : {};
68
+ const NANOS = 1e9;
69
+ const usd = (n) => n == null ? "—" : "$" + (n / NANOS).toFixed(n && n < NANOS/100 ? 6 : 2);
70
+ const q = (o) => Object.entries(o).filter(([,v]) => v != null && v !== "").map(([k,v]) => k+"="+encodeURIComponent(v)).join("&");
71
+ async function get(path, params) { const r = await fetch(API + path + (params ? "?" + q(params) : ""), { headers: H, credentials: "same-origin" }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
72
+ async function post(path, body) { const r = await fetch(API + path, { method: "POST", headers: { "content-type": "application/json", ...H }, credentials: "same-origin", body: JSON.stringify(body) }); if (!r.ok) throw new Error(await r.text()); return r.json(); }
73
+ const el = (t, a = {}, kids = []) => { const e = document.createElement(t); for (const k in a) { if (k === "class") e.className = a[k]; else if (k.startsWith("on")) e.addEventListener(k.slice(2), a[k]); else if (k === "value") e.value = a[k] ?? ""; else e.setAttribute(k, a[k]); } for (const c of [].concat(kids)) e.append(c?.nodeType ? c : document.createTextNode(c ?? "")); return e; };
74
+ const main = document.getElementById("main");
75
+ let tab = "buckets";
76
+
77
+ document.querySelectorAll("nav button").forEach((b) =>
78
+ b.addEventListener("click", () => { tab = b.dataset.tab; document.querySelectorAll("nav button").forEach((x) => x.classList.toggle("active", x === b)); render(); }));
79
+
80
+ // number input that saves on Enter/blur
81
+ function numInput(value, onSave, { money = false, width = 90 } = {}) {
82
+ const shown = value == null ? "" : money ? value / NANOS : value;
83
+ const i = el("input", { value: shown, style: "width:" + width + "px" });
84
+ const save = () => { const t = i.value.trim(); onSave(t === "" ? undefined : money ? Math.round(Number(t) * NANOS) : Number(t)); };
85
+ i.addEventListener("keydown", (e) => { if (e.key === "Enter") i.blur(); });
86
+ i.addEventListener("blur", save);
87
+ return i;
88
+ }
89
+
90
+ async function render() {
91
+ main.innerHTML = "";
92
+ try { await ({ buckets: renderBuckets, requests: renderRequests, usage: renderUsage, settings: renderSettings }[tab])(); }
93
+ catch (e) { main.append(el("div", { class: "err" }, "Error: " + e.message)); }
94
+ }
95
+
96
+ async function renderBuckets() {
97
+ const dims = ["user", "action"];
98
+ const state = { dimension: window.__dim ?? "" };
99
+ const rows = await get("/buckets", state.dimension ? { dimension: state.dimension } : {});
100
+ const grand = rows.reduce((s, b) => s + b.totalSpendNanos, 0);
101
+ document.getElementById("total").textContent = rows.length + " buckets · " + usd(grand) + " total";
102
+ const dimSet = [...new Set(rows.map((b) => b.dimension).concat(dims))];
103
+ const sel = el("select", { value: state.dimension, style: "width:140px",
104
+ onchange: (e) => { window.__dim = e.target.value; render(); } },
105
+ [el("option", { value: "" }, "all dimensions")].concat(dimSet.map((d) => el("option", { value: d }, d))));
106
+ main.append(el("div", { class: "row" }, ["Dimension:", sel]));
107
+
108
+ const head = ["dimension", "value", "today", "month", "total", "daily $", "monthly $", "warn %", "max/min", "blocked", ""];
109
+ const table = el("table", {}, el("thead", {}, el("tr", {}, head.map((h) => el("th", {}, h)))));
110
+ const body = el("tbody");
111
+ for (const b of rows) {
112
+ const set = (patch) => post("/setLimits", { dimension: b.dimension, value: b.value, ...patch }).then(render);
113
+ body.append(el("tr", {}, [
114
+ el("td", { class: "mono muted" }, b.dimension),
115
+ el("td", {}, el("b", {}, b.value)),
116
+ el("td", { class: "mono" }, usd(b.spendTodayNanos)),
117
+ el("td", { class: "mono" }, usd(b.spendThisMonthNanos)),
118
+ el("td", { class: "mono" }, usd(b.totalSpendNanos)),
119
+ el("td", {}, numInput(b.dailySpendLimitNanos, (v) => set({ dailySpendLimitNanos: v }), { money: true })),
120
+ el("td", {}, numInput(b.monthlySpendLimitNanos, (v) => set({ monthlySpendLimitNanos: v }), { money: true })),
121
+ el("td", {}, numInput(b.warnAtPct == null ? undefined : Math.round(b.warnAtPct * 100), (v) => set({ warnAtPct: v == null ? undefined : v / 100 }), { width: 55 })),
122
+ el("td", {}, numInput(b.maxConcurrent, (v) => set({ maxConcurrent: v }), { width: 55 })),
123
+ el("td", {}, el("input", { type: "checkbox", style: "width:auto", ...(b.blocked ? { checked: "" } : {}), onchange: (e) => set({ blocked: e.target.checked }) })),
124
+ el("td", {}, [
125
+ el("button", { class: "act", onclick: () => post("/adjust", { dimension: b.dimension, value: b.value, deltaNanos: -NANOS, reason: "dashboard credit" }).then(render) }, "−$1"),
126
+ " ",
127
+ el("button", { class: "act", onclick: () => post("/bump", { dimension: b.dimension, value: b.value, dailyNanos: NANOS }).then(render) }, "+$1 today"),
128
+ ]),
129
+ ]));
130
+ }
131
+ table.append(body);
132
+ main.append(table);
133
+ }
134
+
135
+ async function renderRequests() {
136
+ const filter = window.__reqFilter ?? {};
137
+ const dimInput = el("input", { value: filter.dimension ?? "", placeholder: "dimension", style: "width:110px" });
138
+ const valInput = el("input", { value: filter.value ?? "", placeholder: "value", style: "width:130px" });
139
+ const go = () => { window.__reqFilter = { dimension: dimInput.value.trim() || undefined, value: valInput.value.trim() || undefined }; render(); };
140
+ main.append(el("div", { class: "row" }, ["Filter by tag:", dimInput, valInput,
141
+ el("button", { class: "act", onclick: go }, "Apply"),
142
+ el("button", { class: "act", onclick: () => { window.__reqFilter = {}; render(); } }, "Clear")]));
143
+
144
+ const rows = await get("/requests", filter);
145
+ const head = ["time", "user", "action", "model", "status", "tokens", "cached", "cost"];
146
+ const table = el("table", {}, el("thead", {}, el("tr", {}, head.map((h) => el("th", {}, h)))));
147
+ const body = el("tbody");
148
+ for (const r of rows) {
149
+ body.append(el("tr", {}, [
150
+ el("td", { class: "muted" }, new Date(r._creationTime).toLocaleTimeString()),
151
+ el("td", { class: "mono" }, r.userId),
152
+ el("td", { class: "mono muted" }, r.actionName ?? "—"),
153
+ el("td", { class: "mono muted" }, r.model),
154
+ el("td", { class: r.status === "success" ? "ok" : r.status === "blocked" || r.status === "error" ? "bad" : "" }, r.status),
155
+ el("td", { class: "mono" }, ((r.promptTokens ?? 0) + (r.completionTokens ?? 0)) || "—"),
156
+ el("td", { class: "mono muted" }, r.cachedTokens || "—"),
157
+ el("td", { class: "mono" }, usd(r.costNanos)),
158
+ ]));
159
+ }
160
+ table.append(body);
161
+ main.append(rows.length ? table : el("div", { class: "muted" }, "No matching requests."));
162
+ }
163
+
164
+ async function renderUsage() {
165
+ const s = window.__usage ?? (window.__usage = { dimension: "user", value: "", period: "day" });
166
+ const buckets = await get("/buckets", { dimension: s.dimension });
167
+ if (!s.value && buckets[0]) s.value = buckets[0].value;
168
+ const dimInput = el("input", { value: s.dimension, style: "width:110px", onchange: (e) => { s.dimension = e.target.value.trim(); s.value = ""; render(); } });
169
+ const valSel = el("select", { value: s.value, style: "width:150px", onchange: (e) => { s.value = e.target.value; render(); } },
170
+ buckets.map((b) => el("option", { value: b.value }, b.value)));
171
+ const perSel = el("select", { value: s.period, style: "width:90px", onchange: (e) => { s.period = e.target.value; render(); } },
172
+ [el("option", { value: "day" }, "day"), el("option", { value: "month" }, "month")]);
173
+ main.append(el("div", { class: "row" }, ["Dimension:", dimInput, "Value:", valSel, "Period:", perSel]));
174
+ if (!s.value) { main.append(el("div", { class: "muted" }, "No buckets in this dimension yet.")); return; }
175
+
176
+ const hist = (await get("/usage", { dimension: s.dimension, value: s.value, period: s.period })).slice().reverse();
177
+ if (!hist.length) { main.append(el("div", { class: "muted" }, "No usage history yet.")); return; }
178
+ const max = Math.max(...hist.map((h) => h.spendNanos), 1);
179
+ main.append(el("h2", {}, "Spend per " + s.period + " — " + s.dimension + " \"" + s.value + "\""));
180
+ main.append(el("div", { class: "bars" }, hist.map((h) =>
181
+ el("div", { class: "bar", title: h.stamp + ": " + usd(h.spendNanos), style: "height:" + Math.max(2, (h.spendNanos / max) * 100) + "%" },
182
+ el("span", {}, h.stamp.slice(5))))));
183
+ const total = hist.reduce((a, h) => a + h.spendNanos, 0);
184
+ main.append(el("div", { class: "muted", style: "margin-top:26px" }, hist.length + " " + s.period + "s · " + usd(total) + " total"));
185
+ }
186
+
187
+ async function renderSettings() {
188
+ const g = await get("/global");
189
+ main.append(el("h2", {}, "Global (deployment-wide) cap"));
190
+ const setG = (patch) => post("/global/setLimits", patch).then(render);
191
+ main.append(el("div", { class: "row" }, [
192
+ "Daily $:", numInput(g.dailySpendLimitNanos, (v) => setG({ dailySpendLimitNanos: v }), { money: true }),
193
+ "Lifetime $:", numInput(g.lifetimeSpendLimitNanos, (v) => setG({ lifetimeSpendLimitNanos: v }), { money: true }),
194
+ el("span", { class: "pill" }, "today " + usd(g.spentTodayNanos) + " · total " + usd(g.spentTotalNanos)),
195
+ ]));
196
+ main.append(el("h2", {}, "Alerts & retention"));
197
+ main.append(el("div", { class: "row" }, [
198
+ "Alert at %:", numInput(g.defaultWarnAtPct == null ? undefined : Math.round(g.defaultWarnAtPct * 100),
199
+ (v) => post("/global/setAlertDefaults", { warnAtPct: v == null ? undefined : v / 100 }).then(render), { width: 55 }),
200
+ "Retention (ms):", numInput(g.retentionMs, (v) => post("/global/setRetention", { retentionMs: v ?? 0 }).then(render), { width: 130 }),
201
+ ]));
202
+
203
+ const prices = await get("/prices");
204
+ main.append(el("h2", {}, "Model prices (nanodollars / Mtok)"));
205
+ const table = el("table", {}, el("thead", {}, el("tr", {}, ["model", "input", "output", "cached", ""].map((h) => el("th", {}, h)))));
206
+ const body = el("tbody");
207
+ for (const [model, p] of Object.entries(prices)) {
208
+ body.append(el("tr", {}, [
209
+ el("td", { class: "mono" }, model),
210
+ el("td", { class: "mono" }, p.input),
211
+ el("td", { class: "mono" }, p.output),
212
+ el("td", { class: "mono muted" }, p.cached ?? "default"),
213
+ el("td", {}, p.overridden ? el("span", { class: "pill" }, "override") : ""),
214
+ ]));
215
+ }
216
+ table.append(body);
217
+ main.append(table);
218
+ }
219
+
220
+ render();
221
+ </script>
222
+ </body>
223
+ </html>`;