@emesoft/tokenhub 0.2.0 → 0.3.1
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 +5 -0
- package/bin/tokenhub.js +17 -0
- package/lib/dashboard.d.ts +34 -0
- package/lib/dashboard.js +457 -0
- package/lib/usage.d.ts +1 -0
- package/lib/usage.js +43 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,6 +5,7 @@ Pair Claude Code on this machine with a [TokenHub](https://tokenhub.aimpact.io.v
|
|
|
5
5
|
```
|
|
6
6
|
npx @emesoft/tokenhub connect https://tokenhub.aimpact.io.vn
|
|
7
7
|
npx @emesoft/tokenhub usage
|
|
8
|
+
npx @emesoft/tokenhub dashboard
|
|
8
9
|
npx @emesoft/tokenhub disconnect
|
|
9
10
|
```
|
|
10
11
|
|
|
@@ -12,6 +13,10 @@ npx @emesoft/tokenhub disconnect
|
|
|
12
13
|
|
|
13
14
|
`usage` shows today's and this week's spend against your limits, your credit and the pool's health (`--json` for the raw report). It exits 2 when the pool would currently refuse your requests (out of credit or budget reached).
|
|
14
15
|
|
|
16
|
+
`dashboard` is a live full-screen view: today's and this week's spend as bars against your limits (green under 70%, orange to 90%, red above), credit, pool health, a banner when you are blocked, and a table of your recent requests, newest first. It refreshes every 3 seconds (`--interval <sec>`, minimum 2). Press `q` or Ctrl+C to quit and `r` to refresh now. On a connection error it keeps the last good screen, shows `reconnecting…` and backs off (honouring `retry-after`). Without a terminal, with `NO_COLOR` set or with `--once`, it prints one plain frame and exits (code 2 when blocked, like `usage`).
|
|
17
|
+
|
|
18
|
+
Requests to the pool carry your key and one extra header, `x-tokenhub-cli: <version>` (this CLI's version), and nothing else.
|
|
19
|
+
|
|
15
20
|
`disconnect` removes only those two entries.
|
|
16
21
|
|
|
17
22
|
If `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN` or `ANTHROPIC_API_KEY` is set in your shell, it overrides `settings.json`.
|
package/bin/tokenhub.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { parseDashboardArgs, runDashboard } from "../lib/dashboard.js";
|
|
3
4
|
import { runConnect } from "../lib/pair.js";
|
|
4
5
|
import { disconnectSettings, readSettings, settingsPath } from "../lib/settings.js";
|
|
5
6
|
import { fetchUsage, formatUsage, resolveCredentials } from "../lib/usage.js";
|
|
@@ -9,6 +10,8 @@ const HELP = `tokenhub - pair Claude Code with a TokenHub pool
|
|
|
9
10
|
Usage:
|
|
10
11
|
tokenhub connect <poolUrl> Pair this machine and write ~/.claude/settings.json
|
|
11
12
|
tokenhub usage [--json] Show today's and this week's spend, credit and pool health
|
|
13
|
+
tokenhub dashboard [--interval <sec>] [--once]
|
|
14
|
+
Live full-screen view of spend, pool health and recent requests (q quits)
|
|
12
15
|
tokenhub disconnect Remove the pool entries from settings.json
|
|
13
16
|
tokenhub --help | --version
|
|
14
17
|
`;
|
|
@@ -54,6 +57,20 @@ async function main(argv) {
|
|
|
54
57
|
return 1;
|
|
55
58
|
}
|
|
56
59
|
}
|
|
60
|
+
if (command === "dashboard") {
|
|
61
|
+
try {
|
|
62
|
+
const options = parseDashboardArgs(argv.slice(1));
|
|
63
|
+
const credentials = resolveCredentials(readSettings(file).settings);
|
|
64
|
+
if (!credentials) {
|
|
65
|
+
console.error("Not connected. Run `tokenhub connect <poolUrl>` first.");
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
return await runDashboard(credentials, options);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
console.error(`Error: ${error.message}`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
57
74
|
if (command === "disconnect") {
|
|
58
75
|
try {
|
|
59
76
|
console.log(disconnectSettings(file) ? `Disconnected. Updated ${file}` : `Nothing to remove in ${file}`);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface DashboardState {
|
|
2
|
+
report: Record<string, any> | null;
|
|
3
|
+
recent: Record<string, any>[];
|
|
4
|
+
refreshedAt: number | null;
|
|
5
|
+
error: string | null;
|
|
6
|
+
intervalSec: number;
|
|
7
|
+
poolHost?: string;
|
|
8
|
+
timeZone?: string;
|
|
9
|
+
}
|
|
10
|
+
export const DEFAULT_INTERVAL_SEC: number;
|
|
11
|
+
export const MIN_INTERVAL_SEC: number;
|
|
12
|
+
export function parseDashboardArgs(args: string[]): { intervalSec: number; once: boolean };
|
|
13
|
+
export function renderFrame(state: DashboardState, view: { columns: number; rows: number; color: boolean; now: number; fill?: boolean }): string;
|
|
14
|
+
export function startPolling(
|
|
15
|
+
credentials: { baseUrl: string; key: string },
|
|
16
|
+
options: {
|
|
17
|
+
intervalMs: number;
|
|
18
|
+
getLimit: () => number;
|
|
19
|
+
onState: (state: DashboardState) => void;
|
|
20
|
+
signal: AbortSignal;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
timeZone?: string;
|
|
23
|
+
},
|
|
24
|
+
): { done: Promise<void>; refreshNow: () => void };
|
|
25
|
+
export function runDashboard(
|
|
26
|
+
credentials: { baseUrl: string; key: string },
|
|
27
|
+
options: { intervalSec: number; once: boolean },
|
|
28
|
+
io?: {
|
|
29
|
+
stdout?: NodeJS.WriteStream;
|
|
30
|
+
stdin?: NodeJS.ReadStream;
|
|
31
|
+
env?: Record<string, string | undefined>;
|
|
32
|
+
fetchImpl?: typeof fetch;
|
|
33
|
+
},
|
|
34
|
+
): Promise<number>;
|
package/lib/dashboard.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { fetchRecent, fetchUsage } from "./usage.js";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_INTERVAL_SEC = 3;
|
|
4
|
+
export const MIN_INTERVAL_SEC = 2;
|
|
5
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
6
|
+
const BAR_WIDTH = 20;
|
|
7
|
+
const ENTER_SCREEN = "\x1b[?1049h\x1b[?25l";
|
|
8
|
+
const LEAVE_SCREEN = "\x1b[?25h\x1b[?1049l";
|
|
9
|
+
const COLORS = { green: "32", clay: "38;5;173", red: "31", grey: "90", bold: "1" };
|
|
10
|
+
const ANSI = /\x1b\[[0-9;]*m/y;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse the arguments after `dashboard`.
|
|
14
|
+
* @param {string[]} args Raw arguments.
|
|
15
|
+
* @returns {{ intervalSec: number, once: boolean }} Validated options.
|
|
16
|
+
* @throws {Error} On an unknown flag or an interval that is not a number of at least MIN_INTERVAL_SEC.
|
|
17
|
+
*/
|
|
18
|
+
export function parseDashboardArgs(args) {
|
|
19
|
+
let intervalSec = DEFAULT_INTERVAL_SEC;
|
|
20
|
+
let once = false;
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
if (args[i] === "--once") once = true;
|
|
23
|
+
else if (args[i] === "--interval") {
|
|
24
|
+
intervalSec = Number(args[++i]);
|
|
25
|
+
if (!Number.isFinite(intervalSec) || intervalSec < MIN_INTERVAL_SEC) {
|
|
26
|
+
throw new Error(`--interval must be a number of seconds, ${MIN_INTERVAL_SEC} or more.`);
|
|
27
|
+
}
|
|
28
|
+
} else throw new Error(`Unknown option: ${args[i]}`);
|
|
29
|
+
}
|
|
30
|
+
return { intervalSec, once };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Wrap text in an SGR colour when colour is on.
|
|
35
|
+
* @param {boolean} color Whether to emit escape codes.
|
|
36
|
+
* @param {keyof typeof COLORS} name Colour name.
|
|
37
|
+
* @param {string} text Text to paint.
|
|
38
|
+
* @returns {string} The text, painted or untouched.
|
|
39
|
+
*/
|
|
40
|
+
function paint(color, name, text) {
|
|
41
|
+
return color ? `\x1b[${COLORS[name]}m${text}\x1b[0m` : text;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shorten text to a width, marking the cut with an ellipsis.
|
|
46
|
+
* @param {string} text Plain text.
|
|
47
|
+
* @param {number} width Maximum visible width.
|
|
48
|
+
* @returns {string} Text of at most `width` characters.
|
|
49
|
+
*/
|
|
50
|
+
function truncate(text, width) {
|
|
51
|
+
if (text.length <= width) return text;
|
|
52
|
+
return width <= 1 ? text.slice(0, Math.max(width, 0)) : `${text.slice(0, width - 1)}…`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cut a line that may contain SGR codes to a visible width so it never wraps.
|
|
57
|
+
* @param {string} line Line with optional colour codes.
|
|
58
|
+
* @param {number} columns Maximum visible width.
|
|
59
|
+
* @returns {string} The line, closed with a reset when it was cut.
|
|
60
|
+
*/
|
|
61
|
+
function clip(line, columns) {
|
|
62
|
+
let out = "";
|
|
63
|
+
let visible = 0;
|
|
64
|
+
let i = 0;
|
|
65
|
+
while (i < line.length) {
|
|
66
|
+
ANSI.lastIndex = i;
|
|
67
|
+
const code = ANSI.exec(line);
|
|
68
|
+
if (code) {
|
|
69
|
+
out += code[0];
|
|
70
|
+
i += code[0].length;
|
|
71
|
+
} else if (visible < columns) {
|
|
72
|
+
out += line[i++];
|
|
73
|
+
visible++;
|
|
74
|
+
} else return `${out}\x1b[0m`;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format a dollar amount.
|
|
81
|
+
* @param {number} amount USD.
|
|
82
|
+
* @returns {string} For example `$1.20`, or `$0.0042` for amounts under a cent.
|
|
83
|
+
*/
|
|
84
|
+
function money(amount) {
|
|
85
|
+
const n = Number(amount) || 0;
|
|
86
|
+
return n > 0 && n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Format a token count compactly.
|
|
91
|
+
* @param {number | null | undefined} count Tokens.
|
|
92
|
+
* @returns {string} For example `842`, `12.3k` or `1.2M`; `-` when unknown.
|
|
93
|
+
*/
|
|
94
|
+
function tokens(count) {
|
|
95
|
+
if (count === null || count === undefined) return "-";
|
|
96
|
+
if (count >= 1e6) return `${(count / 1e6).toFixed(1)}M`;
|
|
97
|
+
if (count >= 1e4) return `${(count / 1e3).toFixed(1)}k`;
|
|
98
|
+
return String(count);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Format a latency.
|
|
103
|
+
* @param {number | null | undefined} ms Milliseconds.
|
|
104
|
+
* @returns {string} For example `340ms` or `2.4s`; `-` when unknown.
|
|
105
|
+
*/
|
|
106
|
+
function latency(ms) {
|
|
107
|
+
if (ms === null || ms === undefined) return "-";
|
|
108
|
+
return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms)}ms`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Format a time of day as HH:MM:SS.
|
|
113
|
+
* @param {string | number} when ISO string or epoch milliseconds.
|
|
114
|
+
* @param {string | undefined} timeZone IANA zone (system default when omitted).
|
|
115
|
+
* @returns {string} Time of day.
|
|
116
|
+
*/
|
|
117
|
+
function clock(when, timeZone) {
|
|
118
|
+
return new Date(when).toLocaleTimeString("en-GB", { timeZone, hour12: false });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Draw one spend window as a bar against its limit.
|
|
123
|
+
* @param {string} label Row label.
|
|
124
|
+
* @param {{ spent_usd: number, limit_usd: number | null }} window Spend and limit.
|
|
125
|
+
* @param {number} barWidth Cells in the bar.
|
|
126
|
+
* @param {boolean} color Whether to emit colour.
|
|
127
|
+
* @returns {string} One line.
|
|
128
|
+
*/
|
|
129
|
+
function spendLine(label, window, barWidth, color) {
|
|
130
|
+
const head = label.padEnd(8);
|
|
131
|
+
if (window.limit_usd === null || window.limit_usd === undefined) {
|
|
132
|
+
return `${head}${paint(color, "grey", "░".repeat(barWidth))} ${money(window.spent_usd)} ${paint(color, "grey", "no limit")}`;
|
|
133
|
+
}
|
|
134
|
+
const ratio = window.limit_usd > 0 ? window.spent_usd / window.limit_usd : 1;
|
|
135
|
+
const tone = ratio > 0.9 ? "red" : ratio >= 0.7 ? "clay" : "green";
|
|
136
|
+
const filled = Math.round(Math.min(ratio, 1) * barWidth);
|
|
137
|
+
const bar = paint(color, tone, "█".repeat(filled)) + paint(color, "grey", "░".repeat(barWidth - filled));
|
|
138
|
+
return `${head}${bar} ${money(window.spent_usd)} / ${money(window.limit_usd)} ${paint(color, tone, `${Math.round(ratio * 100)}%`)}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Explain a block in one line for the banner.
|
|
143
|
+
* @param {Record<string, any>} report Status report with `blocked` set.
|
|
144
|
+
* @param {string | undefined} timeZone IANA zone for the lift time.
|
|
145
|
+
* @returns {string} Banner text.
|
|
146
|
+
*/
|
|
147
|
+
function blockedText(report, timeZone) {
|
|
148
|
+
if (report.blocked_reason === "credit") return "BLOCKED: out of credit. Top up on the member site.";
|
|
149
|
+
const period = report.blocked_reason === "weekly_budget" ? "weekly" : "daily";
|
|
150
|
+
const lifts = report.blocked_until ? `, lifts ${new Date(report.blocked_until).toLocaleString("en-GB", { timeZone, dateStyle: "medium", timeStyle: "short" })}` : "";
|
|
151
|
+
return `BLOCKED: ${period} budget reached${lifts}.`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const MAX_MODEL_WIDTH = 24;
|
|
155
|
+
const MIN_MODEL_WIDTH = 5;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Format a request cost at the precision the pool logs it, so small amounts are never rounded away.
|
|
159
|
+
* @param {number} amount USD.
|
|
160
|
+
* @returns {string} For example `$0.0435`.
|
|
161
|
+
*/
|
|
162
|
+
function exactMoney(amount) {
|
|
163
|
+
return `$${(Number(amount) || 0).toFixed(4)}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Colour name for an HTTP status.
|
|
168
|
+
* @param {number} status HTTP status of a logged request.
|
|
169
|
+
* @returns {keyof typeof COLORS} Green below 400, clay for 4xx, red otherwise.
|
|
170
|
+
*/
|
|
171
|
+
function statusTone(status) {
|
|
172
|
+
return status < 400 ? "green" : status < 500 ? "clay" : "red";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Choose the table columns: sized to the content, then thinned to fit the terminal.
|
|
177
|
+
* Drops In/Out first, then Latency, then shrinks the model column.
|
|
178
|
+
* @param {number} columns Terminal width.
|
|
179
|
+
* @param {Record<string, any>[]} recent Requests to show (the model column fits the longest name, capped).
|
|
180
|
+
* @returns {{ id: string, title: string, width: number, right: boolean }[]} Visible columns in order.
|
|
181
|
+
*/
|
|
182
|
+
function tableColumns(columns, recent) {
|
|
183
|
+
const model = Math.min(MAX_MODEL_WIDTH, Math.max(MIN_MODEL_WIDTH, ...recent.map((request) => String(request.model ?? "-").length)));
|
|
184
|
+
let cols = [
|
|
185
|
+
{ id: "time", title: "Time", width: 8, right: false },
|
|
186
|
+
{ id: "model", title: "Model", width: model, right: false },
|
|
187
|
+
{ id: "status", title: "Status", width: 6, right: false },
|
|
188
|
+
{ id: "latency", title: "Latency", width: 7, right: true },
|
|
189
|
+
{ id: "in", title: "In", width: 6, right: true },
|
|
190
|
+
{ id: "out", title: "Out", width: 6, right: true },
|
|
191
|
+
{ id: "cost", title: "Cost", width: 9, right: true },
|
|
192
|
+
];
|
|
193
|
+
for (const drop of [["in", "out"], ["latency"]]) {
|
|
194
|
+
if (tableWidth(cols) <= columns - 1) break;
|
|
195
|
+
cols = cols.filter((col) => !drop.includes(col.id));
|
|
196
|
+
}
|
|
197
|
+
const excess = tableWidth(cols) - (columns - 1);
|
|
198
|
+
if (excess > 0) {
|
|
199
|
+
const modelCol = cols.find((col) => col.id === "model");
|
|
200
|
+
modelCol.width = Math.max(MIN_MODEL_WIDTH, modelCol.width - excess);
|
|
201
|
+
}
|
|
202
|
+
return cols;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Total width of a bordered table.
|
|
207
|
+
* @param {{ width: number }[]} cols Columns.
|
|
208
|
+
* @returns {number} Characters per line, borders and padding included.
|
|
209
|
+
*/
|
|
210
|
+
function tableWidth(cols) {
|
|
211
|
+
return cols.reduce((sum, col) => sum + col.width + 3, 1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Draw a horizontal table border.
|
|
216
|
+
* @param {{ width: number }[]} cols Columns.
|
|
217
|
+
* @param {[string, string, string]} corners Left, junction and right characters.
|
|
218
|
+
* @param {boolean} color Whether to emit colour.
|
|
219
|
+
* @returns {string} One grey line.
|
|
220
|
+
*/
|
|
221
|
+
function border(cols, [left, mid, right], color) {
|
|
222
|
+
return paint(color, "grey", `${left}${cols.map((col) => "─".repeat(col.width + 2)).join(mid)}${right}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Draw one table line from already padded cells.
|
|
227
|
+
* @param {string[]} cells Cell text, each exactly its column width (colour codes allowed).
|
|
228
|
+
* @param {boolean} color Whether to emit colour.
|
|
229
|
+
* @returns {string} One line with grey vertical borders.
|
|
230
|
+
*/
|
|
231
|
+
function tableLine(cells, color) {
|
|
232
|
+
const bar = paint(color, "grey", "│");
|
|
233
|
+
return `${bar}${cells.map((cell) => ` ${cell} `).join(bar)}${bar}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Pad text to a column width on its alignment side.
|
|
238
|
+
* @param {string} text Plain text, already no longer than the column.
|
|
239
|
+
* @param {{ width: number, right: boolean }} col Column.
|
|
240
|
+
* @returns {string} Text of exactly `col.width` characters.
|
|
241
|
+
*/
|
|
242
|
+
function fit(text, col) {
|
|
243
|
+
return col.right ? text.padStart(col.width) : text.padEnd(col.width);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Render one request as table cells.
|
|
248
|
+
* @param {Record<string, any>} request Entry from `/member/api/recent`.
|
|
249
|
+
* @param {{ id: string, width: number, right: boolean }[]} cols Visible columns.
|
|
250
|
+
* @param {boolean} color Whether to emit colour.
|
|
251
|
+
* @param {string | undefined} timeZone IANA zone.
|
|
252
|
+
* @returns {string[]} One padded cell per column.
|
|
253
|
+
*/
|
|
254
|
+
function requestCells(request, cols, color, timeZone) {
|
|
255
|
+
const text = {
|
|
256
|
+
time: clock(request.started_at, timeZone),
|
|
257
|
+
model: String(request.model ?? "-"),
|
|
258
|
+
status: String(request.status),
|
|
259
|
+
latency: latency(request.latency_ms),
|
|
260
|
+
in: tokens(request.input_tokens),
|
|
261
|
+
out: tokens(request.output_tokens),
|
|
262
|
+
cost: exactMoney(request.usd),
|
|
263
|
+
};
|
|
264
|
+
return cols.map((col) => {
|
|
265
|
+
const cell = fit(truncate(text[col.id], col.width), col);
|
|
266
|
+
return col.id === "status" ? paint(color, statusTone(request.status), cell) : cell;
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Render the whole dashboard as text. Pure: no I/O, so it can be tested without a terminal.
|
|
272
|
+
* @param {{ report: Record<string, any> | null, recent: Record<string, any>[], refreshedAt: number | null, error: string | null, intervalSec: number, poolHost?: string, timeZone?: string }} state Latest poll result.
|
|
273
|
+
* @param {{ columns: number, rows: number, color: boolean, now: number, fill?: boolean }} view Terminal size, colour switch, current epoch ms and whether to pad to `rows` so the footer sits on the last row (default true).
|
|
274
|
+
* @returns {string} Lines joined with `\n`, each at most `columns - 1` wide (the last column is left free: some terminals clip it), at most `rows` lines, no trailing newline.
|
|
275
|
+
*/
|
|
276
|
+
export function renderFrame(state, { columns, rows, color, now, fill = true }) {
|
|
277
|
+
const { report, recent, refreshedAt, error, intervalSec, timeZone } = state;
|
|
278
|
+
const cols = tableColumns(columns, recent);
|
|
279
|
+
const width = tableWidth(cols);
|
|
280
|
+
const lines = [];
|
|
281
|
+
const stale = refreshedAt !== null && now - refreshedAt > intervalSec * 2000 + 1000;
|
|
282
|
+
const refreshed = refreshedAt === null ? "waiting for first refresh" : `refreshed ${clock(refreshedAt, timeZone)}${stale ? " (stale)" : ""}`;
|
|
283
|
+
const title = `${report ? report.member : "TokenHub"}${state.poolHost ? ` @ ${state.poolHost}` : ""}`;
|
|
284
|
+
const right = `${refreshed} · every ${intervalSec}s`;
|
|
285
|
+
lines.push(`${paint(color, "bold", title)}${" ".repeat(Math.max(2, width - title.length - right.length))}${paint(color, "grey", right)}`);
|
|
286
|
+
lines.push("");
|
|
287
|
+
|
|
288
|
+
if (report) {
|
|
289
|
+
const barWidth = Math.max(6, Math.min(BAR_WIDTH, columns - 40));
|
|
290
|
+
lines.push(spendLine("Today", report.today, barWidth, color), spendLine("Week", report.week, barWidth, color));
|
|
291
|
+
const poolState = report.pool?.state ?? "unknown";
|
|
292
|
+
const poolTone = poolState === "ok" ? "green" : poolState === "degraded" ? "clay" : poolState === "down" ? "red" : "grey";
|
|
293
|
+
lines.push(`${"Credit".padEnd(8)}${money(report.credit_usd)} Pool ${paint(color, poolTone, poolState)}`);
|
|
294
|
+
if (report.blocked) lines.push(paint(color, "red", blockedText(report, timeZone)));
|
|
295
|
+
} else {
|
|
296
|
+
lines.push(paint(color, "grey", "No data yet."));
|
|
297
|
+
}
|
|
298
|
+
lines.push("");
|
|
299
|
+
|
|
300
|
+
const footer = error ? paint(color, "red", `reconnecting… ${error}`) : paint(color, "grey", "q quit · r refresh now");
|
|
301
|
+
lines.push(border(cols, ["┌", "┬", "┐"], color));
|
|
302
|
+
lines.push(tableLine(cols.map((col) => paint(color, "bold", fit(col.title, col))), color));
|
|
303
|
+
lines.push(border(cols, ["├", "┼", "┤"], color));
|
|
304
|
+
const room = Math.max(0, rows - lines.length - 2);
|
|
305
|
+
if (recent.length === 0) {
|
|
306
|
+
if (room > 0) lines.push(tableLine([paint(color, "grey", "No requests yet.".padEnd(width - 4))], color));
|
|
307
|
+
} else {
|
|
308
|
+
for (const request of recent.slice(0, room)) lines.push(tableLine(requestCells(request, cols, color, timeZone), color));
|
|
309
|
+
}
|
|
310
|
+
lines.push(border(cols, ["└", "┴", "┘"], color));
|
|
311
|
+
while (fill && lines.length < rows - 1) lines.push("");
|
|
312
|
+
lines.push(footer);
|
|
313
|
+
return lines.slice(-Math.max(rows, 1)).map((line) => clip(line, Math.max(columns - 1, 1))).join("\n");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Poll status and recent requests until aborted, keeping the last good data when a poll fails.
|
|
318
|
+
* Failures back off exponentially; a 429 waits at least its `retry-after`.
|
|
319
|
+
* @param {{ baseUrl: string, key: string }} credentials Pool and key.
|
|
320
|
+
* @param {{ intervalMs: number, getLimit: () => number, onState: (state: Record<string, any>) => void, signal: AbortSignal, fetchImpl?: typeof fetch, timeZone?: string }} options Poll settings; `onState` receives a fresh state object after every poll.
|
|
321
|
+
* @returns {{ done: Promise<void>, refreshNow: () => void }} `done` settles after abort; `refreshNow` cuts the current wait short.
|
|
322
|
+
*/
|
|
323
|
+
export function startPolling(credentials, { intervalMs, getLimit, onState, signal, fetchImpl = fetch, timeZone }) {
|
|
324
|
+
let state = { report: null, recent: [], refreshedAt: null, error: null, intervalSec: intervalMs / 1000, poolHost: new URL(credentials.baseUrl).host, timeZone };
|
|
325
|
+
let failures = 0;
|
|
326
|
+
let wake = () => {};
|
|
327
|
+
|
|
328
|
+
const poll = async () => {
|
|
329
|
+
const [status, recentResult] = await Promise.allSettled([
|
|
330
|
+
fetchUsage(credentials.baseUrl, credentials.key, fetchImpl),
|
|
331
|
+
fetchRecent(credentials.baseUrl, credentials.key, getLimit(), fetchImpl),
|
|
332
|
+
]);
|
|
333
|
+
const failed = [status, recentResult].filter((result) => result.status === "rejected");
|
|
334
|
+
state = {
|
|
335
|
+
...state,
|
|
336
|
+
report: status.status === "fulfilled" ? status.value : state.report,
|
|
337
|
+
recent: recentResult.status === "fulfilled" ? (recentResult.value.requests ?? []) : state.recent,
|
|
338
|
+
refreshedAt: status.status === "fulfilled" ? Date.now() : state.refreshedAt,
|
|
339
|
+
error: failed.length ? failed[0].reason.message : null,
|
|
340
|
+
};
|
|
341
|
+
onState(state);
|
|
342
|
+
if (!failed.length) {
|
|
343
|
+
failures = 0;
|
|
344
|
+
return intervalMs;
|
|
345
|
+
}
|
|
346
|
+
failures++;
|
|
347
|
+
const retryAfterMs = Math.max(0, ...failed.map((result) => (result.reason.retryAfter ?? 0) * 1000));
|
|
348
|
+
return Math.max(retryAfterMs, Math.min(intervalMs * 2 ** failures, MAX_BACKOFF_MS));
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const wait = (ms) =>
|
|
352
|
+
new Promise((resolve) => {
|
|
353
|
+
const timer = setTimeout(resolve, ms);
|
|
354
|
+
wake = () => {
|
|
355
|
+
clearTimeout(timer);
|
|
356
|
+
resolve();
|
|
357
|
+
};
|
|
358
|
+
signal.addEventListener("abort", wake, { once: true });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const done = (async () => {
|
|
362
|
+
while (!signal.aborted) {
|
|
363
|
+
const delay = await poll();
|
|
364
|
+
if (!signal.aborted) await wait(delay);
|
|
365
|
+
}
|
|
366
|
+
})();
|
|
367
|
+
return { done, refreshNow: () => wake() };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Run the dashboard: a live full-screen view on a TTY, or one plain frame otherwise.
|
|
372
|
+
* @param {{ baseUrl: string, key: string }} credentials Pool and key.
|
|
373
|
+
* @param {{ intervalSec: number, once: boolean }} options Parsed `dashboard` options.
|
|
374
|
+
* @param {{ stdout?: NodeJS.WriteStream, stdin?: NodeJS.ReadStream, env?: Record<string, string | undefined>, fetchImpl?: typeof fetch }} [io] Streams and environment (injected in tests).
|
|
375
|
+
* @returns {Promise<number>} Exit code: 0 on quit, 1 on a failed single frame, 2 when a single frame shows a block.
|
|
376
|
+
*/
|
|
377
|
+
export async function runDashboard(credentials, { intervalSec, once }, { stdout = process.stdout, stdin = process.stdin, env = process.env, fetchImpl = fetch } = {}) {
|
|
378
|
+
const live = !once && stdout.isTTY && stdin.isTTY && !env.NO_COLOR;
|
|
379
|
+
if (!live) return printOnce(credentials, intervalSec, stdout, fetchImpl);
|
|
380
|
+
|
|
381
|
+
const controller = new AbortController();
|
|
382
|
+
const size = () => ({ columns: stdout.columns || 80, rows: stdout.rows || 24 });
|
|
383
|
+
let state = null;
|
|
384
|
+
const draw = () => {
|
|
385
|
+
if (!state) return;
|
|
386
|
+
const { columns, rows } = size();
|
|
387
|
+
const frame = renderFrame(state, { columns, rows, color: true, now: Date.now() });
|
|
388
|
+
stdout.write(`\x1b[H${frame.split("\n").map((line) => `${line}\x1b[K`).join("\n")}\x1b[J`);
|
|
389
|
+
};
|
|
390
|
+
const restore = () => {
|
|
391
|
+
if (stdin.isRaw) stdin.setRawMode(false);
|
|
392
|
+
stdout.write(LEAVE_SCREEN);
|
|
393
|
+
};
|
|
394
|
+
const fail = (error) => {
|
|
395
|
+
restore();
|
|
396
|
+
console.error(`Error: ${error?.message ?? error}`);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
stdout.write(ENTER_SCREEN);
|
|
401
|
+
stdin.setRawMode(true);
|
|
402
|
+
stdin.resume();
|
|
403
|
+
process.once("exit", restore);
|
|
404
|
+
process.once("SIGTERM", () => controller.abort());
|
|
405
|
+
process.once("SIGINT", () => controller.abort());
|
|
406
|
+
process.once("uncaughtException", fail);
|
|
407
|
+
process.once("unhandledRejection", fail);
|
|
408
|
+
stdout.on("resize", draw);
|
|
409
|
+
|
|
410
|
+
const polling = startPolling(credentials, {
|
|
411
|
+
intervalMs: intervalSec * 1000,
|
|
412
|
+
getLimit: () => Math.min(Math.max(size().rows, 10), 100),
|
|
413
|
+
onState: (next) => {
|
|
414
|
+
state = next;
|
|
415
|
+
draw();
|
|
416
|
+
},
|
|
417
|
+
signal: controller.signal,
|
|
418
|
+
fetchImpl,
|
|
419
|
+
});
|
|
420
|
+
stdin.on("data", (chunk) => {
|
|
421
|
+
const keys = String(chunk);
|
|
422
|
+
if (keys === "q" || keys === "Q" || keys === "\x03") controller.abort();
|
|
423
|
+
else if (keys === "r" || keys === "R") polling.refreshNow();
|
|
424
|
+
});
|
|
425
|
+
const redraw = setInterval(draw, 1000);
|
|
426
|
+
|
|
427
|
+
await polling.done;
|
|
428
|
+
clearInterval(redraw);
|
|
429
|
+
stdout.off("resize", draw);
|
|
430
|
+
stdin.pause();
|
|
431
|
+
restore();
|
|
432
|
+
return 0;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Print a single plain (uncoloured) frame and finish.
|
|
437
|
+
* @param {{ baseUrl: string, key: string }} credentials Pool and key.
|
|
438
|
+
* @param {number} intervalSec Interval shown in the header.
|
|
439
|
+
* @param {NodeJS.WriteStream} stdout Output stream.
|
|
440
|
+
* @param {typeof fetch} fetchImpl Fetch implementation.
|
|
441
|
+
* @returns {Promise<number>} 1 on failure, 2 when blocked, else 0.
|
|
442
|
+
*/
|
|
443
|
+
async function printOnce(credentials, intervalSec, stdout, fetchImpl) {
|
|
444
|
+
const rows = stdout.rows || 30;
|
|
445
|
+
try {
|
|
446
|
+
const [report, recent] = await Promise.all([
|
|
447
|
+
fetchUsage(credentials.baseUrl, credentials.key, fetchImpl),
|
|
448
|
+
fetchRecent(credentials.baseUrl, credentials.key, Math.min(Math.max(rows, 10), 100), fetchImpl),
|
|
449
|
+
]);
|
|
450
|
+
const state = { report, recent: recent.requests ?? [], refreshedAt: Date.now(), error: null, intervalSec, poolHost: new URL(credentials.baseUrl).host };
|
|
451
|
+
stdout.write(`${renderFrame(state, { columns: stdout.columns || 80, rows, color: false, now: Date.now(), fill: false })}\n`);
|
|
452
|
+
return report.blocked ? 2 : 0;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
console.error(`Error: ${error.message}`);
|
|
455
|
+
return 1;
|
|
456
|
+
}
|
|
457
|
+
}
|
package/lib/usage.d.ts
CHANGED
|
@@ -3,4 +3,5 @@ export function resolveCredentials(
|
|
|
3
3
|
env?: Record<string, string | undefined>,
|
|
4
4
|
): { baseUrl: string; key: string } | null;
|
|
5
5
|
export function fetchUsage(baseUrl: string, key: string, fetchImpl?: typeof fetch): Promise<Record<string, any>>;
|
|
6
|
+
export function fetchRecent(baseUrl: string, key: string, limit: number, fetchImpl?: typeof fetch): Promise<Record<string, any>>;
|
|
6
7
|
export function formatUsage(report: Record<string, any>, locale?: string, timeZone?: string): string;
|
package/lib/usage.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
1
3
|
const STATUS_PATH = "/member/api/status";
|
|
4
|
+
const RECENT_PATH = "/member/api/recent";
|
|
5
|
+
const CLI_VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
2
6
|
const REQUEST_TIMEOUT_MS = 10_000;
|
|
3
7
|
|
|
4
8
|
/**
|
|
@@ -15,29 +19,60 @@ export function resolveCredentials(settings, env = process.env) {
|
|
|
15
19
|
}
|
|
16
20
|
|
|
17
21
|
/**
|
|
18
|
-
*
|
|
22
|
+
* GET a JSON endpoint on the pool with the member's key, mapping failures to readable errors.
|
|
19
23
|
* @param {string} baseUrl Pool origin.
|
|
24
|
+
* @param {string} path Absolute path (with query) on the pool.
|
|
20
25
|
* @param {string} key Gateway key.
|
|
21
|
-
* @param {typeof fetch}
|
|
22
|
-
* @returns {Promise<Record<string, any>>} The
|
|
23
|
-
* @throws {Error}
|
|
26
|
+
* @param {typeof fetch} fetchImpl Fetch implementation.
|
|
27
|
+
* @returns {Promise<Record<string, any>>} The parsed body.
|
|
28
|
+
* @throws {Error} On network errors, 401, 429 (with `retryAfter` seconds when the pool sent it) and other failures.
|
|
24
29
|
*/
|
|
25
|
-
|
|
30
|
+
async function getJson(baseUrl, path, key, fetchImpl) {
|
|
26
31
|
let res;
|
|
27
32
|
try {
|
|
28
|
-
res = await fetchImpl(`${baseUrl}${
|
|
29
|
-
headers: { authorization: `Bearer ${key}
|
|
33
|
+
res = await fetchImpl(`${baseUrl}${path}`, {
|
|
34
|
+
headers: { authorization: `Bearer ${key}`, "x-tokenhub-cli": CLI_VERSION },
|
|
30
35
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
31
36
|
});
|
|
32
37
|
} catch (error) {
|
|
33
38
|
throw new Error(`Could not reach ${baseUrl} (${error.message})`);
|
|
34
39
|
}
|
|
35
40
|
if (res.status === 401) throw new Error("The pool rejected this key. It may be revoked; run `tokenhub connect` again.");
|
|
36
|
-
if (res.status === 429)
|
|
41
|
+
if (res.status === 429) {
|
|
42
|
+
const retryAfter = res.headers.get("retry-after");
|
|
43
|
+
throw Object.assign(new Error(`Too many requests. Retry in ${retryAfter ?? "a few"}s.`), {
|
|
44
|
+
retryAfter: Number(retryAfter) > 0 ? Number(retryAfter) : undefined,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
37
47
|
if (!res.ok) throw new Error(`Pool answered HTTP ${res.status}`);
|
|
38
48
|
return res.json();
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Fetch the member's status from the pool.
|
|
53
|
+
* @param {string} baseUrl Pool origin.
|
|
54
|
+
* @param {string} key Gateway key.
|
|
55
|
+
* @param {typeof fetch} [fetchImpl] Fetch implementation (injected in tests).
|
|
56
|
+
* @returns {Promise<Record<string, any>>} The `GET /member/api/status` body.
|
|
57
|
+
* @throws {Error} With a user-readable message on network errors, 401/429 and other failures.
|
|
58
|
+
*/
|
|
59
|
+
export function fetchUsage(baseUrl, key, fetchImpl = fetch) {
|
|
60
|
+
return getJson(baseUrl, STATUS_PATH, key, fetchImpl);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fetch the member's most recent requests, newest first.
|
|
65
|
+
* @param {string} baseUrl Pool origin.
|
|
66
|
+
* @param {string} key Gateway key.
|
|
67
|
+
* @param {number} limit How many requests to ask for.
|
|
68
|
+
* @param {typeof fetch} [fetchImpl] Fetch implementation (injected in tests).
|
|
69
|
+
* @returns {Promise<Record<string, any>>} The `GET /member/api/recent` body (`{ requests: [...] }`).
|
|
70
|
+
* @throws {Error} Same mapping as `fetchUsage`.
|
|
71
|
+
*/
|
|
72
|
+
export function fetchRecent(baseUrl, key, limit, fetchImpl = fetch) {
|
|
73
|
+
return getJson(baseUrl, `${RECENT_PATH}?limit=${encodeURIComponent(limit)}`, key, fetchImpl);
|
|
74
|
+
}
|
|
75
|
+
|
|
41
76
|
/**
|
|
42
77
|
* Render a status report as a few readable lines.
|
|
43
78
|
* @param {Record<string, any>} report Body returned by `fetchUsage`.
|