tunnelhook 0.1.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 +64 -0
- package/package.json +44 -0
- package/src/index.tsx +1739 -0
package/src/index.tsx
ADDED
|
@@ -0,0 +1,1739 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createCliRenderer } from "@opentui/core";
|
|
6
|
+
import {
|
|
7
|
+
createRoot,
|
|
8
|
+
useKeyboard,
|
|
9
|
+
useRenderer,
|
|
10
|
+
useTerminalDimensions,
|
|
11
|
+
} from "@opentui/react";
|
|
12
|
+
import { createORPCClient } from "@orpc/client";
|
|
13
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
14
|
+
import type { AppRouterClient } from "@tunnelhook/api/routers/index";
|
|
15
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Configuration
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const SERVER_URL =
|
|
22
|
+
process.env.TUNNELHOOK_SERVER_URL ??
|
|
23
|
+
"https://tunnelhook-server-shkumbinhasani.shkumbinhasani20001439.workers.dev";
|
|
24
|
+
const WS_URL = SERVER_URL.replace(/^http/, "ws");
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// CLI argument parsing
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
interface CliArgs {
|
|
31
|
+
/** "login" subcommand */
|
|
32
|
+
command: "login" | "listen" | "interactive";
|
|
33
|
+
/** --forward / -f URL */
|
|
34
|
+
forwardUrl?: string;
|
|
35
|
+
/** --machine / -m name override */
|
|
36
|
+
machineName?: string;
|
|
37
|
+
/** Endpoint slug (positional arg for listen mode) */
|
|
38
|
+
slug?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseArgs(): CliArgs {
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
|
|
44
|
+
if (args.length === 0) {
|
|
45
|
+
return { command: "interactive" };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (args[0] === "login") {
|
|
49
|
+
return { command: "login" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// tunnelhook <slug> --forward <url> [--machine <name>]
|
|
53
|
+
const slug = args[0];
|
|
54
|
+
let forwardUrl: string | undefined;
|
|
55
|
+
let machineName: string | undefined;
|
|
56
|
+
|
|
57
|
+
for (let i = 1; i < args.length; i++) {
|
|
58
|
+
const arg = args[i];
|
|
59
|
+
if ((arg === "--forward" || arg === "-f") && args[i + 1]) {
|
|
60
|
+
forwardUrl = args[i + 1];
|
|
61
|
+
i++;
|
|
62
|
+
} else if ((arg === "--machine" || arg === "-m") && args[i + 1]) {
|
|
63
|
+
machineName = args[i + 1];
|
|
64
|
+
i++;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!forwardUrl) {
|
|
69
|
+
console.error(
|
|
70
|
+
"Usage: tunnelhook <endpoint-slug> --forward <url> [--machine <name>]"
|
|
71
|
+
);
|
|
72
|
+
console.error(" tunnelhook login");
|
|
73
|
+
console.error(" tunnelhook (interactive mode)");
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { command: "listen", slug, forwardUrl, machineName };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cliArgs = parseArgs();
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Types
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
interface Endpoint {
|
|
87
|
+
createdAt: string;
|
|
88
|
+
description: string | null;
|
|
89
|
+
enabled: boolean;
|
|
90
|
+
forwardUrl: string | null;
|
|
91
|
+
id: string;
|
|
92
|
+
name: string;
|
|
93
|
+
slug: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface Machine {
|
|
97
|
+
endpointId: string;
|
|
98
|
+
forwardUrl: string;
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
status: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface WebhookEvent {
|
|
105
|
+
body: string | null;
|
|
106
|
+
contentType: string | null;
|
|
107
|
+
createdAt: string;
|
|
108
|
+
deliveryId?: string;
|
|
109
|
+
endpointId?: string;
|
|
110
|
+
eventId?: string;
|
|
111
|
+
headers: string;
|
|
112
|
+
id?: string;
|
|
113
|
+
method: string;
|
|
114
|
+
query: string | null;
|
|
115
|
+
sourceIp: string | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface DeliveryResult {
|
|
119
|
+
deliveryId: string;
|
|
120
|
+
duration: number | null;
|
|
121
|
+
error: string | null;
|
|
122
|
+
eventId: string;
|
|
123
|
+
machineId: string;
|
|
124
|
+
machineName: string;
|
|
125
|
+
responseBody: string | null;
|
|
126
|
+
responseStatus: number | null;
|
|
127
|
+
status: "delivered" | "failed";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type Screen =
|
|
131
|
+
| "login"
|
|
132
|
+
| "endpoints"
|
|
133
|
+
| "machine-setup"
|
|
134
|
+
| "monitor"
|
|
135
|
+
| "event-detail";
|
|
136
|
+
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Session persistence (~/.tunnelhook/session.json)
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
const CONFIG_DIR = join(homedir(), ".tunnelhook");
|
|
142
|
+
const SESSION_FILE = join(CONFIG_DIR, "session.json");
|
|
143
|
+
|
|
144
|
+
interface SessionData {
|
|
145
|
+
cookies: string;
|
|
146
|
+
serverUrl: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function loadSession(): string | null {
|
|
150
|
+
try {
|
|
151
|
+
if (!existsSync(SESSION_FILE)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const raw = readFileSync(SESSION_FILE, "utf-8");
|
|
155
|
+
const data = JSON.parse(raw) as SessionData;
|
|
156
|
+
// Only use session if it matches the current server URL
|
|
157
|
+
if (data.serverUrl === SERVER_URL && data.cookies) {
|
|
158
|
+
return data.cookies;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function saveSession(cookies: string): void {
|
|
167
|
+
try {
|
|
168
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
169
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
170
|
+
}
|
|
171
|
+
const data: SessionData = { cookies, serverUrl: SERVER_URL };
|
|
172
|
+
writeFileSync(SESSION_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
173
|
+
} catch {
|
|
174
|
+
// Non-critical — session just won't persist
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// Auth helpers
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
let authCookies: string | null = loadSession();
|
|
183
|
+
|
|
184
|
+
async function signIn(
|
|
185
|
+
email: string,
|
|
186
|
+
password: string
|
|
187
|
+
): Promise<{ success: boolean; error?: string }> {
|
|
188
|
+
try {
|
|
189
|
+
const res = await fetch(`${SERVER_URL}/api/auth/sign-in/email`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: { "Content-Type": "application/json" },
|
|
192
|
+
body: JSON.stringify({ email, password }),
|
|
193
|
+
redirect: "manual",
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
return { success: false, error: "Invalid credentials" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const cookies = res.headers.getSetCookie?.() ?? [];
|
|
201
|
+
if (cookies.length > 0) {
|
|
202
|
+
authCookies = cookies.map((c: string) => c.split(";")[0]).join("; ");
|
|
203
|
+
saveSession(authCookies);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { success: true };
|
|
207
|
+
} catch (err) {
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
error: err instanceof Error ? err.message : "Network error",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Validate the current session is still active by calling the auth session endpoint. */
|
|
216
|
+
async function validateSession(): Promise<boolean> {
|
|
217
|
+
if (!authCookies) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const res = await fetch(`${SERVER_URL}/api/auth/get-session`, {
|
|
222
|
+
headers: { Cookie: authCookies },
|
|
223
|
+
});
|
|
224
|
+
if (!res.ok) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
const data = (await res.json()) as { session?: unknown };
|
|
228
|
+
return Boolean(data.session);
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function getAuthHeaders(): Record<string, string> {
|
|
235
|
+
const headers: Record<string, string> = {};
|
|
236
|
+
if (authCookies) {
|
|
237
|
+
headers.Cookie = authCookies;
|
|
238
|
+
}
|
|
239
|
+
return headers;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
// oRPC client
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
const link = new RPCLink({
|
|
247
|
+
url: `${SERVER_URL}/rpc`,
|
|
248
|
+
headers: () => getAuthHeaders(),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const rpcClient: AppRouterClient = createORPCClient(link);
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// API helpers
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
async function fetchEndpoints(): Promise<Endpoint[]> {
|
|
258
|
+
const result = await rpcClient.endpoints.list({});
|
|
259
|
+
return result as unknown as Endpoint[];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function fetchMachines(endpointId: string): Promise<Machine[]> {
|
|
263
|
+
const result = await rpcClient.machines.list({ endpointId });
|
|
264
|
+
return result as unknown as Machine[];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function registerMachine(
|
|
268
|
+
endpointId: string,
|
|
269
|
+
name: string,
|
|
270
|
+
forwardUrl: string
|
|
271
|
+
): Promise<Machine> {
|
|
272
|
+
const result = await rpcClient.machines.register({
|
|
273
|
+
endpointId,
|
|
274
|
+
name,
|
|
275
|
+
forwardUrl,
|
|
276
|
+
});
|
|
277
|
+
return result as unknown as Machine;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function createEndpointApi(
|
|
281
|
+
name: string,
|
|
282
|
+
forwardUrl?: string
|
|
283
|
+
): Promise<Endpoint> {
|
|
284
|
+
const result = await rpcClient.endpoints.create({
|
|
285
|
+
name,
|
|
286
|
+
forwardUrl: forwardUrl || undefined,
|
|
287
|
+
});
|
|
288
|
+
return result as unknown as Endpoint;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function reportDeliveryResult(params: {
|
|
292
|
+
deliveryId: string;
|
|
293
|
+
duration: number | null;
|
|
294
|
+
error: string | null;
|
|
295
|
+
responseBody: string | null;
|
|
296
|
+
responseStatus: number | null;
|
|
297
|
+
status: "delivered" | "failed";
|
|
298
|
+
}): Promise<void> {
|
|
299
|
+
await rpcClient.machines.reportDelivery(params);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Machine resolution (find or create a machine for direct CLI mode)
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
const LOCAL_SUFFIX_RE = /\.local$/;
|
|
307
|
+
|
|
308
|
+
function getMachineName(): string {
|
|
309
|
+
return hostname().replace(LOCAL_SUFFIX_RE, "");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function findEndpointBySlug(slug: string): Promise<Endpoint> {
|
|
313
|
+
const endpoints = await fetchEndpoints();
|
|
314
|
+
const found = endpoints.find((ep) => ep.slug === slug);
|
|
315
|
+
if (found) {
|
|
316
|
+
return found;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Auto-create the endpoint when it doesn't exist yet
|
|
320
|
+
const created = await rpcClient.endpoints.create({ name: slug, slug });
|
|
321
|
+
return created as unknown as Endpoint;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const MACHINE_NAME_SUFFIX_RE = /^(.+)-(\d+)$/;
|
|
325
|
+
|
|
326
|
+
async function findOrCreateMachine(
|
|
327
|
+
endpointId: string,
|
|
328
|
+
forwardUrl: string,
|
|
329
|
+
nameOverride?: string
|
|
330
|
+
): Promise<Machine> {
|
|
331
|
+
const baseName = nameOverride ?? getMachineName();
|
|
332
|
+
const machines = await fetchMachines(endpointId);
|
|
333
|
+
|
|
334
|
+
// Find an existing offline machine with this base name (or base-N variant)
|
|
335
|
+
// that we can reuse, so we don't leak machine records.
|
|
336
|
+
const ownMachines = machines.filter((m) => {
|
|
337
|
+
if (m.name === baseName) {
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
const match = MACHINE_NAME_SUFFIX_RE.exec(m.name);
|
|
341
|
+
return match?.[1] === baseName;
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const offlineMachine = ownMachines.find((m) => m.status === "offline");
|
|
345
|
+
if (offlineMachine) {
|
|
346
|
+
// Reuse an offline machine, updating forward URL if needed
|
|
347
|
+
if (offlineMachine.forwardUrl !== forwardUrl) {
|
|
348
|
+
const updated = await rpcClient.machines.update({
|
|
349
|
+
id: offlineMachine.id,
|
|
350
|
+
forwardUrl,
|
|
351
|
+
});
|
|
352
|
+
return updated as unknown as Machine;
|
|
353
|
+
}
|
|
354
|
+
return offlineMachine;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// All existing machines with this name are online — create a new one
|
|
358
|
+
// with an incremented suffix: MacBook-Pro-2, MacBook-Pro-3, etc.
|
|
359
|
+
let nextName = baseName;
|
|
360
|
+
if (ownMachines.length > 0) {
|
|
361
|
+
nextName = `${baseName}-${ownMachines.length + 1}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return registerMachine(endpointId, nextName, forwardUrl);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// CLI command handlers (non-interactive)
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
async function handleLoginCommand(): Promise<never> {
|
|
372
|
+
const readline = await import("node:readline");
|
|
373
|
+
const rl = readline.createInterface({
|
|
374
|
+
input: process.stdin,
|
|
375
|
+
output: process.stdout,
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
const ask = (q: string): Promise<string> =>
|
|
379
|
+
new Promise((resolve) => rl.question(q, resolve));
|
|
380
|
+
|
|
381
|
+
console.log("tunnelhook login");
|
|
382
|
+
console.log(`Server: ${SERVER_URL}\n`);
|
|
383
|
+
|
|
384
|
+
const email = await ask("Email: ");
|
|
385
|
+
const password = await ask("Password: ");
|
|
386
|
+
rl.close();
|
|
387
|
+
|
|
388
|
+
console.log("\nSigning in...");
|
|
389
|
+
const result = await signIn(email, password);
|
|
390
|
+
|
|
391
|
+
if (result.success) {
|
|
392
|
+
console.log(
|
|
393
|
+
"Logged in successfully. Session saved to ~/.tunnelhook/session.json"
|
|
394
|
+
);
|
|
395
|
+
} else {
|
|
396
|
+
console.error(`Login failed: ${result.error ?? "Unknown error"}`);
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
process.exit(0);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function handleListenCommand(
|
|
404
|
+
slug: string,
|
|
405
|
+
forwardUrl: string,
|
|
406
|
+
machineNameOverride?: string
|
|
407
|
+
): Promise<{ endpoint: Endpoint; machine: Machine }> {
|
|
408
|
+
// Validate session
|
|
409
|
+
const valid = await validateSession();
|
|
410
|
+
if (!valid) {
|
|
411
|
+
console.error(
|
|
412
|
+
"Not logged in or session expired. Run `tunnelhook login` first."
|
|
413
|
+
);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Resolve endpoint
|
|
418
|
+
const endpoint = await findEndpointBySlug(slug);
|
|
419
|
+
|
|
420
|
+
// Find or create machine
|
|
421
|
+
const machine = await findOrCreateMachine(
|
|
422
|
+
endpoint.id,
|
|
423
|
+
forwardUrl,
|
|
424
|
+
machineNameOverride
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
return { endpoint, machine };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
// WebSocket helpers
|
|
432
|
+
// ---------------------------------------------------------------------------
|
|
433
|
+
|
|
434
|
+
interface WebhookMessage {
|
|
435
|
+
body: string | null;
|
|
436
|
+
contentType: string | null;
|
|
437
|
+
createdAt: string;
|
|
438
|
+
deliveryId: string;
|
|
439
|
+
eventId: string;
|
|
440
|
+
headers: string;
|
|
441
|
+
method: string;
|
|
442
|
+
query: string | null;
|
|
443
|
+
sourceIp: string | null;
|
|
444
|
+
type: "webhook";
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
interface DeliveryResultMessage {
|
|
448
|
+
deliveryId: string;
|
|
449
|
+
duration: number | null;
|
|
450
|
+
error: string | null;
|
|
451
|
+
eventId: string;
|
|
452
|
+
machineId: string;
|
|
453
|
+
machineName: string;
|
|
454
|
+
responseBody: string | null;
|
|
455
|
+
responseStatus: number | null;
|
|
456
|
+
status: "delivered" | "failed";
|
|
457
|
+
type: "delivery-result";
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
interface MachineStatusMessage {
|
|
461
|
+
machineId: string;
|
|
462
|
+
machineName: string;
|
|
463
|
+
status: "online" | "offline";
|
|
464
|
+
type: "machine-status";
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
type ServerMessage =
|
|
468
|
+
| WebhookMessage
|
|
469
|
+
| DeliveryResultMessage
|
|
470
|
+
| MachineStatusMessage;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Forward a webhook to a local URL and return the result.
|
|
474
|
+
*/
|
|
475
|
+
async function forwardWebhookLocally(
|
|
476
|
+
forwardUrl: string,
|
|
477
|
+
msg: WebhookMessage
|
|
478
|
+
): Promise<{
|
|
479
|
+
duration: number;
|
|
480
|
+
error: string | null;
|
|
481
|
+
responseBody: string | null;
|
|
482
|
+
responseStatus: number | null;
|
|
483
|
+
status: "delivered" | "failed";
|
|
484
|
+
}> {
|
|
485
|
+
const startTime = Date.now();
|
|
486
|
+
try {
|
|
487
|
+
let parsedHeaders: Record<string, string> = {};
|
|
488
|
+
try {
|
|
489
|
+
parsedHeaders = JSON.parse(msg.headers) as Record<string, string>;
|
|
490
|
+
} catch {
|
|
491
|
+
// Use empty headers
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Remove host header to avoid conflicts
|
|
495
|
+
const { host: _host, ...forwardHeaders } = parsedHeaders;
|
|
496
|
+
|
|
497
|
+
const res = await fetch(forwardUrl, {
|
|
498
|
+
method: msg.method,
|
|
499
|
+
headers: {
|
|
500
|
+
...forwardHeaders,
|
|
501
|
+
host: new URL(forwardUrl).host,
|
|
502
|
+
"x-tunnelhook-event-id": msg.eventId,
|
|
503
|
+
"x-tunnelhook-delivery-id": msg.deliveryId,
|
|
504
|
+
},
|
|
505
|
+
body:
|
|
506
|
+
msg.method !== "GET" && msg.method !== "HEAD" ? msg.body : undefined,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const duration = Date.now() - startTime;
|
|
510
|
+
let responseBody: string | null = null;
|
|
511
|
+
try {
|
|
512
|
+
responseBody = await res.text();
|
|
513
|
+
if (responseBody.length > 10_000) {
|
|
514
|
+
responseBody = responseBody.slice(0, 10_000);
|
|
515
|
+
}
|
|
516
|
+
} catch {
|
|
517
|
+
// No response body
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
status: res.ok ? "delivered" : "failed",
|
|
522
|
+
responseStatus: res.status,
|
|
523
|
+
responseBody,
|
|
524
|
+
error: null,
|
|
525
|
+
duration,
|
|
526
|
+
};
|
|
527
|
+
} catch (err) {
|
|
528
|
+
const duration = Date.now() - startTime;
|
|
529
|
+
return {
|
|
530
|
+
status: "failed",
|
|
531
|
+
responseStatus: null,
|
|
532
|
+
responseBody: null,
|
|
533
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
534
|
+
duration,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// Color theme
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
const COLORS = {
|
|
544
|
+
bg: "#0d1117",
|
|
545
|
+
panel: "#161b22",
|
|
546
|
+
border: "#30363d",
|
|
547
|
+
text: "#c9d1d9",
|
|
548
|
+
textDim: "#8b949e",
|
|
549
|
+
accent: "#58a6ff",
|
|
550
|
+
accentBright: "#79c0ff",
|
|
551
|
+
green: "#3fb950",
|
|
552
|
+
red: "#f85149",
|
|
553
|
+
yellow: "#d29922",
|
|
554
|
+
purple: "#bc8cff",
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const METHOD_COLORS: Record<string, string> = {
|
|
558
|
+
GET: "#3fb950",
|
|
559
|
+
POST: "#58a6ff",
|
|
560
|
+
PUT: "#d29922",
|
|
561
|
+
PATCH: "#d29922",
|
|
562
|
+
DELETE: "#f85149",
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
function methodColor(method: string): string {
|
|
566
|
+
return METHOD_COLORS[method.toUpperCase()] ?? COLORS.text;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function statusColor(status: number | null): string {
|
|
570
|
+
if (status === null) {
|
|
571
|
+
return COLORS.textDim;
|
|
572
|
+
}
|
|
573
|
+
if (status >= 200 && status < 300) {
|
|
574
|
+
return COLORS.green;
|
|
575
|
+
}
|
|
576
|
+
if (status >= 300 && status < 400) {
|
|
577
|
+
return COLORS.yellow;
|
|
578
|
+
}
|
|
579
|
+
return COLORS.red;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Components
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
function StatusBar({
|
|
587
|
+
screen,
|
|
588
|
+
endpointName,
|
|
589
|
+
}: {
|
|
590
|
+
screen: Screen;
|
|
591
|
+
endpointName?: string;
|
|
592
|
+
}) {
|
|
593
|
+
const breadcrumbMap: Record<Screen, string> = {
|
|
594
|
+
login: "Login",
|
|
595
|
+
endpoints: "Endpoints",
|
|
596
|
+
"machine-setup": `${endpointName ?? "..."} > Machine Setup`,
|
|
597
|
+
monitor: `${endpointName ?? "..."} > Live Monitor`,
|
|
598
|
+
"event-detail": `${endpointName ?? "..."} > Detail`,
|
|
599
|
+
};
|
|
600
|
+
const breadcrumb = breadcrumbMap[screen];
|
|
601
|
+
|
|
602
|
+
return (
|
|
603
|
+
<box
|
|
604
|
+
backgroundColor={COLORS.accent}
|
|
605
|
+
flexDirection="row"
|
|
606
|
+
height={1}
|
|
607
|
+
justifyContent="space-between"
|
|
608
|
+
paddingX={1}
|
|
609
|
+
>
|
|
610
|
+
<text fg="#ffffff">
|
|
611
|
+
<strong>tunnelhook</strong>
|
|
612
|
+
</text>
|
|
613
|
+
<text fg="#ffffff">{breadcrumb}</text>
|
|
614
|
+
<text fg="#ffffff">
|
|
615
|
+
{screen === "login" ? "Enter:submit Tab:switch" : "q:quit esc:back"}
|
|
616
|
+
</text>
|
|
617
|
+
</box>
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function HelpBar({ screen }: { screen: Screen }) {
|
|
622
|
+
const hints: Record<Screen, string> = {
|
|
623
|
+
login: "Tab: switch fields Enter: submit",
|
|
624
|
+
endpoints: "j/k: navigate Enter: select n: new r: refresh q: quit",
|
|
625
|
+
"machine-setup": "j/k: navigate Enter: select n: new machine esc: back",
|
|
626
|
+
monitor: "j/k: navigate Enter: detail esc: back q: quit",
|
|
627
|
+
"event-detail":
|
|
628
|
+
"1: body 2: headers 3: deliveries Tab: switch esc: back",
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
return (
|
|
632
|
+
<box
|
|
633
|
+
backgroundColor={COLORS.panel}
|
|
634
|
+
flexDirection="row"
|
|
635
|
+
gap={2}
|
|
636
|
+
height={1}
|
|
637
|
+
paddingX={1}
|
|
638
|
+
>
|
|
639
|
+
<text fg={COLORS.textDim}>{hints[screen]}</text>
|
|
640
|
+
</box>
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
// Login Screen
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
function LoginScreen({ onLogin }: { onLogin: () => void }) {
|
|
649
|
+
const [email, setEmail] = useState("");
|
|
650
|
+
const [password, setPassword] = useState("");
|
|
651
|
+
const [focusField, setFocusField] = useState<"email" | "password">("email");
|
|
652
|
+
const [error, setError] = useState<string | null>(null);
|
|
653
|
+
const [loading, setLoading] = useState(false);
|
|
654
|
+
|
|
655
|
+
const handleSubmit = useCallback(() => {
|
|
656
|
+
if (loading) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
setLoading(true);
|
|
660
|
+
setError(null);
|
|
661
|
+
signIn(email, password).then((result) => {
|
|
662
|
+
setLoading(false);
|
|
663
|
+
if (result.success) {
|
|
664
|
+
onLogin();
|
|
665
|
+
} else {
|
|
666
|
+
setError(result.error ?? "Login failed");
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
}, [email, password, loading, onLogin]);
|
|
670
|
+
|
|
671
|
+
useKeyboard((key) => {
|
|
672
|
+
if (key.name === "tab") {
|
|
673
|
+
setFocusField((prev: "email" | "password") =>
|
|
674
|
+
prev === "email" ? "password" : "email"
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
if ((key.name === "enter" || key.name === "return") && !loading) {
|
|
678
|
+
handleSubmit();
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
return (
|
|
683
|
+
<box
|
|
684
|
+
alignItems="center"
|
|
685
|
+
backgroundColor={COLORS.bg}
|
|
686
|
+
flexGrow={1}
|
|
687
|
+
justifyContent="center"
|
|
688
|
+
>
|
|
689
|
+
<box
|
|
690
|
+
backgroundColor={COLORS.panel}
|
|
691
|
+
border
|
|
692
|
+
borderColor={COLORS.border}
|
|
693
|
+
borderStyle="rounded"
|
|
694
|
+
padding={2}
|
|
695
|
+
width={50}
|
|
696
|
+
>
|
|
697
|
+
<box flexDirection="column" gap={1}>
|
|
698
|
+
<ascii-font color={COLORS.accent} font="tiny" text="tunnelhook" />
|
|
699
|
+
<text fg={COLORS.textDim}>
|
|
700
|
+
Sign in to manage your webhook endpoints
|
|
701
|
+
</text>
|
|
702
|
+
|
|
703
|
+
<box height={1} />
|
|
704
|
+
|
|
705
|
+
<text fg={COLORS.text}>Email</text>
|
|
706
|
+
<input
|
|
707
|
+
backgroundColor={COLORS.bg}
|
|
708
|
+
focused={focusField === "email"}
|
|
709
|
+
focusedBackgroundColor="#1c2128"
|
|
710
|
+
onChange={setEmail}
|
|
711
|
+
placeholder="you@example.com"
|
|
712
|
+
textColor={COLORS.text}
|
|
713
|
+
value={email}
|
|
714
|
+
width={40}
|
|
715
|
+
/>
|
|
716
|
+
|
|
717
|
+
<text fg={COLORS.text}>Password</text>
|
|
718
|
+
<input
|
|
719
|
+
backgroundColor={COLORS.bg}
|
|
720
|
+
focused={focusField === "password"}
|
|
721
|
+
focusedBackgroundColor="#1c2128"
|
|
722
|
+
onChange={setPassword}
|
|
723
|
+
placeholder="password"
|
|
724
|
+
textColor={COLORS.text}
|
|
725
|
+
value={password}
|
|
726
|
+
width={40}
|
|
727
|
+
/>
|
|
728
|
+
|
|
729
|
+
{error ? <text fg={COLORS.red}>{error}</text> : null}
|
|
730
|
+
{loading ? (
|
|
731
|
+
<text fg={COLORS.yellow}>Signing in...</text>
|
|
732
|
+
) : (
|
|
733
|
+
<text fg={COLORS.textDim}>
|
|
734
|
+
Press Enter to sign in, Tab to switch fields
|
|
735
|
+
</text>
|
|
736
|
+
)}
|
|
737
|
+
</box>
|
|
738
|
+
</box>
|
|
739
|
+
</box>
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// ---------------------------------------------------------------------------
|
|
744
|
+
// Endpoints List Screen
|
|
745
|
+
// ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
function EndpointsScreen({
|
|
748
|
+
onSelect,
|
|
749
|
+
onQuit,
|
|
750
|
+
}: {
|
|
751
|
+
onSelect: (ep: Endpoint) => void;
|
|
752
|
+
onQuit: () => void;
|
|
753
|
+
}) {
|
|
754
|
+
const [endpoints, setEndpoints] = useState<Endpoint[]>([]);
|
|
755
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
756
|
+
const [loading, setLoading] = useState(true);
|
|
757
|
+
const [error, setError] = useState<string | null>(null);
|
|
758
|
+
const [creating, setCreating] = useState(false);
|
|
759
|
+
const [newName, setNewName] = useState("");
|
|
760
|
+
|
|
761
|
+
const loadEndpoints = useCallback(() => {
|
|
762
|
+
setLoading(true);
|
|
763
|
+
fetchEndpoints()
|
|
764
|
+
.then((eps: Endpoint[]) => {
|
|
765
|
+
setEndpoints(eps);
|
|
766
|
+
setError(null);
|
|
767
|
+
})
|
|
768
|
+
.catch((err: Error) => setError(err.message))
|
|
769
|
+
.finally(() => setLoading(false));
|
|
770
|
+
}, []);
|
|
771
|
+
|
|
772
|
+
useEffect(() => {
|
|
773
|
+
loadEndpoints();
|
|
774
|
+
}, [loadEndpoints]);
|
|
775
|
+
|
|
776
|
+
const handleCreatingKey = useCallback(
|
|
777
|
+
(key: { name: string }) => {
|
|
778
|
+
if (key.name === "escape") {
|
|
779
|
+
setCreating(false);
|
|
780
|
+
setNewName("");
|
|
781
|
+
}
|
|
782
|
+
if ((key.name === "enter" || key.name === "return") && newName.trim()) {
|
|
783
|
+
createEndpointApi(newName.trim())
|
|
784
|
+
.then(() => {
|
|
785
|
+
setCreating(false);
|
|
786
|
+
setNewName("");
|
|
787
|
+
loadEndpoints();
|
|
788
|
+
})
|
|
789
|
+
.catch((err: Error) => setError(err.message));
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
[newName, loadEndpoints]
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
const isEnter = (name: string) => name === "enter" || name === "return";
|
|
796
|
+
|
|
797
|
+
useKeyboard((key) => {
|
|
798
|
+
if (creating) {
|
|
799
|
+
handleCreatingKey(key);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
804
|
+
onQuit();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (key.name === "j" || key.name === "down") {
|
|
809
|
+
setSelectedIndex((idx: number) =>
|
|
810
|
+
Math.min(endpoints.length - 1, idx + 1)
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
if (key.name === "k" || key.name === "up") {
|
|
814
|
+
setSelectedIndex((idx: number) => Math.max(0, idx - 1));
|
|
815
|
+
}
|
|
816
|
+
if (isEnter(key.name) && endpoints[selectedIndex]) {
|
|
817
|
+
onSelect(endpoints[selectedIndex]);
|
|
818
|
+
}
|
|
819
|
+
if (key.name === "n") {
|
|
820
|
+
setCreating(true);
|
|
821
|
+
}
|
|
822
|
+
if (key.name === "r") {
|
|
823
|
+
loadEndpoints();
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const { height } = useTerminalDimensions();
|
|
828
|
+
|
|
829
|
+
return (
|
|
830
|
+
<box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
|
|
831
|
+
<box flexDirection="row" gap={2} height={3} paddingX={1} paddingY={1}>
|
|
832
|
+
<text fg={COLORS.text}>
|
|
833
|
+
<strong>Webhook Endpoints</strong>
|
|
834
|
+
</text>
|
|
835
|
+
<text fg={COLORS.textDim}>({endpoints.length} total)</text>
|
|
836
|
+
{loading ? <text fg={COLORS.yellow}>loading...</text> : null}
|
|
837
|
+
</box>
|
|
838
|
+
|
|
839
|
+
{creating ? (
|
|
840
|
+
<box flexDirection="row" gap={1} height={3} paddingX={1}>
|
|
841
|
+
<text fg={COLORS.accent}>New endpoint name:</text>
|
|
842
|
+
<input
|
|
843
|
+
backgroundColor={COLORS.bg}
|
|
844
|
+
focused
|
|
845
|
+
focusedBackgroundColor="#1c2128"
|
|
846
|
+
onChange={setNewName}
|
|
847
|
+
placeholder="My Webhook"
|
|
848
|
+
textColor={COLORS.text}
|
|
849
|
+
value={newName}
|
|
850
|
+
width={30}
|
|
851
|
+
/>
|
|
852
|
+
<text fg={COLORS.textDim}>(enter to create, esc to cancel)</text>
|
|
853
|
+
</box>
|
|
854
|
+
) : null}
|
|
855
|
+
|
|
856
|
+
{error ? (
|
|
857
|
+
<box height={1} paddingX={1}>
|
|
858
|
+
<text fg={COLORS.red}>Error: {error}</text>
|
|
859
|
+
</box>
|
|
860
|
+
) : null}
|
|
861
|
+
|
|
862
|
+
<scrollbox focused={!creating} height={height - 8}>
|
|
863
|
+
{endpoints.map((ep: Endpoint, idx: number) => (
|
|
864
|
+
<box
|
|
865
|
+
alignItems="center"
|
|
866
|
+
backgroundColor={idx === selectedIndex ? "#1c2128" : "transparent"}
|
|
867
|
+
flexDirection="row"
|
|
868
|
+
gap={2}
|
|
869
|
+
height={3}
|
|
870
|
+
key={ep.id}
|
|
871
|
+
paddingX={2}
|
|
872
|
+
paddingY={0}
|
|
873
|
+
>
|
|
874
|
+
<text
|
|
875
|
+
fg={idx === selectedIndex ? COLORS.accent : COLORS.text}
|
|
876
|
+
width={3}
|
|
877
|
+
>
|
|
878
|
+
{idx === selectedIndex ? " > " : " "}
|
|
879
|
+
</text>
|
|
880
|
+
<box flexDirection="column" flexGrow={1}>
|
|
881
|
+
<text
|
|
882
|
+
fg={idx === selectedIndex ? COLORS.accentBright : COLORS.text}
|
|
883
|
+
>
|
|
884
|
+
<strong>{ep.name}</strong>
|
|
885
|
+
</text>
|
|
886
|
+
<text fg={COLORS.textDim}>
|
|
887
|
+
{SERVER_URL}/hooks/{ep.slug}
|
|
888
|
+
</text>
|
|
889
|
+
</box>
|
|
890
|
+
<text fg={ep.enabled ? COLORS.green : COLORS.red}>
|
|
891
|
+
{ep.enabled ? "active" : "disabled"}
|
|
892
|
+
</text>
|
|
893
|
+
</box>
|
|
894
|
+
))}
|
|
895
|
+
{endpoints.length === 0 && !loading ? (
|
|
896
|
+
<box paddingX={2} paddingY={1}>
|
|
897
|
+
<text fg={COLORS.textDim}>
|
|
898
|
+
No endpoints yet. Press 'n' to create one.
|
|
899
|
+
</text>
|
|
900
|
+
</box>
|
|
901
|
+
) : null}
|
|
902
|
+
</scrollbox>
|
|
903
|
+
</box>
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
// Machine Setup Screen
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
|
|
911
|
+
function MachineSetupScreen({
|
|
912
|
+
endpoint: ep,
|
|
913
|
+
onConnect,
|
|
914
|
+
onBack,
|
|
915
|
+
onQuit,
|
|
916
|
+
}: {
|
|
917
|
+
endpoint: Endpoint;
|
|
918
|
+
onConnect: (machine: Machine) => void;
|
|
919
|
+
onBack: () => void;
|
|
920
|
+
onQuit: () => void;
|
|
921
|
+
}) {
|
|
922
|
+
const [machines, setMachines] = useState<Machine[]>([]);
|
|
923
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
924
|
+
const [loading, setLoading] = useState(true);
|
|
925
|
+
const [creating, setCreating] = useState(false);
|
|
926
|
+
const [newName, setNewName] = useState("");
|
|
927
|
+
const [newUrl, setNewUrl] = useState("http://localhost:3000/webhook");
|
|
928
|
+
const [createFocus, setCreateFocus] = useState<"name" | "url">("name");
|
|
929
|
+
const [error, setError] = useState<string | null>(null);
|
|
930
|
+
|
|
931
|
+
const loadMachines = useCallback(() => {
|
|
932
|
+
setLoading(true);
|
|
933
|
+
fetchMachines(ep.id)
|
|
934
|
+
.then((ms: Machine[]) => {
|
|
935
|
+
setMachines(ms);
|
|
936
|
+
setError(null);
|
|
937
|
+
})
|
|
938
|
+
.catch((err: Error) => setError(err.message))
|
|
939
|
+
.finally(() => setLoading(false));
|
|
940
|
+
}, [ep.id]);
|
|
941
|
+
|
|
942
|
+
useEffect(() => {
|
|
943
|
+
loadMachines();
|
|
944
|
+
}, [loadMachines]);
|
|
945
|
+
|
|
946
|
+
const handleCreateSubmit = useCallback(() => {
|
|
947
|
+
if (!(newName.trim() && newUrl.trim())) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
setError(null);
|
|
951
|
+
registerMachine(ep.id, newName.trim(), newUrl.trim())
|
|
952
|
+
.then((m: Machine) => {
|
|
953
|
+
setCreating(false);
|
|
954
|
+
setNewName("");
|
|
955
|
+
setNewUrl("http://localhost:3000/webhook");
|
|
956
|
+
onConnect(m);
|
|
957
|
+
})
|
|
958
|
+
.catch((err: Error) => setError(err.message));
|
|
959
|
+
}, [ep.id, newName, newUrl, onConnect]);
|
|
960
|
+
|
|
961
|
+
const isEnter = (name: string) => name === "enter" || name === "return";
|
|
962
|
+
|
|
963
|
+
useKeyboard((key) => {
|
|
964
|
+
if (creating) {
|
|
965
|
+
if (key.name === "escape") {
|
|
966
|
+
setCreating(false);
|
|
967
|
+
setNewName("");
|
|
968
|
+
setNewUrl("http://localhost:3000/webhook");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (key.name === "tab") {
|
|
972
|
+
setCreateFocus((prev) => (prev === "name" ? "url" : "name"));
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (isEnter(key.name)) {
|
|
976
|
+
handleCreateSubmit();
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
983
|
+
onQuit();
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
if (key.name === "escape") {
|
|
987
|
+
onBack();
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
if (key.name === "j" || key.name === "down") {
|
|
992
|
+
setSelectedIndex((idx: number) => Math.min(machines.length - 1, idx + 1));
|
|
993
|
+
}
|
|
994
|
+
if (key.name === "k" || key.name === "up") {
|
|
995
|
+
setSelectedIndex((idx: number) => Math.max(0, idx - 1));
|
|
996
|
+
}
|
|
997
|
+
if (isEnter(key.name) && machines[selectedIndex]) {
|
|
998
|
+
onConnect(machines[selectedIndex]);
|
|
999
|
+
}
|
|
1000
|
+
if (key.name === "n") {
|
|
1001
|
+
setCreating(true);
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
const { height } = useTerminalDimensions();
|
|
1006
|
+
|
|
1007
|
+
return (
|
|
1008
|
+
<box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
|
|
1009
|
+
<box
|
|
1010
|
+
border
|
|
1011
|
+
borderColor={COLORS.border}
|
|
1012
|
+
flexDirection="column"
|
|
1013
|
+
height={4}
|
|
1014
|
+
paddingX={1}
|
|
1015
|
+
>
|
|
1016
|
+
<text fg={COLORS.text}>
|
|
1017
|
+
<strong>Select or create a machine for: {ep.name}</strong>
|
|
1018
|
+
</text>
|
|
1019
|
+
<text fg={COLORS.textDim}>
|
|
1020
|
+
Machines forward webhooks to a local URL on your computer
|
|
1021
|
+
</text>
|
|
1022
|
+
</box>
|
|
1023
|
+
|
|
1024
|
+
{creating ? (
|
|
1025
|
+
<box
|
|
1026
|
+
border
|
|
1027
|
+
borderColor={COLORS.accent}
|
|
1028
|
+
flexDirection="column"
|
|
1029
|
+
gap={1}
|
|
1030
|
+
marginX={1}
|
|
1031
|
+
marginY={1}
|
|
1032
|
+
padding={1}
|
|
1033
|
+
>
|
|
1034
|
+
<text fg={COLORS.accent}>
|
|
1035
|
+
<strong>Register New Machine</strong>
|
|
1036
|
+
</text>
|
|
1037
|
+
<text fg={COLORS.text}>Machine Name</text>
|
|
1038
|
+
<input
|
|
1039
|
+
backgroundColor={COLORS.bg}
|
|
1040
|
+
focused={createFocus === "name"}
|
|
1041
|
+
focusedBackgroundColor="#1c2128"
|
|
1042
|
+
onChange={setNewName}
|
|
1043
|
+
placeholder="My MacBook"
|
|
1044
|
+
textColor={COLORS.text}
|
|
1045
|
+
value={newName}
|
|
1046
|
+
width={40}
|
|
1047
|
+
/>
|
|
1048
|
+
<text fg={COLORS.text}>Forward URL</text>
|
|
1049
|
+
<input
|
|
1050
|
+
backgroundColor={COLORS.bg}
|
|
1051
|
+
focused={createFocus === "url"}
|
|
1052
|
+
focusedBackgroundColor="#1c2128"
|
|
1053
|
+
onChange={setNewUrl}
|
|
1054
|
+
placeholder="http://localhost:3000/webhook"
|
|
1055
|
+
textColor={COLORS.text}
|
|
1056
|
+
value={newUrl}
|
|
1057
|
+
width={50}
|
|
1058
|
+
/>
|
|
1059
|
+
{error ? <text fg={COLORS.red}>{error}</text> : null}
|
|
1060
|
+
<text fg={COLORS.textDim}>
|
|
1061
|
+
Enter: create and connect | Tab: switch fields | Esc: cancel
|
|
1062
|
+
</text>
|
|
1063
|
+
</box>
|
|
1064
|
+
) : null}
|
|
1065
|
+
|
|
1066
|
+
<scrollbox focused={!creating} height={height - (creating ? 18 : 8)}>
|
|
1067
|
+
{machines.map((m: Machine, idx: number) => (
|
|
1068
|
+
<box
|
|
1069
|
+
alignItems="center"
|
|
1070
|
+
backgroundColor={idx === selectedIndex ? "#1c2128" : "transparent"}
|
|
1071
|
+
flexDirection="row"
|
|
1072
|
+
gap={2}
|
|
1073
|
+
height={3}
|
|
1074
|
+
key={m.id}
|
|
1075
|
+
paddingX={2}
|
|
1076
|
+
>
|
|
1077
|
+
<text
|
|
1078
|
+
fg={idx === selectedIndex ? COLORS.accent : COLORS.text}
|
|
1079
|
+
width={3}
|
|
1080
|
+
>
|
|
1081
|
+
{idx === selectedIndex ? " > " : " "}
|
|
1082
|
+
</text>
|
|
1083
|
+
<box flexDirection="column" flexGrow={1}>
|
|
1084
|
+
<text
|
|
1085
|
+
fg={idx === selectedIndex ? COLORS.accentBright : COLORS.text}
|
|
1086
|
+
>
|
|
1087
|
+
<strong>{m.name}</strong>
|
|
1088
|
+
</text>
|
|
1089
|
+
<text fg={COLORS.textDim}>{m.forwardUrl}</text>
|
|
1090
|
+
</box>
|
|
1091
|
+
<text fg={m.status === "online" ? COLORS.green : COLORS.textDim}>
|
|
1092
|
+
{m.status}
|
|
1093
|
+
</text>
|
|
1094
|
+
</box>
|
|
1095
|
+
))}
|
|
1096
|
+
{machines.length === 0 && !loading ? (
|
|
1097
|
+
<box paddingX={2} paddingY={1}>
|
|
1098
|
+
<text fg={COLORS.textDim}>
|
|
1099
|
+
No machines registered. Press 'n' to create one.
|
|
1100
|
+
</text>
|
|
1101
|
+
</box>
|
|
1102
|
+
) : null}
|
|
1103
|
+
{loading ? (
|
|
1104
|
+
<box paddingX={2} paddingY={1}>
|
|
1105
|
+
<text fg={COLORS.yellow}>Loading machines...</text>
|
|
1106
|
+
</box>
|
|
1107
|
+
) : null}
|
|
1108
|
+
</scrollbox>
|
|
1109
|
+
</box>
|
|
1110
|
+
);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// ---------------------------------------------------------------------------
|
|
1114
|
+
// Live Monitor Screen (WebSocket machine connection)
|
|
1115
|
+
// ---------------------------------------------------------------------------
|
|
1116
|
+
|
|
1117
|
+
interface MonitorEvent {
|
|
1118
|
+
deliveryId: string;
|
|
1119
|
+
deliveryResult?: DeliveryResult;
|
|
1120
|
+
event: WebhookEvent;
|
|
1121
|
+
eventId: string;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function MonitorScreen({
|
|
1125
|
+
endpoint: ep,
|
|
1126
|
+
machine: mach,
|
|
1127
|
+
onBack,
|
|
1128
|
+
onSelectEvent,
|
|
1129
|
+
onQuit,
|
|
1130
|
+
}: {
|
|
1131
|
+
endpoint: Endpoint;
|
|
1132
|
+
machine: Machine;
|
|
1133
|
+
onBack: () => void;
|
|
1134
|
+
onSelectEvent: (evt: MonitorEvent) => void;
|
|
1135
|
+
onQuit: () => void;
|
|
1136
|
+
}) {
|
|
1137
|
+
const [events, setEvents] = useState<MonitorEvent[]>([]);
|
|
1138
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
1139
|
+
const [connected, setConnected] = useState(false);
|
|
1140
|
+
const [wsStatus, setWsStatus] = useState<string>("connecting...");
|
|
1141
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
1142
|
+
|
|
1143
|
+
// Connect to WebSocket as a machine
|
|
1144
|
+
useEffect(() => {
|
|
1145
|
+
const wsUrl = `${WS_URL}/hooks/${ep.slug}/ws?role=machine&machineId=${mach.id}&machineName=${encodeURIComponent(mach.name)}`;
|
|
1146
|
+
|
|
1147
|
+
let ws: WebSocket;
|
|
1148
|
+
try {
|
|
1149
|
+
ws = new WebSocket(wsUrl, {
|
|
1150
|
+
headers: getAuthHeaders(),
|
|
1151
|
+
} as unknown as string[]);
|
|
1152
|
+
} catch {
|
|
1153
|
+
setWsStatus("failed to create WebSocket");
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
wsRef.current = ws;
|
|
1158
|
+
|
|
1159
|
+
ws.addEventListener("open", () => {
|
|
1160
|
+
setConnected(true);
|
|
1161
|
+
setWsStatus("connected");
|
|
1162
|
+
});
|
|
1163
|
+
|
|
1164
|
+
ws.addEventListener("message", (msgEvent) => {
|
|
1165
|
+
const data = msgEvent.data;
|
|
1166
|
+
if (typeof data !== "string") {
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
let parsed: ServerMessage;
|
|
1171
|
+
try {
|
|
1172
|
+
parsed = JSON.parse(data) as ServerMessage;
|
|
1173
|
+
} catch {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
if (parsed.type === "webhook") {
|
|
1178
|
+
const webhookMsg = parsed as WebhookMessage;
|
|
1179
|
+
const monitorEvent: MonitorEvent = {
|
|
1180
|
+
eventId: webhookMsg.eventId,
|
|
1181
|
+
deliveryId: webhookMsg.deliveryId,
|
|
1182
|
+
event: {
|
|
1183
|
+
method: webhookMsg.method,
|
|
1184
|
+
headers: webhookMsg.headers,
|
|
1185
|
+
body: webhookMsg.body,
|
|
1186
|
+
query: webhookMsg.query,
|
|
1187
|
+
contentType: webhookMsg.contentType,
|
|
1188
|
+
sourceIp: webhookMsg.sourceIp,
|
|
1189
|
+
createdAt: webhookMsg.createdAt,
|
|
1190
|
+
},
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
setEvents((prev) => [monitorEvent, ...prev]);
|
|
1194
|
+
|
|
1195
|
+
// Forward locally and report back
|
|
1196
|
+
forwardWebhookLocally(mach.forwardUrl, webhookMsg).then((result) => {
|
|
1197
|
+
// Send delivery report back via WebSocket
|
|
1198
|
+
const report = {
|
|
1199
|
+
type: "delivery-report" as const,
|
|
1200
|
+
eventId: webhookMsg.eventId,
|
|
1201
|
+
deliveryId: webhookMsg.deliveryId,
|
|
1202
|
+
status: result.status,
|
|
1203
|
+
responseStatus: result.responseStatus,
|
|
1204
|
+
responseBody: result.responseBody,
|
|
1205
|
+
error: result.error,
|
|
1206
|
+
duration: result.duration,
|
|
1207
|
+
};
|
|
1208
|
+
|
|
1209
|
+
try {
|
|
1210
|
+
ws.send(JSON.stringify(report));
|
|
1211
|
+
} catch {
|
|
1212
|
+
// WebSocket closed
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Also persist via oRPC
|
|
1216
|
+
reportDeliveryResult({
|
|
1217
|
+
deliveryId: webhookMsg.deliveryId,
|
|
1218
|
+
status: result.status,
|
|
1219
|
+
responseStatus: result.responseStatus,
|
|
1220
|
+
responseBody: result.responseBody,
|
|
1221
|
+
error: result.error,
|
|
1222
|
+
duration: result.duration,
|
|
1223
|
+
}).catch(() => {
|
|
1224
|
+
// Non-critical — delivery result already sent via WS
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
// Update local state with delivery result
|
|
1228
|
+
setEvents((prev) =>
|
|
1229
|
+
prev.map((e) =>
|
|
1230
|
+
e.deliveryId === webhookMsg.deliveryId
|
|
1231
|
+
? {
|
|
1232
|
+
...e,
|
|
1233
|
+
deliveryResult: {
|
|
1234
|
+
deliveryId: webhookMsg.deliveryId,
|
|
1235
|
+
eventId: webhookMsg.eventId,
|
|
1236
|
+
machineId: mach.id,
|
|
1237
|
+
machineName: mach.name,
|
|
1238
|
+
status: result.status,
|
|
1239
|
+
responseStatus: result.responseStatus,
|
|
1240
|
+
responseBody: result.responseBody,
|
|
1241
|
+
error: result.error,
|
|
1242
|
+
duration: result.duration,
|
|
1243
|
+
},
|
|
1244
|
+
}
|
|
1245
|
+
: e
|
|
1246
|
+
)
|
|
1247
|
+
);
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (parsed.type === "delivery-result") {
|
|
1252
|
+
// Delivery result from another machine — update if we have the event
|
|
1253
|
+
const resultMsg = parsed as DeliveryResultMessage;
|
|
1254
|
+
setEvents((prev) =>
|
|
1255
|
+
prev.map((e) =>
|
|
1256
|
+
e.eventId === resultMsg.eventId && !e.deliveryResult
|
|
1257
|
+
? {
|
|
1258
|
+
...e,
|
|
1259
|
+
deliveryResult: {
|
|
1260
|
+
deliveryId: resultMsg.deliveryId,
|
|
1261
|
+
eventId: resultMsg.eventId,
|
|
1262
|
+
machineId: resultMsg.machineId,
|
|
1263
|
+
machineName: resultMsg.machineName,
|
|
1264
|
+
status: resultMsg.status,
|
|
1265
|
+
responseStatus: resultMsg.responseStatus,
|
|
1266
|
+
responseBody: resultMsg.responseBody,
|
|
1267
|
+
error: resultMsg.error,
|
|
1268
|
+
duration: resultMsg.duration,
|
|
1269
|
+
},
|
|
1270
|
+
}
|
|
1271
|
+
: e
|
|
1272
|
+
)
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
ws.addEventListener("close", () => {
|
|
1278
|
+
setConnected(false);
|
|
1279
|
+
setWsStatus("disconnected");
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
ws.addEventListener("error", () => {
|
|
1283
|
+
setWsStatus("connection error");
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
return () => {
|
|
1287
|
+
ws.close();
|
|
1288
|
+
};
|
|
1289
|
+
}, [ep.slug, mach.id, mach.name, mach.forwardUrl]);
|
|
1290
|
+
|
|
1291
|
+
useKeyboard((key) => {
|
|
1292
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
1293
|
+
onQuit();
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (key.name === "escape") {
|
|
1297
|
+
onBack();
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (key.name === "j" || key.name === "down") {
|
|
1301
|
+
setSelectedIndex((idx: number) => Math.min(events.length - 1, idx + 1));
|
|
1302
|
+
}
|
|
1303
|
+
if (key.name === "k" || key.name === "up") {
|
|
1304
|
+
setSelectedIndex((idx: number) => Math.max(0, idx - 1));
|
|
1305
|
+
}
|
|
1306
|
+
if (
|
|
1307
|
+
(key.name === "enter" || key.name === "return") &&
|
|
1308
|
+
events[selectedIndex]
|
|
1309
|
+
) {
|
|
1310
|
+
onSelectEvent(events[selectedIndex]);
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
const { width, height } = useTerminalDimensions();
|
|
1315
|
+
const webhookUrl = `${SERVER_URL}/hooks/${ep.slug}`;
|
|
1316
|
+
|
|
1317
|
+
return (
|
|
1318
|
+
<box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
|
|
1319
|
+
{/* Header */}
|
|
1320
|
+
<box
|
|
1321
|
+
border
|
|
1322
|
+
borderColor={COLORS.border}
|
|
1323
|
+
flexDirection="column"
|
|
1324
|
+
height={6}
|
|
1325
|
+
paddingX={1}
|
|
1326
|
+
paddingY={1}
|
|
1327
|
+
>
|
|
1328
|
+
<box flexDirection="row" gap={2}>
|
|
1329
|
+
<text fg={COLORS.text}>
|
|
1330
|
+
<strong>{ep.name}</strong>
|
|
1331
|
+
</text>
|
|
1332
|
+
<text fg={connected ? COLORS.green : COLORS.yellow}>
|
|
1333
|
+
[ {wsStatus} ]
|
|
1334
|
+
</text>
|
|
1335
|
+
</box>
|
|
1336
|
+
<text fg={COLORS.accent}>{webhookUrl}</text>
|
|
1337
|
+
<box flexDirection="row" gap={2}>
|
|
1338
|
+
<text fg={COLORS.textDim}>
|
|
1339
|
+
Machine: <span fg={COLORS.purple}>{mach.name}</span>
|
|
1340
|
+
</text>
|
|
1341
|
+
<text fg={COLORS.textDim}>
|
|
1342
|
+
Forward: <span fg={COLORS.accent}>{mach.forwardUrl}</span>
|
|
1343
|
+
</text>
|
|
1344
|
+
</box>
|
|
1345
|
+
<text fg={COLORS.textDim}>
|
|
1346
|
+
{events.length} events received this session
|
|
1347
|
+
</text>
|
|
1348
|
+
</box>
|
|
1349
|
+
|
|
1350
|
+
{/* Events list */}
|
|
1351
|
+
<scrollbox focused height={height - 10}>
|
|
1352
|
+
{events.map((me: MonitorEvent, idx: number) => {
|
|
1353
|
+
const time = new Date(me.event.createdAt).toLocaleTimeString();
|
|
1354
|
+
const dr = me.deliveryResult;
|
|
1355
|
+
|
|
1356
|
+
let statusText = "pending";
|
|
1357
|
+
let statusFg = COLORS.yellow;
|
|
1358
|
+
if (dr) {
|
|
1359
|
+
statusText =
|
|
1360
|
+
dr.status === "delivered"
|
|
1361
|
+
? `${String(dr.responseStatus ?? "?")} ${dr.duration ?? "?"}ms`
|
|
1362
|
+
: `failed${dr.error ? `: ${dr.error.slice(0, 30)}` : ""}`;
|
|
1363
|
+
statusFg =
|
|
1364
|
+
dr.status === "delivered"
|
|
1365
|
+
? statusColor(dr.responseStatus)
|
|
1366
|
+
: COLORS.red;
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
let bodyPreview = "";
|
|
1370
|
+
if (me.event.body) {
|
|
1371
|
+
try {
|
|
1372
|
+
const parsed = JSON.parse(me.event.body);
|
|
1373
|
+
bodyPreview = JSON.stringify(parsed).slice(
|
|
1374
|
+
0,
|
|
1375
|
+
Math.max(0, width - 60)
|
|
1376
|
+
);
|
|
1377
|
+
} catch {
|
|
1378
|
+
bodyPreview = me.event.body.slice(0, Math.max(0, width - 60));
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
return (
|
|
1383
|
+
<box
|
|
1384
|
+
alignItems="center"
|
|
1385
|
+
backgroundColor={
|
|
1386
|
+
idx === selectedIndex ? "#1c2128" : "transparent"
|
|
1387
|
+
}
|
|
1388
|
+
flexDirection="row"
|
|
1389
|
+
gap={1}
|
|
1390
|
+
height={2}
|
|
1391
|
+
key={me.deliveryId}
|
|
1392
|
+
paddingX={2}
|
|
1393
|
+
>
|
|
1394
|
+
<text
|
|
1395
|
+
fg={idx === selectedIndex ? COLORS.accent : COLORS.textDim}
|
|
1396
|
+
width={3}
|
|
1397
|
+
>
|
|
1398
|
+
{idx === selectedIndex ? " > " : " "}
|
|
1399
|
+
</text>
|
|
1400
|
+
<text fg={methodColor(me.event.method)} width={7}>
|
|
1401
|
+
<strong>{me.event.method.padEnd(6)}</strong>
|
|
1402
|
+
</text>
|
|
1403
|
+
<text fg={COLORS.textDim} width={10}>
|
|
1404
|
+
{time}
|
|
1405
|
+
</text>
|
|
1406
|
+
<text fg={statusFg} width={20}>
|
|
1407
|
+
{statusText}
|
|
1408
|
+
</text>
|
|
1409
|
+
<text fg={COLORS.text}>{bodyPreview || "(no body)"}</text>
|
|
1410
|
+
</box>
|
|
1411
|
+
);
|
|
1412
|
+
})}
|
|
1413
|
+
{events.length === 0 ? (
|
|
1414
|
+
<box padding={2}>
|
|
1415
|
+
<text fg={COLORS.textDim}>
|
|
1416
|
+
Waiting for webhooks... Send a request to: {webhookUrl}
|
|
1417
|
+
</text>
|
|
1418
|
+
</box>
|
|
1419
|
+
) : null}
|
|
1420
|
+
</scrollbox>
|
|
1421
|
+
</box>
|
|
1422
|
+
);
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// ---------------------------------------------------------------------------
|
|
1426
|
+
// Event Detail Screen
|
|
1427
|
+
// ---------------------------------------------------------------------------
|
|
1428
|
+
|
|
1429
|
+
function EventDetailScreen({
|
|
1430
|
+
monitorEvent,
|
|
1431
|
+
onBack,
|
|
1432
|
+
onQuit,
|
|
1433
|
+
}: {
|
|
1434
|
+
monitorEvent: MonitorEvent;
|
|
1435
|
+
onBack: () => void;
|
|
1436
|
+
onQuit: () => void;
|
|
1437
|
+
}) {
|
|
1438
|
+
const [tab, setTab] = useState<"body" | "headers" | "delivery">("body");
|
|
1439
|
+
const evt = monitorEvent.event;
|
|
1440
|
+
const dr = monitorEvent.deliveryResult;
|
|
1441
|
+
|
|
1442
|
+
useKeyboard((key) => {
|
|
1443
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
1444
|
+
onQuit();
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (key.name === "escape") {
|
|
1448
|
+
onBack();
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (key.name === "1") {
|
|
1452
|
+
setTab("body");
|
|
1453
|
+
}
|
|
1454
|
+
if (key.name === "2") {
|
|
1455
|
+
setTab("headers");
|
|
1456
|
+
}
|
|
1457
|
+
if (key.name === "3") {
|
|
1458
|
+
setTab("delivery");
|
|
1459
|
+
}
|
|
1460
|
+
if (key.name === "tab") {
|
|
1461
|
+
setTab((prev) => {
|
|
1462
|
+
if (prev === "body") {
|
|
1463
|
+
return "headers";
|
|
1464
|
+
}
|
|
1465
|
+
if (prev === "headers") {
|
|
1466
|
+
return "delivery";
|
|
1467
|
+
}
|
|
1468
|
+
return "body";
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
const time = new Date(evt.createdAt).toLocaleString();
|
|
1474
|
+
let formattedBody = evt.body ?? "(no body)";
|
|
1475
|
+
try {
|
|
1476
|
+
if (evt.body) {
|
|
1477
|
+
formattedBody = JSON.stringify(JSON.parse(evt.body), null, 2);
|
|
1478
|
+
}
|
|
1479
|
+
} catch {
|
|
1480
|
+
// Use raw body
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
let formattedHeaders = "{}";
|
|
1484
|
+
try {
|
|
1485
|
+
formattedHeaders = JSON.stringify(JSON.parse(evt.headers), null, 2);
|
|
1486
|
+
} catch {
|
|
1487
|
+
formattedHeaders = evt.headers;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
let deliveryContent = "No delivery result yet (pending)";
|
|
1491
|
+
if (dr) {
|
|
1492
|
+
const deliveryInfo = {
|
|
1493
|
+
deliveryId: dr.deliveryId,
|
|
1494
|
+
status: dr.status,
|
|
1495
|
+
machineId: dr.machineId,
|
|
1496
|
+
machineName: dr.machineName,
|
|
1497
|
+
responseStatus: dr.responseStatus,
|
|
1498
|
+
duration: dr.duration ? `${dr.duration}ms` : null,
|
|
1499
|
+
error: dr.error,
|
|
1500
|
+
responseBody: dr.responseBody,
|
|
1501
|
+
};
|
|
1502
|
+
deliveryContent = JSON.stringify(deliveryInfo, null, 2);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
let content: string;
|
|
1506
|
+
if (tab === "body") {
|
|
1507
|
+
content = formattedBody;
|
|
1508
|
+
} else if (tab === "headers") {
|
|
1509
|
+
content = formattedHeaders;
|
|
1510
|
+
} else {
|
|
1511
|
+
content = deliveryContent;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const { height } = useTerminalDimensions();
|
|
1515
|
+
|
|
1516
|
+
return (
|
|
1517
|
+
<box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
|
|
1518
|
+
{/* Meta info */}
|
|
1519
|
+
<box
|
|
1520
|
+
border
|
|
1521
|
+
borderColor={COLORS.border}
|
|
1522
|
+
flexDirection="column"
|
|
1523
|
+
height={5}
|
|
1524
|
+
paddingX={1}
|
|
1525
|
+
paddingY={1}
|
|
1526
|
+
>
|
|
1527
|
+
<box flexDirection="row" gap={2}>
|
|
1528
|
+
<text fg={methodColor(evt.method)}>
|
|
1529
|
+
<strong>{evt.method}</strong>
|
|
1530
|
+
</text>
|
|
1531
|
+
<text fg={COLORS.text}>{monitorEvent.eventId}</text>
|
|
1532
|
+
{dr ? (
|
|
1533
|
+
<text fg={dr.status === "delivered" ? COLORS.green : COLORS.red}>
|
|
1534
|
+
{dr.status} {dr.responseStatus ?? ""}{" "}
|
|
1535
|
+
{dr.duration ? `${dr.duration}ms` : ""}
|
|
1536
|
+
</text>
|
|
1537
|
+
) : (
|
|
1538
|
+
<text fg={COLORS.yellow}>pending</text>
|
|
1539
|
+
)}
|
|
1540
|
+
</box>
|
|
1541
|
+
<box flexDirection="row" gap={2}>
|
|
1542
|
+
<text fg={COLORS.textDim}>{time}</text>
|
|
1543
|
+
<text fg={COLORS.textDim}>
|
|
1544
|
+
{evt.contentType ?? "no content-type"}
|
|
1545
|
+
</text>
|
|
1546
|
+
{evt.sourceIp ? (
|
|
1547
|
+
<text fg={COLORS.textDim}>from {evt.sourceIp}</text>
|
|
1548
|
+
) : null}
|
|
1549
|
+
</box>
|
|
1550
|
+
<box flexDirection="row" gap={2}>
|
|
1551
|
+
<text fg={tab === "body" ? COLORS.accent : COLORS.textDim}>
|
|
1552
|
+
[1] Body
|
|
1553
|
+
</text>
|
|
1554
|
+
<text fg={tab === "headers" ? COLORS.accent : COLORS.textDim}>
|
|
1555
|
+
[2] Headers
|
|
1556
|
+
</text>
|
|
1557
|
+
<text fg={tab === "delivery" ? COLORS.accent : COLORS.textDim}>
|
|
1558
|
+
[3] Delivery
|
|
1559
|
+
</text>
|
|
1560
|
+
</box>
|
|
1561
|
+
</box>
|
|
1562
|
+
|
|
1563
|
+
{/* Content */}
|
|
1564
|
+
<scrollbox focused height={height - 9}>
|
|
1565
|
+
<box padding={1}>
|
|
1566
|
+
{content.split("\n").map((line: string, idx: number) => (
|
|
1567
|
+
<box flexDirection="row" key={`line-${String(idx)}`}>
|
|
1568
|
+
<text fg={COLORS.textDim} width={5}>
|
|
1569
|
+
{String(idx + 1).padStart(4)}
|
|
1570
|
+
</text>
|
|
1571
|
+
<text fg={COLORS.text}>{line}</text>
|
|
1572
|
+
</box>
|
|
1573
|
+
))}
|
|
1574
|
+
</box>
|
|
1575
|
+
</scrollbox>
|
|
1576
|
+
</box>
|
|
1577
|
+
);
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// ---------------------------------------------------------------------------
|
|
1581
|
+
// App root
|
|
1582
|
+
// ---------------------------------------------------------------------------
|
|
1583
|
+
|
|
1584
|
+
interface AppProps {
|
|
1585
|
+
/** Pre-resolved endpoint for direct CLI mode */
|
|
1586
|
+
initialEndpoint?: Endpoint;
|
|
1587
|
+
/** Pre-resolved machine for direct CLI mode */
|
|
1588
|
+
initialMachine?: Machine;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
function getStartScreen(hasDirectMode: boolean): Screen {
|
|
1592
|
+
if (hasDirectMode) {
|
|
1593
|
+
return "monitor";
|
|
1594
|
+
}
|
|
1595
|
+
if (authCookies) {
|
|
1596
|
+
return "endpoints";
|
|
1597
|
+
}
|
|
1598
|
+
return "login";
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
function App({ initialEndpoint, initialMachine }: AppProps) {
|
|
1602
|
+
const renderer = useRenderer();
|
|
1603
|
+
|
|
1604
|
+
// If we have initial endpoint + machine, skip straight to monitor
|
|
1605
|
+
const hasDirectMode = Boolean(initialEndpoint && initialMachine);
|
|
1606
|
+
const startScreen = getStartScreen(hasDirectMode);
|
|
1607
|
+
|
|
1608
|
+
const [screen, setScreen] = useState<Screen>(startScreen);
|
|
1609
|
+
const [selectedEndpoint, setSelectedEndpoint] = useState<Endpoint | null>(
|
|
1610
|
+
initialEndpoint ?? null
|
|
1611
|
+
);
|
|
1612
|
+
const [selectedMachine, setSelectedMachine] = useState<Machine | null>(
|
|
1613
|
+
initialMachine ?? null
|
|
1614
|
+
);
|
|
1615
|
+
const [selectedEvent, setSelectedEvent] = useState<MonitorEvent | null>(null);
|
|
1616
|
+
const [sessionChecked, setSessionChecked] = useState(hasDirectMode);
|
|
1617
|
+
|
|
1618
|
+
// On interactive mode, validate session before showing endpoints
|
|
1619
|
+
useEffect(() => {
|
|
1620
|
+
if (hasDirectMode || sessionChecked) {
|
|
1621
|
+
return;
|
|
1622
|
+
}
|
|
1623
|
+
if (!authCookies) {
|
|
1624
|
+
setSessionChecked(true);
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
validateSession().then((valid) => {
|
|
1628
|
+
if (valid) {
|
|
1629
|
+
setScreen("endpoints");
|
|
1630
|
+
} else {
|
|
1631
|
+
authCookies = null;
|
|
1632
|
+
setScreen("login");
|
|
1633
|
+
}
|
|
1634
|
+
setSessionChecked(true);
|
|
1635
|
+
});
|
|
1636
|
+
}, [hasDirectMode, sessionChecked]);
|
|
1637
|
+
|
|
1638
|
+
const handleQuit = useCallback(() => {
|
|
1639
|
+
renderer.destroy();
|
|
1640
|
+
}, [renderer]);
|
|
1641
|
+
|
|
1642
|
+
return (
|
|
1643
|
+
<box backgroundColor={COLORS.bg} flexDirection="column" flexGrow={1}>
|
|
1644
|
+
<StatusBar endpointName={selectedEndpoint?.name} screen={screen} />
|
|
1645
|
+
|
|
1646
|
+
{screen === "login" ? (
|
|
1647
|
+
<LoginScreen onLogin={() => setScreen("endpoints")} />
|
|
1648
|
+
) : null}
|
|
1649
|
+
|
|
1650
|
+
{screen === "endpoints" ? (
|
|
1651
|
+
<EndpointsScreen
|
|
1652
|
+
onQuit={handleQuit}
|
|
1653
|
+
onSelect={(ep: Endpoint) => {
|
|
1654
|
+
setSelectedEndpoint(ep);
|
|
1655
|
+
setScreen("machine-setup");
|
|
1656
|
+
}}
|
|
1657
|
+
/>
|
|
1658
|
+
) : null}
|
|
1659
|
+
|
|
1660
|
+
{screen === "machine-setup" && selectedEndpoint ? (
|
|
1661
|
+
<MachineSetupScreen
|
|
1662
|
+
endpoint={selectedEndpoint}
|
|
1663
|
+
onBack={() => {
|
|
1664
|
+
setScreen("endpoints");
|
|
1665
|
+
setSelectedEndpoint(null);
|
|
1666
|
+
}}
|
|
1667
|
+
onConnect={(m: Machine) => {
|
|
1668
|
+
setSelectedMachine(m);
|
|
1669
|
+
setScreen("monitor");
|
|
1670
|
+
}}
|
|
1671
|
+
onQuit={handleQuit}
|
|
1672
|
+
/>
|
|
1673
|
+
) : null}
|
|
1674
|
+
|
|
1675
|
+
{screen === "monitor" && selectedEndpoint && selectedMachine ? (
|
|
1676
|
+
<MonitorScreen
|
|
1677
|
+
endpoint={selectedEndpoint}
|
|
1678
|
+
machine={selectedMachine}
|
|
1679
|
+
onBack={() => {
|
|
1680
|
+
// In direct mode, quit instead of going back
|
|
1681
|
+
if (hasDirectMode) {
|
|
1682
|
+
handleQuit();
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
setScreen("machine-setup");
|
|
1686
|
+
setSelectedMachine(null);
|
|
1687
|
+
}}
|
|
1688
|
+
onQuit={handleQuit}
|
|
1689
|
+
onSelectEvent={(evt: MonitorEvent) => {
|
|
1690
|
+
setSelectedEvent(evt);
|
|
1691
|
+
setScreen("event-detail");
|
|
1692
|
+
}}
|
|
1693
|
+
/>
|
|
1694
|
+
) : null}
|
|
1695
|
+
|
|
1696
|
+
{screen === "event-detail" && selectedEvent ? (
|
|
1697
|
+
<EventDetailScreen
|
|
1698
|
+
monitorEvent={selectedEvent}
|
|
1699
|
+
onBack={() => {
|
|
1700
|
+
setScreen("monitor");
|
|
1701
|
+
setSelectedEvent(null);
|
|
1702
|
+
}}
|
|
1703
|
+
onQuit={handleQuit}
|
|
1704
|
+
/>
|
|
1705
|
+
) : null}
|
|
1706
|
+
|
|
1707
|
+
<HelpBar screen={screen} />
|
|
1708
|
+
</box>
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// ---------------------------------------------------------------------------
|
|
1713
|
+
// Bootstrap
|
|
1714
|
+
// ---------------------------------------------------------------------------
|
|
1715
|
+
|
|
1716
|
+
if (cliArgs.command === "login") {
|
|
1717
|
+
await handleLoginCommand();
|
|
1718
|
+
} else if (cliArgs.command === "listen" && cliArgs.slug && cliArgs.forwardUrl) {
|
|
1719
|
+
// Direct mode: resolve endpoint + machine, then launch TUI at monitor screen
|
|
1720
|
+
const { endpoint, machine } = await handleListenCommand(
|
|
1721
|
+
cliArgs.slug,
|
|
1722
|
+
cliArgs.forwardUrl,
|
|
1723
|
+
cliArgs.machineName
|
|
1724
|
+
);
|
|
1725
|
+
|
|
1726
|
+
console.log(
|
|
1727
|
+
`Forwarding ${SERVER_URL}/hooks/${endpoint.slug} -> ${machine.forwardUrl}`
|
|
1728
|
+
);
|
|
1729
|
+
console.log(`Machine: ${machine.name} (${machine.id})\n`);
|
|
1730
|
+
|
|
1731
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
1732
|
+
createRoot(renderer).render(
|
|
1733
|
+
<App initialEndpoint={endpoint} initialMachine={machine} />
|
|
1734
|
+
);
|
|
1735
|
+
} else {
|
|
1736
|
+
// Interactive TUI mode
|
|
1737
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
1738
|
+
createRoot(renderer).render(<App />);
|
|
1739
|
+
}
|