routstrd 0.3.2 → 0.3.4
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/bun.lock +2 -2
- package/dist/daemon/index.js +791 -135
- package/dist/index.js +131 -346
- package/package.json +2 -2
- package/src/daemon/http/usage-summary.test.ts +259 -0
- package/src/daemon/http/usage-summary.ts +361 -0
- package/src/daemon/request-response-log-sink.ts +269 -0
- package/src/tui/usage/app.ts +85 -69
- package/src/tui/usage/data.ts +12 -193
- package/src/tui/usage/render.ts +57 -107
- package/src/tui/usage/types.ts +4 -45
- package/src/utils/config.ts +8 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { createWriteStream, mkdirSync, type WriteStream } from "fs";
|
|
2
|
+
import { writeFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import type { SdkLogger } from "@routstr/sdk";
|
|
5
|
+
|
|
6
|
+
export interface RequestResponseLogRequestInput {
|
|
7
|
+
method: string;
|
|
8
|
+
url: string;
|
|
9
|
+
path: string;
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
headers: Record<string, string>;
|
|
12
|
+
body?: unknown;
|
|
13
|
+
rawBody?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RequestResponseLogSink {
|
|
17
|
+
logRequest?(input: RequestResponseLogRequestInput): string | undefined | Promise<string | undefined>;
|
|
18
|
+
logResponseStart?(id: string | undefined, response: Response): void | Promise<void>;
|
|
19
|
+
logResponseChunk?(id: string | undefined, sequence: number, text: string): void | Promise<void>;
|
|
20
|
+
logResponseEnd?(id: string | undefined): void | Promise<void>;
|
|
21
|
+
logResponseError?(id: string | undefined, error: unknown): void | Promise<void>;
|
|
22
|
+
logResponseBody?(id: string | undefined, response: Response): void | Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ActiveResponseLog {
|
|
26
|
+
stream: WriteStream;
|
|
27
|
+
pending: Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FileRequestResponseLogSinkOptions {
|
|
31
|
+
dir: string;
|
|
32
|
+
logger?: SdkLogger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const SENSITIVE_HEADER_NAMES = new Set([
|
|
36
|
+
"authorization",
|
|
37
|
+
"x-cashu",
|
|
38
|
+
"cookie",
|
|
39
|
+
"set-cookie",
|
|
40
|
+
"proxy-authorization",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const SENSITIVE_BODY_FIELD_NAMES = new Set([
|
|
44
|
+
"authorization",
|
|
45
|
+
"api_key",
|
|
46
|
+
"apikey",
|
|
47
|
+
"apiKey",
|
|
48
|
+
"access_token",
|
|
49
|
+
"accessToken",
|
|
50
|
+
"bearer",
|
|
51
|
+
"cashu",
|
|
52
|
+
"cookie",
|
|
53
|
+
"key",
|
|
54
|
+
"password",
|
|
55
|
+
"secret",
|
|
56
|
+
"token",
|
|
57
|
+
"x-cashu",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const REDACTED = "[REDACTED]";
|
|
61
|
+
|
|
62
|
+
const sanitizeForFilename = (value: string): string =>
|
|
63
|
+
value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
64
|
+
|
|
65
|
+
const makeId = (): string => {
|
|
66
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
67
|
+
const random = crypto.randomUUID().slice(0, 8);
|
|
68
|
+
return `${timestamp}-${random}`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const headersToObject = (headers: Headers): Record<string, string> => {
|
|
72
|
+
const out: Record<string, string> = {};
|
|
73
|
+
headers.forEach((value, key) => {
|
|
74
|
+
out[key] = value;
|
|
75
|
+
});
|
|
76
|
+
return out;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const redactHeaders = (headers: Record<string, string>): Record<string, string> =>
|
|
80
|
+
Object.fromEntries(
|
|
81
|
+
Object.entries(headers).map(([key, value]) => [
|
|
82
|
+
key,
|
|
83
|
+
SENSITIVE_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value,
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const redactBody = (value: unknown): unknown => {
|
|
88
|
+
if (!value || typeof value !== "object") return value;
|
|
89
|
+
if (Array.isArray(value)) return value.map(redactBody);
|
|
90
|
+
|
|
91
|
+
return Object.fromEntries(
|
|
92
|
+
Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
|
|
93
|
+
key,
|
|
94
|
+
SENSITIVE_BODY_FIELD_NAMES.has(key) || SENSITIVE_BODY_FIELD_NAMES.has(key.toLowerCase())
|
|
95
|
+
? REDACTED
|
|
96
|
+
: redactBody(entry),
|
|
97
|
+
]),
|
|
98
|
+
);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const redactRawBody = (rawBody: string | undefined): string | undefined => {
|
|
102
|
+
if (!rawBody) return undefined;
|
|
103
|
+
try {
|
|
104
|
+
return JSON.stringify(redactBody(JSON.parse(rawBody)));
|
|
105
|
+
} catch {
|
|
106
|
+
return rawBody;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export class FileRequestResponseLogSink implements RequestResponseLogSink {
|
|
111
|
+
private requestsDir: string;
|
|
112
|
+
private responsesDir: string;
|
|
113
|
+
private activeResponses = new Map<string, ActiveResponseLog>();
|
|
114
|
+
|
|
115
|
+
constructor(private options: FileRequestResponseLogSinkOptions) {
|
|
116
|
+
this.requestsDir = join(options.dir, "requests");
|
|
117
|
+
this.responsesDir = join(options.dir, "responses");
|
|
118
|
+
mkdirSync(this.requestsDir, { recursive: true });
|
|
119
|
+
mkdirSync(this.responsesDir, { recursive: true });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async logRequest(input: RequestResponseLogRequestInput): Promise<string | undefined> {
|
|
123
|
+
try {
|
|
124
|
+
const id = makeId();
|
|
125
|
+
const filePath = join(this.requestsDir, `${sanitizeForFilename(id)}.json`);
|
|
126
|
+
await writeFile(
|
|
127
|
+
filePath,
|
|
128
|
+
JSON.stringify(
|
|
129
|
+
{
|
|
130
|
+
id,
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
method: input.method,
|
|
133
|
+
url: input.url,
|
|
134
|
+
path: input.path,
|
|
135
|
+
baseUrl: input.baseUrl,
|
|
136
|
+
headers: redactHeaders(input.headers),
|
|
137
|
+
body: redactBody(input.body),
|
|
138
|
+
rawBody: redactRawBody(input.rawBody),
|
|
139
|
+
},
|
|
140
|
+
null,
|
|
141
|
+
2,
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
return id;
|
|
145
|
+
} catch (error) {
|
|
146
|
+
this.options.logger?.error?.("[request-response-log] failed to log request:", error);
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async logResponseStart(id: string | undefined, response: Response): Promise<void> {
|
|
152
|
+
if (!id) return;
|
|
153
|
+
await this.append(id, {
|
|
154
|
+
type: "response_start",
|
|
155
|
+
status: response.status,
|
|
156
|
+
statusText: response.statusText,
|
|
157
|
+
headers: redactHeaders(headersToObject(response.headers)),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
logResponseChunk(id: string | undefined, sequence: number, text: string): void {
|
|
162
|
+
if (!id) return;
|
|
163
|
+
void this.append(id, {
|
|
164
|
+
type: "chunk",
|
|
165
|
+
sequence,
|
|
166
|
+
text,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async logResponseEnd(id: string | undefined): Promise<void> {
|
|
171
|
+
if (!id) return;
|
|
172
|
+
await this.append(id, { type: "end" });
|
|
173
|
+
await this.close(id);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async logResponseError(id: string | undefined, error: unknown): Promise<void> {
|
|
177
|
+
if (!id) return;
|
|
178
|
+
await this.append(id, {
|
|
179
|
+
type: "error",
|
|
180
|
+
error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),
|
|
181
|
+
});
|
|
182
|
+
await this.close(id);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async logResponseBody(id: string | undefined, response: Response): Promise<void> {
|
|
186
|
+
if (!id) return;
|
|
187
|
+
try {
|
|
188
|
+
if (!response.body) {
|
|
189
|
+
await this.logResponseEnd(id);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const reader = response.body.getReader();
|
|
194
|
+
const decoder = new TextDecoder("utf-8");
|
|
195
|
+
let sequence = 0;
|
|
196
|
+
|
|
197
|
+
while (true) {
|
|
198
|
+
const { done, value } = await reader.read();
|
|
199
|
+
if (done) break;
|
|
200
|
+
if (value && value.byteLength > 0) {
|
|
201
|
+
await this.append(id, {
|
|
202
|
+
type: "chunk",
|
|
203
|
+
sequence: sequence++,
|
|
204
|
+
text: decoder.decode(value, { stream: true }),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const tail = decoder.decode();
|
|
210
|
+
if (tail) {
|
|
211
|
+
await this.append(id, {
|
|
212
|
+
type: "chunk",
|
|
213
|
+
sequence: sequence++,
|
|
214
|
+
text: tail,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
await this.logResponseEnd(id);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
await this.logResponseError(id, error);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private getOrCreate(id: string): ActiveResponseLog {
|
|
225
|
+
const existing = this.activeResponses.get(id);
|
|
226
|
+
if (existing) return existing;
|
|
227
|
+
|
|
228
|
+
const filePath = join(this.responsesDir, `${sanitizeForFilename(id)}.jsonl`);
|
|
229
|
+
const stream = createWriteStream(filePath, { flags: "a" });
|
|
230
|
+
const active: ActiveResponseLog = {
|
|
231
|
+
stream,
|
|
232
|
+
pending: Promise.resolve(),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
stream.on("error", (error) => {
|
|
236
|
+
this.options.logger?.error?.("[request-response-log] response log stream error:", error);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
this.activeResponses.set(id, active);
|
|
240
|
+
return active;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async append(id: string, event: Record<string, unknown>): Promise<void> {
|
|
244
|
+
try {
|
|
245
|
+
const active = this.getOrCreate(id);
|
|
246
|
+
const line = JSON.stringify({ requestLogId: id, timestamp: new Date().toISOString(), ...event }) + "\n";
|
|
247
|
+
active.pending = active.pending.then(
|
|
248
|
+
() =>
|
|
249
|
+
new Promise<void>((resolve, reject) => {
|
|
250
|
+
active.stream.write(line, (error) => {
|
|
251
|
+
if (error) reject(error);
|
|
252
|
+
else resolve();
|
|
253
|
+
});
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
await active.pending;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
this.options.logger?.error?.("[request-response-log] failed to append response event:", error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private async close(id: string): Promise<void> {
|
|
263
|
+
const active = this.activeResponses.get(id);
|
|
264
|
+
if (!active) return;
|
|
265
|
+
this.activeResponses.delete(id);
|
|
266
|
+
await active.pending;
|
|
267
|
+
await new Promise<void>((resolve) => active.stream.end(resolve));
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/tui/usage/app.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getVisibleTabs } from "./constants.ts";
|
|
2
2
|
import type { Tab } from "./types.ts";
|
|
3
|
-
import { fetchBalance, fetchClients, fetchStatus,
|
|
3
|
+
import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
|
|
4
4
|
import {
|
|
5
5
|
applyScrollToContent,
|
|
6
6
|
exitSearchMode,
|
|
@@ -49,10 +49,9 @@ export async function runUsageTui(): Promise<void> {
|
|
|
49
49
|
let clients: ClientInfo[] = [];
|
|
50
50
|
let visibleTabs: Tab[] = getVisibleTabs(false);
|
|
51
51
|
let refreshInterval: ReturnType<typeof setInterval> | null = null;
|
|
52
|
-
let shouldUpdate = true;
|
|
53
52
|
let autoRefresh = true;
|
|
54
53
|
let cleanedUp = false;
|
|
55
|
-
let
|
|
54
|
+
let fetching = false;
|
|
56
55
|
|
|
57
56
|
if (isInteractive) {
|
|
58
57
|
stdout.write(enterAlternateScreen() + hideCursor());
|
|
@@ -80,123 +79,141 @@ export async function runUsageTui(): Promise<void> {
|
|
|
80
79
|
process.on("SIGINT", () => cleanup(0));
|
|
81
80
|
process.on("SIGTERM", () => cleanup(0));
|
|
82
81
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Background fetch — async, never blocks rendering. Updates the state
|
|
84
|
+
* variables only on success, then triggers a repaint. Overlapping calls
|
|
85
|
+
* are skipped via the `fetching` guard so we don't race stale results.
|
|
86
|
+
*/
|
|
87
|
+
async function fetchData(): Promise<void> {
|
|
88
|
+
if (fetching) return;
|
|
89
|
+
fetching = true;
|
|
87
90
|
try {
|
|
88
|
-
const
|
|
89
|
-
|
|
91
|
+
const running = await isDaemonRunning();
|
|
92
|
+
if (!running) {
|
|
93
|
+
stats = null;
|
|
94
|
+
} else {
|
|
95
|
+
// Fire all 4 fetches concurrently — cuts the blocked window significantly.
|
|
96
|
+
const [newStats, newBalance, newStatus, newClients] = await Promise.all([
|
|
97
|
+
fetchUsageSummary(),
|
|
98
|
+
fetchBalance(),
|
|
99
|
+
fetchStatus(),
|
|
100
|
+
fetchClients(),
|
|
101
|
+
]);
|
|
102
|
+
if (newStats) stats = newStats;
|
|
103
|
+
if (newBalance) balance = newBalance;
|
|
104
|
+
if (newStatus) status = newStatus;
|
|
105
|
+
if (newClients && newClients.length > 0) clients = newClients;
|
|
90
106
|
|
|
91
|
-
if (forceFetch || shouldUpdate) {
|
|
92
|
-
stats = await fetchUsage(10000);
|
|
93
|
-
balance = await fetchBalance();
|
|
94
|
-
status = await fetchStatus();
|
|
95
|
-
clients = await fetchClients();
|
|
96
107
|
const npubsVisible = hasAnyNpubs(clients);
|
|
97
108
|
visibleTabs = getVisibleTabs(npubsVisible);
|
|
98
|
-
// If current tab is npubs but it's no longer visible, fall back to clients
|
|
99
109
|
if (currentTab === "npubs" && !npubsVisible) {
|
|
100
110
|
currentTab = "clients";
|
|
101
111
|
vimState.scrollPos = 0;
|
|
102
112
|
}
|
|
103
|
-
shouldUpdate = false;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!stats) {
|
|
107
|
-
stdout.write(
|
|
108
|
-
moveCursor(1, 1) +
|
|
109
|
-
eraseDown() +
|
|
110
|
-
`${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
|
|
111
|
-
`Make sure routstrd is running.\n` +
|
|
112
|
-
`\nPress Q to quit.`
|
|
113
|
-
);
|
|
114
|
-
return;
|
|
115
113
|
}
|
|
114
|
+
render();
|
|
115
|
+
} finally {
|
|
116
|
+
fetching = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
116
119
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const visibleContent = applyScrollToContent(content, contentViewportHeight);
|
|
125
|
-
const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
|
|
120
|
+
/**
|
|
121
|
+
* Synchronous paint — never awaits I/O. Reads the current state variables
|
|
122
|
+
* and writes to stdout. Safe to call from key handlers without blocking.
|
|
123
|
+
*/
|
|
124
|
+
function render(): void {
|
|
125
|
+
const width = getWidth();
|
|
126
|
+
const height = getHeight();
|
|
126
127
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
128
|
+
if (!stats) {
|
|
129
|
+
stdout.write(
|
|
130
|
+
moveCursor(1, 1) +
|
|
131
|
+
eraseDown() +
|
|
132
|
+
`${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
|
|
133
|
+
`Make sure routstrd is running.\n` +
|
|
134
|
+
`\nPress Q to quit.`
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
130
137
|
}
|
|
138
|
+
|
|
139
|
+
const content = renderTabContent(currentTab, stats, balance, status, width, clients);
|
|
140
|
+
const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
|
|
141
|
+
const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
|
|
142
|
+
const chromeLines = chrome.split("\n").length - 1;
|
|
143
|
+
const footerSeparator = renderSeparator(width);
|
|
144
|
+
const footerLines = footerSeparator.split("\n").length - 1;
|
|
145
|
+
const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
|
|
146
|
+
const visibleContent = applyScrollToContent(content, contentViewportHeight);
|
|
147
|
+
const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
|
|
148
|
+
|
|
149
|
+
stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
|
|
131
150
|
}
|
|
132
151
|
|
|
133
152
|
const handleKey = (key: string) => {
|
|
134
153
|
if (vimState.isSearching) {
|
|
135
154
|
if (key === "\x1b" || key === "\x1b[3~") {
|
|
136
155
|
exitSearchMode();
|
|
137
|
-
|
|
156
|
+
render();
|
|
138
157
|
return;
|
|
139
158
|
}
|
|
140
159
|
if (key === "\r" || key === "\n") {
|
|
141
160
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
142
161
|
exitSearchMode();
|
|
143
|
-
|
|
162
|
+
render();
|
|
144
163
|
return;
|
|
145
164
|
}
|
|
146
165
|
if (key === "\x7f" || key === "\x08") {
|
|
147
166
|
vimState.searchQuery = vimState.searchQuery.slice(0, -1);
|
|
148
167
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
149
|
-
|
|
168
|
+
render();
|
|
150
169
|
return;
|
|
151
170
|
}
|
|
152
171
|
if (key === "\x03") {
|
|
153
172
|
exitSearchMode();
|
|
154
|
-
|
|
173
|
+
render();
|
|
155
174
|
return;
|
|
156
175
|
}
|
|
157
176
|
if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
|
|
158
177
|
vimState.searchQuery += key;
|
|
159
178
|
if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
|
|
160
|
-
|
|
179
|
+
render();
|
|
161
180
|
}
|
|
162
181
|
return;
|
|
163
182
|
}
|
|
164
183
|
|
|
165
184
|
if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
|
|
166
185
|
if (key === "r" || key === "R") {
|
|
167
|
-
|
|
168
|
-
void render(true);
|
|
186
|
+
void fetchData();
|
|
169
187
|
return;
|
|
170
188
|
}
|
|
171
189
|
if (key === "a" || key === "A") {
|
|
172
190
|
autoRefresh = !autoRefresh;
|
|
173
|
-
|
|
174
|
-
void render(false);
|
|
191
|
+
render();
|
|
175
192
|
return;
|
|
176
193
|
}
|
|
177
194
|
|
|
178
195
|
if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
|
|
179
196
|
scrollDown();
|
|
180
|
-
|
|
197
|
+
render();
|
|
181
198
|
return;
|
|
182
199
|
}
|
|
183
200
|
if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
|
|
184
201
|
scrollUp();
|
|
185
|
-
|
|
202
|
+
render();
|
|
186
203
|
return;
|
|
187
204
|
}
|
|
188
205
|
if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
|
|
189
206
|
const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
|
|
190
207
|
currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id;
|
|
191
208
|
vimState.scrollPos = 0;
|
|
192
|
-
|
|
209
|
+
render();
|
|
193
210
|
return;
|
|
194
211
|
}
|
|
195
212
|
if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
|
|
196
213
|
const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
|
|
197
214
|
currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id;
|
|
198
215
|
vimState.scrollPos = 0;
|
|
199
|
-
|
|
216
|
+
render();
|
|
200
217
|
return;
|
|
201
218
|
}
|
|
202
219
|
|
|
@@ -204,54 +221,53 @@ export async function runUsageTui(): Promise<void> {
|
|
|
204
221
|
if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
|
|
205
222
|
scrollToBottom();
|
|
206
223
|
vimState.lastKey = "";
|
|
207
|
-
|
|
224
|
+
render();
|
|
208
225
|
return;
|
|
209
226
|
}
|
|
210
227
|
vimState.lastKey = "g";
|
|
211
228
|
vimState.lastKeyTime = Date.now();
|
|
212
229
|
scrollToTop();
|
|
213
|
-
|
|
230
|
+
render();
|
|
214
231
|
return;
|
|
215
232
|
}
|
|
216
233
|
|
|
217
|
-
if (key === "\x02") { pageUp();
|
|
218
|
-
if (key === "\x06") { pageDown();
|
|
219
|
-
if (key === "\x15") { scrollUp(10);
|
|
220
|
-
if (key === "\x04") { scrollDown(10);
|
|
221
|
-
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop();
|
|
222
|
-
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom();
|
|
223
|
-
if (key === "/") { startSearch(false);
|
|
224
|
-
if (key === "?") { startSearch(true);
|
|
234
|
+
if (key === "\x02") { pageUp(); render(); return; }
|
|
235
|
+
if (key === "\x06") { pageDown(); render(); return; }
|
|
236
|
+
if (key === "\x15") { scrollUp(10); render(); return; }
|
|
237
|
+
if (key === "\x04") { scrollDown(10); render(); return; }
|
|
238
|
+
if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); render(); return; }
|
|
239
|
+
if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); render(); return; }
|
|
240
|
+
if (key === "/") { startSearch(false); render(); return; }
|
|
241
|
+
if (key === "?") { startSearch(true); render(); return; }
|
|
225
242
|
if (key === "n") {
|
|
226
243
|
if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
|
|
227
244
|
else nextSearchResult(stats?.entries.length || 0);
|
|
228
|
-
|
|
245
|
+
render();
|
|
229
246
|
return;
|
|
230
247
|
}
|
|
231
248
|
if (key === "N") {
|
|
232
249
|
if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
|
|
233
250
|
else prevSearchResult(stats?.entries.length || 0);
|
|
234
|
-
|
|
251
|
+
render();
|
|
235
252
|
return;
|
|
236
253
|
}
|
|
237
|
-
if (key === "\x1b") { scrollToTop();
|
|
254
|
+
if (key === "\x1b") { scrollToTop(); render(); return; }
|
|
238
255
|
|
|
239
256
|
const tab = visibleTabs.find((t) => t.key === key);
|
|
240
257
|
if (tab) {
|
|
241
258
|
currentTab = tab.id;
|
|
242
259
|
vimState.scrollPos = 0;
|
|
243
|
-
|
|
260
|
+
render();
|
|
244
261
|
}
|
|
245
262
|
};
|
|
246
263
|
|
|
247
264
|
if (isInteractive) stdin.on("data", handleKey);
|
|
248
265
|
|
|
249
|
-
await
|
|
266
|
+
await fetchData();
|
|
250
267
|
|
|
251
268
|
refreshInterval = setInterval(() => {
|
|
252
269
|
if (autoRefresh) {
|
|
253
|
-
|
|
254
|
-
void render(true);
|
|
270
|
+
void fetchData();
|
|
255
271
|
}
|
|
256
272
|
}, 2000);
|
|
257
273
|
}
|