thingd-cli 0.1.0 → 0.2.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 +99 -28
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +81 -20
- package/dist/interactive.d.ts +2 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +1540 -0
- package/package.json +4 -2
|
@@ -0,0 +1,1540 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import * as crypto from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import readline from "node:readline";
|
|
7
|
+
import pc from "picocolors";
|
|
8
|
+
import { ThingD } from "thingd";
|
|
9
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
10
|
+
function highlightJson(val) {
|
|
11
|
+
const str = JSON.stringify(val, null, 2);
|
|
12
|
+
return str.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, (match) => {
|
|
13
|
+
if (/^"/.test(match)) {
|
|
14
|
+
return /:$/.test(match) ? pc.cyan(match) : pc.green(match);
|
|
15
|
+
}
|
|
16
|
+
if (/true|false/.test(match))
|
|
17
|
+
return pc.magenta(match);
|
|
18
|
+
if (/null/.test(match))
|
|
19
|
+
return pc.dim(match);
|
|
20
|
+
return pc.yellow(match);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Strip ANSI escape codes to get the visible character count. */
|
|
24
|
+
function stripAnsi(s) {
|
|
25
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes require matching the ESC character
|
|
26
|
+
return s.replace(/\u001B\[[0-9;]*[a-zA-Z]/g, "");
|
|
27
|
+
}
|
|
28
|
+
/** Measure the visible width of a string accounting for wide characters (CJK, emoji). */
|
|
29
|
+
function visibleWidth(s) {
|
|
30
|
+
const clean = stripAnsi(s);
|
|
31
|
+
let w = 0;
|
|
32
|
+
for (const ch of clean) {
|
|
33
|
+
const cp = ch.codePointAt(0);
|
|
34
|
+
// Emoji (surrogate pairs / high codepoints) and CJK fullwidth ranges
|
|
35
|
+
if (cp > 0xffff ||
|
|
36
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
37
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
38
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
39
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
40
|
+
(cp >= 0xfe10 && cp <= 0xfe6f) ||
|
|
41
|
+
(cp >= 0xff01 && cp <= 0xff60) ||
|
|
42
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
43
|
+
(cp >= 0x20000 && cp <= 0x2fffd) ||
|
|
44
|
+
(cp >= 0x30000 && cp <= 0x3fffd) ||
|
|
45
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
46
|
+
(cp >= 0x200d && cp <= 0x200d) ||
|
|
47
|
+
(cp >= 0xe0100 && cp <= 0xe01ef)) {
|
|
48
|
+
w += 2;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
w += 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return w;
|
|
55
|
+
}
|
|
56
|
+
// ── State ────────────────────────────────────────────────────────────// Connection State
|
|
57
|
+
let db;
|
|
58
|
+
let driver = "";
|
|
59
|
+
let dbPath = "";
|
|
60
|
+
let connected = false;
|
|
61
|
+
let authToken = "";
|
|
62
|
+
let collections = [];
|
|
63
|
+
let streams = [];
|
|
64
|
+
let queues = [];
|
|
65
|
+
let objectsByCollection = new Map();
|
|
66
|
+
const expandedSet = new Set(["cat:collections", "cat:streams", "cat:queues"]);
|
|
67
|
+
let cursorIndex = 0;
|
|
68
|
+
let scrollOffset = 0;
|
|
69
|
+
let startedAt = 0; // ms since epoch when we connected
|
|
70
|
+
let totalObjects = 0;
|
|
71
|
+
let totalEventsCount = 0;
|
|
72
|
+
let totalActiveJobsCount = 0;
|
|
73
|
+
let totalDeadJobsCount = 0;
|
|
74
|
+
let objectsHistory = [];
|
|
75
|
+
let eventsHistory = [];
|
|
76
|
+
let activeJobsHistory = [];
|
|
77
|
+
let deadJobsHistory = [];
|
|
78
|
+
let dbSizeHistory = [];
|
|
79
|
+
let objectWriteRateHistory = [];
|
|
80
|
+
let eventAppendRateHistory = [];
|
|
81
|
+
const colHistory = new Map();
|
|
82
|
+
const streamHistory = new Map();
|
|
83
|
+
const queueActiveHistory = new Map();
|
|
84
|
+
const queueDeadHistory = new Map();
|
|
85
|
+
let viewerLines = ["Select an item to view details."];
|
|
86
|
+
let viewerScroll = 0;
|
|
87
|
+
let loadedItemId = "";
|
|
88
|
+
let loadTimer = null;
|
|
89
|
+
let pollTimer = null;
|
|
90
|
+
let keypressHandler = null;
|
|
91
|
+
let formState = null;
|
|
92
|
+
function openForm(title, fields, onSubmit) {
|
|
93
|
+
formState = {
|
|
94
|
+
active: true,
|
|
95
|
+
title,
|
|
96
|
+
fields: fields.map((f) => ({
|
|
97
|
+
id: f.id,
|
|
98
|
+
label: f.label,
|
|
99
|
+
value: f.value || (f.options?.[0] ?? ""),
|
|
100
|
+
placeholder: f.placeholder,
|
|
101
|
+
isSecret: f.isSecret,
|
|
102
|
+
options: f.options,
|
|
103
|
+
allowCustom: f.allowCustom,
|
|
104
|
+
})),
|
|
105
|
+
activeIndex: 0,
|
|
106
|
+
onCancel: () => {
|
|
107
|
+
formState = null;
|
|
108
|
+
viewerLines = [];
|
|
109
|
+
loadedItemId = ""; // Force reload
|
|
110
|
+
draw();
|
|
111
|
+
const n = buildTree()[cursorIndex];
|
|
112
|
+
if (n)
|
|
113
|
+
scheduleLoad(n);
|
|
114
|
+
},
|
|
115
|
+
onSubmit: async (vals) => {
|
|
116
|
+
if (!formState)
|
|
117
|
+
return;
|
|
118
|
+
formState.isSubmitting = true;
|
|
119
|
+
formState.error = undefined;
|
|
120
|
+
draw();
|
|
121
|
+
try {
|
|
122
|
+
await onSubmit(vals);
|
|
123
|
+
formState = null;
|
|
124
|
+
viewerLines = [];
|
|
125
|
+
loadedItemId = ""; // Force reload
|
|
126
|
+
await fetchResources();
|
|
127
|
+
draw();
|
|
128
|
+
const n = buildTree()[cursorIndex];
|
|
129
|
+
if (n)
|
|
130
|
+
scheduleLoad(n);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
if (formState) {
|
|
134
|
+
formState.error = err?.message || String(err) || "Unknown error occurred";
|
|
135
|
+
formState.isSubmitting = false;
|
|
136
|
+
draw();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
viewerScroll = 0;
|
|
142
|
+
draw();
|
|
143
|
+
}
|
|
144
|
+
// ── Data Fetching ────────────────────────────────────────────────────
|
|
145
|
+
const SPARK_WIDTH = 30;
|
|
146
|
+
function drawSparkline(data, baselineMax = 0, width = SPARK_WIDTH) {
|
|
147
|
+
const dataChars = ["\u2581", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
148
|
+
const track = "\u2581"; // Lower 1/8 block as baseline
|
|
149
|
+
if (data.length === 0)
|
|
150
|
+
return track.repeat(width);
|
|
151
|
+
const recent = data.slice(-width);
|
|
152
|
+
const padLen = width - recent.length;
|
|
153
|
+
const max = Math.max(baselineMax, ...recent);
|
|
154
|
+
// Left pad = no data yet
|
|
155
|
+
let result = track.repeat(padLen);
|
|
156
|
+
if (max === 0) {
|
|
157
|
+
result += track.repeat(recent.length);
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
result += recent
|
|
161
|
+
.map((v) => {
|
|
162
|
+
if (v === 0)
|
|
163
|
+
return track;
|
|
164
|
+
const ratio = v / max;
|
|
165
|
+
const idx = Math.max(0, Math.min(dataChars.length - 1, Math.floor(ratio * dataChars.length)));
|
|
166
|
+
return dataChars[idx];
|
|
167
|
+
})
|
|
168
|
+
.join("");
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
function formatUptime(ms) {
|
|
172
|
+
const s = Math.floor(ms / 1000);
|
|
173
|
+
if (s < 60)
|
|
174
|
+
return `${s}s`;
|
|
175
|
+
const m = Math.floor(s / 60);
|
|
176
|
+
if (m < 60)
|
|
177
|
+
return `${m}m ${s % 60}s`;
|
|
178
|
+
const h = Math.floor(m / 60);
|
|
179
|
+
return `${h}h ${m % 60}m`;
|
|
180
|
+
}
|
|
181
|
+
async function fetchResourcesFallback() {
|
|
182
|
+
try {
|
|
183
|
+
const nativeCollections = await db.listCollections();
|
|
184
|
+
const nativeStreams = await db.listStreams();
|
|
185
|
+
// Maintain default collections/streams for UI if none exist
|
|
186
|
+
const defaultCols = new Set(["decisions", "load-test"]);
|
|
187
|
+
const defaultStrs = new Set(["project:thingd", "load-events", "activity-log"]);
|
|
188
|
+
for (const c of nativeCollections)
|
|
189
|
+
defaultCols.add(c);
|
|
190
|
+
for (const s of nativeStreams)
|
|
191
|
+
defaultStrs.add(s);
|
|
192
|
+
collections = Array.from(defaultCols).sort();
|
|
193
|
+
streams = Array.from(defaultStrs).sort();
|
|
194
|
+
// Fallback totals
|
|
195
|
+
totalObjects = await db.countObjects();
|
|
196
|
+
totalEventsCount = await db.countEvents();
|
|
197
|
+
totalActiveJobsCount = await db.countActiveJobs();
|
|
198
|
+
totalDeadJobsCount = await db.countDeadJobs();
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
collections = [];
|
|
202
|
+
streams = [];
|
|
203
|
+
totalObjects = 0;
|
|
204
|
+
}
|
|
205
|
+
// Queues
|
|
206
|
+
try {
|
|
207
|
+
const store = db.store;
|
|
208
|
+
if (store?.queues) {
|
|
209
|
+
queues = Array.from(store.queues.keys()).sort();
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
queues = ["embed", "load-queue", "worker-queue"];
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
queues = ["embed", "load-queue", "worker-queue"];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function fetchResources() {
|
|
220
|
+
if (driver === "native" && dbPath) {
|
|
221
|
+
try {
|
|
222
|
+
// Override the tracked totals with the actual exact DB count!
|
|
223
|
+
const [objCount, evtCount, activeCount, deadCount, nativeCollections, nativeStreams, nativeQueues,] = await Promise.all([
|
|
224
|
+
db.countObjects(),
|
|
225
|
+
db.countEvents(),
|
|
226
|
+
db.countActiveJobs(),
|
|
227
|
+
db.countDeadJobs(),
|
|
228
|
+
db.listCollections(),
|
|
229
|
+
db.listStreams(),
|
|
230
|
+
db.listQueues?.() ?? Promise.resolve([]),
|
|
231
|
+
]);
|
|
232
|
+
totalObjects = isNaN(objCount) || objCount === 0 ? totalObjects : objCount;
|
|
233
|
+
totalEventsCount = isNaN(evtCount) || evtCount === 0 ? totalEventsCount : evtCount;
|
|
234
|
+
totalActiveJobsCount =
|
|
235
|
+
isNaN(activeCount) || activeCount === 0 ? totalActiveJobsCount : activeCount;
|
|
236
|
+
totalDeadJobsCount = isNaN(deadCount) || deadCount === 0 ? totalDeadJobsCount : deadCount;
|
|
237
|
+
collections = nativeCollections.length > 0 ? nativeCollections : ["decisions", "load-test"];
|
|
238
|
+
streams =
|
|
239
|
+
nativeStreams.length > 0
|
|
240
|
+
? nativeStreams
|
|
241
|
+
: ["project:thingd", "load-events", "activity-log"];
|
|
242
|
+
queues = nativeQueues.length > 0 ? nativeQueues : ["embed", "load-queue", "worker-queue"];
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Fallback if sqlite3 fails
|
|
246
|
+
await fetchResourcesFallback();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
await fetchResourcesFallback();
|
|
251
|
+
}
|
|
252
|
+
// Calculate Deltas for Operations Throughput Rates
|
|
253
|
+
const prevObjects = objectsHistory.length > 0 ? objectsHistory[objectsHistory.length - 1] : totalObjects;
|
|
254
|
+
const prevEvents = eventsHistory.length > 0 ? eventsHistory[eventsHistory.length - 1] : totalEventsCount;
|
|
255
|
+
// Polling is every 2000ms. Operations per second = delta / 2
|
|
256
|
+
const objectWriteRate = Math.max(0, Math.round((totalObjects - prevObjects) / 2));
|
|
257
|
+
const eventAppendRate = Math.max(0, Math.round((totalEventsCount - prevEvents) / 2));
|
|
258
|
+
// Push Histories with Initial Pre-population to prevent misleading growth wiggles
|
|
259
|
+
if (objectsHistory.length === 0) {
|
|
260
|
+
objectsHistory = new Array(60).fill(totalObjects);
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
objectsHistory.push(totalObjects);
|
|
264
|
+
if (objectsHistory.length > 60)
|
|
265
|
+
objectsHistory.shift();
|
|
266
|
+
}
|
|
267
|
+
if (eventsHistory.length === 0) {
|
|
268
|
+
eventsHistory = new Array(60).fill(totalEventsCount);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
eventsHistory.push(totalEventsCount);
|
|
272
|
+
if (eventsHistory.length > 60)
|
|
273
|
+
eventsHistory.shift();
|
|
274
|
+
}
|
|
275
|
+
if (activeJobsHistory.length === 0) {
|
|
276
|
+
activeJobsHistory = new Array(60).fill(totalActiveJobsCount);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
activeJobsHistory.push(totalActiveJobsCount);
|
|
280
|
+
if (activeJobsHistory.length > 60)
|
|
281
|
+
activeJobsHistory.shift();
|
|
282
|
+
}
|
|
283
|
+
if (deadJobsHistory.length === 0) {
|
|
284
|
+
deadJobsHistory = new Array(60).fill(totalDeadJobsCount);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
deadJobsHistory.push(totalDeadJobsCount);
|
|
288
|
+
if (deadJobsHistory.length > 60)
|
|
289
|
+
deadJobsHistory.shift();
|
|
290
|
+
}
|
|
291
|
+
if (objectWriteRateHistory.length === 0) {
|
|
292
|
+
objectWriteRateHistory = new Array(60).fill(objectWriteRate);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
objectWriteRateHistory.push(objectWriteRate);
|
|
296
|
+
if (objectWriteRateHistory.length > 60)
|
|
297
|
+
objectWriteRateHistory.shift();
|
|
298
|
+
}
|
|
299
|
+
if (eventAppendRateHistory.length === 0) {
|
|
300
|
+
eventAppendRateHistory = new Array(60).fill(eventAppendRate);
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
eventAppendRateHistory.push(eventAppendRate);
|
|
304
|
+
if (eventAppendRateHistory.length > 60)
|
|
305
|
+
eventAppendRateHistory.shift();
|
|
306
|
+
}
|
|
307
|
+
// Database Size (only if native)
|
|
308
|
+
let sizeKb = 0;
|
|
309
|
+
if (driver === "native" && dbPath) {
|
|
310
|
+
try {
|
|
311
|
+
sizeKb = Math.round(fs.statSync(dbPath).size / 1024);
|
|
312
|
+
}
|
|
313
|
+
catch { }
|
|
314
|
+
}
|
|
315
|
+
if (dbSizeHistory.length === 0) {
|
|
316
|
+
dbSizeHistory = new Array(60).fill(sizeKb);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
dbSizeHistory.push(sizeKb);
|
|
320
|
+
if (dbSizeHistory.length > 60)
|
|
321
|
+
dbSizeHistory.shift();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function buildTree() {
|
|
325
|
+
if (!connected) {
|
|
326
|
+
return [
|
|
327
|
+
{
|
|
328
|
+
id: "drv:memory",
|
|
329
|
+
type: "driver",
|
|
330
|
+
label: `${pc.dim("●")} ${pc.bold("Memory")} ${pc.dim("ephemeral")}`,
|
|
331
|
+
depth: 0,
|
|
332
|
+
expandable: false,
|
|
333
|
+
ref: { driver: "memory" },
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
id: "drv:native",
|
|
337
|
+
type: "driver",
|
|
338
|
+
label: `${pc.dim("●")} ${pc.bold("Native")} ${pc.dim("SQLite file")}`,
|
|
339
|
+
depth: 0,
|
|
340
|
+
expandable: false,
|
|
341
|
+
ref: { driver: "native" },
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
id: "drv:cloud",
|
|
345
|
+
type: "driver",
|
|
346
|
+
label: `${pc.dim("●")} ${pc.bold("Cloud")} ${pc.dim("remote")}`,
|
|
347
|
+
depth: 0,
|
|
348
|
+
expandable: false,
|
|
349
|
+
ref: { driver: "cloud" },
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
}
|
|
353
|
+
const nodes = [];
|
|
354
|
+
// Collections
|
|
355
|
+
const colsOpen = expandedSet.has("cat:collections");
|
|
356
|
+
nodes.push({
|
|
357
|
+
id: "cat:collections",
|
|
358
|
+
type: "category",
|
|
359
|
+
label: `${colsOpen ? pc.yellow("▾") : pc.dim("▸")} ${pc.bold("Collections")}`,
|
|
360
|
+
depth: 0,
|
|
361
|
+
expandable: true,
|
|
362
|
+
});
|
|
363
|
+
if (colsOpen) {
|
|
364
|
+
if (collections.length === 0) {
|
|
365
|
+
nodes.push({
|
|
366
|
+
id: "empty:collections",
|
|
367
|
+
type: "status",
|
|
368
|
+
label: pc.dim("(empty)"),
|
|
369
|
+
depth: 1,
|
|
370
|
+
expandable: false,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
for (const col of collections) {
|
|
374
|
+
const colId = `col:${col}`;
|
|
375
|
+
const colOpen = expandedSet.has(colId);
|
|
376
|
+
nodes.push({
|
|
377
|
+
id: colId,
|
|
378
|
+
parentId: "cat:collections",
|
|
379
|
+
type: "collection",
|
|
380
|
+
label: `${colOpen ? pc.yellow("▾") : pc.dim("▸")} ${pc.cyan(col)}`,
|
|
381
|
+
depth: 1,
|
|
382
|
+
expandable: true,
|
|
383
|
+
ref: { name: col },
|
|
384
|
+
});
|
|
385
|
+
if (colOpen) {
|
|
386
|
+
const objs = objectsByCollection.get(col) ?? [];
|
|
387
|
+
if (objs.length === 0) {
|
|
388
|
+
nodes.push({
|
|
389
|
+
id: `empty:${col}`,
|
|
390
|
+
parentId: colId,
|
|
391
|
+
type: "status",
|
|
392
|
+
label: pc.dim("(no objects)"),
|
|
393
|
+
depth: 2,
|
|
394
|
+
expandable: false,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
for (const objId of objs) {
|
|
398
|
+
nodes.push({
|
|
399
|
+
id: `obj:${col}:${objId}`,
|
|
400
|
+
parentId: colId,
|
|
401
|
+
type: "object",
|
|
402
|
+
label: `${pc.dim("●")} ${objId}`,
|
|
403
|
+
depth: 2,
|
|
404
|
+
expandable: false,
|
|
405
|
+
ref: { collection: col, id: objId },
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Streams
|
|
412
|
+
const strsOpen = expandedSet.has("cat:streams");
|
|
413
|
+
nodes.push({
|
|
414
|
+
id: "cat:streams",
|
|
415
|
+
type: "category",
|
|
416
|
+
label: `${strsOpen ? pc.yellow("▾") : pc.dim("▸")} ${pc.bold("Streams")}`,
|
|
417
|
+
depth: 0,
|
|
418
|
+
expandable: true,
|
|
419
|
+
});
|
|
420
|
+
if (strsOpen) {
|
|
421
|
+
if (streams.length === 0) {
|
|
422
|
+
nodes.push({
|
|
423
|
+
id: "empty:streams",
|
|
424
|
+
type: "status",
|
|
425
|
+
label: pc.dim("(empty)"),
|
|
426
|
+
depth: 1,
|
|
427
|
+
expandable: false,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
for (const stream of streams) {
|
|
431
|
+
nodes.push({
|
|
432
|
+
id: `stream:${stream}`,
|
|
433
|
+
parentId: "cat:streams",
|
|
434
|
+
type: "stream",
|
|
435
|
+
label: `${pc.dim("~")} ${pc.green(stream)}`,
|
|
436
|
+
depth: 1,
|
|
437
|
+
expandable: false,
|
|
438
|
+
ref: { name: stream },
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// Queues
|
|
443
|
+
const qOpen = expandedSet.has("cat:queues");
|
|
444
|
+
nodes.push({
|
|
445
|
+
id: "cat:queues",
|
|
446
|
+
type: "category",
|
|
447
|
+
label: `${qOpen ? pc.yellow("▾") : pc.dim("▸")} ${pc.bold("Queues")}`,
|
|
448
|
+
depth: 0,
|
|
449
|
+
expandable: true,
|
|
450
|
+
});
|
|
451
|
+
if (qOpen) {
|
|
452
|
+
if (queues.length === 0) {
|
|
453
|
+
nodes.push({
|
|
454
|
+
id: "empty:queues",
|
|
455
|
+
type: "status",
|
|
456
|
+
label: pc.dim("(empty)"),
|
|
457
|
+
depth: 1,
|
|
458
|
+
expandable: false,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
for (const q of queues) {
|
|
462
|
+
nodes.push({
|
|
463
|
+
id: `queue:${q}`,
|
|
464
|
+
parentId: "cat:queues",
|
|
465
|
+
type: "queue",
|
|
466
|
+
label: `${pc.dim("◆")} ${pc.magenta(q)}`,
|
|
467
|
+
depth: 1,
|
|
468
|
+
expandable: false,
|
|
469
|
+
ref: { name: q },
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Metrics
|
|
474
|
+
nodes.push({
|
|
475
|
+
id: "node:status",
|
|
476
|
+
type: "status",
|
|
477
|
+
label: `${pc.dim("○")} ${pc.dim("Metrics")}`,
|
|
478
|
+
depth: 0,
|
|
479
|
+
expandable: false,
|
|
480
|
+
});
|
|
481
|
+
return nodes;
|
|
482
|
+
}
|
|
483
|
+
// ── Content Loading ──────────────────────────────────────────────────
|
|
484
|
+
function scheduleLoad(node) {
|
|
485
|
+
if (!connected) {
|
|
486
|
+
// Show driver info in viewer
|
|
487
|
+
if (node.type === "driver" && node.ref) {
|
|
488
|
+
const d = node.ref.driver;
|
|
489
|
+
let info = "";
|
|
490
|
+
if (d === "memory") {
|
|
491
|
+
info = `${pc.bold("Memory Driver")}\n\n`;
|
|
492
|
+
info += ` Ephemeral in-memory database.\n`;
|
|
493
|
+
info += ` All data is destroyed on exit.\n\n`;
|
|
494
|
+
info += ` ${pc.dim("Best for: testing, prototyping")}\n\n`;
|
|
495
|
+
info += ` Press ${pc.bold("Enter")} to connect.`;
|
|
496
|
+
}
|
|
497
|
+
else if (d === "native") {
|
|
498
|
+
info = `${pc.bold("Native Driver")}\n\n`;
|
|
499
|
+
info += ` Persistent SQLite database.\n`;
|
|
500
|
+
info += ` Data is stored on disk.\n\n`;
|
|
501
|
+
info += ` ${pc.dim("Best for: local development, single-node")}\n\n`;
|
|
502
|
+
info += ` Press ${pc.bold("Enter")} to connect.`;
|
|
503
|
+
}
|
|
504
|
+
else if (d === "cloud") {
|
|
505
|
+
info = `${pc.bold("Cloud Driver")}\n\n`;
|
|
506
|
+
info += ` Connect to a remote thingd instance.\n`;
|
|
507
|
+
info += ` Requires a URL and optional auth token.\n\n`;
|
|
508
|
+
info += ` ${pc.dim("Best for: production, multi-node")}\n\n`;
|
|
509
|
+
info += ` Press ${pc.bold("Enter")} to connect.`;
|
|
510
|
+
}
|
|
511
|
+
viewerLines = info.split("\n");
|
|
512
|
+
loadedItemId = node.id;
|
|
513
|
+
}
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (loadTimer)
|
|
517
|
+
clearTimeout(loadTimer);
|
|
518
|
+
if (loadedItemId === node.id)
|
|
519
|
+
return;
|
|
520
|
+
loadedItemId = node.id;
|
|
521
|
+
viewerLines = [pc.dim("Loading...")];
|
|
522
|
+
viewerScroll = 0;
|
|
523
|
+
draw();
|
|
524
|
+
loadTimer = setTimeout(async () => {
|
|
525
|
+
await loadContent(node);
|
|
526
|
+
}, 80);
|
|
527
|
+
}
|
|
528
|
+
async function loadContent(node) {
|
|
529
|
+
const snapId = node.id;
|
|
530
|
+
try {
|
|
531
|
+
let content = "";
|
|
532
|
+
if (node.type === "object" && node.ref) {
|
|
533
|
+
const data = await db.get(node.ref.collection, node.ref.id);
|
|
534
|
+
content = data ? highlightJson(data) : pc.yellow("Object not found.");
|
|
535
|
+
}
|
|
536
|
+
else if (node.type === "collection" && node.ref) {
|
|
537
|
+
const objs = objectsByCollection.get(node.ref.name) ?? [];
|
|
538
|
+
const hist = colHistory.get(node.ref.name) ?? [];
|
|
539
|
+
let res = `${pc.bold(node.ref.name)} ${pc.dim(`(${objs.length} objects)`)}\n\n`;
|
|
540
|
+
res += `${pc.bold("Performance")}\n`;
|
|
541
|
+
res += ` Volume ${pc.cyan(drawSparkline(hist))}\n\n`;
|
|
542
|
+
if (objs.length === 0) {
|
|
543
|
+
res += pc.dim(" No objects in this collection.");
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
const lines = objs.map((id) => ` ${pc.dim("●")} ${id}`);
|
|
547
|
+
res += lines.join("\n");
|
|
548
|
+
}
|
|
549
|
+
content = res;
|
|
550
|
+
}
|
|
551
|
+
else if (node.type === "stream" && node.ref) {
|
|
552
|
+
const events = await db.events.list(node.ref.name);
|
|
553
|
+
const hist = streamHistory.get(node.ref.name) ?? [];
|
|
554
|
+
let res = `${pc.bold(node.ref.name)} ${pc.dim(`(${events.length} events)`)}\n\n`;
|
|
555
|
+
res += `${pc.bold("Performance")}\n`;
|
|
556
|
+
res += ` Volume ${pc.green(drawSparkline(hist))}\n\n`;
|
|
557
|
+
if (events.length === 0) {
|
|
558
|
+
res += pc.dim(" No events in this stream.");
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
const lines = events.map((e) => {
|
|
562
|
+
const ts = e.createdAt ? pc.dim(String(e.createdAt)) : "";
|
|
563
|
+
const type = pc.magenta(e.type || "unknown");
|
|
564
|
+
return ` ${ts} ${type}`;
|
|
565
|
+
});
|
|
566
|
+
res += lines.join("\n");
|
|
567
|
+
}
|
|
568
|
+
content = res;
|
|
569
|
+
}
|
|
570
|
+
else if (node.type === "queue" && node.ref) {
|
|
571
|
+
const queue = db.queue(node.ref.name);
|
|
572
|
+
const [active, dead] = await Promise.all([queue.list(), queue.dead()]);
|
|
573
|
+
const aHist = queueActiveHistory.get(node.ref.name) ?? [];
|
|
574
|
+
const dHist = queueDeadHistory.get(node.ref.name) ?? [];
|
|
575
|
+
let res = `${pc.bold(node.ref.name)}\n\n`;
|
|
576
|
+
res += `${pc.bold("Performance")}\n`;
|
|
577
|
+
res += ` Active ${pc.cyan(drawSparkline(aHist))}\n`;
|
|
578
|
+
res += ` Dead ${pc.red(drawSparkline(dHist))}\n\n`;
|
|
579
|
+
res += `${pc.cyan("Active")} ${pc.dim(`(${active.length})`)}\n`;
|
|
580
|
+
if (active.length === 0) {
|
|
581
|
+
res += pc.dim(" No active jobs\n");
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
for (const j of active) {
|
|
585
|
+
res += ` ${pc.dim("●")} ${j.id} ${pc.yellow(j.status)} ${pc.dim(`${j.attempts}/${j.maxAttempts}`)}\n`;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
res += `\n${pc.red("Dead")} ${pc.dim(`(${dead.length})`)}\n`;
|
|
589
|
+
if (dead.length === 0) {
|
|
590
|
+
res += pc.dim(" No dead jobs\n");
|
|
591
|
+
}
|
|
592
|
+
else {
|
|
593
|
+
for (const j of dead) {
|
|
594
|
+
res += ` ${pc.dim("●")} ${j.id} ${pc.dim(`${j.attempts}/${j.maxAttempts}`)}\n`;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
content = res;
|
|
598
|
+
}
|
|
599
|
+
else if (node.type === "status") {
|
|
600
|
+
const W = process.stdout.columns || 80;
|
|
601
|
+
const sideW = Math.min(40, Math.max(20, Math.floor(W * 0.35)));
|
|
602
|
+
const viewW = Math.max(20, W - sideW - 3);
|
|
603
|
+
const fullRule = pc.dim("─".repeat(Math.max(10, viewW - 2)));
|
|
604
|
+
const uptime = startedAt ? formatUptime(Date.now() - startedAt) : "--";
|
|
605
|
+
// ── Header
|
|
606
|
+
const titleStr = `${pc.bold("thingd")} ${pc.cyan("METRICS")}`;
|
|
607
|
+
const pathStr = pc.dim(dbPath || ":memory:");
|
|
608
|
+
const pathRaw = dbPath || ":memory:";
|
|
609
|
+
const gap = Math.max(2, viewW - 2 - 8 - "METRICS".length - pathRaw.length);
|
|
610
|
+
content = ` ${titleStr}${" ".repeat(gap)}${pathStr}\n`;
|
|
611
|
+
content += ` ${pc.dim("uptime")} ${pc.dim(uptime)}\n`;
|
|
612
|
+
content += ` ${fullRule}\n\n`;
|
|
613
|
+
// ── Physical Store & Driver Logic
|
|
614
|
+
let sizeKb = 0;
|
|
615
|
+
if (driver === "native" && dbPath) {
|
|
616
|
+
try {
|
|
617
|
+
sizeKb = Math.round(fs.statSync(dbPath).size / 1024);
|
|
618
|
+
}
|
|
619
|
+
catch { }
|
|
620
|
+
}
|
|
621
|
+
const dbSizeStr = driver === "native" ? `${sizeKb} KB` : "--";
|
|
622
|
+
let driverName = "Unknown";
|
|
623
|
+
if (driver === "memory")
|
|
624
|
+
driverName = "SQLite (Memory)";
|
|
625
|
+
else if (driver === "native")
|
|
626
|
+
driverName = "SQLite (Native)";
|
|
627
|
+
else if (driver === "cloud")
|
|
628
|
+
driverName = "Cloud (Remote)";
|
|
629
|
+
const objVal = String(totalObjects).padEnd(8);
|
|
630
|
+
const evtVal = String(totalEventsCount).padEnd(8);
|
|
631
|
+
const actVal = String(totalActiveJobsCount).padEnd(8);
|
|
632
|
+
const ddtVal = String(totalDeadJobsCount).padEnd(8);
|
|
633
|
+
// ── Metrics Layout
|
|
634
|
+
content += ` ${pc.bold("CAPACITY & STORAGE METRICS")}\n`;
|
|
635
|
+
content += ` ${fullRule}\n`;
|
|
636
|
+
content += ` ${pc.dim("Objects").padEnd(20)} ${pc.cyan(objVal)} ${pc.dim("total objects stored")}\n`;
|
|
637
|
+
content += ` ${pc.dim("Events").padEnd(20)} ${pc.green(evtVal)} ${pc.dim("total events in streams")}\n`;
|
|
638
|
+
content += ` ${pc.dim("Active Jobs").padEnd(20)} ${pc.yellow(actVal)} ${pc.dim("jobs currently processing")}\n`;
|
|
639
|
+
content += ` ${pc.dim("Dead Jobs").padEnd(20)} ${pc.red(ddtVal)} ${pc.dim("failed/dead jobs")}\n\n`;
|
|
640
|
+
content += ` ${pc.bold("PHYSICAL STORE & CONNECTION")}\n`;
|
|
641
|
+
content += ` ${fullRule}\n`;
|
|
642
|
+
content += ` ${pc.dim("Database Size").padEnd(20)} ${pc.blue(dbSizeStr)}\n`;
|
|
643
|
+
content += ` ${pc.dim("Driver Type").padEnd(20)} ${driverName}\n`;
|
|
644
|
+
content += ` ${pc.dim("Storage Path").padEnd(20)} ${dbPath || ":memory:"}\n`;
|
|
645
|
+
content += ` ${pc.dim("CLI Shortcuts").padEnd(20)} ${pc.bold("[c]")} Create ${pc.bold("[r]")} Refresh\n\n`;
|
|
646
|
+
// ── Throughput & Activity Metrics
|
|
647
|
+
const currentWrite = objectWriteRateHistory[objectWriteRateHistory.length - 1] ?? 0;
|
|
648
|
+
const currentAppend = eventAppendRateHistory[eventAppendRateHistory.length - 1] ?? 0;
|
|
649
|
+
const peakWrite = Math.max(5, ...objectWriteRateHistory);
|
|
650
|
+
const peakAppend = Math.max(5, ...eventAppendRateHistory);
|
|
651
|
+
// Adjust sparkline width to prevent terminal wrapping. Total fixed chars ~55.
|
|
652
|
+
const sparkW = Math.max(10, viewW - 55);
|
|
653
|
+
const wLine = drawSparkline(objectWriteRateHistory, 5, sparkW);
|
|
654
|
+
const apLine = drawSparkline(eventAppendRateHistory, 5, sparkW);
|
|
655
|
+
content += ` ${pc.bold("THROUGHPUT & ACTIVITY METRICS")}\n`;
|
|
656
|
+
content += ` ${fullRule}\n`;
|
|
657
|
+
content += ` ${pc.dim("Object Writes".padEnd(16))} ${pc.cyan(wLine)} ${pc.cyan(String(currentWrite).padEnd(4))} ${pc.dim(`writes/s (Peak: ${peakWrite}/s)`)}\n\n`;
|
|
658
|
+
content += ` ${pc.dim("Event Appends".padEnd(16))} ${pc.green(apLine)} ${pc.green(String(currentAppend).padEnd(4))} ${pc.dim(`appends/s (Peak: ${peakAppend}/s)`)}\n\n`;
|
|
659
|
+
}
|
|
660
|
+
else if (node.type === "category") {
|
|
661
|
+
content = pc.dim("Expand to browse items.");
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
content = "";
|
|
665
|
+
}
|
|
666
|
+
if (loadedItemId === snapId) {
|
|
667
|
+
viewerLines = content.split("\n");
|
|
668
|
+
draw();
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
if (loadedItemId === snapId) {
|
|
673
|
+
viewerLines = [pc.red(`Error: ${err.message}`)];
|
|
674
|
+
draw();
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
// ── Rendering ────────────────────────────────────────────────────────
|
|
679
|
+
function draw() {
|
|
680
|
+
const W = process.stdout.columns || 80;
|
|
681
|
+
const H = process.stdout.rows || 24;
|
|
682
|
+
const sideW = Math.min(40, Math.max(20, Math.floor(W * 0.35)));
|
|
683
|
+
const viewW = Math.max(1, W - sideW - 3); // 3 = " | "
|
|
684
|
+
const bodyH = Math.max(1, H - 4); // header(1) + separator(1) + separator(1) + footer(1)
|
|
685
|
+
const tree = buildTree();
|
|
686
|
+
// Clamp cursor
|
|
687
|
+
if (tree.length === 0) {
|
|
688
|
+
cursorIndex = 0;
|
|
689
|
+
}
|
|
690
|
+
else if (cursorIndex >= tree.length) {
|
|
691
|
+
cursorIndex = tree.length - 1;
|
|
692
|
+
}
|
|
693
|
+
if (cursorIndex < 0)
|
|
694
|
+
cursorIndex = 0;
|
|
695
|
+
// Scroll sidebar
|
|
696
|
+
if (cursorIndex >= scrollOffset + bodyH) {
|
|
697
|
+
scrollOffset = cursorIndex - bodyH + 1;
|
|
698
|
+
}
|
|
699
|
+
else if (cursorIndex < scrollOffset) {
|
|
700
|
+
scrollOffset = cursorIndex;
|
|
701
|
+
}
|
|
702
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, tree.length - bodyH)));
|
|
703
|
+
let buf = "\u001B[H"; // Move to top-left
|
|
704
|
+
// Header
|
|
705
|
+
let titleStr;
|
|
706
|
+
if (!connected) {
|
|
707
|
+
titleStr = ` thingd ${pc.dim("|")} Select Environment `;
|
|
708
|
+
}
|
|
709
|
+
else if (formState?.active) {
|
|
710
|
+
titleStr = ` thingd ${pc.dim("|")} ${driver.toUpperCase()} ${pc.dim("|")} Input Mode `;
|
|
711
|
+
}
|
|
712
|
+
else {
|
|
713
|
+
titleStr = ` thingd ${pc.dim("|")} ${driver.toUpperCase()} ${pc.dim("|")} ${dbPath} `;
|
|
714
|
+
}
|
|
715
|
+
buf += pc.inverse(padToWidth(titleStr, W)) + "\n";
|
|
716
|
+
// Separator
|
|
717
|
+
buf += pc.dim("─".repeat(sideW) + "─┬─" + "─".repeat(viewW)) + "\n";
|
|
718
|
+
// Build Form Lines if active
|
|
719
|
+
if (formState?.active) {
|
|
720
|
+
viewerLines = [`${pc.bgCyan(pc.black(` ${formState.title} `))}`, ""];
|
|
721
|
+
for (let i = 0; i < formState.fields.length; i++) {
|
|
722
|
+
const f = formState.fields[i];
|
|
723
|
+
if (!f)
|
|
724
|
+
continue;
|
|
725
|
+
const isSel = i === formState.activeIndex;
|
|
726
|
+
let displayLabel = f.label;
|
|
727
|
+
if (f.options && f.allowCustom && f.value && !f.options.includes(f.value)) {
|
|
728
|
+
displayLabel += pc.green(" (New)");
|
|
729
|
+
}
|
|
730
|
+
viewerLines.push(`${isSel ? pc.yellow("▶") : " "} ${pc.bold(displayLabel)}`);
|
|
731
|
+
let displayVal = f.value;
|
|
732
|
+
if (f.isSecret)
|
|
733
|
+
displayVal = "*".repeat(displayVal.length);
|
|
734
|
+
if (displayVal === "" && f.placeholder) {
|
|
735
|
+
displayVal = pc.dim(f.placeholder);
|
|
736
|
+
}
|
|
737
|
+
if (isSel && !formState.isSubmitting) {
|
|
738
|
+
if (f.options && !f.allowCustom) {
|
|
739
|
+
viewerLines.push(` ${pc.cyan("◀ ")}${pc.inverse(displayVal || " ")}${pc.cyan(" ▶")}`);
|
|
740
|
+
}
|
|
741
|
+
else if (f.options && f.allowCustom) {
|
|
742
|
+
const inOptions = f.options.includes(f.value);
|
|
743
|
+
if (inOptions) {
|
|
744
|
+
viewerLines.push(` ${pc.cyan("◀ ")}${displayVal}${pc.inverse(" ")}${pc.cyan(" ▶")}`);
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
viewerLines.push(` ${displayVal}${pc.inverse(" ")}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
viewerLines.push(` ${displayVal}${pc.inverse(" ")}`); // cursor block
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
else {
|
|
755
|
+
viewerLines.push(` ${displayVal}`);
|
|
756
|
+
}
|
|
757
|
+
viewerLines.push("");
|
|
758
|
+
}
|
|
759
|
+
if (formState.error) {
|
|
760
|
+
viewerLines.push(pc.red(formState.error));
|
|
761
|
+
}
|
|
762
|
+
if (formState.isSubmitting) {
|
|
763
|
+
viewerLines.push(pc.cyan("Processing..."));
|
|
764
|
+
}
|
|
765
|
+
viewerLines.push("");
|
|
766
|
+
viewerLines.push(pc.dim(" [Enter] Next/Submit [Esc] Cancel"));
|
|
767
|
+
}
|
|
768
|
+
// Body rows
|
|
769
|
+
for (let r = 0; r < bodyH; r++) {
|
|
770
|
+
// Sidebar
|
|
771
|
+
const treeIdx = r + scrollOffset;
|
|
772
|
+
const node = tree[treeIdx];
|
|
773
|
+
const isActive = treeIdx === cursorIndex;
|
|
774
|
+
let left;
|
|
775
|
+
if (!node) {
|
|
776
|
+
left = " ".repeat(sideW);
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
const indent = " ".repeat(node.depth);
|
|
780
|
+
const raw = indent + node.label;
|
|
781
|
+
left = fitToWidth(raw, sideW, isActive);
|
|
782
|
+
}
|
|
783
|
+
// Viewer
|
|
784
|
+
const vLine = viewerLines[r + viewerScroll] ?? "";
|
|
785
|
+
const right = fitToWidth(vLine, viewW, false);
|
|
786
|
+
buf += left + pc.dim(" │ ") + right + "\n";
|
|
787
|
+
}
|
|
788
|
+
// Separator
|
|
789
|
+
buf += pc.dim("─".repeat(sideW) + "─┴─" + "─".repeat(viewW)) + "\n";
|
|
790
|
+
// Footer
|
|
791
|
+
let help;
|
|
792
|
+
if (formState?.active) {
|
|
793
|
+
const hasOptions = formState.fields[formState.activeIndex]?.options;
|
|
794
|
+
help = ` ${pc.dim("↑↓")} focus ${hasOptions ? pc.dim("←→") + " select " : ""}${pc.dim("enter")} submit ${pc.dim("ctrl+e")} editor ${pc.dim("esc")} cancel `;
|
|
795
|
+
}
|
|
796
|
+
else if (!connected) {
|
|
797
|
+
help = ` ${pc.dim("↑↓")} nav ${pc.dim("enter")} connect ${pc.dim("q")} quit `;
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
help = ` ${pc.dim("↑↓")} nav ${pc.dim("←→")} toggle ${pc.dim("c")} create ${pc.dim("e")} edit ${pc.dim("d")} delete ${pc.dim("/")} search ${pc.dim("i")} info ${pc.dim("r")} refresh ${pc.dim("s")} switch ${pc.dim("q")} quit `;
|
|
801
|
+
}
|
|
802
|
+
buf += padToWidth(help, W);
|
|
803
|
+
// Clear to end
|
|
804
|
+
buf += "\u001B[J";
|
|
805
|
+
process.stdout.write(buf);
|
|
806
|
+
}
|
|
807
|
+
/** Pad/truncate `text` to exactly `width` visible characters. */
|
|
808
|
+
function fitToWidth(text, width, highlight) {
|
|
809
|
+
const vw = visibleWidth(text);
|
|
810
|
+
let result;
|
|
811
|
+
if (vw > width) {
|
|
812
|
+
// Truncate (crude but safe: just truncate the clean text approach)
|
|
813
|
+
result = truncateToWidth(text, width - 1) + pc.dim("…");
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
result = text + " ".repeat(Math.max(0, width - vw));
|
|
817
|
+
}
|
|
818
|
+
return highlight ? pc.inverse(result) : result;
|
|
819
|
+
}
|
|
820
|
+
/** Truncate a string (potentially with ANSI codes) to a target visible width. */
|
|
821
|
+
function truncateToWidth(text, targetW) {
|
|
822
|
+
let w = 0;
|
|
823
|
+
let i = 0;
|
|
824
|
+
const chars = [...text];
|
|
825
|
+
let result = "";
|
|
826
|
+
while (i < chars.length && w < targetW) {
|
|
827
|
+
const ch = chars[i];
|
|
828
|
+
if (ch === undefined)
|
|
829
|
+
break;
|
|
830
|
+
if (ch === "\u001B") {
|
|
831
|
+
// Consume ANSI sequence
|
|
832
|
+
let seq = ch;
|
|
833
|
+
i++;
|
|
834
|
+
while (i < chars.length) {
|
|
835
|
+
const next = chars[i];
|
|
836
|
+
if (next === undefined || /[a-zA-Z]/.test(next))
|
|
837
|
+
break;
|
|
838
|
+
seq += next;
|
|
839
|
+
i++;
|
|
840
|
+
}
|
|
841
|
+
if (i < chars.length && chars[i] !== undefined) {
|
|
842
|
+
seq += chars[i];
|
|
843
|
+
i++;
|
|
844
|
+
}
|
|
845
|
+
result += seq;
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
const cp = ch.codePointAt(0);
|
|
849
|
+
if (cp === undefined)
|
|
850
|
+
break;
|
|
851
|
+
const cw = cp > 0xffff ? 2 : 1;
|
|
852
|
+
if (w + cw > targetW)
|
|
853
|
+
break;
|
|
854
|
+
result += ch;
|
|
855
|
+
w += cw;
|
|
856
|
+
i++;
|
|
857
|
+
}
|
|
858
|
+
return result;
|
|
859
|
+
}
|
|
860
|
+
/** Simple pad with visible width awareness. */
|
|
861
|
+
function padToWidth(text, width) {
|
|
862
|
+
const vw = visibleWidth(text);
|
|
863
|
+
if (vw >= width)
|
|
864
|
+
return text;
|
|
865
|
+
return text + " ".repeat(width - vw);
|
|
866
|
+
}
|
|
867
|
+
// ── Utils ────────────────────────────────────────────────────────────
|
|
868
|
+
async function launchEditor(f) {
|
|
869
|
+
if (process.stdin.isTTY)
|
|
870
|
+
process.stdin.setRawMode(false);
|
|
871
|
+
process.stdin.removeListener("keypress", keypressHandler);
|
|
872
|
+
console.clear();
|
|
873
|
+
const tmpFile = path.join(os.tmpdir(), `thingd-edit-${Date.now()}.json`);
|
|
874
|
+
let initialContent = "";
|
|
875
|
+
if (f.value && f.value !== "") {
|
|
876
|
+
try {
|
|
877
|
+
initialContent = JSON.stringify(JSON.parse(f.value), null, 2);
|
|
878
|
+
}
|
|
879
|
+
catch {
|
|
880
|
+
initialContent = f.value;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
initialContent = "{\n \n}\n";
|
|
885
|
+
}
|
|
886
|
+
fs.writeFileSync(tmpFile, initialContent);
|
|
887
|
+
const editor = process.env.EDITOR || "vim";
|
|
888
|
+
await new Promise((resolve) => {
|
|
889
|
+
const child = spawn(editor, [tmpFile], { stdio: "inherit" });
|
|
890
|
+
child.on("exit", () => resolve());
|
|
891
|
+
child.on("error", (err) => {
|
|
892
|
+
console.error("Failed to start editor:", err);
|
|
893
|
+
setTimeout(() => resolve(), 2000);
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
try {
|
|
897
|
+
const newContent = fs.readFileSync(tmpFile, "utf-8");
|
|
898
|
+
f.value = newContent.trim();
|
|
899
|
+
}
|
|
900
|
+
catch (e) { }
|
|
901
|
+
if (process.stdin.isTTY)
|
|
902
|
+
process.stdin.setRawMode(true);
|
|
903
|
+
process.stdin.on("keypress", keypressHandler);
|
|
904
|
+
draw();
|
|
905
|
+
}
|
|
906
|
+
function parsePayload(str) {
|
|
907
|
+
str = str.trim();
|
|
908
|
+
if (!str)
|
|
909
|
+
return {};
|
|
910
|
+
if (str.startsWith("{") || str.startsWith("[")) {
|
|
911
|
+
return JSON.parse(str);
|
|
912
|
+
}
|
|
913
|
+
const obj = {};
|
|
914
|
+
const parts = str.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
915
|
+
for (const part of parts) {
|
|
916
|
+
const eqIdx = part.indexOf("=");
|
|
917
|
+
if (eqIdx === -1) {
|
|
918
|
+
obj[part] = true;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const k = part.substring(0, eqIdx);
|
|
922
|
+
let v = part.substring(eqIdx + 1);
|
|
923
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
924
|
+
v = v.substring(1, v.length - 1);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
if (v === "true")
|
|
928
|
+
v = true;
|
|
929
|
+
else if (v === "false")
|
|
930
|
+
v = false;
|
|
931
|
+
else if (!isNaN(Number(v)))
|
|
932
|
+
v = Number(v);
|
|
933
|
+
}
|
|
934
|
+
obj[k] = v;
|
|
935
|
+
}
|
|
936
|
+
return obj;
|
|
937
|
+
}
|
|
938
|
+
// ── Mutation Handlers ────────────────────────────────────────────────
|
|
939
|
+
async function handleCreate(selected) {
|
|
940
|
+
let defaultCol = "";
|
|
941
|
+
let defaultStream = "";
|
|
942
|
+
let defaultQueue = "";
|
|
943
|
+
if (selected) {
|
|
944
|
+
if (selected.type === "collection") {
|
|
945
|
+
defaultCol = selected.ref?.name ?? "";
|
|
946
|
+
}
|
|
947
|
+
else if (selected.type === "object") {
|
|
948
|
+
defaultCol = selected.ref?.collection ?? "";
|
|
949
|
+
}
|
|
950
|
+
else if (selected.type === "stream") {
|
|
951
|
+
defaultStream = selected.ref?.name ?? "";
|
|
952
|
+
}
|
|
953
|
+
else if (selected.type === "queue") {
|
|
954
|
+
defaultQueue = selected.ref?.name ?? "";
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
openForm("Create Resource", [
|
|
958
|
+
{
|
|
959
|
+
id: "kind",
|
|
960
|
+
label: "Kind (object, event, queue)",
|
|
961
|
+
value: defaultStream ? "event" : defaultQueue ? "queue" : "object",
|
|
962
|
+
options: ["object", "event", "queue"],
|
|
963
|
+
},
|
|
964
|
+
{
|
|
965
|
+
id: "target",
|
|
966
|
+
label: "Target (Collection, Stream, or Queue Name)",
|
|
967
|
+
value: defaultCol || defaultStream || defaultQueue,
|
|
968
|
+
options: Array.from(new Set([...collections, ...streams, ...queues])).sort(),
|
|
969
|
+
allowCustom: true,
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
id: "objId",
|
|
973
|
+
label: "Object ID (only for objects, auto if blank)",
|
|
974
|
+
placeholder: "Leave blank to auto-generate",
|
|
975
|
+
},
|
|
976
|
+
{ id: "payload", label: "Data (JSON or key=value)", placeholder: 'e.g. name="John" age=30' },
|
|
977
|
+
], async (vals) => {
|
|
978
|
+
const kind = (vals.kind || "").toLowerCase();
|
|
979
|
+
const target = (vals.target || "").trim();
|
|
980
|
+
if (!target)
|
|
981
|
+
throw new Error("Target is required.");
|
|
982
|
+
if (kind === "object") {
|
|
983
|
+
let id = vals.objId?.trim();
|
|
984
|
+
if (!id) {
|
|
985
|
+
try {
|
|
986
|
+
id = crypto.randomUUID();
|
|
987
|
+
}
|
|
988
|
+
catch (e) {
|
|
989
|
+
id = "obj_" + Date.now().toString(36) + Math.random().toString(36).substring(2);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
const data = parsePayload(vals.payload || "");
|
|
993
|
+
await db.put(target, { id, ...data });
|
|
994
|
+
expandedSet.add("cat:collections");
|
|
995
|
+
expandedSet.add(`col:${target}`);
|
|
996
|
+
}
|
|
997
|
+
else if (kind === "event") {
|
|
998
|
+
if (!vals.payload?.trim())
|
|
999
|
+
throw new Error("Event Type is required (in Data field for events).");
|
|
1000
|
+
await db.events.append(target, { type: vals.payload.trim() });
|
|
1001
|
+
expandedSet.add("cat:streams");
|
|
1002
|
+
}
|
|
1003
|
+
else if (kind === "queue") {
|
|
1004
|
+
if (!vals.payload?.trim())
|
|
1005
|
+
throw new Error("Payload is required.");
|
|
1006
|
+
const data = parsePayload(vals.payload);
|
|
1007
|
+
await db.queue(target).push(data);
|
|
1008
|
+
expandedSet.add("cat:queues");
|
|
1009
|
+
}
|
|
1010
|
+
else {
|
|
1011
|
+
throw new Error("Kind must be 'object', 'event', or 'queue'.");
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
async function handleEdit(selected) {
|
|
1016
|
+
if (!selected)
|
|
1017
|
+
return;
|
|
1018
|
+
if (selected.type === "object" && selected.ref) {
|
|
1019
|
+
const ref = selected.ref;
|
|
1020
|
+
const current = await db.get(ref.collection, ref.id);
|
|
1021
|
+
const clean = current ? { ...current } : {};
|
|
1022
|
+
for (const k of ["id", "collection", "createdAt", "updatedAt", "version"]) {
|
|
1023
|
+
delete clean[k];
|
|
1024
|
+
}
|
|
1025
|
+
openForm(`Edit Object: ${ref.id}`, [{ id: "payload", label: "Data (JSON or key=value)", value: JSON.stringify(clean) }], async (vals) => {
|
|
1026
|
+
const data = parsePayload(vals.payload || "");
|
|
1027
|
+
await db.put(ref.collection, { id: ref.id, ...data });
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
else if (selected.type === "queue" && selected.ref) {
|
|
1031
|
+
const ref = selected.ref;
|
|
1032
|
+
const queue = db.queue(ref.name);
|
|
1033
|
+
openForm(`Manage Queue: ${ref.name}`, [
|
|
1034
|
+
{ id: "action", label: "Action (claim, push)", value: "claim", options: ["claim", "push"] },
|
|
1035
|
+
{
|
|
1036
|
+
id: "payload",
|
|
1037
|
+
label: "Job Data (JSON or key=value, only for push)",
|
|
1038
|
+
placeholder: 'task="email"',
|
|
1039
|
+
},
|
|
1040
|
+
], async (vals) => {
|
|
1041
|
+
const action = vals.action || "";
|
|
1042
|
+
if (action === "claim") {
|
|
1043
|
+
const job = await queue.claim();
|
|
1044
|
+
if (job) {
|
|
1045
|
+
throw new Error(`Claimed job: ${job.id}`);
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
throw new Error("No ready jobs.");
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
else if (action === "push") {
|
|
1052
|
+
const data = parsePayload(vals.payload || "");
|
|
1053
|
+
await queue.push(data);
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
throw new Error("Action must be 'claim' or 'push'.");
|
|
1057
|
+
}
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
else {
|
|
1061
|
+
openForm("Edit Not Supported", [{ id: "msg", label: "Error", value: "Editing is only available for Objects and Queues." }], async () => { });
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
async function handleDelete(selected) {
|
|
1065
|
+
if (!selected)
|
|
1066
|
+
return;
|
|
1067
|
+
if (selected.type === "object" && selected.ref) {
|
|
1068
|
+
const ref = selected.ref;
|
|
1069
|
+
openForm(`Delete Object: ${ref.id}`, [{ id: "confirm", label: 'Type "yes" to confirm deletion', placeholder: "yes" }], async (vals) => {
|
|
1070
|
+
if ((vals.confirm || "").toLowerCase() !== "yes")
|
|
1071
|
+
throw new Error("Canceled");
|
|
1072
|
+
await db.delete(ref.collection, ref.id);
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
else if (selected.type === "queue" && selected.ref) {
|
|
1076
|
+
const ref = selected.ref;
|
|
1077
|
+
openForm(`Resolve Queue Job`, [
|
|
1078
|
+
{ id: "jobId", label: "Leased Job ID", placeholder: "job-id" },
|
|
1079
|
+
{ id: "action", label: "Action (ack, nack)", value: "ack" },
|
|
1080
|
+
], async (vals) => {
|
|
1081
|
+
const jobId = (vals.jobId || "").trim();
|
|
1082
|
+
const action = vals.action || "";
|
|
1083
|
+
if (!jobId)
|
|
1084
|
+
throw new Error("Job ID required.");
|
|
1085
|
+
if (action === "ack") {
|
|
1086
|
+
await db.queue(ref.name).ack(jobId);
|
|
1087
|
+
}
|
|
1088
|
+
else if (action === "nack") {
|
|
1089
|
+
await db.queue(ref.name).nack(jobId, { error: "Rejected via CLI" });
|
|
1090
|
+
}
|
|
1091
|
+
else {
|
|
1092
|
+
throw new Error("Action must be 'ack' or 'nack'.");
|
|
1093
|
+
}
|
|
1094
|
+
});
|
|
1095
|
+
}
|
|
1096
|
+
else {
|
|
1097
|
+
openForm("Delete Not Supported", [{ id: "msg", label: "Error", value: "Deletion is only available for Objects and Queues." }], async () => { });
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
async function handleSearch() {
|
|
1101
|
+
openForm("Global Search", [
|
|
1102
|
+
{ id: "query", label: "Search Query", placeholder: "text to search" },
|
|
1103
|
+
{ id: "limit", label: "Limit (optional)", placeholder: "100" },
|
|
1104
|
+
], async (vals) => {
|
|
1105
|
+
const query = (vals.query || "").trim();
|
|
1106
|
+
if (!query)
|
|
1107
|
+
throw new Error("Search query required.");
|
|
1108
|
+
const limitStr = vals.limit || "";
|
|
1109
|
+
const options = {};
|
|
1110
|
+
if (limitStr) {
|
|
1111
|
+
const limit = parseInt(limitStr, 10);
|
|
1112
|
+
if (!isNaN(limit))
|
|
1113
|
+
options.limit = limit;
|
|
1114
|
+
}
|
|
1115
|
+
const results = await db.search(query, options);
|
|
1116
|
+
// Display results in the viewer
|
|
1117
|
+
viewerLines = [
|
|
1118
|
+
` ${pc.bold("Search Results:")} ${pc.cyan(query)}`,
|
|
1119
|
+
"",
|
|
1120
|
+
...(results.length === 0 ? [" No results found."] : []),
|
|
1121
|
+
...results.map((r) => {
|
|
1122
|
+
const id = pc.green(r.id);
|
|
1123
|
+
const col = pc.cyan(r.kind === "object" ? r.collection : r.stream);
|
|
1124
|
+
const textStr = r.value?.text ? pc.dim(r.value.text.substring(0, 100)) : "";
|
|
1125
|
+
return ` ${col} / ${id} ${textStr}`;
|
|
1126
|
+
}),
|
|
1127
|
+
];
|
|
1128
|
+
loadedItemId = "search_results";
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
async function handleInfo() {
|
|
1132
|
+
const lines = [
|
|
1133
|
+
` ${pc.bold("Connection Status")}`,
|
|
1134
|
+
"",
|
|
1135
|
+
` Driver: ${pc.cyan(driver)}`,
|
|
1136
|
+
` Path: ${pc.cyan(dbPath)}`,
|
|
1137
|
+
];
|
|
1138
|
+
if (driver === "cloud") {
|
|
1139
|
+
try {
|
|
1140
|
+
const baseUrl = dbPath.startsWith("thingd://")
|
|
1141
|
+
? `http://${dbPath.slice("thingd://".length)}`
|
|
1142
|
+
: dbPath;
|
|
1143
|
+
const urlObj = new URL(baseUrl);
|
|
1144
|
+
if (urlObj.pathname === "/mcp")
|
|
1145
|
+
urlObj.pathname = "/";
|
|
1146
|
+
const fetchJson = async (p) => {
|
|
1147
|
+
const u = new URL(p, urlObj.toString());
|
|
1148
|
+
const headers = {};
|
|
1149
|
+
if (authToken)
|
|
1150
|
+
headers["Authorization"] = `Bearer ${authToken}`;
|
|
1151
|
+
const res = await fetch(u, { headers });
|
|
1152
|
+
if (!res.ok)
|
|
1153
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1154
|
+
return res.json();
|
|
1155
|
+
};
|
|
1156
|
+
const health = await fetchJson("/healthz");
|
|
1157
|
+
const cluster = await fetchJson("/cluster/status");
|
|
1158
|
+
lines.push("");
|
|
1159
|
+
lines.push(` ${pc.bold("Cloud Health")}`);
|
|
1160
|
+
lines.push(...JSON.stringify(health, null, 2)
|
|
1161
|
+
.split("\n")
|
|
1162
|
+
.map((l) => ` ${pc.dim(l)}`));
|
|
1163
|
+
lines.push("");
|
|
1164
|
+
lines.push(` ${pc.bold("Cloud Cluster")}`);
|
|
1165
|
+
lines.push(...JSON.stringify(cluster, null, 2)
|
|
1166
|
+
.split("\n")
|
|
1167
|
+
.map((l) => ` ${pc.dim(l)}`));
|
|
1168
|
+
}
|
|
1169
|
+
catch (err) {
|
|
1170
|
+
lines.push("", ` ${pc.red("Cloud Query Failed:")} ${err.message}`);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
viewerLines = lines;
|
|
1174
|
+
loadedItemId = "info_status";
|
|
1175
|
+
}
|
|
1176
|
+
// ── Keyboard Listener ────────────────────────────────────────────────
|
|
1177
|
+
function setupKeypress() {
|
|
1178
|
+
process.stdin.removeAllListeners("keypress");
|
|
1179
|
+
readline.emitKeypressEvents(process.stdin);
|
|
1180
|
+
if (process.stdin.isTTY) {
|
|
1181
|
+
process.stdin.setRawMode(true);
|
|
1182
|
+
}
|
|
1183
|
+
process.stdin.resume();
|
|
1184
|
+
keypressHandler = async (str, key) => {
|
|
1185
|
+
if (!key)
|
|
1186
|
+
return;
|
|
1187
|
+
// Quit
|
|
1188
|
+
if ((key.ctrl && key.name === "c") || key.name === "q") {
|
|
1189
|
+
if (formState?.active && key.name !== "q") {
|
|
1190
|
+
formState.onCancel();
|
|
1191
|
+
return;
|
|
1192
|
+
}
|
|
1193
|
+
else if (!formState?.active) {
|
|
1194
|
+
cleanup();
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (formState?.active && !formState.isSubmitting) {
|
|
1199
|
+
if (key.ctrl && key.name === "e") {
|
|
1200
|
+
const f = formState.fields[formState.activeIndex];
|
|
1201
|
+
if (f) {
|
|
1202
|
+
await launchEditor(f);
|
|
1203
|
+
}
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
else if (key.name === "escape") {
|
|
1207
|
+
formState.onCancel();
|
|
1208
|
+
}
|
|
1209
|
+
else if (key.name === "up" || (key.name === "tab" && key.shift)) {
|
|
1210
|
+
if (formState.activeIndex > 0)
|
|
1211
|
+
formState.activeIndex--;
|
|
1212
|
+
formState.error = undefined;
|
|
1213
|
+
draw();
|
|
1214
|
+
}
|
|
1215
|
+
else if (key.name === "down" || key.name === "tab") {
|
|
1216
|
+
if (formState.activeIndex < formState.fields.length - 1)
|
|
1217
|
+
formState.activeIndex++;
|
|
1218
|
+
formState.error = undefined;
|
|
1219
|
+
draw();
|
|
1220
|
+
}
|
|
1221
|
+
else if (key.name === "return") {
|
|
1222
|
+
if (formState.activeIndex < formState.fields.length - 1) {
|
|
1223
|
+
formState.activeIndex++;
|
|
1224
|
+
draw();
|
|
1225
|
+
}
|
|
1226
|
+
else {
|
|
1227
|
+
const vals = {};
|
|
1228
|
+
for (const f of formState.fields)
|
|
1229
|
+
vals[f.id] = f.value;
|
|
1230
|
+
formState.onSubmit(vals);
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
else if (key.name === "left" || key.name === "right") {
|
|
1234
|
+
const f = formState.fields[formState.activeIndex];
|
|
1235
|
+
if (f && f.options && f.options.length > 0) {
|
|
1236
|
+
const currentIndex = f.options.indexOf(f.value);
|
|
1237
|
+
let nextIndex = key.name === "right" ? currentIndex + 1 : currentIndex - 1;
|
|
1238
|
+
if (nextIndex < 0)
|
|
1239
|
+
nextIndex = f.options.length - 1;
|
|
1240
|
+
if (nextIndex >= f.options.length)
|
|
1241
|
+
nextIndex = 0;
|
|
1242
|
+
f.value = f.options[nextIndex] ?? "";
|
|
1243
|
+
formState.error = undefined;
|
|
1244
|
+
draw();
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
else if (key.name === "backspace") {
|
|
1248
|
+
const f = formState.fields[formState.activeIndex];
|
|
1249
|
+
if (f && (!f.options || f.allowCustom) && f.value.length > 0) {
|
|
1250
|
+
f.value = f.value.slice(0, -1);
|
|
1251
|
+
formState.error = undefined;
|
|
1252
|
+
draw();
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
else if (str) {
|
|
1256
|
+
const f = formState.fields[formState.activeIndex];
|
|
1257
|
+
if (f && (!f.options || f.allowCustom)) {
|
|
1258
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: we need to filter control characters
|
|
1259
|
+
const clean = str.replace(/[\x00-\x1F\x7F]/g, "");
|
|
1260
|
+
if (clean) {
|
|
1261
|
+
f.value += clean;
|
|
1262
|
+
formState.error = undefined;
|
|
1263
|
+
draw();
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
const tree = buildTree();
|
|
1270
|
+
// Navigation (works in both connected and disconnected states)
|
|
1271
|
+
if (key.name === "up" || str === "k") {
|
|
1272
|
+
if (cursorIndex > 0) {
|
|
1273
|
+
cursorIndex--;
|
|
1274
|
+
draw();
|
|
1275
|
+
const n = tree[cursorIndex];
|
|
1276
|
+
if (n)
|
|
1277
|
+
scheduleLoad(n);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
else if (key.name === "down" || str === "j") {
|
|
1281
|
+
if (cursorIndex < tree.length - 1) {
|
|
1282
|
+
cursorIndex++;
|
|
1283
|
+
draw();
|
|
1284
|
+
const n = tree[cursorIndex];
|
|
1285
|
+
if (n)
|
|
1286
|
+
scheduleLoad(n);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
else if (!connected) {
|
|
1290
|
+
// Driver selection mode — only Enter works
|
|
1291
|
+
if (key.name === "return") {
|
|
1292
|
+
const node = tree[cursorIndex];
|
|
1293
|
+
if (node)
|
|
1294
|
+
await handleConnect(node);
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
else {
|
|
1298
|
+
// Connected mode — full set of shortcuts
|
|
1299
|
+
if (key.name === "right" || str === "l") {
|
|
1300
|
+
const node = tree[cursorIndex];
|
|
1301
|
+
if (node?.expandable) {
|
|
1302
|
+
if (!expandedSet.has(node.id)) {
|
|
1303
|
+
expandedSet.add(node.id);
|
|
1304
|
+
if (node.type === "collection")
|
|
1305
|
+
await fetchResources();
|
|
1306
|
+
draw();
|
|
1307
|
+
}
|
|
1308
|
+
else {
|
|
1309
|
+
const newTree = buildTree();
|
|
1310
|
+
if (cursorIndex + 1 < newTree.length) {
|
|
1311
|
+
cursorIndex++;
|
|
1312
|
+
draw();
|
|
1313
|
+
const n = newTree[cursorIndex];
|
|
1314
|
+
if (n)
|
|
1315
|
+
scheduleLoad(n);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
else if (key.name === "left" || str === "h") {
|
|
1321
|
+
const node = tree[cursorIndex];
|
|
1322
|
+
if (node) {
|
|
1323
|
+
if (node.expandable && expandedSet.has(node.id)) {
|
|
1324
|
+
expandedSet.delete(node.id);
|
|
1325
|
+
draw();
|
|
1326
|
+
}
|
|
1327
|
+
else if (node.parentId) {
|
|
1328
|
+
const parentIdx = tree.findIndex((n) => n.id === node.parentId);
|
|
1329
|
+
if (parentIdx !== -1) {
|
|
1330
|
+
cursorIndex = parentIdx;
|
|
1331
|
+
draw();
|
|
1332
|
+
const n = tree[cursorIndex];
|
|
1333
|
+
if (n)
|
|
1334
|
+
scheduleLoad(n);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
else if (key.name === "return") {
|
|
1340
|
+
const node = tree[cursorIndex];
|
|
1341
|
+
if (node?.expandable) {
|
|
1342
|
+
if (expandedSet.has(node.id)) {
|
|
1343
|
+
expandedSet.delete(node.id);
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
expandedSet.add(node.id);
|
|
1347
|
+
if (node.type === "collection")
|
|
1348
|
+
await fetchResources();
|
|
1349
|
+
}
|
|
1350
|
+
draw();
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
else if (str === "r" || str === "R") {
|
|
1354
|
+
loadedItemId = "";
|
|
1355
|
+
await fetchResources();
|
|
1356
|
+
draw();
|
|
1357
|
+
const n = tree[cursorIndex];
|
|
1358
|
+
if (n)
|
|
1359
|
+
scheduleLoad(n);
|
|
1360
|
+
}
|
|
1361
|
+
else if (str === "s" || str === "S") {
|
|
1362
|
+
await handleSwitch();
|
|
1363
|
+
}
|
|
1364
|
+
else if (str === "c" || str === "C") {
|
|
1365
|
+
await handleCreate(tree[cursorIndex]);
|
|
1366
|
+
}
|
|
1367
|
+
else if (str === "e" || str === "E") {
|
|
1368
|
+
await handleEdit(tree[cursorIndex]);
|
|
1369
|
+
}
|
|
1370
|
+
else if (str === "d" || str === "D") {
|
|
1371
|
+
await handleDelete(tree[cursorIndex]);
|
|
1372
|
+
}
|
|
1373
|
+
else if (str === "/" || str === "f" || str === "F") {
|
|
1374
|
+
await handleSearch();
|
|
1375
|
+
}
|
|
1376
|
+
else if (str === "i" || str === "I") {
|
|
1377
|
+
await handleInfo();
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
process.stdin.on("keypress", keypressHandler);
|
|
1382
|
+
if (process.stdout.isTTY) {
|
|
1383
|
+
process.stdout.on("resize", () => {
|
|
1384
|
+
draw();
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
function cleanup() {
|
|
1389
|
+
if (pollTimer)
|
|
1390
|
+
clearInterval(pollTimer);
|
|
1391
|
+
if (process.stdin.isTTY) {
|
|
1392
|
+
process.stdin.setRawMode(false);
|
|
1393
|
+
}
|
|
1394
|
+
process.stdout.write("\u001B[?1049l\u001B[?25h");
|
|
1395
|
+
console.clear();
|
|
1396
|
+
const finish = () => {
|
|
1397
|
+
process.exit(0);
|
|
1398
|
+
};
|
|
1399
|
+
if (connected && db) {
|
|
1400
|
+
db.close().then(finish);
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
finish();
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
async function handleConnect(node) {
|
|
1407
|
+
if (node.type !== "driver" || !node.ref)
|
|
1408
|
+
return;
|
|
1409
|
+
const selectedDriver = node.ref.driver;
|
|
1410
|
+
if (selectedDriver === "native" || selectedDriver === "cloud") {
|
|
1411
|
+
openForm(`Connect to ${selectedDriver}`, [
|
|
1412
|
+
...(selectedDriver === "cloud"
|
|
1413
|
+
? [
|
|
1414
|
+
{ id: "url", label: "Cloud URL", value: "http://localhost:3000" },
|
|
1415
|
+
{ id: "token", label: "Bearer Token (optional)", isSecret: true },
|
|
1416
|
+
]
|
|
1417
|
+
: [
|
|
1418
|
+
{
|
|
1419
|
+
id: "path",
|
|
1420
|
+
label: "Database Path",
|
|
1421
|
+
value: path.join(os.homedir(), "Downloads", "data.db"),
|
|
1422
|
+
},
|
|
1423
|
+
]),
|
|
1424
|
+
], async (vals) => {
|
|
1425
|
+
const resolvedPath = selectedDriver === "cloud" ? vals.url || "" : vals.path || "";
|
|
1426
|
+
// Allow the underlying SDK/SQLite driver to automatically create the file
|
|
1427
|
+
// if it does not exist, rather than throwing an error here.
|
|
1428
|
+
db = await ThingD.open({
|
|
1429
|
+
path: resolvedPath,
|
|
1430
|
+
url: selectedDriver === "cloud" ? resolvedPath : undefined,
|
|
1431
|
+
driver: selectedDriver,
|
|
1432
|
+
authToken: vals.token,
|
|
1433
|
+
});
|
|
1434
|
+
driver = selectedDriver;
|
|
1435
|
+
dbPath = resolvedPath;
|
|
1436
|
+
// Update global authToken safely
|
|
1437
|
+
if (typeof vals.token === "string") {
|
|
1438
|
+
authToken = vals.token;
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
authToken = "";
|
|
1442
|
+
}
|
|
1443
|
+
connected = true;
|
|
1444
|
+
startedAt = Date.now();
|
|
1445
|
+
cursorIndex = 0;
|
|
1446
|
+
scrollOffset = 0;
|
|
1447
|
+
loadedItemId = "";
|
|
1448
|
+
await fetchResources();
|
|
1449
|
+
draw();
|
|
1450
|
+
const tree = buildTree();
|
|
1451
|
+
const first = tree[cursorIndex];
|
|
1452
|
+
if (first)
|
|
1453
|
+
scheduleLoad(first);
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
else {
|
|
1457
|
+
// Memory — connect directly without suspending
|
|
1458
|
+
driver = selectedDriver;
|
|
1459
|
+
dbPath = ":memory:";
|
|
1460
|
+
viewerLines = [pc.dim("Connecting...")];
|
|
1461
|
+
draw();
|
|
1462
|
+
try {
|
|
1463
|
+
db = await ThingD.open({
|
|
1464
|
+
path: ":memory:",
|
|
1465
|
+
driver: "memory",
|
|
1466
|
+
});
|
|
1467
|
+
connected = true;
|
|
1468
|
+
startedAt = Date.now();
|
|
1469
|
+
cursorIndex = 0;
|
|
1470
|
+
scrollOffset = 0;
|
|
1471
|
+
loadedItemId = "";
|
|
1472
|
+
await fetchResources();
|
|
1473
|
+
draw();
|
|
1474
|
+
const tree = buildTree();
|
|
1475
|
+
const first = tree[cursorIndex];
|
|
1476
|
+
if (first)
|
|
1477
|
+
scheduleLoad(first);
|
|
1478
|
+
}
|
|
1479
|
+
catch (error) {
|
|
1480
|
+
viewerLines = [pc.red(`Failed to connect: ${error.message}`)];
|
|
1481
|
+
draw();
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
async function handleSwitch() {
|
|
1486
|
+
if (!connected)
|
|
1487
|
+
return;
|
|
1488
|
+
// Close current connection
|
|
1489
|
+
try {
|
|
1490
|
+
await db.close();
|
|
1491
|
+
}
|
|
1492
|
+
catch {
|
|
1493
|
+
// ignore close errors
|
|
1494
|
+
}
|
|
1495
|
+
// Reset state
|
|
1496
|
+
connected = false;
|
|
1497
|
+
driver = "";
|
|
1498
|
+
dbPath = "";
|
|
1499
|
+
collections = [];
|
|
1500
|
+
streams = [];
|
|
1501
|
+
queues = [];
|
|
1502
|
+
objectsByCollection = new Map();
|
|
1503
|
+
cursorIndex = 0;
|
|
1504
|
+
scrollOffset = 0;
|
|
1505
|
+
loadedItemId = "";
|
|
1506
|
+
viewerLines = ["Select an environment to connect."];
|
|
1507
|
+
draw();
|
|
1508
|
+
const tree = buildTree();
|
|
1509
|
+
const first = tree[cursorIndex];
|
|
1510
|
+
if (first)
|
|
1511
|
+
scheduleLoad(first);
|
|
1512
|
+
}
|
|
1513
|
+
// ── Entry Point ──────────────────────────────────────────────────────
|
|
1514
|
+
export async function runInteractiveCli() {
|
|
1515
|
+
// Go straight into the TUI — no pre-prompts
|
|
1516
|
+
console.clear();
|
|
1517
|
+
process.stdout.write("\u001B[?1049h\u001B[H\u001B[?25l");
|
|
1518
|
+
// Show the driver selection screen
|
|
1519
|
+
viewerLines = ["Select an environment to connect."];
|
|
1520
|
+
draw();
|
|
1521
|
+
const tree = buildTree();
|
|
1522
|
+
const first = tree[cursorIndex];
|
|
1523
|
+
if (first)
|
|
1524
|
+
scheduleLoad(first);
|
|
1525
|
+
setupKeypress();
|
|
1526
|
+
// Background polling loop for real-time updates
|
|
1527
|
+
pollTimer = setInterval(async () => {
|
|
1528
|
+
if (connected && !formState?.active) {
|
|
1529
|
+
const snapItemId = loadedItemId;
|
|
1530
|
+
await fetchResources();
|
|
1531
|
+
draw();
|
|
1532
|
+
const tree = buildTree();
|
|
1533
|
+
const n = tree[cursorIndex];
|
|
1534
|
+
// Silently reload content if the same node is still actively viewed
|
|
1535
|
+
if (n && snapItemId === n.id && n.type !== "category") {
|
|
1536
|
+
await loadContent(n).catch(() => { });
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}, 2000);
|
|
1540
|
+
}
|