@thingd/cli 0.77.0 → 0.77.2
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/dist/commands/cloud.d.ts +3 -0
- package/dist/commands/cloud.d.ts.map +1 -0
- package/dist/commands/cloud.js +669 -0
- package/dist/commands/mcp-connect.d.ts +3 -0
- package/dist/commands/mcp-connect.d.ts.map +1 -0
- package/dist/commands/mcp-connect.js +154 -0
- package/dist/commands/sync.d.ts +3 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +197 -0
- package/dist/dashboard/public/assets/favicon-CgGFvG_0.svg +4 -0
- package/dist/dashboard/public/assets/index-C6PkDB7y.css +1 -0
- package/dist/dashboard/public/assets/index-DrpfyClj.js +4 -0
- package/dist/dashboard/public/index.html +19 -0
- package/dist/dashboard/server.d.ts +6 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +684 -0
- package/dist/data-movement.d.ts +6 -0
- package/dist/data-movement.d.ts.map +1 -0
- package/dist/data-movement.js +475 -0
- package/dist/doctor.d.ts +3 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +108 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1446 -0
- package/dist/install.d.ts +3 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +229 -0
- package/dist/interactive.d.ts +2 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +3039 -0
- package/dist/lib/cloud-api.d.ts +143 -0
- package/dist/lib/cloud-api.d.ts.map +1 -0
- package/dist/lib/cloud-api.js +207 -0
- package/dist/lib/cloud-config.d.ts +33 -0
- package/dist/lib/cloud-config.d.ts.map +1 -0
- package/dist/lib/cloud-config.js +42 -0
- package/dist/lib/mcp-config-writer.d.ts +26 -0
- package/dist/lib/mcp-config-writer.d.ts.map +1 -0
- package/dist/lib/mcp-config-writer.js +86 -0
- package/dist/lib/sync-config.d.ts +22 -0
- package/dist/lib/sync-config.d.ts.map +1 -0
- package/dist/lib/sync-config.js +25 -0
- package/dist/logo.d.ts +3 -0
- package/dist/logo.d.ts.map +1 -0
- package/dist/logo.js +8 -0
- package/dist/mcp/cluster.d.ts +69 -0
- package/dist/mcp/cluster.d.ts.map +1 -0
- package/dist/mcp/cluster.js +304 -0
- package/dist/mcp/config.d.ts +14 -0
- package/dist/mcp/config.d.ts.map +1 -0
- package/dist/mcp/config.js +67 -0
- package/dist/mcp/http.d.ts +35 -0
- package/dist/mcp/http.d.ts.map +1 -0
- package/dist/mcp/http.js +957 -0
- package/dist/mcp/index.d.ts +5 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +3 -0
- package/dist/mcp-http.d.ts +3 -0
- package/dist/mcp-http.d.ts.map +1 -0
- package/dist/mcp-http.js +42 -0
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +28 -0
- package/dist/paths.d.ts +4 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +14 -0
- package/package.json +2 -2
|
@@ -0,0 +1,3039 @@
|
|
|
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 { ThingD, } from "@thingd/sdk";
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
import { deriveRestUrl, listInstances, listProjects } from "./lib/cloud-api.js";
|
|
10
|
+
import { readCloudConfig, removeCloudConfig, resolveCloudUrl, writeCloudConfig, } from "./lib/cloud-config.js";
|
|
11
|
+
import { logoText } from "./logo.js";
|
|
12
|
+
import { defaultThingdDbPath } from "./paths.js";
|
|
13
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
14
|
+
function highlightJson(val) {
|
|
15
|
+
const str = JSON.stringify(val, null, 2);
|
|
16
|
+
return str.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, (match) => {
|
|
17
|
+
if (/^"/.test(match)) {
|
|
18
|
+
return /:$/.test(match) ? pc.cyan(match) : pc.green(match);
|
|
19
|
+
}
|
|
20
|
+
if (/true|false/.test(match)) {
|
|
21
|
+
return pc.magenta(match);
|
|
22
|
+
}
|
|
23
|
+
if (/null/.test(match)) {
|
|
24
|
+
return pc.dim(match);
|
|
25
|
+
}
|
|
26
|
+
return pc.yellow(match);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** Strip ANSI escape codes to get the visible character count. */
|
|
30
|
+
function stripAnsi(s) {
|
|
31
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escapes require matching the ESC character
|
|
32
|
+
return s.replace(/\u001B\[[0-9;]*[a-zA-Z]/g, "");
|
|
33
|
+
}
|
|
34
|
+
/** Measure the visible width of a string accounting for wide characters (CJK, emoji). */
|
|
35
|
+
function visibleWidth(s) {
|
|
36
|
+
const clean = stripAnsi(s);
|
|
37
|
+
let w = 0;
|
|
38
|
+
for (const ch of clean) {
|
|
39
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
40
|
+
// Emoji (surrogate pairs / high codepoints) and CJK fullwidth ranges
|
|
41
|
+
if (cp > 0xffff ||
|
|
42
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
43
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
44
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
45
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
46
|
+
(cp >= 0xfe10 && cp <= 0xfe6f) ||
|
|
47
|
+
(cp >= 0xff01 && cp <= 0xff60) ||
|
|
48
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
49
|
+
(cp >= 0x20000 && cp <= 0x2fffd) ||
|
|
50
|
+
(cp >= 0x30000 && cp <= 0x3fffd) ||
|
|
51
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) ||
|
|
52
|
+
(cp >= 0x200d && cp <= 0x200d) ||
|
|
53
|
+
(cp >= 0xe0100 && cp <= 0xe01ef)) {
|
|
54
|
+
w += 2;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
w += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return w;
|
|
61
|
+
}
|
|
62
|
+
// ── State ────────────────────────────────────────────────────────────// Connection State
|
|
63
|
+
let db;
|
|
64
|
+
let driver = "";
|
|
65
|
+
let dbPath = "";
|
|
66
|
+
let connected = false;
|
|
67
|
+
let authToken = "";
|
|
68
|
+
let collections = [];
|
|
69
|
+
let streams = [];
|
|
70
|
+
let queues = [];
|
|
71
|
+
let objectsByCollection = new Map();
|
|
72
|
+
const collectionCounts = new Map();
|
|
73
|
+
const collectionOptions = new Map();
|
|
74
|
+
const expandedSet = new Set(["cat:collections", "cat:streams", "cat:queues"]);
|
|
75
|
+
let cursorIndex = 0;
|
|
76
|
+
let maintenanceCursor = 0;
|
|
77
|
+
let scrollOffset = 0;
|
|
78
|
+
let startedAt = 0; // ms since epoch when we connected
|
|
79
|
+
let totalObjects = 0;
|
|
80
|
+
let totalEventsCount = 0;
|
|
81
|
+
let totalActiveJobsCount = 0;
|
|
82
|
+
let totalDeadJobsCount = 0;
|
|
83
|
+
let totalLinksCount = 0;
|
|
84
|
+
let cloudError = null;
|
|
85
|
+
const eventsByStream = new Map();
|
|
86
|
+
const jobsByQueue = new Map();
|
|
87
|
+
let objectsHistory = [];
|
|
88
|
+
let eventsHistory = [];
|
|
89
|
+
let activeJobsHistory = [];
|
|
90
|
+
let deadJobsHistory = [];
|
|
91
|
+
let dbSizeHistory = [];
|
|
92
|
+
let objectWriteRateHistory = [];
|
|
93
|
+
let eventAppendRateHistory = [];
|
|
94
|
+
let viewerLines = ["Select an item to view details."];
|
|
95
|
+
let viewerScroll = 0;
|
|
96
|
+
let showHelp = false;
|
|
97
|
+
const loading = false;
|
|
98
|
+
const toasts = [];
|
|
99
|
+
function addToast(msg) {
|
|
100
|
+
toasts.push(msg);
|
|
101
|
+
if (toasts.length > 3) {
|
|
102
|
+
toasts.shift();
|
|
103
|
+
}
|
|
104
|
+
setTimeout(() => {
|
|
105
|
+
const idx = toasts.indexOf(msg);
|
|
106
|
+
if (idx !== -1) {
|
|
107
|
+
toasts.splice(idx, 1);
|
|
108
|
+
draw();
|
|
109
|
+
}
|
|
110
|
+
}, 3000);
|
|
111
|
+
}
|
|
112
|
+
let lastNeighborsRef = "";
|
|
113
|
+
let loadedItemId = "";
|
|
114
|
+
let loadTimer = null;
|
|
115
|
+
let pollTimer = null;
|
|
116
|
+
let polling = false;
|
|
117
|
+
let keypressHandler = null;
|
|
118
|
+
let formState = null;
|
|
119
|
+
function openForm(title, fields, onSubmit, keepViewer) {
|
|
120
|
+
formState = {
|
|
121
|
+
active: true,
|
|
122
|
+
title,
|
|
123
|
+
fields: fields.map((f) => ({
|
|
124
|
+
id: f.id,
|
|
125
|
+
label: f.label,
|
|
126
|
+
value: f.value || (f.options?.[0] ?? ""),
|
|
127
|
+
placeholder: f.placeholder,
|
|
128
|
+
isSecret: f.isSecret,
|
|
129
|
+
dirty: false,
|
|
130
|
+
options: f.options,
|
|
131
|
+
allowCustom: f.allowCustom,
|
|
132
|
+
})),
|
|
133
|
+
activeIndex: 0,
|
|
134
|
+
onCancel: () => {
|
|
135
|
+
formState = null;
|
|
136
|
+
viewerLines = [];
|
|
137
|
+
loadedItemId = ""; // Force reload
|
|
138
|
+
draw();
|
|
139
|
+
const n = buildTree()[cursorIndex];
|
|
140
|
+
if (n) {
|
|
141
|
+
scheduleLoad(n);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
onSubmit: async (vals) => {
|
|
145
|
+
if (!formState) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
formState.isSubmitting = true;
|
|
149
|
+
formState.error = undefined;
|
|
150
|
+
draw();
|
|
151
|
+
try {
|
|
152
|
+
await onSubmit(vals);
|
|
153
|
+
formState = null;
|
|
154
|
+
if (keepViewer) {
|
|
155
|
+
draw();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
viewerLines = [];
|
|
159
|
+
loadedItemId = ""; // Force reload
|
|
160
|
+
await fetchResources();
|
|
161
|
+
draw();
|
|
162
|
+
const n = buildTree()[cursorIndex];
|
|
163
|
+
if (n) {
|
|
164
|
+
scheduleLoad(n);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (formState) {
|
|
169
|
+
formState.error =
|
|
170
|
+
err instanceof Error ? err.message : String(err) || "Unknown error occurred";
|
|
171
|
+
formState.isSubmitting = false;
|
|
172
|
+
draw();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
viewerScroll = 0;
|
|
178
|
+
draw();
|
|
179
|
+
}
|
|
180
|
+
// ── Data Fetching ────────────────────────────────────────────────────
|
|
181
|
+
const SPARK_WIDTH = 30;
|
|
182
|
+
function drawSparkline(data, baselineMax = 0, width = SPARK_WIDTH) {
|
|
183
|
+
const dataChars = ["\u2581", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
184
|
+
const track = "\u2581"; // Lower 1/8 block as baseline
|
|
185
|
+
if (data.length === 0) {
|
|
186
|
+
return track.repeat(width);
|
|
187
|
+
}
|
|
188
|
+
const recent = data.slice(-width);
|
|
189
|
+
const padLen = width - recent.length;
|
|
190
|
+
const max = Math.max(baselineMax, ...recent);
|
|
191
|
+
// Left pad = no data yet
|
|
192
|
+
let result = track.repeat(padLen);
|
|
193
|
+
if (max === 0) {
|
|
194
|
+
result += track.repeat(recent.length);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
result += recent
|
|
198
|
+
.map((v) => {
|
|
199
|
+
if (v === 0) {
|
|
200
|
+
return track;
|
|
201
|
+
}
|
|
202
|
+
const ratio = v / max;
|
|
203
|
+
const idx = Math.max(0, Math.min(dataChars.length - 1, Math.floor(ratio * dataChars.length)));
|
|
204
|
+
return dataChars[idx] ?? dataChars[0] ?? "▁";
|
|
205
|
+
})
|
|
206
|
+
.join("");
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
function formatUptime(ms) {
|
|
210
|
+
const s = Math.floor(ms / 1000);
|
|
211
|
+
if (s < 60) {
|
|
212
|
+
return `${s}s`;
|
|
213
|
+
}
|
|
214
|
+
const m = Math.floor(s / 60);
|
|
215
|
+
if (m < 60) {
|
|
216
|
+
return `${m}m ${s % 60}s`;
|
|
217
|
+
}
|
|
218
|
+
const h = Math.floor(m / 60);
|
|
219
|
+
return `${h}h ${m % 60}m`;
|
|
220
|
+
}
|
|
221
|
+
function formatRelativeTime(iso) {
|
|
222
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
223
|
+
const s = Math.floor(ms / 1000);
|
|
224
|
+
if (s < 0) {
|
|
225
|
+
return "now";
|
|
226
|
+
}
|
|
227
|
+
if (s < 5) {
|
|
228
|
+
return "now";
|
|
229
|
+
}
|
|
230
|
+
if (s < 60) {
|
|
231
|
+
return `${s}s ago`;
|
|
232
|
+
}
|
|
233
|
+
const m = Math.floor(s / 60);
|
|
234
|
+
if (m < 60) {
|
|
235
|
+
return `${m}m ago`;
|
|
236
|
+
}
|
|
237
|
+
const h = Math.floor(m / 60);
|
|
238
|
+
if (h < 24) {
|
|
239
|
+
return `${h}h ago`;
|
|
240
|
+
}
|
|
241
|
+
const d = Math.floor(h / 24);
|
|
242
|
+
return `${d}d ago`;
|
|
243
|
+
}
|
|
244
|
+
function formatCount(n) {
|
|
245
|
+
if (n >= 1000) {
|
|
246
|
+
return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
247
|
+
}
|
|
248
|
+
return String(n);
|
|
249
|
+
}
|
|
250
|
+
async function fetchResourcesFallback() {
|
|
251
|
+
cloudError = null;
|
|
252
|
+
// Collections and streams — parallel fetch
|
|
253
|
+
let nativeCollections;
|
|
254
|
+
let nativeStreams;
|
|
255
|
+
try {
|
|
256
|
+
[nativeCollections, nativeStreams] = await Promise.all([
|
|
257
|
+
db.listCollections(),
|
|
258
|
+
db.listStreams(),
|
|
259
|
+
]);
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
263
|
+
cloudError = `Failed to load resources: ${msg}`;
|
|
264
|
+
nativeCollections = [];
|
|
265
|
+
nativeStreams = [];
|
|
266
|
+
if (viewerLines.length === 1 && viewerLines[0] === "Select an item to view details.") {
|
|
267
|
+
viewerLines = [pc.yellow(cloudError), pc.dim("Press 'r' to retry or 's' to switch driver.")];
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
collections = [...new Set(nativeCollections)].sort();
|
|
271
|
+
streams = [...new Set(nativeStreams)].sort();
|
|
272
|
+
// Counts, queues, links — all parallel, each with independent error handling
|
|
273
|
+
const [objCount, evtCount, activeCount, deadCount, listedQueues, linkCount] = await Promise.all([
|
|
274
|
+
db.countObjects().catch(() => 0),
|
|
275
|
+
db.countEvents().catch(() => 0),
|
|
276
|
+
db.countActiveJobs().catch(() => 0),
|
|
277
|
+
db.countDeadJobs().catch(() => 0),
|
|
278
|
+
db.listQueues().catch(() => {
|
|
279
|
+
cloudError = cloudError ?? "Failed to load queues";
|
|
280
|
+
return [];
|
|
281
|
+
}),
|
|
282
|
+
db.countLinks().catch(() => 0),
|
|
283
|
+
]);
|
|
284
|
+
totalObjects = objCount;
|
|
285
|
+
totalEventsCount = evtCount;
|
|
286
|
+
totalActiveJobsCount = activeCount;
|
|
287
|
+
totalDeadJobsCount = deadCount;
|
|
288
|
+
queues = [...new Set(listedQueues ?? [])].sort();
|
|
289
|
+
totalLinksCount = linkCount;
|
|
290
|
+
// Objects per collection — parallel
|
|
291
|
+
objectsByCollection.clear();
|
|
292
|
+
await Promise.all(collections.map(async (col) => {
|
|
293
|
+
try {
|
|
294
|
+
const opts = collectionOptions.get(col);
|
|
295
|
+
const listOpts = {};
|
|
296
|
+
if (opts?.sortBy) {
|
|
297
|
+
listOpts.sortBy = { field: opts.sortBy, direction: opts.sortDir ?? "asc" };
|
|
298
|
+
}
|
|
299
|
+
if (opts?.limit) {
|
|
300
|
+
listOpts.limit = opts.limit;
|
|
301
|
+
}
|
|
302
|
+
if (opts?.offset) {
|
|
303
|
+
listOpts.offset = opts.offset;
|
|
304
|
+
}
|
|
305
|
+
if (opts?.filter) {
|
|
306
|
+
listOpts.filter = opts.filter;
|
|
307
|
+
}
|
|
308
|
+
const list = await db.listObjects(col, Object.keys(listOpts).length > 0 ? listOpts : undefined);
|
|
309
|
+
objectsByCollection.set(col, list.map((o) => ({ id: o.id, createdAt: o.createdAt })));
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
objectsByCollection.set(col, []);
|
|
313
|
+
}
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
async function fetchResources() {
|
|
317
|
+
if (driver === "native" && dbPath) {
|
|
318
|
+
try {
|
|
319
|
+
// Override the tracked totals with the actual exact DB count!
|
|
320
|
+
const [objCount, evtCount, activeCount, deadCount, linkCount, nativeCollections, nativeStreams, nativeQueues,] = await Promise.all([
|
|
321
|
+
db.countObjects(),
|
|
322
|
+
db.countEvents(),
|
|
323
|
+
db.countActiveJobs(),
|
|
324
|
+
db.countDeadJobs(),
|
|
325
|
+
db.countLinks(),
|
|
326
|
+
db.listCollections(),
|
|
327
|
+
db.listStreams(),
|
|
328
|
+
db.listQueues?.() ?? Promise.resolve([]),
|
|
329
|
+
]);
|
|
330
|
+
totalObjects = Number.isNaN(objCount) || objCount === 0 ? totalObjects : objCount;
|
|
331
|
+
totalEventsCount = Number.isNaN(evtCount) || evtCount === 0 ? totalEventsCount : evtCount;
|
|
332
|
+
totalActiveJobsCount =
|
|
333
|
+
Number.isNaN(activeCount) || activeCount === 0 ? totalActiveJobsCount : activeCount;
|
|
334
|
+
totalDeadJobsCount =
|
|
335
|
+
Number.isNaN(deadCount) || deadCount === 0 ? totalDeadJobsCount : deadCount;
|
|
336
|
+
totalLinksCount = Number.isNaN(linkCount) ? totalLinksCount : linkCount;
|
|
337
|
+
collections = nativeCollections.length > 0 ? nativeCollections : [];
|
|
338
|
+
streams = nativeStreams.length > 0 ? nativeStreams : [];
|
|
339
|
+
queues = nativeQueues.length > 0 ? nativeQueues : [];
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
// Fallback if sqlite3 fails
|
|
343
|
+
await fetchResourcesFallback();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
await fetchResourcesFallback();
|
|
348
|
+
}
|
|
349
|
+
// Populate objectsByCollection from live data
|
|
350
|
+
objectsByCollection.clear();
|
|
351
|
+
for (const col of collections) {
|
|
352
|
+
try {
|
|
353
|
+
const opts = collectionOptions.get(col);
|
|
354
|
+
const listOpts = {};
|
|
355
|
+
if (opts?.sortBy) {
|
|
356
|
+
listOpts.sortBy = { field: opts.sortBy, direction: opts.sortDir ?? "asc" };
|
|
357
|
+
}
|
|
358
|
+
if (opts?.limit) {
|
|
359
|
+
listOpts.limit = opts.limit;
|
|
360
|
+
}
|
|
361
|
+
if (opts?.offset) {
|
|
362
|
+
listOpts.offset = opts.offset;
|
|
363
|
+
}
|
|
364
|
+
if (opts?.filter) {
|
|
365
|
+
listOpts.filter = opts.filter;
|
|
366
|
+
}
|
|
367
|
+
const list = await db.listObjects(col, Object.keys(listOpts).length > 0 ? listOpts : undefined);
|
|
368
|
+
objectsByCollection.set(col, list.map((o) => ({ id: o.id, createdAt: o.createdAt })));
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
objectsByCollection.set(col, []);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// Populate events per stream
|
|
375
|
+
eventsByStream.clear();
|
|
376
|
+
await Promise.all(streams.map(async (s) => {
|
|
377
|
+
try {
|
|
378
|
+
const evts = await db.events.list(s, { limit: 20 });
|
|
379
|
+
eventsByStream.set(s, evts);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
eventsByStream.set(s, []);
|
|
383
|
+
}
|
|
384
|
+
}));
|
|
385
|
+
// Populate jobs per queue
|
|
386
|
+
jobsByQueue.clear();
|
|
387
|
+
await Promise.all(queues.map(async (q) => {
|
|
388
|
+
try {
|
|
389
|
+
const active = await db.queue(q).list();
|
|
390
|
+
const dead = await db.queue(q).dead();
|
|
391
|
+
jobsByQueue.set(q, { active, dead });
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
jobsByQueue.set(q, { active: [], dead: [] });
|
|
395
|
+
}
|
|
396
|
+
}));
|
|
397
|
+
// Fetch per-collection counts from schema
|
|
398
|
+
collectionCounts.clear();
|
|
399
|
+
try {
|
|
400
|
+
const schemas = await db.schema();
|
|
401
|
+
for (const s of schemas) {
|
|
402
|
+
collectionCounts.set(s.name, s.objectCount);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// Fallback — count from objectsByCollection
|
|
407
|
+
for (const col of collections) {
|
|
408
|
+
collectionCounts.set(col, objectsByCollection.get(col)?.length ?? 0);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// Calculate Deltas for Operations Throughput Rates
|
|
412
|
+
const prevObjects = objectsHistory.length > 0
|
|
413
|
+
? (objectsHistory[objectsHistory.length - 1] ?? totalObjects)
|
|
414
|
+
: totalObjects;
|
|
415
|
+
const prevEvents = eventsHistory.length > 0
|
|
416
|
+
? (eventsHistory[eventsHistory.length - 1] ?? totalEventsCount)
|
|
417
|
+
: totalEventsCount;
|
|
418
|
+
const objectWriteRate = Math.max(0, Math.round((totalObjects - prevObjects) / 10));
|
|
419
|
+
const eventAppendRate = Math.max(0, Math.round((totalEventsCount - prevEvents) / 10));
|
|
420
|
+
// Push Histories with Initial Pre-population to prevent misleading growth wiggles
|
|
421
|
+
if (objectsHistory.length === 0) {
|
|
422
|
+
objectsHistory = new Array(60).fill(totalObjects);
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
objectsHistory.push(totalObjects);
|
|
426
|
+
if (objectsHistory.length > 60) {
|
|
427
|
+
objectsHistory.shift();
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (eventsHistory.length === 0) {
|
|
431
|
+
eventsHistory = new Array(60).fill(totalEventsCount);
|
|
432
|
+
}
|
|
433
|
+
else {
|
|
434
|
+
eventsHistory.push(totalEventsCount);
|
|
435
|
+
if (eventsHistory.length > 60) {
|
|
436
|
+
eventsHistory.shift();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (activeJobsHistory.length === 0) {
|
|
440
|
+
activeJobsHistory = new Array(60).fill(totalActiveJobsCount);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
activeJobsHistory.push(totalActiveJobsCount);
|
|
444
|
+
if (activeJobsHistory.length > 60) {
|
|
445
|
+
activeJobsHistory.shift();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (deadJobsHistory.length === 0) {
|
|
449
|
+
deadJobsHistory = new Array(60).fill(totalDeadJobsCount);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
deadJobsHistory.push(totalDeadJobsCount);
|
|
453
|
+
if (deadJobsHistory.length > 60) {
|
|
454
|
+
deadJobsHistory.shift();
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (objectWriteRateHistory.length === 0) {
|
|
458
|
+
objectWriteRateHistory = new Array(60).fill(objectWriteRate);
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
objectWriteRateHistory.push(objectWriteRate);
|
|
462
|
+
if (objectWriteRateHistory.length > 60) {
|
|
463
|
+
objectWriteRateHistory.shift();
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
if (eventAppendRateHistory.length === 0) {
|
|
467
|
+
eventAppendRateHistory = new Array(60).fill(eventAppendRate);
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
eventAppendRateHistory.push(eventAppendRate);
|
|
471
|
+
if (eventAppendRateHistory.length > 60) {
|
|
472
|
+
eventAppendRateHistory.shift();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// Database Size (only if native)
|
|
476
|
+
let sizeKb = 0;
|
|
477
|
+
if (driver === "native" && dbPath) {
|
|
478
|
+
try {
|
|
479
|
+
sizeKb = Math.round(fs.statSync(dbPath).size / 1024);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
sizeKb = 0;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
if (dbSizeHistory.length === 0) {
|
|
486
|
+
dbSizeHistory = new Array(60).fill(sizeKb);
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
dbSizeHistory.push(sizeKb);
|
|
490
|
+
if (dbSizeHistory.length > 60) {
|
|
491
|
+
dbSizeHistory.shift();
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
// ── Connection helpers ───────────────────────────────────────────────
|
|
496
|
+
async function connectToDriver(selectedDriver, resolvedPath, url, token, instanceSlug) {
|
|
497
|
+
db = await ThingD.open({
|
|
498
|
+
path: resolvedPath,
|
|
499
|
+
url,
|
|
500
|
+
driver: selectedDriver,
|
|
501
|
+
authToken: token,
|
|
502
|
+
instanceSlug,
|
|
503
|
+
});
|
|
504
|
+
driver = selectedDriver;
|
|
505
|
+
dbPath = resolvedPath;
|
|
506
|
+
authToken = typeof token === "string" ? token : "";
|
|
507
|
+
connected = true;
|
|
508
|
+
startedAt = Date.now();
|
|
509
|
+
cursorIndex = 0;
|
|
510
|
+
scrollOffset = 0;
|
|
511
|
+
loadedItemId = "";
|
|
512
|
+
await fetchResources();
|
|
513
|
+
draw();
|
|
514
|
+
const t = buildTree();
|
|
515
|
+
const first = t[cursorIndex];
|
|
516
|
+
if (first) {
|
|
517
|
+
scheduleLoad(first);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function buildTree() {
|
|
521
|
+
if (!connected) {
|
|
522
|
+
return [
|
|
523
|
+
{
|
|
524
|
+
id: "drv:memory",
|
|
525
|
+
type: "driver",
|
|
526
|
+
label: `${pc.cyan("●")} ${pc.bold("Memory")} ${pc.dim("ephemeral")}`,
|
|
527
|
+
depth: 0,
|
|
528
|
+
expandable: false,
|
|
529
|
+
ref: { driver: "memory" },
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
id: "drv:native",
|
|
533
|
+
type: "driver",
|
|
534
|
+
label: `${pc.cyan("●")} ${pc.bold("Native")} ${pc.dim("persistent directory")}`,
|
|
535
|
+
depth: 0,
|
|
536
|
+
expandable: false,
|
|
537
|
+
ref: { driver: "native" },
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
id: "drv:cloud",
|
|
541
|
+
type: "driver",
|
|
542
|
+
label: `${pc.cyan("●")} ${pc.bold("Cloud")} ${pc.dim("remote")}`,
|
|
543
|
+
depth: 0,
|
|
544
|
+
expandable: false,
|
|
545
|
+
ref: { driver: "cloud" },
|
|
546
|
+
},
|
|
547
|
+
];
|
|
548
|
+
}
|
|
549
|
+
const nodes = [];
|
|
550
|
+
// Collections
|
|
551
|
+
const colsOpen = expandedSet.has("cat:collections");
|
|
552
|
+
nodes.push({
|
|
553
|
+
id: "cat:collections",
|
|
554
|
+
type: "category",
|
|
555
|
+
label: `${colsOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Collections")}`,
|
|
556
|
+
depth: 0,
|
|
557
|
+
expandable: true,
|
|
558
|
+
});
|
|
559
|
+
if (colsOpen) {
|
|
560
|
+
if (collections.length === 0) {
|
|
561
|
+
nodes.push({
|
|
562
|
+
id: "empty:collections",
|
|
563
|
+
type: "status",
|
|
564
|
+
label: pc.dim("(empty)"),
|
|
565
|
+
depth: 1,
|
|
566
|
+
expandable: false,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
for (const col of collections) {
|
|
570
|
+
const colId = `col:${col}`;
|
|
571
|
+
const colOpen = expandedSet.has(colId);
|
|
572
|
+
const colCount = collectionCounts.get(col);
|
|
573
|
+
const colSuffix = colCount !== undefined ? pc.dim(` ${formatCount(colCount)}`) : "";
|
|
574
|
+
nodes.push({
|
|
575
|
+
id: colId,
|
|
576
|
+
parentId: "cat:collections",
|
|
577
|
+
type: "collection",
|
|
578
|
+
label: `${colOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.cyan(col)}${colSuffix}`,
|
|
579
|
+
depth: 1,
|
|
580
|
+
expandable: true,
|
|
581
|
+
ref: { name: col },
|
|
582
|
+
});
|
|
583
|
+
if (colOpen) {
|
|
584
|
+
const objs = objectsByCollection.get(col) ?? [];
|
|
585
|
+
if (objs.length === 0) {
|
|
586
|
+
nodes.push({
|
|
587
|
+
id: `empty:${col}`,
|
|
588
|
+
parentId: colId,
|
|
589
|
+
type: "status",
|
|
590
|
+
label: pc.dim("(no objects)"),
|
|
591
|
+
depth: 2,
|
|
592
|
+
expandable: false,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
for (const objData of objs) {
|
|
596
|
+
const objAge = objData.createdAt ? pc.dim(formatRelativeTime(objData.createdAt)) : "";
|
|
597
|
+
nodes.push({
|
|
598
|
+
id: `obj:${col}:${objData.id}`,
|
|
599
|
+
parentId: colId,
|
|
600
|
+
type: "object",
|
|
601
|
+
label: `${pc.cyan("○")} ${objData.id} ${objAge}`,
|
|
602
|
+
depth: 2,
|
|
603
|
+
expandable: false,
|
|
604
|
+
ref: { collection: col, id: objData.id },
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Streams
|
|
611
|
+
const strsOpen = expandedSet.has("cat:streams");
|
|
612
|
+
nodes.push({
|
|
613
|
+
id: "cat:streams",
|
|
614
|
+
type: "category",
|
|
615
|
+
label: `${strsOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Streams")}`,
|
|
616
|
+
depth: 0,
|
|
617
|
+
expandable: true,
|
|
618
|
+
});
|
|
619
|
+
if (strsOpen) {
|
|
620
|
+
if (streams.length === 0) {
|
|
621
|
+
nodes.push({
|
|
622
|
+
id: "empty:streams",
|
|
623
|
+
type: "status",
|
|
624
|
+
label: pc.dim("(empty)"),
|
|
625
|
+
depth: 1,
|
|
626
|
+
expandable: false,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
for (const stream of streams) {
|
|
630
|
+
const sOpen = expandedSet.has(`stream:${stream}`);
|
|
631
|
+
const evtCount = eventsByStream.get(stream)?.length ?? 0;
|
|
632
|
+
const evtSuffix = evtCount > 0 ? pc.dim(` ${formatCount(evtCount)}`) : "";
|
|
633
|
+
nodes.push({
|
|
634
|
+
id: `stream:${stream}`,
|
|
635
|
+
parentId: "cat:streams",
|
|
636
|
+
type: "stream",
|
|
637
|
+
label: `${sOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.green(stream)}${evtSuffix}`,
|
|
638
|
+
depth: 1,
|
|
639
|
+
expandable: true,
|
|
640
|
+
ref: { name: stream },
|
|
641
|
+
});
|
|
642
|
+
if (sOpen) {
|
|
643
|
+
const evts = eventsByStream.get(stream) ?? [];
|
|
644
|
+
if (evts.length === 0) {
|
|
645
|
+
nodes.push({
|
|
646
|
+
id: `empty:evt:${stream}`,
|
|
647
|
+
parentId: `stream:${stream}`,
|
|
648
|
+
type: "status",
|
|
649
|
+
label: pc.dim("(no events)"),
|
|
650
|
+
depth: 2,
|
|
651
|
+
expandable: false,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
for (const evt of evts) {
|
|
655
|
+
nodes.push({
|
|
656
|
+
id: `evt:${stream}:${evt.id}`,
|
|
657
|
+
parentId: `stream:${stream}`,
|
|
658
|
+
type: "event",
|
|
659
|
+
label: `${pc.dim("·")} ${pc.dim(evt.type || "unknown")} ${pc.dim(formatRelativeTime(evt.createdAt))}`,
|
|
660
|
+
depth: 2,
|
|
661
|
+
expandable: false,
|
|
662
|
+
ref: { stream: stream, eventId: evt.id, eventData: evt },
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Queues
|
|
669
|
+
const qOpen = expandedSet.has("cat:queues");
|
|
670
|
+
nodes.push({
|
|
671
|
+
id: "cat:queues",
|
|
672
|
+
type: "category",
|
|
673
|
+
label: `${qOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Queues")}`,
|
|
674
|
+
depth: 0,
|
|
675
|
+
expandable: true,
|
|
676
|
+
});
|
|
677
|
+
if (qOpen) {
|
|
678
|
+
if (queues.length === 0) {
|
|
679
|
+
nodes.push({
|
|
680
|
+
id: "empty:queues",
|
|
681
|
+
type: "status",
|
|
682
|
+
label: pc.dim("(empty)"),
|
|
683
|
+
depth: 1,
|
|
684
|
+
expandable: false,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
for (const q of queues) {
|
|
688
|
+
const qOpen = expandedSet.has(`queue:${q}`);
|
|
689
|
+
const qJobData = jobsByQueue.get(q);
|
|
690
|
+
const qActive = qJobData?.active.length ?? 0;
|
|
691
|
+
const qDead = qJobData?.dead.length ?? 0;
|
|
692
|
+
const qSuffix = qActive > 0 || qDead > 0
|
|
693
|
+
? ` ${qActive > 0 ? pc.cyan(String(qActive)) : pc.dim("0")}${pc.cyan(" ●")} ${qDead > 0 ? pc.red(String(qDead)) : pc.dim("0")}${pc.red(" ○")}`
|
|
694
|
+
: "";
|
|
695
|
+
nodes.push({
|
|
696
|
+
id: `queue:${q}`,
|
|
697
|
+
parentId: "cat:queues",
|
|
698
|
+
type: "queue",
|
|
699
|
+
label: `${qOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.magenta(q)}${qSuffix}`,
|
|
700
|
+
depth: 1,
|
|
701
|
+
expandable: true,
|
|
702
|
+
ref: { name: q },
|
|
703
|
+
});
|
|
704
|
+
if (qOpen) {
|
|
705
|
+
const jobData = jobsByQueue.get(q);
|
|
706
|
+
const activeJobs = jobData?.active ?? [];
|
|
707
|
+
const deadJobs = jobData?.dead ?? [];
|
|
708
|
+
// Active jobs subcategory
|
|
709
|
+
const activeOpen = expandedSet.has(`queue:${q}:active`);
|
|
710
|
+
nodes.push({
|
|
711
|
+
id: `queue:${q}:active`,
|
|
712
|
+
parentId: `queue:${q}`,
|
|
713
|
+
type: "category",
|
|
714
|
+
label: `${activeOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Active")} ${pc.dim(`(${activeJobs.length})`)}`,
|
|
715
|
+
depth: 2,
|
|
716
|
+
expandable: true,
|
|
717
|
+
});
|
|
718
|
+
if (activeOpen) {
|
|
719
|
+
if (activeJobs.length === 0) {
|
|
720
|
+
nodes.push({
|
|
721
|
+
id: `empty:active:${q}`,
|
|
722
|
+
parentId: `queue:${q}:active`,
|
|
723
|
+
type: "status",
|
|
724
|
+
label: pc.dim("(no active jobs)"),
|
|
725
|
+
depth: 3,
|
|
726
|
+
expandable: false,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
for (const job of activeJobs) {
|
|
730
|
+
const jobStatus = pc.cyan(job.status ?? "ready");
|
|
731
|
+
const jobAttempts = pc.dim(`${job.attempts}/${job.maxAttempts}`);
|
|
732
|
+
nodes.push({
|
|
733
|
+
id: `job:${q}:active:${job.id}`,
|
|
734
|
+
parentId: `queue:${q}:active`,
|
|
735
|
+
type: "job",
|
|
736
|
+
label: `${pc.cyan("●")} ${pc.dim(job.id.slice(0, 12))} ${jobAttempts} ${jobStatus}`,
|
|
737
|
+
depth: 3,
|
|
738
|
+
expandable: false,
|
|
739
|
+
ref: { queue: q, jobId: job.id, status: "active", jobData: job },
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// Dead jobs subcategory
|
|
744
|
+
const deadOpen = expandedSet.has(`queue:${q}:dead`);
|
|
745
|
+
nodes.push({
|
|
746
|
+
id: `queue:${q}:dead`,
|
|
747
|
+
parentId: `queue:${q}`,
|
|
748
|
+
type: "category",
|
|
749
|
+
label: `${deadOpen ? pc.cyan("▾") : pc.dim("▸")} ${pc.bold("Dead")} ${pc.dim(`(${deadJobs.length})`)}`,
|
|
750
|
+
depth: 2,
|
|
751
|
+
expandable: true,
|
|
752
|
+
});
|
|
753
|
+
if (deadOpen) {
|
|
754
|
+
if (deadJobs.length === 0) {
|
|
755
|
+
nodes.push({
|
|
756
|
+
id: `empty:dead:${q}`,
|
|
757
|
+
parentId: `queue:${q}:dead`,
|
|
758
|
+
type: "status",
|
|
759
|
+
label: pc.dim("(no dead jobs)"),
|
|
760
|
+
depth: 3,
|
|
761
|
+
expandable: false,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
for (const job of deadJobs) {
|
|
765
|
+
const jobAttempts = pc.dim(`${job.attempts}/${job.maxAttempts}`);
|
|
766
|
+
const lastErr = job.lastError ? pc.dim(job.lastError.slice(0, 20)) : "";
|
|
767
|
+
nodes.push({
|
|
768
|
+
id: `job:${q}:dead:${job.id}`,
|
|
769
|
+
parentId: `queue:${q}:dead`,
|
|
770
|
+
type: "job",
|
|
771
|
+
label: `${pc.red("○")} ${pc.dim(job.id.slice(0, 12))} ${jobAttempts}${lastErr ? ` ${pc.red("⚠")} ${lastErr}` : ""}`,
|
|
772
|
+
depth: 3,
|
|
773
|
+
expandable: false,
|
|
774
|
+
ref: { queue: q, jobId: job.id, status: "dead", jobData: job },
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
// Links
|
|
782
|
+
nodes.push({
|
|
783
|
+
id: "node:links",
|
|
784
|
+
type: "link",
|
|
785
|
+
label: `${pc.blue("◈")} ${pc.dim("Links")} ${pc.dim(`(${totalLinksCount})`)}`,
|
|
786
|
+
depth: 0,
|
|
787
|
+
expandable: false,
|
|
788
|
+
});
|
|
789
|
+
// Metrics
|
|
790
|
+
nodes.push({
|
|
791
|
+
id: "node:status",
|
|
792
|
+
type: "status",
|
|
793
|
+
label: `${pc.cyan("◉")} ${pc.dim("Metrics")}`,
|
|
794
|
+
depth: 0,
|
|
795
|
+
expandable: false,
|
|
796
|
+
});
|
|
797
|
+
// Maintenance
|
|
798
|
+
nodes.push({
|
|
799
|
+
id: "node:maintenance",
|
|
800
|
+
type: "maintenance",
|
|
801
|
+
label: `${pc.yellow("⬡")} ${pc.bold("Maintenance")}`,
|
|
802
|
+
depth: 0,
|
|
803
|
+
expandable: true,
|
|
804
|
+
children: [
|
|
805
|
+
{ id: "maintenance:integrity", label: "Run Health Check" },
|
|
806
|
+
{ id: "maintenance:checkpoint", label: "WAL Checkpoint" },
|
|
807
|
+
{ id: "maintenance:backup", label: "Create Backup" },
|
|
808
|
+
],
|
|
809
|
+
});
|
|
810
|
+
return nodes;
|
|
811
|
+
}
|
|
812
|
+
function scheduleLoad(node) {
|
|
813
|
+
if (!connected) {
|
|
814
|
+
// Show driver info in viewer
|
|
815
|
+
if (node.type === "driver" && node.ref) {
|
|
816
|
+
const d = node.ref.driver;
|
|
817
|
+
const info = [
|
|
818
|
+
` ${logoText()} ${pc.dim("— local data engine")}`,
|
|
819
|
+
"",
|
|
820
|
+
` ${pc.bold(d === "memory" ? "Memory Driver" : d === "native" ? "Native Driver" : "Cloud Driver")}`,
|
|
821
|
+
"",
|
|
822
|
+
d === "memory"
|
|
823
|
+
? ` Ephemeral in-memory database.\n All data is destroyed on exit.\n\n ${pc.dim("Best for: testing, prototyping")}\n\n ${pc.dim("Press")} ${pc.bold("Enter")} ${pc.dim("to connect.")}`
|
|
824
|
+
: d === "native"
|
|
825
|
+
? ` PersistentEngine database.\n Data is stored on disk.\n\n ${pc.dim("Best for: local development, single-node")}\n\n ${pc.dim("Press")} ${pc.bold("Enter")} ${pc.dim("to connect.")}`
|
|
826
|
+
: (() => {
|
|
827
|
+
const cfg = readCloudConfig();
|
|
828
|
+
const hasCfg = !!(cfg?.userToken ?? cfg?.token);
|
|
829
|
+
return hasCfg
|
|
830
|
+
? ` Connect to a remote thingd instance.\n Requires a URL and optional auth token.\n\n ${pc.dim("Best for: production, multi-node")}\n\n ${pc.dim("Press")} ${pc.bold("Enter")} ${pc.dim("to connect.")}`
|
|
831
|
+
: ` ${pc.yellow("Not logged in to thingd Cloud.")}\n\n Run ${pc.cyan("thingd cloud login")} to authenticate, or\n press ${pc.bold("Enter")} to connect with a URL and token manually.`;
|
|
832
|
+
})(),
|
|
833
|
+
].join("\n");
|
|
834
|
+
viewerLines = info.split("\n");
|
|
835
|
+
loadedItemId = node.id;
|
|
836
|
+
}
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (loadTimer) {
|
|
840
|
+
clearTimeout(loadTimer);
|
|
841
|
+
}
|
|
842
|
+
if (loadedItemId === node.id) {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
loadedItemId = node.id;
|
|
846
|
+
viewerLines = [pc.dim("Loading...")];
|
|
847
|
+
viewerScroll = 0;
|
|
848
|
+
draw();
|
|
849
|
+
loadTimer = setTimeout(async () => {
|
|
850
|
+
await loadContent(node);
|
|
851
|
+
}, 80);
|
|
852
|
+
}
|
|
853
|
+
async function loadContent(node) {
|
|
854
|
+
const snapId = node.id;
|
|
855
|
+
try {
|
|
856
|
+
let content = "";
|
|
857
|
+
if (node.type === "object" && node.ref) {
|
|
858
|
+
const ref = node.ref;
|
|
859
|
+
const data = await db.get(ref.collection, ref.id);
|
|
860
|
+
content = data ? highlightJson(data) : pc.yellow("Object not found.");
|
|
861
|
+
}
|
|
862
|
+
else if (node.type === "collection" && node.ref) {
|
|
863
|
+
const ref = node.ref;
|
|
864
|
+
const objs = objectsByCollection.get(ref.name) ?? [];
|
|
865
|
+
let res = `${pc.bold(ref.name)} ${pc.dim(`(${objs.length} objects)`)}\n\n`;
|
|
866
|
+
// Schema info
|
|
867
|
+
try {
|
|
868
|
+
const schemas = await db.schema(ref.name);
|
|
869
|
+
if (schemas.length > 0) {
|
|
870
|
+
const fields = schemas[0]?.fields ?? [];
|
|
871
|
+
if (fields.length > 0) {
|
|
872
|
+
res += `${pc.bold("Fields")}\n`;
|
|
873
|
+
for (const f of fields) {
|
|
874
|
+
const icon = f.nullable ? pc.dim("⊙") : pc.cyan("◎");
|
|
875
|
+
const sample = f.sampleValues.length > 0
|
|
876
|
+
? pc.dim(` e.g. ${String(f.sampleValues[0]).slice(0, 20)}`)
|
|
877
|
+
: "";
|
|
878
|
+
res += ` ${icon} ${f.name}: ${f.type}${sample}\n`;
|
|
879
|
+
}
|
|
880
|
+
res += "\n";
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
// Schema not available for this store
|
|
886
|
+
}
|
|
887
|
+
if (objs.length === 0) {
|
|
888
|
+
res += pc.dim("No objects in this collection.");
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
const lines = objs.map((o) => ` ${pc.cyan("○")} ${o.id}${o.createdAt ? ` ${pc.dim(formatRelativeTime(o.createdAt))}` : ""}`);
|
|
892
|
+
res += lines.join("\n");
|
|
893
|
+
}
|
|
894
|
+
content = res;
|
|
895
|
+
}
|
|
896
|
+
else if (node.type === "stream" && node.ref) {
|
|
897
|
+
const ref = node.ref;
|
|
898
|
+
const evts = eventsByStream.get(ref.name) ?? [];
|
|
899
|
+
let res = `${pc.bold(ref.name)} ${pc.dim(`(${evts.length} events shown)`)}\n\n`;
|
|
900
|
+
res += pc.dim("Expand to browse individual events, or press [c] to append.\n");
|
|
901
|
+
content = res;
|
|
902
|
+
}
|
|
903
|
+
else if (node.type === "event" && node.ref) {
|
|
904
|
+
const ref = node.ref;
|
|
905
|
+
const evt = ref.eventData;
|
|
906
|
+
let res = `${pc.bold(evt.type || "unknown")} ${pc.dim(evt.id)}\n`;
|
|
907
|
+
res += ` ${pc.dim("Stream:")} ${pc.green(ref.stream)}\n`;
|
|
908
|
+
res += ` ${pc.dim("Created:")} ${pc.dim(evt.createdAt || "—")}\n\n`;
|
|
909
|
+
const display = { ...evt };
|
|
910
|
+
for (const k of ["id", "stream", "sequence", "createdAt", "idempotencyKey"]) {
|
|
911
|
+
delete display[k];
|
|
912
|
+
}
|
|
913
|
+
if (Object.keys(display).length > 0) {
|
|
914
|
+
res += highlightJson(display);
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
res += pc.dim("No payload.");
|
|
918
|
+
}
|
|
919
|
+
content = res;
|
|
920
|
+
}
|
|
921
|
+
else if (node.type === "queue" && node.ref) {
|
|
922
|
+
const ref = node.ref;
|
|
923
|
+
const jobData = jobsByQueue.get(ref.name);
|
|
924
|
+
const active = jobData?.active ?? [];
|
|
925
|
+
const dead = jobData?.dead ?? [];
|
|
926
|
+
let res = `${pc.bold(ref.name)}\n\n`;
|
|
927
|
+
res += `${pc.cyan("Active")} ${pc.dim(`(${active.length})`)}\n`;
|
|
928
|
+
res += `${pc.red("Dead")} ${pc.dim(`(${dead.length})`)}\n\n`;
|
|
929
|
+
res += pc.dim("Expand Active or Dead to browse jobs, [c] to push new job.");
|
|
930
|
+
content = res;
|
|
931
|
+
}
|
|
932
|
+
else if (node.type === "job" && node.ref) {
|
|
933
|
+
const ref = node.ref;
|
|
934
|
+
const job = ref.jobData;
|
|
935
|
+
let res = `${pc.bold(job.id)} ${pc.yellow(job.status || ref.status)}\n`;
|
|
936
|
+
res += ` ${pc.dim("Queue:")} ${pc.magenta(ref.queue)}\n`;
|
|
937
|
+
res += ` ${pc.dim("Attempts:")} ${job.attempts}/${job.maxAttempts}\n`;
|
|
938
|
+
res += ` ${pc.dim("Created:")} ${pc.dim(job.createdAt || "—")}\n`;
|
|
939
|
+
if (job.lastError) {
|
|
940
|
+
res += ` ${pc.dim("Error:")} ${pc.red(job.lastError)}\n`;
|
|
941
|
+
}
|
|
942
|
+
res += "\n";
|
|
943
|
+
if (job.payload && Object.keys(job.payload).length > 0) {
|
|
944
|
+
res += highlightJson(job.payload);
|
|
945
|
+
}
|
|
946
|
+
else {
|
|
947
|
+
res += pc.dim("No payload.");
|
|
948
|
+
}
|
|
949
|
+
if (ref.status === "dead") {
|
|
950
|
+
res += `\n\n${pc.dim("[e] Retry (ack) [d] Nack (remove from dead letter)")}`;
|
|
951
|
+
}
|
|
952
|
+
content = res;
|
|
953
|
+
}
|
|
954
|
+
else if (node.type === "status") {
|
|
955
|
+
const W = process.stdout.columns || 80;
|
|
956
|
+
const sideW = Math.min(40, Math.max(20, Math.floor(W * 0.35)));
|
|
957
|
+
const viewW = Math.max(20, W - sideW - 3);
|
|
958
|
+
const uptime = startedAt ? formatUptime(Date.now() - startedAt) : "--";
|
|
959
|
+
// ── Header
|
|
960
|
+
const pathStr = pc.dim(dbPath || ":memory:");
|
|
961
|
+
const pathRaw = dbPath || ":memory:";
|
|
962
|
+
const titleStr = `${pc.bold("thingd")} ${pc.cyan("METRICS")}`;
|
|
963
|
+
const gap = Math.max(2, viewW - 2 - 8 - "METRICS".length - pathRaw.length);
|
|
964
|
+
content = ` ${titleStr}${" ".repeat(gap)}${pathStr}\n`;
|
|
965
|
+
content += ` ${pc.dim("uptime")} ${pc.dim(uptime)}\n\n`;
|
|
966
|
+
// ── Physical Store & Driver Logic
|
|
967
|
+
let sizeKb = 0;
|
|
968
|
+
if (driver === "native" && dbPath) {
|
|
969
|
+
try {
|
|
970
|
+
sizeKb = Math.round(fs.statSync(dbPath).size / 1024);
|
|
971
|
+
}
|
|
972
|
+
catch {
|
|
973
|
+
sizeKb = 0;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
const dbSizeStr = driver === "native" ? `${sizeKb} KB` : "--";
|
|
977
|
+
let driverName = "Unknown";
|
|
978
|
+
if (driver === "memory") {
|
|
979
|
+
driverName = "In-Memory";
|
|
980
|
+
}
|
|
981
|
+
else if (driver === "native") {
|
|
982
|
+
driverName = "persistent";
|
|
983
|
+
}
|
|
984
|
+
else if (driver === "cloud") {
|
|
985
|
+
driverName = "Cloud";
|
|
986
|
+
}
|
|
987
|
+
// ── Metrics Layout (opencode style: clean groups, no horizontal rules)
|
|
988
|
+
content += ` ${pc.bold("Capacity & Storage")}\n`;
|
|
989
|
+
content += ` ${pc.dim("Objects".padEnd(14))} ${pc.cyan(String(totalObjects).padEnd(6))} ${pc.dim("total")}\n`;
|
|
990
|
+
content += ` ${pc.dim("Events".padEnd(14))} ${pc.green(String(totalEventsCount).padEnd(6))} ${pc.dim("total")}\n`;
|
|
991
|
+
content += ` ${pc.dim("Links".padEnd(14))} ${pc.blue(String(totalLinksCount).padEnd(6))} ${pc.dim("total")}\n`;
|
|
992
|
+
content += ` ${pc.dim("Active Jobs".padEnd(14))} ${pc.yellow(String(totalActiveJobsCount).padEnd(6))} ${pc.dim("in flight")}\n`;
|
|
993
|
+
content += ` ${pc.dim("Dead Jobs".padEnd(14))} ${pc.red(String(totalDeadJobsCount).padEnd(6))} ${pc.dim("failed")}\n\n`;
|
|
994
|
+
// Collection breakdown bar chart
|
|
995
|
+
if (collections.length > 0) {
|
|
996
|
+
content += ` ${pc.bold("Collections")}\n`;
|
|
997
|
+
const maxCount = Math.max(1, ...collections.map((c) => (objectsByCollection.get(c) ?? []).length));
|
|
998
|
+
const barMax = Math.max(5, viewW - 30);
|
|
999
|
+
for (const col of collections) {
|
|
1000
|
+
const count = (objectsByCollection.get(col) ?? []).length;
|
|
1001
|
+
const barLen = Math.max(1, Math.round((count / maxCount) * barMax));
|
|
1002
|
+
const bar = barLen > 0 ? pc.cyan("█".repeat(barLen)) : "";
|
|
1003
|
+
content += ` ${pc.dim(col.slice(0, 12).padEnd(12))} ${bar} ${pc.dim(String(count))}\n`;
|
|
1004
|
+
}
|
|
1005
|
+
content += "\n";
|
|
1006
|
+
}
|
|
1007
|
+
// Capacity gauge bar
|
|
1008
|
+
const totalHistorical = totalObjects +
|
|
1009
|
+
totalEventsCount +
|
|
1010
|
+
totalLinksCount +
|
|
1011
|
+
totalActiveJobsCount +
|
|
1012
|
+
totalDeadJobsCount;
|
|
1013
|
+
if (totalHistorical > 0) {
|
|
1014
|
+
const capBarW = Math.max(5, viewW - 20);
|
|
1015
|
+
const objFrac = totalObjects / totalHistorical;
|
|
1016
|
+
const evtFrac = totalEventsCount / totalHistorical;
|
|
1017
|
+
const linkFrac = totalLinksCount / totalHistorical;
|
|
1018
|
+
const objChars = Math.round(objFrac * capBarW);
|
|
1019
|
+
const evtChars = Math.round(evtFrac * capBarW);
|
|
1020
|
+
const linkChars = Math.round(linkFrac * capBarW);
|
|
1021
|
+
const jobChars = capBarW - objChars - evtChars - linkChars;
|
|
1022
|
+
content += ` ${pc.bold("Distribution")}\n`;
|
|
1023
|
+
content += ` ${pc.dim(" ")}${pc.cyan("█".repeat(Math.max(0, objChars)))}${pc.green("█".repeat(Math.max(0, evtChars)))}${pc.blue("█".repeat(Math.max(0, linkChars)))}${pc.yellow("█".repeat(Math.max(0, jobChars)))}\n`;
|
|
1024
|
+
content += ` ${pc.cyan("■")} objs ${pc.green("■")} evts ${pc.blue("■")} links ${pc.yellow("■")} jobs\n\n`;
|
|
1025
|
+
}
|
|
1026
|
+
content += ` ${pc.bold("Connection")}\n`;
|
|
1027
|
+
content += ` ${pc.dim("Driver".padEnd(14))} ${driverName}\n`;
|
|
1028
|
+
content += ` ${pc.dim("Path".padEnd(14))} ${dbPath || ":memory:"}\n`;
|
|
1029
|
+
content += ` ${pc.dim("Size".padEnd(14))} ${dbSizeStr}\n`;
|
|
1030
|
+
if (driver === "native") {
|
|
1031
|
+
const sizeSpark = drawSparkline(dbSizeHistory, 5, Math.max(10, viewW - 55));
|
|
1032
|
+
content += ` ${pc.dim("Size History".padEnd(14))} ${pc.cyan(sizeSpark)}\n`;
|
|
1033
|
+
}
|
|
1034
|
+
content += `\n`;
|
|
1035
|
+
// ── Throughput & Activity Metrics
|
|
1036
|
+
const currentWrite = objectWriteRateHistory[objectWriteRateHistory.length - 1] ?? 0;
|
|
1037
|
+
const currentAppend = eventAppendRateHistory[eventAppendRateHistory.length - 1] ?? 0;
|
|
1038
|
+
// Adjust sparkline width to prevent terminal wrapping.
|
|
1039
|
+
const sparkW = Math.max(10, viewW - 55);
|
|
1040
|
+
const wLine = drawSparkline(objectWriteRateHistory, 5, sparkW);
|
|
1041
|
+
const apLine = drawSparkline(eventAppendRateHistory, 5, sparkW);
|
|
1042
|
+
content += ` ${pc.bold("Throughput & Activity")}\n`;
|
|
1043
|
+
content += ` ${pc.dim("Writes".padEnd(14))} ${pc.cyan(wLine)} ${pc.cyan(String(currentWrite).padEnd(4))} ${pc.dim(`w/s`)}\n`;
|
|
1044
|
+
content += ` ${pc.dim("Appends".padEnd(14))} ${pc.green(apLine)} ${pc.green(String(currentAppend).padEnd(4))} ${pc.dim(`e/s`)}\n\n`;
|
|
1045
|
+
content += ` ${pc.dim("Shortcuts:")} ${pc.bold("[c]")} Create ${pc.bold("[r]")} Refresh ${pc.bold("[/]")} Search\n`;
|
|
1046
|
+
if (cloudError) {
|
|
1047
|
+
content += `\n ${pc.yellow("⚠")} ${pc.dim(cloudError)}\n`;
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
else if (node.type === "link") {
|
|
1051
|
+
content = [
|
|
1052
|
+
` ${pc.bold("Links")} ${pc.dim(`(${totalLinksCount} total`)}${lastNeighborsRef ? pc.dim(`, last browsed: ${lastNeighborsRef}`) : ""})`,
|
|
1053
|
+
"",
|
|
1054
|
+
totalLinksCount === 0
|
|
1055
|
+
? ` ${pc.dim("No links yet.")}`
|
|
1056
|
+
: ` ${pc.dim("Select an object and press")} ${pc.bold("n")} ${pc.dim("to browse its neighbors.")}`,
|
|
1057
|
+
"",
|
|
1058
|
+
` ${pc.bold("Operations")}`,
|
|
1059
|
+
` ${pc.bold("[c]")} Create a new link`,
|
|
1060
|
+
` ${pc.bold("[d]")} Delete a link by ID`,
|
|
1061
|
+
].join("\n");
|
|
1062
|
+
}
|
|
1063
|
+
else if (node.type === "category") {
|
|
1064
|
+
content = pc.dim("Expand to browse items.");
|
|
1065
|
+
}
|
|
1066
|
+
else {
|
|
1067
|
+
content = "";
|
|
1068
|
+
}
|
|
1069
|
+
if (loadedItemId === snapId) {
|
|
1070
|
+
viewerLines = content.split("\n");
|
|
1071
|
+
draw();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
catch (err) {
|
|
1075
|
+
if (loadedItemId === snapId) {
|
|
1076
|
+
viewerLines = [pc.red(`Error: ${err instanceof Error ? err.message : String(err)}`)];
|
|
1077
|
+
draw();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// ── Rendering ────────────────────────────────────────────────────────
|
|
1082
|
+
function draw() {
|
|
1083
|
+
const W = process.stdout.columns || 80;
|
|
1084
|
+
const H = process.stdout.rows || 24;
|
|
1085
|
+
const sideW = Math.min(40, Math.max(20, Math.floor(W * 0.35)));
|
|
1086
|
+
const viewW = Math.max(1, W - sideW - 3); // 3 = " | "
|
|
1087
|
+
const bodyH = Math.max(1, H - 4); // header(1) + separator(1) + separator(1) + footer(1)
|
|
1088
|
+
const tree = buildTree();
|
|
1089
|
+
// Clamp cursor
|
|
1090
|
+
if (tree.length === 0) {
|
|
1091
|
+
cursorIndex = 0;
|
|
1092
|
+
}
|
|
1093
|
+
else if (cursorIndex >= tree.length) {
|
|
1094
|
+
cursorIndex = tree.length - 1;
|
|
1095
|
+
}
|
|
1096
|
+
if (cursorIndex < 0) {
|
|
1097
|
+
cursorIndex = 0;
|
|
1098
|
+
}
|
|
1099
|
+
// Scroll sidebar
|
|
1100
|
+
if (cursorIndex >= scrollOffset + bodyH) {
|
|
1101
|
+
scrollOffset = cursorIndex - bodyH + 1;
|
|
1102
|
+
}
|
|
1103
|
+
else if (cursorIndex < scrollOffset) {
|
|
1104
|
+
scrollOffset = cursorIndex;
|
|
1105
|
+
}
|
|
1106
|
+
scrollOffset = Math.max(0, Math.min(scrollOffset, Math.max(0, tree.length - bodyH)));
|
|
1107
|
+
let buf = "\u001B[H"; // Move to top-left
|
|
1108
|
+
// Header — opencode style: clean, no inverse bar
|
|
1109
|
+
if (showHelp) {
|
|
1110
|
+
buf += ` ${pc.cyan("◈")} ${pc.bold("thingd")} ${pc.dim("Help (press any key)")}\n`;
|
|
1111
|
+
}
|
|
1112
|
+
else if (!connected) {
|
|
1113
|
+
buf += ` ${pc.cyan("◈")} ${pc.bold("thingd")} ${pc.dim("Select Environment")} ${pc.dim("[?] help")}\n`;
|
|
1114
|
+
}
|
|
1115
|
+
else if (formState?.active) {
|
|
1116
|
+
buf += ` ${pc.cyan("◈")} ${pc.bold("thingd")} ${pc.cyan(driver.toUpperCase())} ${pc.dim("Input Mode")} ${pc.dim("[?] help")}\n`;
|
|
1117
|
+
}
|
|
1118
|
+
else {
|
|
1119
|
+
const breadcrumb = computeBreadcrumbs();
|
|
1120
|
+
const base = ` ${pc.cyan("◈")} ${pc.bold("thingd")} ${pc.cyan(driver.toUpperCase())}`;
|
|
1121
|
+
const dash = breadcrumb ? ` ${pc.dim("▸")} ` : "";
|
|
1122
|
+
const label = `${base}${dash}${breadcrumb} ${pc.dim("[?] help")}`;
|
|
1123
|
+
buf += `${padToWidth(label, W)}\n`;
|
|
1124
|
+
}
|
|
1125
|
+
buf += `${pc.dim("─".repeat(W))}\n`;
|
|
1126
|
+
// Build Form Lines if active
|
|
1127
|
+
if (formState?.active) {
|
|
1128
|
+
viewerLines = [` ${pc.cyan(formState.title)}`, ""];
|
|
1129
|
+
for (let i = 0; i < formState.fields.length; i++) {
|
|
1130
|
+
const f = formState.fields[i];
|
|
1131
|
+
if (!f) {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
const isSel = i === formState.activeIndex;
|
|
1135
|
+
let displayLabel = f.label;
|
|
1136
|
+
if (f.options && f.allowCustom && f.value && !f.options.includes(f.value)) {
|
|
1137
|
+
displayLabel += pc.green(" (New)");
|
|
1138
|
+
}
|
|
1139
|
+
viewerLines.push(`${isSel ? pc.cyan("▸") : " "} ${pc.bold(displayLabel)}`);
|
|
1140
|
+
let displayVal = f.value;
|
|
1141
|
+
if (f.isSecret) {
|
|
1142
|
+
displayVal = "*".repeat(displayVal.length);
|
|
1143
|
+
}
|
|
1144
|
+
if (displayVal === "" && f.placeholder) {
|
|
1145
|
+
displayVal = pc.dim(f.placeholder);
|
|
1146
|
+
}
|
|
1147
|
+
if (isSel && !formState.isSubmitting) {
|
|
1148
|
+
if (f.options && !f.allowCustom) {
|
|
1149
|
+
viewerLines.push(` ${pc.cyan("◀ ")}${pc.inverse(displayVal || " ")}${pc.cyan(" ▶")}`);
|
|
1150
|
+
}
|
|
1151
|
+
else if (f.options && f.allowCustom) {
|
|
1152
|
+
const inOptions = f.options.includes(f.value);
|
|
1153
|
+
if (inOptions) {
|
|
1154
|
+
viewerLines.push(` ${pc.cyan("◀ ")}${displayVal}${pc.inverse(" ")}${pc.cyan(" ▶")}`);
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
viewerLines.push(` ${displayVal}${pc.inverse(" ")}`);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
viewerLines.push(` ${displayVal}${pc.inverse(" ")}`); // cursor block
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
viewerLines.push(` ${displayVal}`);
|
|
1166
|
+
}
|
|
1167
|
+
viewerLines.push("");
|
|
1168
|
+
}
|
|
1169
|
+
if (formState.error) {
|
|
1170
|
+
viewerLines.push(` ${pc.red(formState.error)}`);
|
|
1171
|
+
}
|
|
1172
|
+
if (formState.isSubmitting) {
|
|
1173
|
+
viewerLines.push(` ${pc.cyan("Processing...")}`);
|
|
1174
|
+
}
|
|
1175
|
+
viewerLines.push("");
|
|
1176
|
+
viewerLines.push(pc.dim(" [Enter] Next/Submit [Esc] Cancel"));
|
|
1177
|
+
}
|
|
1178
|
+
// Body rows
|
|
1179
|
+
for (let r = 0; r < bodyH; r++) {
|
|
1180
|
+
// Sidebar
|
|
1181
|
+
const treeIdx = r + scrollOffset;
|
|
1182
|
+
const node = tree[treeIdx];
|
|
1183
|
+
const isActive = treeIdx === cursorIndex;
|
|
1184
|
+
let left;
|
|
1185
|
+
if (!node) {
|
|
1186
|
+
left = " ".repeat(sideW);
|
|
1187
|
+
}
|
|
1188
|
+
else {
|
|
1189
|
+
const indent = " ".repeat(node.depth);
|
|
1190
|
+
const raw = indent + node.label;
|
|
1191
|
+
left = fitToWidth(raw, sideW, isActive);
|
|
1192
|
+
}
|
|
1193
|
+
// Viewer
|
|
1194
|
+
const vLine = viewerLines[r + viewerScroll] ?? "";
|
|
1195
|
+
const right = fitToWidth(vLine, viewW, false);
|
|
1196
|
+
buf += `${left + pc.dim(" │ ") + right}\n`;
|
|
1197
|
+
}
|
|
1198
|
+
// Footer — opencode style: subtle separator + help
|
|
1199
|
+
let help;
|
|
1200
|
+
if (formState?.active) {
|
|
1201
|
+
const hasOptions = formState.fields[formState.activeIndex]?.options;
|
|
1202
|
+
help = ` ${pc.dim("↑↓")} focus ${hasOptions ? `${pc.dim("←→")} select ` : ""}${pc.dim("enter")} submit ${pc.dim("ctrl+e")} editor ${pc.dim("esc")} cancel `;
|
|
1203
|
+
}
|
|
1204
|
+
else if (!connected) {
|
|
1205
|
+
help = ` ${pc.dim("↑↓")} nav ${pc.dim("enter")} connect ${pc.dim("q")} quit `;
|
|
1206
|
+
}
|
|
1207
|
+
else {
|
|
1208
|
+
help = ` ${pc.dim("↑↓")} nav ${pc.dim("←→")} toggle ${pc.dim("c")} create ${pc.dim("e")} edit ${pc.dim("d")} delete ${pc.dim("/")} search ${pc.dim("n")} neighbors ${pc.dim("N")} nlq ${pc.dim("a")} agg ${pc.dim("t")} ts ${pc.dim("o")} opts ${pc.dim("b")} batch ${pc.dim("x")} export ${pc.dim("p")} import ${pc.dim("w")} web ${pc.dim("i")} info ${pc.dim("r")} refresh ${pc.dim("s")} switch ${pc.dim("l")} logout ${pc.dim("q")} quit `;
|
|
1209
|
+
}
|
|
1210
|
+
buf += `${pc.dim("─".repeat(W))}\n`;
|
|
1211
|
+
buf += padToWidth(help, W);
|
|
1212
|
+
if (loading) {
|
|
1213
|
+
buf += `\n${pc.cyan("◇")} ${pc.dim("Working...")}`;
|
|
1214
|
+
}
|
|
1215
|
+
if (toasts.length > 0) {
|
|
1216
|
+
for (const t of toasts) {
|
|
1217
|
+
buf += `\n ${pc.green("●")} ${pc.dim(t)}`;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
// Clear to end
|
|
1221
|
+
buf += "\u001B[J";
|
|
1222
|
+
if (showHelp) {
|
|
1223
|
+
buf += computeHelpOverlay(W, H);
|
|
1224
|
+
}
|
|
1225
|
+
process.stdout.write(buf);
|
|
1226
|
+
}
|
|
1227
|
+
function computeBreadcrumbs() {
|
|
1228
|
+
if (!connected) {
|
|
1229
|
+
return "";
|
|
1230
|
+
}
|
|
1231
|
+
const tree = buildTree();
|
|
1232
|
+
const node = tree[cursorIndex];
|
|
1233
|
+
if (!node) {
|
|
1234
|
+
return "";
|
|
1235
|
+
}
|
|
1236
|
+
const labels = [];
|
|
1237
|
+
const climb = (id) => {
|
|
1238
|
+
if (!id) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
const n = tree.find((t) => t.id === id);
|
|
1242
|
+
if (!n) {
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
if (n.parentId) {
|
|
1246
|
+
climb(n.parentId);
|
|
1247
|
+
}
|
|
1248
|
+
const clean = n.label
|
|
1249
|
+
.replace(/[▾▸●○·◇◈◉⬡]/g, "")
|
|
1250
|
+
.trim()
|
|
1251
|
+
.replace(/^\S+\s+/, "")
|
|
1252
|
+
.trim();
|
|
1253
|
+
if (clean && !clean.startsWith("(")) {
|
|
1254
|
+
labels.push(clean);
|
|
1255
|
+
}
|
|
1256
|
+
};
|
|
1257
|
+
climb(node.id);
|
|
1258
|
+
return labels.length > 0 ? pc.dim(labels.join(" > ")) : "";
|
|
1259
|
+
}
|
|
1260
|
+
function computeHelpOverlay(W, H) {
|
|
1261
|
+
const lines = [
|
|
1262
|
+
`${pc.bold(" thingd TUI Help")}`,
|
|
1263
|
+
"",
|
|
1264
|
+
` ${pc.bold("Navigation")}`,
|
|
1265
|
+
` ${pc.dim("↑↓/j/k")} Move cursor`,
|
|
1266
|
+
` ${pc.dim("←→/h/l")} Expand/collapse tree`,
|
|
1267
|
+
` ${pc.dim("Enter")} Toggle expand`,
|
|
1268
|
+
"",
|
|
1269
|
+
` ${pc.bold("Operations")}`,
|
|
1270
|
+
` ${pc.dim("c")} Create resource`,
|
|
1271
|
+
` ${pc.dim("e")} Edit object/job`,
|
|
1272
|
+
` ${pc.dim("d")} Delete object/link/job`,
|
|
1273
|
+
` ${pc.dim("r")} Refresh data`,
|
|
1274
|
+
` ${pc.dim("i")} Connection info`,
|
|
1275
|
+
"",
|
|
1276
|
+
` ${pc.bold("Data Views")}`,
|
|
1277
|
+
` ${pc.dim("n")} Object neighbors (links)`,
|
|
1278
|
+
` ${pc.dim("a")} Aggregate (count/sum/avg/min/max)`,
|
|
1279
|
+
` ${pc.dim("t")} Time-series query`,
|
|
1280
|
+
` ${pc.dim("N")} Natural language query`,
|
|
1281
|
+
` ${pc.dim("/f")} Search objects`,
|
|
1282
|
+
` ${pc.dim("o")} Object listing options`,
|
|
1283
|
+
` ${pc.dim("b")} Batch put/delete`,
|
|
1284
|
+
"",
|
|
1285
|
+
` ${pc.bold("System")}`,
|
|
1286
|
+
` ${pc.dim("s")} Switch driver`,
|
|
1287
|
+
` ${pc.dim("m")} Maintenance`,
|
|
1288
|
+
` ${pc.dim("l")} Logout`,
|
|
1289
|
+
` ${pc.dim("q")} Quit`,
|
|
1290
|
+
` ${pc.dim("?")} Toggle this help`,
|
|
1291
|
+
"",
|
|
1292
|
+
` ${pc.dim("Press any key to close help.")}`,
|
|
1293
|
+
];
|
|
1294
|
+
const helpW = 44;
|
|
1295
|
+
const helpH = lines.length + 2;
|
|
1296
|
+
const col = Math.max(0, Math.floor((W - helpW) / 2));
|
|
1297
|
+
const row = Math.max(0, Math.floor((H - helpH) / 2));
|
|
1298
|
+
let out = "";
|
|
1299
|
+
for (let r = 0; r < H; r++) {
|
|
1300
|
+
if (r >= row && r < row + helpH) {
|
|
1301
|
+
const lineIdx = r - row;
|
|
1302
|
+
if (lineIdx === 0 || lineIdx === helpH - 1) {
|
|
1303
|
+
out += `${" ".repeat(col) + pc.dim(`┌${"─".repeat(helpW - 2)}┐`)}\n`;
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
const text = lines[lineIdx - 1] ?? "";
|
|
1307
|
+
const padded = text + " ".repeat(Math.max(0, helpW - visibleWidth(text) - 2));
|
|
1308
|
+
out += `${" ".repeat(col) + pc.dim("│")} ${padded} ${pc.dim("│")}\n`;
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
else {
|
|
1312
|
+
out += "\n";
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
return out;
|
|
1316
|
+
}
|
|
1317
|
+
/** Pad/truncate `text` to exactly `width` visible characters. */
|
|
1318
|
+
function fitToWidth(text, width, highlight) {
|
|
1319
|
+
const vw = visibleWidth(text);
|
|
1320
|
+
let result;
|
|
1321
|
+
if (vw > width) {
|
|
1322
|
+
// Truncate (crude but safe: just truncate the clean text approach)
|
|
1323
|
+
result = truncateToWidth(text, width - 1) + pc.dim("…");
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
result = text + " ".repeat(Math.max(0, width - vw));
|
|
1327
|
+
}
|
|
1328
|
+
return highlight ? pc.inverse(result) : result;
|
|
1329
|
+
}
|
|
1330
|
+
/** Truncate a string (potentially with ANSI codes) to a target visible width. */
|
|
1331
|
+
function truncateToWidth(text, targetW) {
|
|
1332
|
+
let w = 0;
|
|
1333
|
+
let i = 0;
|
|
1334
|
+
const chars = [...text];
|
|
1335
|
+
let result = "";
|
|
1336
|
+
while (i < chars.length && w < targetW) {
|
|
1337
|
+
const ch = chars[i];
|
|
1338
|
+
if (ch === undefined) {
|
|
1339
|
+
break;
|
|
1340
|
+
}
|
|
1341
|
+
if (ch === "\u001B") {
|
|
1342
|
+
// Consume ANSI sequence
|
|
1343
|
+
let seq = ch;
|
|
1344
|
+
i++;
|
|
1345
|
+
while (i < chars.length) {
|
|
1346
|
+
const next = chars[i];
|
|
1347
|
+
if (next === undefined || /[a-zA-Z]/.test(next)) {
|
|
1348
|
+
break;
|
|
1349
|
+
}
|
|
1350
|
+
seq += next;
|
|
1351
|
+
i++;
|
|
1352
|
+
}
|
|
1353
|
+
if (i < chars.length && chars[i] !== undefined) {
|
|
1354
|
+
seq += chars[i];
|
|
1355
|
+
i++;
|
|
1356
|
+
}
|
|
1357
|
+
result += seq;
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
const cp = ch.codePointAt(0);
|
|
1361
|
+
if (cp === undefined) {
|
|
1362
|
+
break;
|
|
1363
|
+
}
|
|
1364
|
+
const cw = cp > 0xffff ? 2 : 1;
|
|
1365
|
+
if (w + cw > targetW) {
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1368
|
+
result += ch;
|
|
1369
|
+
w += cw;
|
|
1370
|
+
i++;
|
|
1371
|
+
}
|
|
1372
|
+
return result;
|
|
1373
|
+
}
|
|
1374
|
+
/** Simple pad with visible width awareness. */
|
|
1375
|
+
function padToWidth(text, width) {
|
|
1376
|
+
const vw = visibleWidth(text);
|
|
1377
|
+
if (vw >= width) {
|
|
1378
|
+
return text;
|
|
1379
|
+
}
|
|
1380
|
+
return text + " ".repeat(width - vw);
|
|
1381
|
+
}
|
|
1382
|
+
// ── Utils ────────────────────────────────────────────────────────────
|
|
1383
|
+
async function launchEditor(f) {
|
|
1384
|
+
if (process.stdin.isTTY) {
|
|
1385
|
+
process.stdin.setRawMode(false);
|
|
1386
|
+
}
|
|
1387
|
+
if (keypressHandler) {
|
|
1388
|
+
process.stdin.removeListener("keypress", keypressHandler);
|
|
1389
|
+
}
|
|
1390
|
+
console.clear();
|
|
1391
|
+
const tmpFile = path.join(os.tmpdir(), `thingd-edit-${Date.now()}.json`);
|
|
1392
|
+
let initialContent = "";
|
|
1393
|
+
if (f.value && f.value !== "") {
|
|
1394
|
+
try {
|
|
1395
|
+
initialContent = JSON.stringify(JSON.parse(f.value), null, 2);
|
|
1396
|
+
}
|
|
1397
|
+
catch {
|
|
1398
|
+
initialContent = f.value;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
else {
|
|
1402
|
+
initialContent = "{\n \n}\n";
|
|
1403
|
+
}
|
|
1404
|
+
fs.writeFileSync(tmpFile, initialContent);
|
|
1405
|
+
const editor = process.env.EDITOR || "vim";
|
|
1406
|
+
await new Promise((resolve) => {
|
|
1407
|
+
const child = spawn(editor, [tmpFile], { stdio: "inherit" });
|
|
1408
|
+
child.on("exit", () => resolve());
|
|
1409
|
+
child.on("error", (err) => {
|
|
1410
|
+
console.error("Failed to start editor:", err);
|
|
1411
|
+
setTimeout(() => resolve(), 2000);
|
|
1412
|
+
});
|
|
1413
|
+
});
|
|
1414
|
+
try {
|
|
1415
|
+
const newContent = fs.readFileSync(tmpFile, "utf-8");
|
|
1416
|
+
f.value = newContent.trim();
|
|
1417
|
+
}
|
|
1418
|
+
catch (_e) { }
|
|
1419
|
+
if (process.stdin.isTTY) {
|
|
1420
|
+
process.stdin.setRawMode(true);
|
|
1421
|
+
}
|
|
1422
|
+
if (keypressHandler) {
|
|
1423
|
+
process.stdin.on("keypress", keypressHandler);
|
|
1424
|
+
}
|
|
1425
|
+
draw();
|
|
1426
|
+
}
|
|
1427
|
+
function parsePayload(str) {
|
|
1428
|
+
str = str.trim();
|
|
1429
|
+
if (!str) {
|
|
1430
|
+
return {};
|
|
1431
|
+
}
|
|
1432
|
+
if (str.startsWith("{") || str.startsWith("[")) {
|
|
1433
|
+
return JSON.parse(str);
|
|
1434
|
+
}
|
|
1435
|
+
const obj = {};
|
|
1436
|
+
const parts = str.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
1437
|
+
for (const part of parts) {
|
|
1438
|
+
const eqIdx = part.indexOf("=");
|
|
1439
|
+
if (eqIdx === -1) {
|
|
1440
|
+
obj[part] = true;
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
const k = part.substring(0, eqIdx);
|
|
1444
|
+
let v = part.substring(eqIdx + 1);
|
|
1445
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
1446
|
+
v = v.substring(1, v.length - 1);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
if (v === "true") {
|
|
1450
|
+
v = true;
|
|
1451
|
+
}
|
|
1452
|
+
else if (v === "false") {
|
|
1453
|
+
v = false;
|
|
1454
|
+
}
|
|
1455
|
+
else if (!Number.isNaN(Number(v))) {
|
|
1456
|
+
v = Number(v);
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
obj[k] = v;
|
|
1460
|
+
}
|
|
1461
|
+
return obj;
|
|
1462
|
+
}
|
|
1463
|
+
// ── Mutation Handlers ────────────────────────────────────────────────
|
|
1464
|
+
async function handleCreate(selected) {
|
|
1465
|
+
let defaultCol = "";
|
|
1466
|
+
let defaultStream = "";
|
|
1467
|
+
let defaultQueue = "";
|
|
1468
|
+
if (selected) {
|
|
1469
|
+
const ref = selected.ref;
|
|
1470
|
+
if (selected.type === "collection") {
|
|
1471
|
+
defaultCol = ref?.name ?? "";
|
|
1472
|
+
}
|
|
1473
|
+
else if (selected.type === "object") {
|
|
1474
|
+
defaultCol = ref?.collection ?? "";
|
|
1475
|
+
}
|
|
1476
|
+
else if (selected.type === "stream") {
|
|
1477
|
+
defaultStream = ref?.name ?? "";
|
|
1478
|
+
}
|
|
1479
|
+
else if (selected.type === "queue") {
|
|
1480
|
+
defaultQueue = ref?.name ?? "";
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
openForm("Create Resource", [
|
|
1484
|
+
{
|
|
1485
|
+
id: "kind",
|
|
1486
|
+
label: "Kind (object, event, queue, link)",
|
|
1487
|
+
value: defaultStream
|
|
1488
|
+
? "event"
|
|
1489
|
+
: defaultQueue
|
|
1490
|
+
? "queue"
|
|
1491
|
+
: selected?.type === "link"
|
|
1492
|
+
? "link"
|
|
1493
|
+
: "object",
|
|
1494
|
+
options: ["object", "event", "queue", "link"],
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
id: "target",
|
|
1498
|
+
label: "Target (Collection, Stream, Queue, or From Reference)",
|
|
1499
|
+
value: defaultCol || defaultStream || defaultQueue,
|
|
1500
|
+
options: Array.from(new Set([...collections, ...streams, ...queues])).sort(),
|
|
1501
|
+
allowCustom: true,
|
|
1502
|
+
},
|
|
1503
|
+
{
|
|
1504
|
+
id: "objId",
|
|
1505
|
+
label: "Object / To Reference ID (auto if blank for objects)",
|
|
1506
|
+
placeholder: "Leave blank to auto-generate",
|
|
1507
|
+
},
|
|
1508
|
+
{
|
|
1509
|
+
id: "payload",
|
|
1510
|
+
label: "Data, Link Type, or JSON Fields",
|
|
1511
|
+
placeholder: 'e.g. name="John" age=30 or {"linkType":"follows","weight":1}',
|
|
1512
|
+
},
|
|
1513
|
+
], async (vals) => {
|
|
1514
|
+
const kind = (vals.kind || "").toLowerCase();
|
|
1515
|
+
const target = (vals.target || "").trim();
|
|
1516
|
+
if (!target) {
|
|
1517
|
+
throw new Error("Target is required.");
|
|
1518
|
+
}
|
|
1519
|
+
if (kind === "object") {
|
|
1520
|
+
let id = vals.objId?.trim();
|
|
1521
|
+
if (!id) {
|
|
1522
|
+
try {
|
|
1523
|
+
id = crypto.randomUUID();
|
|
1524
|
+
}
|
|
1525
|
+
catch (_e) {
|
|
1526
|
+
id = `obj_${Date.now().toString(36)}${Math.random().toString(36).substring(2)}`;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
const data = parsePayload(vals.payload || "");
|
|
1530
|
+
await db.put(target, { id, ...data });
|
|
1531
|
+
expandedSet.add("cat:collections");
|
|
1532
|
+
expandedSet.add(`col:${target}`);
|
|
1533
|
+
addToast(`Created object ${id} in ${target}`);
|
|
1534
|
+
}
|
|
1535
|
+
else if (kind === "event") {
|
|
1536
|
+
if (!vals.payload?.trim()) {
|
|
1537
|
+
throw new Error("Event Type is required (in Data field for events).");
|
|
1538
|
+
}
|
|
1539
|
+
await db.events.append(target, { type: vals.payload.trim() });
|
|
1540
|
+
expandedSet.add("cat:streams");
|
|
1541
|
+
addToast(`Appended event to ${target}`);
|
|
1542
|
+
}
|
|
1543
|
+
else if (kind === "queue") {
|
|
1544
|
+
if (!vals.payload?.trim()) {
|
|
1545
|
+
throw new Error("Payload is required.");
|
|
1546
|
+
}
|
|
1547
|
+
const data = parsePayload(vals.payload);
|
|
1548
|
+
await db.queue(target).push(data);
|
|
1549
|
+
expandedSet.add("cat:queues");
|
|
1550
|
+
addToast(`Pushed job to queue ${target}`);
|
|
1551
|
+
}
|
|
1552
|
+
else if (kind === "link") {
|
|
1553
|
+
const toRef = (vals.objId || "").trim();
|
|
1554
|
+
if (!toRef) {
|
|
1555
|
+
throw new Error("To Reference is required (use Object ID field).");
|
|
1556
|
+
}
|
|
1557
|
+
let linkType = "related";
|
|
1558
|
+
let weight;
|
|
1559
|
+
let metadataJson;
|
|
1560
|
+
try {
|
|
1561
|
+
const parsed = JSON.parse(vals.payload || "{}");
|
|
1562
|
+
linkType = parsed.linkType || parsed.link_type || "related";
|
|
1563
|
+
if (parsed.weight !== undefined) {
|
|
1564
|
+
weight = Number(parsed.weight);
|
|
1565
|
+
}
|
|
1566
|
+
if (parsed.metadata || parsed.metadataJson) {
|
|
1567
|
+
metadataJson =
|
|
1568
|
+
typeof parsed.metadata === "string"
|
|
1569
|
+
? parsed.metadata
|
|
1570
|
+
: JSON.stringify(parsed.metadata);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
linkType = (vals.payload || "").trim() || "related";
|
|
1575
|
+
}
|
|
1576
|
+
await db.links.create(target, linkType, toRef, weight, metadataJson);
|
|
1577
|
+
addToast(`Created link: ${target} ${linkType} ${toRef}`);
|
|
1578
|
+
}
|
|
1579
|
+
else {
|
|
1580
|
+
throw new Error("Kind must be 'object', 'event', 'queue', or 'link'.");
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
async function handleEdit(selected) {
|
|
1585
|
+
if (!selected) {
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
if (selected.type === "object" && selected.ref) {
|
|
1589
|
+
const ref = selected.ref;
|
|
1590
|
+
const current = await db.get(ref.collection, ref.id);
|
|
1591
|
+
const clean = current ? { ...current } : {};
|
|
1592
|
+
for (const k of ["id", "collection", "createdAt", "updatedAt", "version"]) {
|
|
1593
|
+
delete clean[k];
|
|
1594
|
+
}
|
|
1595
|
+
openForm(`Edit Object: ${ref.id}`, [{ id: "payload", label: "Data (JSON or key=value)", value: JSON.stringify(clean) }], async (vals) => {
|
|
1596
|
+
const data = parsePayload(vals.payload || "");
|
|
1597
|
+
await db.put(ref.collection, { id: ref.id, ...data });
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
else if (selected.type === "queue" && selected.ref) {
|
|
1601
|
+
const ref = selected.ref;
|
|
1602
|
+
const queue = db.queue(ref.name);
|
|
1603
|
+
openForm(`Manage Queue: ${ref.name}`, [
|
|
1604
|
+
{ id: "action", label: "Action (claim, push)", value: "claim", options: ["claim", "push"] },
|
|
1605
|
+
{
|
|
1606
|
+
id: "payload",
|
|
1607
|
+
label: "Job Data (JSON or key=value, only for push)",
|
|
1608
|
+
placeholder: 'task="email"',
|
|
1609
|
+
},
|
|
1610
|
+
], async (vals) => {
|
|
1611
|
+
const action = vals.action || "";
|
|
1612
|
+
if (action === "claim") {
|
|
1613
|
+
const job = await queue.claim();
|
|
1614
|
+
if (job) {
|
|
1615
|
+
throw new Error(`Claimed job: ${job.id}`);
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
throw new Error("No ready jobs.");
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
else if (action === "push") {
|
|
1622
|
+
const data = parsePayload(vals.payload || "");
|
|
1623
|
+
await queue.push(data);
|
|
1624
|
+
}
|
|
1625
|
+
else {
|
|
1626
|
+
throw new Error("Action must be 'claim' or 'push'.");
|
|
1627
|
+
}
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
else if (selected.type === "job" && selected.ref) {
|
|
1631
|
+
const ref = selected.ref;
|
|
1632
|
+
if (ref.status === "dead") {
|
|
1633
|
+
openForm(`Retry Dead Job: ${ref.jobId.slice(0, 12)}`, [
|
|
1634
|
+
{
|
|
1635
|
+
id: "action",
|
|
1636
|
+
label: "Action",
|
|
1637
|
+
value: "ack",
|
|
1638
|
+
options: ["ack", "nack"],
|
|
1639
|
+
},
|
|
1640
|
+
{
|
|
1641
|
+
id: "error",
|
|
1642
|
+
label: "Error message (for nack)",
|
|
1643
|
+
placeholder: "Optional error",
|
|
1644
|
+
},
|
|
1645
|
+
], async (vals) => {
|
|
1646
|
+
const action = vals.action || "";
|
|
1647
|
+
if (action === "ack") {
|
|
1648
|
+
await db.queue(ref.queue).ack(ref.jobId);
|
|
1649
|
+
}
|
|
1650
|
+
else if (action === "nack") {
|
|
1651
|
+
await db.queue(ref.queue).nack(ref.jobId, { error: vals.error || "Rejected" });
|
|
1652
|
+
}
|
|
1653
|
+
else {
|
|
1654
|
+
throw new Error("Action must be 'ack' or 'nack'.");
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
else {
|
|
1659
|
+
// Active job — nack to fail it back to ready
|
|
1660
|
+
openForm(`Nack Job: ${ref.jobId.slice(0, 12)}`, [{ id: "error", label: "Error message", placeholder: "Optional" }], async (vals) => {
|
|
1661
|
+
await db.queue(ref.queue).nack(ref.jobId, { error: vals.error || "Nacked" });
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
openForm("Edit Not Supported", [{ id: "msg", label: "Error", value: "Editing is only available for Objects and Queues." }], async () => { });
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
async function handleDelete(selected) {
|
|
1670
|
+
if (!selected) {
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
if (selected.type === "object" && selected.ref) {
|
|
1674
|
+
const ref = selected.ref;
|
|
1675
|
+
openForm(`Delete Object: ${ref.id}`, [{ id: "confirm", label: 'Type "yes" to confirm deletion', placeholder: "yes" }], async (vals) => {
|
|
1676
|
+
if ((vals.confirm || "").toLowerCase() !== "yes") {
|
|
1677
|
+
throw new Error("Canceled");
|
|
1678
|
+
}
|
|
1679
|
+
const result = await db.delete(ref.collection, ref.id);
|
|
1680
|
+
if (result && !result.deleted) {
|
|
1681
|
+
throw new Error(`Object '${ref.id}' not found in collection '${ref.collection}'`);
|
|
1682
|
+
}
|
|
1683
|
+
addToast(`Deleted object ${ref.id}`);
|
|
1684
|
+
});
|
|
1685
|
+
}
|
|
1686
|
+
else if (selected.type === "queue" && selected.ref) {
|
|
1687
|
+
const ref = selected.ref;
|
|
1688
|
+
openForm(`Resolve Queue Job`, [
|
|
1689
|
+
{ id: "jobId", label: "Leased Job ID", placeholder: "job-id" },
|
|
1690
|
+
{ id: "action", label: "Action (ack, nack)", value: "ack" },
|
|
1691
|
+
], async (vals) => {
|
|
1692
|
+
const jobId = (vals.jobId || "").trim();
|
|
1693
|
+
const action = vals.action || "";
|
|
1694
|
+
if (!jobId) {
|
|
1695
|
+
throw new Error("Job ID required.");
|
|
1696
|
+
}
|
|
1697
|
+
if (action === "ack") {
|
|
1698
|
+
await db.queue(ref.name).ack(jobId);
|
|
1699
|
+
}
|
|
1700
|
+
else if (action === "nack") {
|
|
1701
|
+
await db.queue(ref.name).nack(jobId, { error: "Rejected via CLI" });
|
|
1702
|
+
}
|
|
1703
|
+
else {
|
|
1704
|
+
throw new Error("Action must be 'ack' or 'nack'.");
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
else if (selected.type === "link" || loadedItemId === "neighbors_result") {
|
|
1709
|
+
openForm("Delete Link", [
|
|
1710
|
+
{
|
|
1711
|
+
id: "linkId",
|
|
1712
|
+
label: "Link ID",
|
|
1713
|
+
placeholder: "Paste the link ID from the neighbors view",
|
|
1714
|
+
},
|
|
1715
|
+
{ id: "confirm", label: 'Type "yes" to confirm deletion', placeholder: "yes" },
|
|
1716
|
+
], async (vals) => {
|
|
1717
|
+
const linkId = (vals.linkId || "").trim();
|
|
1718
|
+
if (!linkId) {
|
|
1719
|
+
throw new Error("Link ID is required.");
|
|
1720
|
+
}
|
|
1721
|
+
if ((vals.confirm || "").toLowerCase() !== "yes") {
|
|
1722
|
+
throw new Error("Canceled");
|
|
1723
|
+
}
|
|
1724
|
+
const ok = await db.links.delete(linkId);
|
|
1725
|
+
if (!ok) {
|
|
1726
|
+
throw new Error(`Link '${linkId}' not found.`);
|
|
1727
|
+
}
|
|
1728
|
+
addToast(`Deleted link ${linkId}`);
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
else if (selected.type === "job" && selected.ref) {
|
|
1732
|
+
const ref = selected.ref;
|
|
1733
|
+
if (ref.status === "dead") {
|
|
1734
|
+
openForm(`Remove Dead Job: ${ref.jobId.slice(0, 12)}`, [
|
|
1735
|
+
{
|
|
1736
|
+
id: "action",
|
|
1737
|
+
label: "Action",
|
|
1738
|
+
value: "nack",
|
|
1739
|
+
options: ["ack", "nack"],
|
|
1740
|
+
},
|
|
1741
|
+
{ id: "confirm", label: 'Type "yes" to confirm', placeholder: "yes" },
|
|
1742
|
+
], async (vals) => {
|
|
1743
|
+
if ((vals.confirm || "").toLowerCase() !== "yes") {
|
|
1744
|
+
throw new Error("Canceled");
|
|
1745
|
+
}
|
|
1746
|
+
if (vals.action === "ack") {
|
|
1747
|
+
await db.queue(ref.queue).ack(ref.jobId);
|
|
1748
|
+
}
|
|
1749
|
+
else {
|
|
1750
|
+
await db.queue(ref.queue).nack(ref.jobId, { error: "Removed from dead letter" });
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
openForm("Delete Not For Active Jobs", [{ id: "msg", label: "Info", value: "Use [e] to nack an active job back to ready state." }], async () => { });
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
else {
|
|
1759
|
+
openForm("Delete Not Supported", [
|
|
1760
|
+
{
|
|
1761
|
+
id: "msg",
|
|
1762
|
+
label: "Error",
|
|
1763
|
+
value: "Deletion is only available for Objects, Links, and Queues.",
|
|
1764
|
+
},
|
|
1765
|
+
], async () => { });
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
async function handleSearch() {
|
|
1769
|
+
openForm("Global Search", [
|
|
1770
|
+
{ id: "query", label: "Search Query", placeholder: "text to search" },
|
|
1771
|
+
{ id: "limit", label: "Limit (optional)", placeholder: "100" },
|
|
1772
|
+
], async (vals) => {
|
|
1773
|
+
const query = (vals.query || "").trim();
|
|
1774
|
+
if (!query) {
|
|
1775
|
+
throw new Error("Search query required.");
|
|
1776
|
+
}
|
|
1777
|
+
const limitStr = vals.limit || "";
|
|
1778
|
+
const options = {};
|
|
1779
|
+
if (limitStr) {
|
|
1780
|
+
const limit = parseInt(limitStr, 10);
|
|
1781
|
+
if (!Number.isNaN(limit)) {
|
|
1782
|
+
options.limit = limit;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
const results = await db.search(query, options);
|
|
1786
|
+
// Display results in the viewer
|
|
1787
|
+
viewerLines = [
|
|
1788
|
+
` ${pc.bold("Search Results:")} ${pc.cyan(query)}`,
|
|
1789
|
+
"",
|
|
1790
|
+
...(results.length === 0 ? [" No results found."] : []),
|
|
1791
|
+
...results.map((r) => {
|
|
1792
|
+
const res = r;
|
|
1793
|
+
const id = pc.green(res.id);
|
|
1794
|
+
const col = pc.cyan(res.kind === "object" ? (res.collection ?? "") : (res.stream ?? ""));
|
|
1795
|
+
const textStr = res.value?.text ? pc.dim(res.value.text.substring(0, 100)) : "";
|
|
1796
|
+
return ` ${col} / ${id} ${textStr}`;
|
|
1797
|
+
}),
|
|
1798
|
+
];
|
|
1799
|
+
loadedItemId = "search_results";
|
|
1800
|
+
}, true);
|
|
1801
|
+
}
|
|
1802
|
+
async function handleInfo() {
|
|
1803
|
+
const lines = [
|
|
1804
|
+
` ${pc.bold("Connection Status")}`,
|
|
1805
|
+
"",
|
|
1806
|
+
` ${pc.dim("Driver")} ${pc.cyan(driver)}`,
|
|
1807
|
+
` ${pc.dim("Path")} ${pc.cyan(dbPath)}`,
|
|
1808
|
+
];
|
|
1809
|
+
if (driver === "cloud") {
|
|
1810
|
+
try {
|
|
1811
|
+
const baseUrl = dbPath.startsWith("thingd://")
|
|
1812
|
+
? `http://${dbPath.slice("thingd://".length)}`
|
|
1813
|
+
: dbPath;
|
|
1814
|
+
const apiRoot = baseUrl.replace(/\/+$/, "");
|
|
1815
|
+
const fetchJson = async (p) => {
|
|
1816
|
+
const u = `${apiRoot}${p}`;
|
|
1817
|
+
const headers = {};
|
|
1818
|
+
if (authToken) {
|
|
1819
|
+
headers.Authorization = `Bearer ${authToken}`;
|
|
1820
|
+
}
|
|
1821
|
+
const res = await fetch(u, { headers });
|
|
1822
|
+
const json = await res.json();
|
|
1823
|
+
if (!res.ok) {
|
|
1824
|
+
const detail = json?.error?.detail ?? json?.error?.message ?? `HTTP ${res.status}`;
|
|
1825
|
+
throw new Error(`${res.status}: ${detail}`);
|
|
1826
|
+
}
|
|
1827
|
+
return json;
|
|
1828
|
+
};
|
|
1829
|
+
// Health check
|
|
1830
|
+
let healthError = null;
|
|
1831
|
+
let health = null;
|
|
1832
|
+
try {
|
|
1833
|
+
health = await fetchJson("/v1/health");
|
|
1834
|
+
}
|
|
1835
|
+
catch (err) {
|
|
1836
|
+
healthError = err instanceof Error ? err.message : String(err);
|
|
1837
|
+
}
|
|
1838
|
+
// Collections check
|
|
1839
|
+
let collectionsResult = null;
|
|
1840
|
+
if (!healthError) {
|
|
1841
|
+
try {
|
|
1842
|
+
const colResp = await fetchJson("/v1/collections");
|
|
1843
|
+
collectionsResult = colResp?.data ?? null;
|
|
1844
|
+
}
|
|
1845
|
+
catch {
|
|
1846
|
+
// collections endpoint may not be available on all setups
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
lines.push("");
|
|
1850
|
+
lines.push(` ${pc.bold("Cloud REST API")}`);
|
|
1851
|
+
if (healthError) {
|
|
1852
|
+
lines.push(` ${pc.red("Health check failed:")} ${healthError}`);
|
|
1853
|
+
lines.push(` ${pc.dim("API URL:")} ${apiRoot}/v1/...`);
|
|
1854
|
+
lines.push(` ${pc.dim("Auth:")} ${authToken ? pc.green("Bearer token set") : pc.red("No token")}`);
|
|
1855
|
+
}
|
|
1856
|
+
else {
|
|
1857
|
+
const h = health;
|
|
1858
|
+
const status = h?.data?.status ?? h?.status ?? "ok";
|
|
1859
|
+
lines.push(` ${pc.dim("Status:")} ${pc.green(String(status))}`);
|
|
1860
|
+
lines.push(` ${pc.dim("Collections:")} ${collectionsResult ? pc.cyan(String(collectionsResult.length)) : pc.yellow("unknown")}`);
|
|
1861
|
+
if (collectionsResult && collectionsResult.length > 0) {
|
|
1862
|
+
lines.push(` ${pc.dim("Names:")} ${collectionsResult.join(", ")}`);
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
if (cloudError) {
|
|
1866
|
+
lines.push("");
|
|
1867
|
+
lines.push(` ${pc.yellow("⚠ Recent error:")} ${cloudError}`);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
catch (err) {
|
|
1871
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
1872
|
+
lines.push("", ` ${pc.red("Cloud Query Failed:")} ${errMsg}`);
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
viewerLines = lines;
|
|
1876
|
+
loadedItemId = "info_status";
|
|
1877
|
+
}
|
|
1878
|
+
async function handleNeighbors(selected) {
|
|
1879
|
+
if (selected?.type !== "object" || !selected.ref) {
|
|
1880
|
+
viewerLines = [
|
|
1881
|
+
` ${pc.yellow("Neighbors")}`,
|
|
1882
|
+
"",
|
|
1883
|
+
` ${pc.dim("Select an object first, then press")} ${pc.bold("n")} ${pc.dim("to browse its links.")}`,
|
|
1884
|
+
];
|
|
1885
|
+
loadedItemId = "neighbors_info";
|
|
1886
|
+
draw();
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
const ref = selected.ref;
|
|
1890
|
+
const fromRef = `${ref.collection}/${ref.id}`;
|
|
1891
|
+
openForm(`Neighbors of ${fromRef}`, [
|
|
1892
|
+
{
|
|
1893
|
+
id: "direction",
|
|
1894
|
+
label: "Direction",
|
|
1895
|
+
value: "Both",
|
|
1896
|
+
options: ["Both", "Outgoing", "Incoming"],
|
|
1897
|
+
},
|
|
1898
|
+
{
|
|
1899
|
+
id: "linkType",
|
|
1900
|
+
label: "Link Type (optional, leave blank for all)",
|
|
1901
|
+
placeholder: "e.g. follows, owns",
|
|
1902
|
+
},
|
|
1903
|
+
{
|
|
1904
|
+
id: "limit",
|
|
1905
|
+
label: "Max Results (optional)",
|
|
1906
|
+
placeholder: "50",
|
|
1907
|
+
},
|
|
1908
|
+
], async (vals) => {
|
|
1909
|
+
const direction = (vals.direction || "Both");
|
|
1910
|
+
const linkType = (vals.linkType || "").trim() || undefined;
|
|
1911
|
+
const limitStr = vals.limit || "";
|
|
1912
|
+
const limit = limitStr ? parseInt(limitStr, 10) || undefined : undefined;
|
|
1913
|
+
const links = await db.links.neighbors(fromRef, direction, { linkType, limit });
|
|
1914
|
+
lastNeighborsRef = fromRef;
|
|
1915
|
+
viewerLines = [
|
|
1916
|
+
` ${pc.bold("Neighbors of")} ${pc.cyan(fromRef)}`,
|
|
1917
|
+
` ${pc.dim(`(${links.length} link${links.length !== 1 ? "s" : ""}${direction !== "Both" ? `, ${direction.toLowerCase()}` : ""}${linkType ? `, type: ${linkType}` : ""})`)}`,
|
|
1918
|
+
"",
|
|
1919
|
+
...(links.length === 0 ? [` ${pc.dim("No links found.")}`] : []),
|
|
1920
|
+
...links.flatMap((link) => [
|
|
1921
|
+
` ${pc.blue("◈")} ${pc.dim(link.id)}`,
|
|
1922
|
+
` ${link.fromRef} ${pc.cyan(link.linkType)} ${link.toRef}`,
|
|
1923
|
+
link.weight !== undefined ? ` ${pc.dim(`weight: ${link.weight}`)}` : "",
|
|
1924
|
+
link.metadataJson && link.metadataJson !== "{}"
|
|
1925
|
+
? ` ${pc.dim(`metadata: ${link.metadataJson}`)}`
|
|
1926
|
+
: "",
|
|
1927
|
+
"",
|
|
1928
|
+
]),
|
|
1929
|
+
pc.dim("[d] Delete a link by ID [c] Create a new link"),
|
|
1930
|
+
];
|
|
1931
|
+
loadedItemId = "neighbors_result";
|
|
1932
|
+
draw();
|
|
1933
|
+
});
|
|
1934
|
+
}
|
|
1935
|
+
async function handleAggregate(selected) {
|
|
1936
|
+
const defaultCol = selected?.type === "collection"
|
|
1937
|
+
? (selected.ref?.name ?? "")
|
|
1938
|
+
: selected?.type === "object"
|
|
1939
|
+
? (selected.ref?.collection ?? "")
|
|
1940
|
+
: "";
|
|
1941
|
+
openForm("Aggregate", [
|
|
1942
|
+
{
|
|
1943
|
+
id: "function",
|
|
1944
|
+
label: "Function",
|
|
1945
|
+
value: "count",
|
|
1946
|
+
options: ["count", "sum", "avg", "min", "max"],
|
|
1947
|
+
},
|
|
1948
|
+
{
|
|
1949
|
+
id: "collection",
|
|
1950
|
+
label: "Collection",
|
|
1951
|
+
value: defaultCol,
|
|
1952
|
+
options: collections,
|
|
1953
|
+
allowCustom: true,
|
|
1954
|
+
},
|
|
1955
|
+
{ id: "field", label: "Field (for sum/avg/min/max)", placeholder: "field name" },
|
|
1956
|
+
{ id: "groupBy", label: "Group By (optional field)", placeholder: "field name" },
|
|
1957
|
+
{ id: "filter", label: "Filter (optional JSON)", placeholder: '{"status":"active"}' },
|
|
1958
|
+
], async (vals) => {
|
|
1959
|
+
const func = vals.function || "count";
|
|
1960
|
+
const collection = (vals.collection || "").trim();
|
|
1961
|
+
if (!collection) {
|
|
1962
|
+
throw new Error("Collection is required.");
|
|
1963
|
+
}
|
|
1964
|
+
const field = (vals.field || "").trim() || undefined;
|
|
1965
|
+
const groupBy = (vals.groupBy || "").trim() || undefined;
|
|
1966
|
+
const filter = vals.filter?.trim() ? JSON.parse(vals.filter.trim()) : undefined;
|
|
1967
|
+
const options = { groupBy, filter };
|
|
1968
|
+
if (func !== "count" && !field) {
|
|
1969
|
+
throw new Error("Field is required for sum, avg, min, and max.");
|
|
1970
|
+
}
|
|
1971
|
+
const aggregateField = field ?? "";
|
|
1972
|
+
const result = func === "count"
|
|
1973
|
+
? await db.aggregate.count(collection, options)
|
|
1974
|
+
: func === "sum"
|
|
1975
|
+
? await db.aggregate.sum(collection, aggregateField, options)
|
|
1976
|
+
: func === "avg"
|
|
1977
|
+
? await db.aggregate.avg(collection, aggregateField, options)
|
|
1978
|
+
: func === "min"
|
|
1979
|
+
? await db.aggregate.min(collection, aggregateField, options)
|
|
1980
|
+
: await db.aggregate.max(collection, aggregateField, options);
|
|
1981
|
+
const lines = [
|
|
1982
|
+
` ${pc.bold("Aggregate")} ${pc.cyan(func)} ${pc.dim(`on ${collection}`)}`,
|
|
1983
|
+
"",
|
|
1984
|
+
` ${pc.dim("Total:")} ${pc.bold(String(result.total))}`,
|
|
1985
|
+
"",
|
|
1986
|
+
];
|
|
1987
|
+
if (result.groups && result.groups.length > 0) {
|
|
1988
|
+
lines.push(` ${pc.bold("Groups")}`);
|
|
1989
|
+
const maxVal = Math.max(...result.groups.map((g) => g.value));
|
|
1990
|
+
for (const g of result.groups) {
|
|
1991
|
+
const barLen = Math.max(1, Math.round((g.value / maxVal) * 20));
|
|
1992
|
+
const bar = pc.cyan("█".repeat(barLen));
|
|
1993
|
+
lines.push(` ${g.key}: ${bar} ${pc.dim(String(g.value))}`);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
viewerLines = lines;
|
|
1997
|
+
loadedItemId = "aggregate_result";
|
|
1998
|
+
draw();
|
|
1999
|
+
});
|
|
2000
|
+
}
|
|
2001
|
+
async function handleTimeseries(selected) {
|
|
2002
|
+
const defaultCol = selected?.type === "collection"
|
|
2003
|
+
? (selected.ref?.name ?? "")
|
|
2004
|
+
: selected?.type === "object"
|
|
2005
|
+
? (selected.ref?.collection ?? "")
|
|
2006
|
+
: "";
|
|
2007
|
+
openForm("Time Series", [
|
|
2008
|
+
{
|
|
2009
|
+
id: "function",
|
|
2010
|
+
label: "Function",
|
|
2011
|
+
value: "count",
|
|
2012
|
+
options: ["count", "sum", "avg", "min", "max"],
|
|
2013
|
+
},
|
|
2014
|
+
{
|
|
2015
|
+
id: "collection",
|
|
2016
|
+
label: "Collection",
|
|
2017
|
+
value: defaultCol,
|
|
2018
|
+
options: collections,
|
|
2019
|
+
allowCustom: true,
|
|
2020
|
+
},
|
|
2021
|
+
{ id: "field", label: "Field (optional)", placeholder: "field name" },
|
|
2022
|
+
{
|
|
2023
|
+
id: "bucket",
|
|
2024
|
+
label: "Bucket",
|
|
2025
|
+
value: "day",
|
|
2026
|
+
options: ["hour", "day", "week", "month"],
|
|
2027
|
+
},
|
|
2028
|
+
{ id: "from", label: "From (ISO date, optional)", placeholder: "2024-01-01" },
|
|
2029
|
+
{ id: "to", label: "To (ISO date, optional)", placeholder: "2024-12-31" },
|
|
2030
|
+
{ id: "filter", label: "Filter (optional JSON)", placeholder: '{"status":"active"}' },
|
|
2031
|
+
], async (vals) => {
|
|
2032
|
+
const func = vals.function || "count";
|
|
2033
|
+
const collection = (vals.collection || "").trim();
|
|
2034
|
+
if (!collection) {
|
|
2035
|
+
throw new Error("Collection is required.");
|
|
2036
|
+
}
|
|
2037
|
+
const field = (vals.field || "").trim() || undefined;
|
|
2038
|
+
const bucket = (vals.bucket || "day");
|
|
2039
|
+
const from = (vals.from || "").trim() || undefined;
|
|
2040
|
+
const to = (vals.to || "").trim() || undefined;
|
|
2041
|
+
const filter = vals.filter?.trim() ? JSON.parse(vals.filter.trim()) : undefined;
|
|
2042
|
+
const result = await db.timeseries(collection, {
|
|
2043
|
+
function: func,
|
|
2044
|
+
field,
|
|
2045
|
+
bucket,
|
|
2046
|
+
from,
|
|
2047
|
+
to,
|
|
2048
|
+
filter,
|
|
2049
|
+
});
|
|
2050
|
+
const lines = [
|
|
2051
|
+
` ${pc.bold("Time Series")} ${pc.cyan(func)} ${pc.dim(`on ${collection}, ${bucket}ly`)}`,
|
|
2052
|
+
"",
|
|
2053
|
+
];
|
|
2054
|
+
if (result.buckets.length === 0) {
|
|
2055
|
+
lines.push(pc.dim("No data for the selected range."));
|
|
2056
|
+
}
|
|
2057
|
+
else {
|
|
2058
|
+
const maxVal = Math.max(...result.buckets.map((b) => b.value));
|
|
2059
|
+
for (const b of result.buckets) {
|
|
2060
|
+
const barLen = Math.max(1, Math.round((b.value / maxVal) * 20));
|
|
2061
|
+
const bar = pc.green("█".repeat(barLen));
|
|
2062
|
+
lines.push(` ${b.label}: ${bar} ${pc.dim(String(b.value))}`);
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
viewerLines = lines;
|
|
2066
|
+
loadedItemId = "timeseries_result";
|
|
2067
|
+
draw();
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
async function handleNlq(selected) {
|
|
2071
|
+
const defaultCol = selected?.type === "collection"
|
|
2072
|
+
? (selected.ref?.name ?? "")
|
|
2073
|
+
: selected?.type === "object"
|
|
2074
|
+
? (selected.ref?.collection ?? "")
|
|
2075
|
+
: "";
|
|
2076
|
+
openForm("Natural Language Query", [
|
|
2077
|
+
{ id: "question", label: "Question", placeholder: "How many active users?" },
|
|
2078
|
+
{
|
|
2079
|
+
id: "collection",
|
|
2080
|
+
label: "Collection (optional)",
|
|
2081
|
+
value: defaultCol,
|
|
2082
|
+
options: collections,
|
|
2083
|
+
allowCustom: true,
|
|
2084
|
+
},
|
|
2085
|
+
], async (vals) => {
|
|
2086
|
+
const question = (vals.question || "").trim();
|
|
2087
|
+
if (!question) {
|
|
2088
|
+
throw new Error("Question is required.");
|
|
2089
|
+
}
|
|
2090
|
+
const collection = (vals.collection || "").trim() || undefined;
|
|
2091
|
+
const result = await db.nlq.query(question, {
|
|
2092
|
+
collection,
|
|
2093
|
+
});
|
|
2094
|
+
const intent = result.intent;
|
|
2095
|
+
const lines = [
|
|
2096
|
+
` ${pc.bold("NLQ Result")}`,
|
|
2097
|
+
` ${pc.dim(`Question: ${question}`)}`,
|
|
2098
|
+
"",
|
|
2099
|
+
` ${result.answer}`,
|
|
2100
|
+
"",
|
|
2101
|
+
];
|
|
2102
|
+
if (intent) {
|
|
2103
|
+
lines.push(` ${pc.dim("Interpreted as:")} ${intent.action} ${pc.dim("on")} ${pc.cyan(intent.collection)}`);
|
|
2104
|
+
if (intent.function) {
|
|
2105
|
+
lines.push(` ${pc.dim("Function:")} ${intent.function}`);
|
|
2106
|
+
}
|
|
2107
|
+
if (intent.field) {
|
|
2108
|
+
lines.push(` ${pc.dim("Field:")} ${intent.field}`);
|
|
2109
|
+
}
|
|
2110
|
+
if (intent.query) {
|
|
2111
|
+
lines.push(` ${pc.dim("Query:")} ${intent.query}`);
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
if (result.data !== undefined && result.data !== null) {
|
|
2115
|
+
lines.push("", highlightJson(result.data));
|
|
2116
|
+
}
|
|
2117
|
+
viewerLines = lines;
|
|
2118
|
+
loadedItemId = "nlq_result";
|
|
2119
|
+
draw();
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
async function handleCollectionOptions(selected) {
|
|
2123
|
+
const colName = selected?.type === "collection"
|
|
2124
|
+
? (selected.ref?.name ?? "")
|
|
2125
|
+
: selected?.type === "object"
|
|
2126
|
+
? (selected.ref?.collection ?? "")
|
|
2127
|
+
: "";
|
|
2128
|
+
if (!colName) {
|
|
2129
|
+
viewerLines = [pc.yellow("Select a collection first, then press [o] to set listing options.")];
|
|
2130
|
+
loadedItemId = "options_info";
|
|
2131
|
+
draw();
|
|
2132
|
+
return;
|
|
2133
|
+
}
|
|
2134
|
+
const current = collectionOptions.get(colName) ?? {};
|
|
2135
|
+
openForm(`Listing Options: ${colName}`, [
|
|
2136
|
+
{
|
|
2137
|
+
id: "sortBy",
|
|
2138
|
+
label: "Sort By",
|
|
2139
|
+
value: current.sortBy ?? "",
|
|
2140
|
+
options: ["", "id", "created_at", "updated_at", "version"],
|
|
2141
|
+
allowCustom: true,
|
|
2142
|
+
},
|
|
2143
|
+
{
|
|
2144
|
+
id: "sortDir",
|
|
2145
|
+
label: "Sort Direction",
|
|
2146
|
+
value: current.sortDir ?? "asc",
|
|
2147
|
+
options: ["asc", "desc"],
|
|
2148
|
+
},
|
|
2149
|
+
{ id: "limit", label: "Limit", value: String(current.limit ?? ""), placeholder: "50" },
|
|
2150
|
+
{ id: "offset", label: "Offset", value: String(current.offset ?? ""), placeholder: "0" },
|
|
2151
|
+
{
|
|
2152
|
+
id: "filter",
|
|
2153
|
+
label: "Filter (JSON)",
|
|
2154
|
+
value: current.filter ? JSON.stringify(current.filter) : "",
|
|
2155
|
+
placeholder: '{"status":"active"}',
|
|
2156
|
+
},
|
|
2157
|
+
], async (vals) => {
|
|
2158
|
+
const opts = {};
|
|
2159
|
+
if (vals.sortBy) {
|
|
2160
|
+
opts.sortBy = vals.sortBy;
|
|
2161
|
+
opts.sortDir = vals.sortDir || "asc";
|
|
2162
|
+
}
|
|
2163
|
+
if (vals.limit) {
|
|
2164
|
+
opts.limit = parseInt(vals.limit, 10) || 50;
|
|
2165
|
+
}
|
|
2166
|
+
if (vals.offset) {
|
|
2167
|
+
opts.offset = parseInt(vals.offset, 10) || 0;
|
|
2168
|
+
}
|
|
2169
|
+
if (vals.filter?.trim()) {
|
|
2170
|
+
opts.filter = JSON.parse(vals.filter.trim());
|
|
2171
|
+
}
|
|
2172
|
+
if (Object.keys(opts).length > 0) {
|
|
2173
|
+
collectionOptions.set(colName, opts);
|
|
2174
|
+
}
|
|
2175
|
+
else {
|
|
2176
|
+
collectionOptions.delete(colName);
|
|
2177
|
+
}
|
|
2178
|
+
await fetchResources();
|
|
2179
|
+
const tree = buildTree();
|
|
2180
|
+
const idx = tree.findIndex((n) => n.id === `col:${colName}`);
|
|
2181
|
+
if (idx !== -1) {
|
|
2182
|
+
cursorIndex = idx;
|
|
2183
|
+
}
|
|
2184
|
+
const n = tree[cursorIndex];
|
|
2185
|
+
if (n) {
|
|
2186
|
+
scheduleLoad(n);
|
|
2187
|
+
}
|
|
2188
|
+
});
|
|
2189
|
+
}
|
|
2190
|
+
async function handleBatchOps(selected) {
|
|
2191
|
+
const colName = selected?.type === "collection"
|
|
2192
|
+
? (selected.ref?.name ?? "")
|
|
2193
|
+
: selected?.type === "object"
|
|
2194
|
+
? (selected.ref?.collection ?? "")
|
|
2195
|
+
: "";
|
|
2196
|
+
if (!colName) {
|
|
2197
|
+
viewerLines = [pc.yellow("Select a collection first, then press [b] for batch operations.")];
|
|
2198
|
+
loadedItemId = "batch_info";
|
|
2199
|
+
draw();
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
openForm(`Batch Ops: ${colName}`, [
|
|
2203
|
+
{
|
|
2204
|
+
id: "action",
|
|
2205
|
+
label: "Action",
|
|
2206
|
+
value: "put",
|
|
2207
|
+
options: ["put", "delete"],
|
|
2208
|
+
},
|
|
2209
|
+
{
|
|
2210
|
+
id: "input",
|
|
2211
|
+
label: "JSON File Path or IDs (comma-sep for delete)",
|
|
2212
|
+
placeholder: "/path/to/file.json or id1,id2,id3",
|
|
2213
|
+
},
|
|
2214
|
+
], async (vals) => {
|
|
2215
|
+
const action = vals.action || "";
|
|
2216
|
+
const input = (vals.input || "").trim();
|
|
2217
|
+
if (!input) {
|
|
2218
|
+
throw new Error("Input is required.");
|
|
2219
|
+
}
|
|
2220
|
+
if (action === "put") {
|
|
2221
|
+
const data = JSON.parse(await fs.promises.readFile(input, "utf-8"));
|
|
2222
|
+
const objects = Array.isArray(data) ? data : (data.objects ?? [data]);
|
|
2223
|
+
const result = await db.putBatch(colName, objects);
|
|
2224
|
+
viewerLines = [
|
|
2225
|
+
` ${pc.bold("Batch Put Complete")}`,
|
|
2226
|
+
` ${pc.dim(`Collection: ${colName}`)}`,
|
|
2227
|
+
` ${pc.dim(`Objects: ${result.length}`)}`,
|
|
2228
|
+
];
|
|
2229
|
+
loadedItemId = "batch_result";
|
|
2230
|
+
draw();
|
|
2231
|
+
}
|
|
2232
|
+
else if (action === "delete") {
|
|
2233
|
+
const ids = input
|
|
2234
|
+
.split(",")
|
|
2235
|
+
.map((s) => s.trim())
|
|
2236
|
+
.filter(Boolean);
|
|
2237
|
+
const count = await db.deleteBatch(colName, ids);
|
|
2238
|
+
viewerLines = [
|
|
2239
|
+
` ${pc.bold("Batch Delete Complete")}`,
|
|
2240
|
+
` ${pc.dim(`Collection: ${colName}`)}`,
|
|
2241
|
+
` ${pc.dim(`Deleted: ${count} objects`)}`,
|
|
2242
|
+
];
|
|
2243
|
+
loadedItemId = "batch_result";
|
|
2244
|
+
draw();
|
|
2245
|
+
}
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
async function handleExport(selected) {
|
|
2249
|
+
const colName = selected?.type === "collection"
|
|
2250
|
+
? (selected.ref?.name ?? "")
|
|
2251
|
+
: selected?.type === "object"
|
|
2252
|
+
? (selected.ref?.collection ?? "")
|
|
2253
|
+
: "";
|
|
2254
|
+
if (!colName) {
|
|
2255
|
+
viewerLines = [pc.yellow("Select a collection first, then press [x] to export.")];
|
|
2256
|
+
loadedItemId = "export_info";
|
|
2257
|
+
draw();
|
|
2258
|
+
return;
|
|
2259
|
+
}
|
|
2260
|
+
openForm(`Export: ${colName}`, [{ id: "path", label: "Output File Path", placeholder: "/tmp/export.jsonl" }], async (vals) => {
|
|
2261
|
+
const filePath = (vals.path || "").trim();
|
|
2262
|
+
if (!filePath) {
|
|
2263
|
+
throw new Error("File path is required.");
|
|
2264
|
+
}
|
|
2265
|
+
const objs = await db.listObjects(colName);
|
|
2266
|
+
const lines = objs.map((o) => JSON.stringify(o)).join("\n");
|
|
2267
|
+
await fs.promises.writeFile(filePath, lines, "utf-8");
|
|
2268
|
+
addToast(`Exported ${objs.length} objects to ${filePath}`);
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
async function handleImport(selected) {
|
|
2272
|
+
const colName = selected?.type === "collection"
|
|
2273
|
+
? (selected.ref?.name ?? "")
|
|
2274
|
+
: selected?.type === "object"
|
|
2275
|
+
? (selected.ref?.collection ?? "")
|
|
2276
|
+
: "";
|
|
2277
|
+
if (!colName) {
|
|
2278
|
+
viewerLines = [pc.yellow("Select a collection first, then press [p] to import.")];
|
|
2279
|
+
loadedItemId = "import_info";
|
|
2280
|
+
draw();
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
openForm(`Import: ${colName}`, [{ id: "path", label: "Input File Path (JSONL)", placeholder: "/tmp/export.jsonl" }], async (vals) => {
|
|
2284
|
+
const filePath = (vals.path || "").trim();
|
|
2285
|
+
if (!filePath) {
|
|
2286
|
+
throw new Error("File path is required.");
|
|
2287
|
+
}
|
|
2288
|
+
const text = await fs.promises.readFile(filePath, "utf-8");
|
|
2289
|
+
const objects = text
|
|
2290
|
+
.split("\n")
|
|
2291
|
+
.filter(Boolean)
|
|
2292
|
+
.map((line) => JSON.parse(line));
|
|
2293
|
+
const result = await db.putBatch(colName, objects);
|
|
2294
|
+
addToast(`Imported ${result.length} objects into ${colName}`);
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
async function handleDashboard() {
|
|
2298
|
+
const bin = process.argv[1] || "thingd";
|
|
2299
|
+
const args = ["dashboard", "--driver", driver];
|
|
2300
|
+
if (dbPath) {
|
|
2301
|
+
args.push("--path", dbPath);
|
|
2302
|
+
}
|
|
2303
|
+
if (authToken) {
|
|
2304
|
+
args.push("--auth-token", authToken);
|
|
2305
|
+
}
|
|
2306
|
+
const child = spawn(bin, args, {
|
|
2307
|
+
stdio: "inherit",
|
|
2308
|
+
detached: true,
|
|
2309
|
+
});
|
|
2310
|
+
child.unref();
|
|
2311
|
+
addToast(`Launching dashboard: ${bin} dashboard`);
|
|
2312
|
+
}
|
|
2313
|
+
async function handleMaintenance() {
|
|
2314
|
+
// Cycle through maintenance operations with each press of 'm'
|
|
2315
|
+
const operations = ["health", "checkpoint", "backup"];
|
|
2316
|
+
const idx = maintenanceCursor % operations.length;
|
|
2317
|
+
maintenanceCursor = (maintenanceCursor + 1) % operations.length;
|
|
2318
|
+
const op = operations[idx];
|
|
2319
|
+
viewerLines = [pc.dim(`Running ${op}...`)];
|
|
2320
|
+
draw();
|
|
2321
|
+
if (op === "backup") {
|
|
2322
|
+
const backupPath = `thingd-backup-${Date.now()}.db`;
|
|
2323
|
+
try {
|
|
2324
|
+
if (db?.backupTo) {
|
|
2325
|
+
db.backupTo(backupPath);
|
|
2326
|
+
viewerLines = [` Backup created: ${backupPath}`, ``];
|
|
2327
|
+
}
|
|
2328
|
+
else {
|
|
2329
|
+
viewerLines = [` Backup not available (cloud driver)`, ``];
|
|
2330
|
+
}
|
|
2331
|
+
}
|
|
2332
|
+
catch (err) {
|
|
2333
|
+
viewerLines = [` Backup failed: ${err instanceof Error ? err.message : String(err)}`, ``];
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
else if (op === "checkpoint") {
|
|
2337
|
+
try {
|
|
2338
|
+
if (db?.walCheckpoint) {
|
|
2339
|
+
const result = db.walCheckpoint();
|
|
2340
|
+
viewerLines = [
|
|
2341
|
+
` WAL Checkpoint complete`,
|
|
2342
|
+
` Frames before: ${result.framesBefore ?? "N/A"}`,
|
|
2343
|
+
` Frames after: ${result.framesAfter ?? "N/A"}`,
|
|
2344
|
+
``,
|
|
2345
|
+
];
|
|
2346
|
+
}
|
|
2347
|
+
else {
|
|
2348
|
+
viewerLines = [` WAL Checkpoint not available (cloud driver)`, ``];
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
catch (err) {
|
|
2352
|
+
viewerLines = [
|
|
2353
|
+
` WAL Checkpoint failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2354
|
+
``,
|
|
2355
|
+
];
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
else {
|
|
2359
|
+
// Health check — verify read path
|
|
2360
|
+
try {
|
|
2361
|
+
if (typeof db !== "undefined" && typeof db.countObjects === "function") {
|
|
2362
|
+
const objCount = await db.countObjects();
|
|
2363
|
+
const evtCount = await db.countEvents();
|
|
2364
|
+
const jobCount = typeof db.countActiveJobs === "function" ? await db.countActiveJobs() : 0;
|
|
2365
|
+
viewerLines = [
|
|
2366
|
+
` Health check passed`,
|
|
2367
|
+
` Objects: ${objCount}, Events: ${evtCount}, Active jobs: ${jobCount}`,
|
|
2368
|
+
``,
|
|
2369
|
+
];
|
|
2370
|
+
}
|
|
2371
|
+
else {
|
|
2372
|
+
viewerLines = [` Health check not available`, ``];
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
catch (err) {
|
|
2376
|
+
viewerLines = [
|
|
2377
|
+
` Health check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
2378
|
+
``,
|
|
2379
|
+
];
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
loadedItemId = "maintenance_result";
|
|
2383
|
+
draw();
|
|
2384
|
+
}
|
|
2385
|
+
// ── Keyboard Listener ────────────────────────────────────────────────
|
|
2386
|
+
function setupKeypress() {
|
|
2387
|
+
process.stdin.removeAllListeners("keypress");
|
|
2388
|
+
readline.emitKeypressEvents(process.stdin);
|
|
2389
|
+
if (process.stdin.isTTY) {
|
|
2390
|
+
process.stdin.setRawMode(true);
|
|
2391
|
+
}
|
|
2392
|
+
process.stdin.resume();
|
|
2393
|
+
keypressHandler = async (str, key) => {
|
|
2394
|
+
if (!key) {
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
2397
|
+
// Help overlay dismisses on any keypress
|
|
2398
|
+
if (showHelp) {
|
|
2399
|
+
showHelp = false;
|
|
2400
|
+
draw();
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
// Quit
|
|
2404
|
+
if ((key.ctrl && key.name === "c") || key.name === "q") {
|
|
2405
|
+
if (formState?.active && key.name !== "q") {
|
|
2406
|
+
formState.onCancel();
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
else if (!formState?.active) {
|
|
2410
|
+
cleanup();
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
if (formState?.active && !formState.isSubmitting) {
|
|
2415
|
+
if (key.ctrl && key.name === "e") {
|
|
2416
|
+
const f = formState.fields[formState.activeIndex];
|
|
2417
|
+
if (f) {
|
|
2418
|
+
await launchEditor(f);
|
|
2419
|
+
}
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
else if (key.name === "escape") {
|
|
2423
|
+
formState.onCancel();
|
|
2424
|
+
}
|
|
2425
|
+
else if (key.name === "up" || (key.name === "tab" && key.shift)) {
|
|
2426
|
+
if (formState.activeIndex > 0) {
|
|
2427
|
+
formState.activeIndex--;
|
|
2428
|
+
}
|
|
2429
|
+
formState.error = undefined;
|
|
2430
|
+
draw();
|
|
2431
|
+
}
|
|
2432
|
+
else if (key.name === "down" || key.name === "tab") {
|
|
2433
|
+
if (formState.activeIndex < formState.fields.length - 1) {
|
|
2434
|
+
formState.activeIndex++;
|
|
2435
|
+
}
|
|
2436
|
+
formState.error = undefined;
|
|
2437
|
+
draw();
|
|
2438
|
+
}
|
|
2439
|
+
else if (key.name === "return") {
|
|
2440
|
+
if (formState.activeIndex < formState.fields.length - 1) {
|
|
2441
|
+
formState.activeIndex++;
|
|
2442
|
+
draw();
|
|
2443
|
+
}
|
|
2444
|
+
else {
|
|
2445
|
+
const vals = {};
|
|
2446
|
+
for (const f of formState.fields) {
|
|
2447
|
+
vals[f.id] = f.value;
|
|
2448
|
+
}
|
|
2449
|
+
formState.onSubmit(vals);
|
|
2450
|
+
}
|
|
2451
|
+
}
|
|
2452
|
+
else if (key.name === "left" || key.name === "right") {
|
|
2453
|
+
const f = formState.fields[formState.activeIndex];
|
|
2454
|
+
if (f?.options && f.options.length > 0) {
|
|
2455
|
+
const currentIndex = f.options.indexOf(f.value);
|
|
2456
|
+
let nextIndex = key.name === "right" ? currentIndex + 1 : currentIndex - 1;
|
|
2457
|
+
if (nextIndex < 0) {
|
|
2458
|
+
nextIndex = f.options.length - 1;
|
|
2459
|
+
}
|
|
2460
|
+
if (nextIndex >= f.options.length) {
|
|
2461
|
+
nextIndex = 0;
|
|
2462
|
+
}
|
|
2463
|
+
f.value = f.options[nextIndex] ?? "";
|
|
2464
|
+
formState.error = undefined;
|
|
2465
|
+
draw();
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
else if (key.name === "backspace") {
|
|
2469
|
+
const f = formState.fields[formState.activeIndex];
|
|
2470
|
+
if (f && (!f.options || f.allowCustom) && f.value.length > 0) {
|
|
2471
|
+
f.value = f.value.slice(0, -1);
|
|
2472
|
+
formState.error = undefined;
|
|
2473
|
+
draw();
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
else if (str) {
|
|
2477
|
+
const f = formState.fields[formState.activeIndex];
|
|
2478
|
+
if (f && (!f.options || f.allowCustom)) {
|
|
2479
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: we need to filter control characters
|
|
2480
|
+
const clean = str.replace(/[\x00-\x1F\x7F]/g, "");
|
|
2481
|
+
if (clean) {
|
|
2482
|
+
if (f.isSecret && !f.dirty && f.value) {
|
|
2483
|
+
f.value = clean;
|
|
2484
|
+
f.dirty = true;
|
|
2485
|
+
}
|
|
2486
|
+
else {
|
|
2487
|
+
f.value += clean;
|
|
2488
|
+
}
|
|
2489
|
+
formState.error = undefined;
|
|
2490
|
+
draw();
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
}
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
const tree = buildTree();
|
|
2497
|
+
// Navigation (works in both connected and disconnected states)
|
|
2498
|
+
if (key.name === "up" || str === "k") {
|
|
2499
|
+
if (cursorIndex > 0) {
|
|
2500
|
+
cursorIndex--;
|
|
2501
|
+
draw();
|
|
2502
|
+
const n = tree[cursorIndex];
|
|
2503
|
+
if (n) {
|
|
2504
|
+
scheduleLoad(n);
|
|
2505
|
+
}
|
|
2506
|
+
}
|
|
2507
|
+
}
|
|
2508
|
+
else if (key.name === "down" || str === "j") {
|
|
2509
|
+
if (cursorIndex < tree.length - 1) {
|
|
2510
|
+
cursorIndex++;
|
|
2511
|
+
draw();
|
|
2512
|
+
const n = tree[cursorIndex];
|
|
2513
|
+
if (n) {
|
|
2514
|
+
scheduleLoad(n);
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
else if (!connected) {
|
|
2519
|
+
// Driver selection mode — only Enter and ? work
|
|
2520
|
+
if (key.name === "return") {
|
|
2521
|
+
const node = tree[cursorIndex];
|
|
2522
|
+
if (node) {
|
|
2523
|
+
await handleConnect(node);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
else if (str === "?") {
|
|
2527
|
+
showHelp = !showHelp;
|
|
2528
|
+
draw();
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
else {
|
|
2532
|
+
// Connected mode — full set of shortcuts
|
|
2533
|
+
if (key.name === "right" || str === "l") {
|
|
2534
|
+
const node = tree[cursorIndex];
|
|
2535
|
+
if (node?.expandable) {
|
|
2536
|
+
if (!expandedSet.has(node.id)) {
|
|
2537
|
+
expandedSet.add(node.id);
|
|
2538
|
+
if (node.type === "collection") {
|
|
2539
|
+
await fetchResources();
|
|
2540
|
+
}
|
|
2541
|
+
draw();
|
|
2542
|
+
}
|
|
2543
|
+
else {
|
|
2544
|
+
const newTree = buildTree();
|
|
2545
|
+
if (cursorIndex + 1 < newTree.length) {
|
|
2546
|
+
cursorIndex++;
|
|
2547
|
+
draw();
|
|
2548
|
+
const n = newTree[cursorIndex];
|
|
2549
|
+
if (n) {
|
|
2550
|
+
scheduleLoad(n);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
else if (key.name === "left" || str === "h") {
|
|
2557
|
+
const node = tree[cursorIndex];
|
|
2558
|
+
if (node) {
|
|
2559
|
+
if (node.expandable && expandedSet.has(node.id)) {
|
|
2560
|
+
expandedSet.delete(node.id);
|
|
2561
|
+
draw();
|
|
2562
|
+
}
|
|
2563
|
+
else if (node.parentId) {
|
|
2564
|
+
const parentIdx = tree.findIndex((n) => n.id === node.parentId);
|
|
2565
|
+
if (parentIdx !== -1) {
|
|
2566
|
+
cursorIndex = parentIdx;
|
|
2567
|
+
draw();
|
|
2568
|
+
const n = tree[cursorIndex];
|
|
2569
|
+
if (n) {
|
|
2570
|
+
scheduleLoad(n);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
}
|
|
2576
|
+
else if (key.name === "return") {
|
|
2577
|
+
const node = tree[cursorIndex];
|
|
2578
|
+
if (node?.expandable) {
|
|
2579
|
+
if (expandedSet.has(node.id)) {
|
|
2580
|
+
expandedSet.delete(node.id);
|
|
2581
|
+
}
|
|
2582
|
+
else {
|
|
2583
|
+
expandedSet.add(node.id);
|
|
2584
|
+
if (node.type === "collection") {
|
|
2585
|
+
await fetchResources();
|
|
2586
|
+
}
|
|
2587
|
+
}
|
|
2588
|
+
draw();
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
else if (str === "r" || str === "R") {
|
|
2592
|
+
loadedItemId = "";
|
|
2593
|
+
await fetchResources();
|
|
2594
|
+
draw();
|
|
2595
|
+
const n = tree[cursorIndex];
|
|
2596
|
+
if (n) {
|
|
2597
|
+
scheduleLoad(n);
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
else if (str === "s" || str === "S") {
|
|
2601
|
+
await handleSwitch();
|
|
2602
|
+
}
|
|
2603
|
+
else if (str === "c" || str === "C") {
|
|
2604
|
+
await handleCreate(tree[cursorIndex]);
|
|
2605
|
+
}
|
|
2606
|
+
else if (str === "e" || str === "E") {
|
|
2607
|
+
await handleEdit(tree[cursorIndex]);
|
|
2608
|
+
}
|
|
2609
|
+
else if (str === "d" || str === "D") {
|
|
2610
|
+
await handleDelete(tree[cursorIndex]);
|
|
2611
|
+
}
|
|
2612
|
+
else if (str === "/" || str === "f" || str === "F") {
|
|
2613
|
+
await handleSearch();
|
|
2614
|
+
}
|
|
2615
|
+
else if (str === "?") {
|
|
2616
|
+
showHelp = !showHelp;
|
|
2617
|
+
draw();
|
|
2618
|
+
}
|
|
2619
|
+
else if (str === "i" || str === "I") {
|
|
2620
|
+
await handleInfo();
|
|
2621
|
+
}
|
|
2622
|
+
else if (str === "n") {
|
|
2623
|
+
await handleNeighbors(tree[cursorIndex]);
|
|
2624
|
+
}
|
|
2625
|
+
else if (str === "N") {
|
|
2626
|
+
await handleNlq(tree[cursorIndex]);
|
|
2627
|
+
}
|
|
2628
|
+
else if (str === "a" || str === "A") {
|
|
2629
|
+
await handleAggregate(tree[cursorIndex]);
|
|
2630
|
+
}
|
|
2631
|
+
else if (str === "t" || str === "T") {
|
|
2632
|
+
await handleTimeseries(tree[cursorIndex]);
|
|
2633
|
+
}
|
|
2634
|
+
else if (str === "o" || str === "O") {
|
|
2635
|
+
await handleCollectionOptions(tree[cursorIndex]);
|
|
2636
|
+
}
|
|
2637
|
+
else if (str === "b" || str === "B") {
|
|
2638
|
+
await handleBatchOps(tree[cursorIndex]);
|
|
2639
|
+
}
|
|
2640
|
+
else if (str === "x" || str === "X") {
|
|
2641
|
+
await handleExport(tree[cursorIndex]);
|
|
2642
|
+
}
|
|
2643
|
+
else if (str === "p" || str === "P") {
|
|
2644
|
+
await handleImport(tree[cursorIndex]);
|
|
2645
|
+
}
|
|
2646
|
+
else if (str === "w" || str === "W") {
|
|
2647
|
+
await handleDashboard();
|
|
2648
|
+
}
|
|
2649
|
+
else if (str === "m" || str === "M") {
|
|
2650
|
+
await handleMaintenance();
|
|
2651
|
+
}
|
|
2652
|
+
else if (str === "l" || str === "L") {
|
|
2653
|
+
await handleLogout();
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
};
|
|
2657
|
+
process.stdin.on("keypress", keypressHandler);
|
|
2658
|
+
if (process.stdout.isTTY) {
|
|
2659
|
+
process.stdout.on("resize", () => {
|
|
2660
|
+
draw();
|
|
2661
|
+
});
|
|
2662
|
+
}
|
|
2663
|
+
}
|
|
2664
|
+
function cleanup() {
|
|
2665
|
+
if (pollTimer) {
|
|
2666
|
+
clearInterval(pollTimer);
|
|
2667
|
+
}
|
|
2668
|
+
if (process.stdin.isTTY) {
|
|
2669
|
+
process.stdin.setRawMode(false);
|
|
2670
|
+
}
|
|
2671
|
+
process.stdout.write("\u001B[?1049l\u001B[?25h");
|
|
2672
|
+
console.clear();
|
|
2673
|
+
const finish = () => {
|
|
2674
|
+
process.exit(0);
|
|
2675
|
+
};
|
|
2676
|
+
if (connected && db) {
|
|
2677
|
+
db.close().then(finish);
|
|
2678
|
+
}
|
|
2679
|
+
else {
|
|
2680
|
+
finish();
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
async function handleConnect(node) {
|
|
2684
|
+
if (node.type !== "driver" || !node.ref) {
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
const selectedDriver = node.ref.driver;
|
|
2688
|
+
// Cloud with saved credentials — fetch projects/instances and let user pick
|
|
2689
|
+
if (selectedDriver === "cloud") {
|
|
2690
|
+
const cloudCfg = readCloudConfig();
|
|
2691
|
+
if (cloudCfg?.token) {
|
|
2692
|
+
try {
|
|
2693
|
+
const { projects } = await listProjects(cloudCfg);
|
|
2694
|
+
const instanceOptions = [];
|
|
2695
|
+
for (const project of projects) {
|
|
2696
|
+
try {
|
|
2697
|
+
const { instances } = await listInstances(cloudCfg, project.id);
|
|
2698
|
+
for (const inst of instances) {
|
|
2699
|
+
if (inst.mcpUrl) {
|
|
2700
|
+
instanceOptions.push({
|
|
2701
|
+
label: `${project.slug}/${inst.slug}`,
|
|
2702
|
+
mcpUrl: inst.mcpUrl,
|
|
2703
|
+
projectSlug: project.slug,
|
|
2704
|
+
instanceSlug: inst.slug,
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
catch {
|
|
2710
|
+
// Skip projects that fail to list instances
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
if (instanceOptions.length > 0) {
|
|
2714
|
+
// Pre-select the saved instance if it exists
|
|
2715
|
+
const savedIdx = cloudCfg.instanceUrl
|
|
2716
|
+
? instanceOptions.findIndex((o) => o.mcpUrl === cloudCfg.instanceUrl)
|
|
2717
|
+
: -1;
|
|
2718
|
+
const defaultVal = savedIdx >= 0 ? instanceOptions[savedIdx]?.label : instanceOptions[0]?.label;
|
|
2719
|
+
openForm("Connect to Cloud", [
|
|
2720
|
+
{
|
|
2721
|
+
id: "instance",
|
|
2722
|
+
label: "Instance",
|
|
2723
|
+
value: defaultVal ?? "",
|
|
2724
|
+
options: instanceOptions.map((o) => o.label),
|
|
2725
|
+
},
|
|
2726
|
+
], async (vals) => {
|
|
2727
|
+
const selected = instanceOptions.find((o) => o.label === vals.instance);
|
|
2728
|
+
if (!selected) {
|
|
2729
|
+
viewerLines = [pc.red("No instance selected.")];
|
|
2730
|
+
draw();
|
|
2731
|
+
return;
|
|
2732
|
+
}
|
|
2733
|
+
// Save selection to cloud config
|
|
2734
|
+
cloudCfg.instanceUrl = selected.mcpUrl;
|
|
2735
|
+
cloudCfg.projectSlug = selected.projectSlug;
|
|
2736
|
+
cloudCfg.instanceSlug = selected.instanceSlug;
|
|
2737
|
+
writeCloudConfig(cloudCfg);
|
|
2738
|
+
await connectToDriver("cloud", deriveRestUrl(selected.mcpUrl), deriveRestUrl(selected.mcpUrl), cloudCfg.userToken ?? cloudCfg.apiKey ?? cloudCfg.token, selected.instanceSlug);
|
|
2739
|
+
});
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
// No instances found — try saved URL or show error
|
|
2743
|
+
if (cloudCfg.instanceUrl) {
|
|
2744
|
+
const restUrl = deriveRestUrl(cloudCfg.instanceUrl);
|
|
2745
|
+
await connectToDriver("cloud", restUrl, restUrl, cloudCfg.userToken ?? cloudCfg.apiKey ?? cloudCfg.token, cloudCfg.instanceSlug);
|
|
2746
|
+
return;
|
|
2747
|
+
}
|
|
2748
|
+
viewerLines = [
|
|
2749
|
+
pc.yellow("No cloud instances found."),
|
|
2750
|
+
pc.dim("Create one at https://thingd.cloud, or press l to logout and re-enter credentials."),
|
|
2751
|
+
];
|
|
2752
|
+
draw();
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
catch {
|
|
2756
|
+
if (cloudCfg.instanceUrl) {
|
|
2757
|
+
const restUrl = deriveRestUrl(cloudCfg.instanceUrl);
|
|
2758
|
+
await connectToDriver("cloud", restUrl, restUrl, cloudCfg.userToken ?? cloudCfg.apiKey ?? cloudCfg.token, cloudCfg.instanceSlug);
|
|
2759
|
+
return;
|
|
2760
|
+
}
|
|
2761
|
+
viewerLines = [
|
|
2762
|
+
pc.yellow("Could not fetch cloud instances."),
|
|
2763
|
+
pc.dim("Check your network or press l to logout and re-enter credentials."),
|
|
2764
|
+
];
|
|
2765
|
+
draw();
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
if (selectedDriver === "native" || selectedDriver === "cloud") {
|
|
2771
|
+
const cloudCfg = selectedDriver === "cloud" ? readCloudConfig() : null;
|
|
2772
|
+
const baseUrl = cloudCfg?.url ?? "https://api.thingd.cloud";
|
|
2773
|
+
const isCloudWithConfig = selectedDriver === "cloud" && cloudCfg?.token;
|
|
2774
|
+
openForm(selectedDriver === "cloud" && !isCloudWithConfig
|
|
2775
|
+
? `Connect to ${selectedDriver} — enter credentials below or run ${pc.cyan("thingd cloud login")} first`
|
|
2776
|
+
: `Connect to ${selectedDriver}`, [
|
|
2777
|
+
...(selectedDriver === "cloud"
|
|
2778
|
+
? isCloudWithConfig
|
|
2779
|
+
? [
|
|
2780
|
+
{
|
|
2781
|
+
id: "project",
|
|
2782
|
+
label: "Cloud Project (slug)",
|
|
2783
|
+
value: "",
|
|
2784
|
+
},
|
|
2785
|
+
{
|
|
2786
|
+
id: "instance",
|
|
2787
|
+
label: "Cloud Instance (slug)",
|
|
2788
|
+
value: "",
|
|
2789
|
+
},
|
|
2790
|
+
{
|
|
2791
|
+
id: "token",
|
|
2792
|
+
label: "Bearer Token (optional)",
|
|
2793
|
+
isSecret: true,
|
|
2794
|
+
value: cloudCfg?.token ?? "",
|
|
2795
|
+
},
|
|
2796
|
+
]
|
|
2797
|
+
: [
|
|
2798
|
+
{
|
|
2799
|
+
id: "url",
|
|
2800
|
+
label: "Cloud URL (from thingd.cloud dashboard)",
|
|
2801
|
+
value: "",
|
|
2802
|
+
},
|
|
2803
|
+
{
|
|
2804
|
+
id: "token",
|
|
2805
|
+
label: "Bearer Token (optional)",
|
|
2806
|
+
isSecret: true,
|
|
2807
|
+
value: cloudCfg?.token ?? "",
|
|
2808
|
+
},
|
|
2809
|
+
]
|
|
2810
|
+
: [
|
|
2811
|
+
{
|
|
2812
|
+
id: "path",
|
|
2813
|
+
label: "Database Path",
|
|
2814
|
+
value: defaultThingdDbPath(),
|
|
2815
|
+
},
|
|
2816
|
+
]),
|
|
2817
|
+
], async (vals) => {
|
|
2818
|
+
let cloudUrl;
|
|
2819
|
+
let instanceSlugVal;
|
|
2820
|
+
if (selectedDriver === "cloud") {
|
|
2821
|
+
if (isCloudWithConfig) {
|
|
2822
|
+
if (!vals.project || !vals.instance) {
|
|
2823
|
+
viewerLines = [pc.red("Project and instance slugs are required.")];
|
|
2824
|
+
draw();
|
|
2825
|
+
return;
|
|
2826
|
+
}
|
|
2827
|
+
instanceSlugVal = vals.instance;
|
|
2828
|
+
// Construct URL from project + instance slugs
|
|
2829
|
+
cloudUrl = `${baseUrl}/mcp/${encodeURIComponent(vals.project)}/${encodeURIComponent(vals.instance)}`;
|
|
2830
|
+
// Save selection to cloud config
|
|
2831
|
+
const cfg = cloudCfg ?? { token: vals.token, url: baseUrl };
|
|
2832
|
+
cfg.instanceUrl = cloudUrl;
|
|
2833
|
+
cfg.projectSlug = vals.project;
|
|
2834
|
+
cfg.instanceSlug = instanceSlugVal;
|
|
2835
|
+
if (vals.token) {
|
|
2836
|
+
cfg.token = vals.token;
|
|
2837
|
+
}
|
|
2838
|
+
writeCloudConfig(cfg);
|
|
2839
|
+
// Connect via REST (derive base URL from MCP URL)
|
|
2840
|
+
cloudUrl = deriveRestUrl(cloudUrl);
|
|
2841
|
+
}
|
|
2842
|
+
else {
|
|
2843
|
+
if (!vals.url) {
|
|
2844
|
+
viewerLines = [pc.red("Cloud URL is required.")];
|
|
2845
|
+
draw();
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
cloudUrl = vals.url;
|
|
2849
|
+
// Save manual credentials to cloud config
|
|
2850
|
+
writeCloudConfig({ token: vals.token || "", url: cloudUrl });
|
|
2851
|
+
}
|
|
2852
|
+
await connectToDriver(selectedDriver, cloudUrl, cloudUrl, cloudCfg?.apiKey ?? vals.token, instanceSlugVal);
|
|
2853
|
+
}
|
|
2854
|
+
else {
|
|
2855
|
+
await connectToDriver(selectedDriver, vals.path || "", undefined, undefined);
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
}
|
|
2859
|
+
else {
|
|
2860
|
+
// Memory — connect directly without suspending
|
|
2861
|
+
driver = selectedDriver;
|
|
2862
|
+
dbPath = ":memory:";
|
|
2863
|
+
viewerLines = [pc.dim("Connecting...")];
|
|
2864
|
+
draw();
|
|
2865
|
+
try {
|
|
2866
|
+
db = await ThingD.open({
|
|
2867
|
+
path: ":memory:",
|
|
2868
|
+
driver: "memory",
|
|
2869
|
+
});
|
|
2870
|
+
connected = true;
|
|
2871
|
+
startedAt = Date.now();
|
|
2872
|
+
cursorIndex = 0;
|
|
2873
|
+
scrollOffset = 0;
|
|
2874
|
+
loadedItemId = "";
|
|
2875
|
+
await fetchResources();
|
|
2876
|
+
draw();
|
|
2877
|
+
const tree = buildTree();
|
|
2878
|
+
const first = tree[cursorIndex];
|
|
2879
|
+
if (first) {
|
|
2880
|
+
scheduleLoad(first);
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2883
|
+
catch (error) {
|
|
2884
|
+
const errMsg = error instanceof Error ? error.message : String(error);
|
|
2885
|
+
viewerLines = [pc.red(`Failed to connect: ${errMsg}`)];
|
|
2886
|
+
draw();
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
async function handleSwitch() {
|
|
2891
|
+
if (!connected) {
|
|
2892
|
+
return;
|
|
2893
|
+
}
|
|
2894
|
+
// Close current connection
|
|
2895
|
+
try {
|
|
2896
|
+
await db.close();
|
|
2897
|
+
}
|
|
2898
|
+
catch {
|
|
2899
|
+
// ignore close errors
|
|
2900
|
+
}
|
|
2901
|
+
// Reset state
|
|
2902
|
+
connected = false;
|
|
2903
|
+
driver = "";
|
|
2904
|
+
dbPath = "";
|
|
2905
|
+
collections = [];
|
|
2906
|
+
streams = [];
|
|
2907
|
+
queues = [];
|
|
2908
|
+
objectsByCollection = new Map();
|
|
2909
|
+
collectionCounts.clear();
|
|
2910
|
+
cursorIndex = 0;
|
|
2911
|
+
scrollOffset = 0;
|
|
2912
|
+
loadedItemId = "";
|
|
2913
|
+
viewerLines = ["Select an environment to connect."];
|
|
2914
|
+
draw();
|
|
2915
|
+
const tree = buildTree();
|
|
2916
|
+
const first = tree[cursorIndex];
|
|
2917
|
+
if (first) {
|
|
2918
|
+
scheduleLoad(first);
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
async function handleLogout() {
|
|
2922
|
+
// Close current connection if any
|
|
2923
|
+
if (connected && db) {
|
|
2924
|
+
try {
|
|
2925
|
+
await db.close();
|
|
2926
|
+
}
|
|
2927
|
+
catch {
|
|
2928
|
+
// ignore close errors
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
// Remove cloud credentials from disk
|
|
2932
|
+
removeCloudConfig();
|
|
2933
|
+
// Reset state
|
|
2934
|
+
connected = false;
|
|
2935
|
+
driver = "";
|
|
2936
|
+
dbPath = "";
|
|
2937
|
+
authToken = "";
|
|
2938
|
+
cloudError = null;
|
|
2939
|
+
collections = [];
|
|
2940
|
+
streams = [];
|
|
2941
|
+
queues = [];
|
|
2942
|
+
objectsByCollection = new Map();
|
|
2943
|
+
collectionCounts.clear();
|
|
2944
|
+
cursorIndex = 0;
|
|
2945
|
+
scrollOffset = 0;
|
|
2946
|
+
loadedItemId = "";
|
|
2947
|
+
viewerLines = [pc.green("Logged out."), pc.dim("Select an environment to connect.")];
|
|
2948
|
+
draw();
|
|
2949
|
+
const tree = buildTree();
|
|
2950
|
+
const first = tree[cursorIndex];
|
|
2951
|
+
if (first) {
|
|
2952
|
+
scheduleLoad(first);
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
// ── Entry Point ──────────────────────────────────────────────────────
|
|
2956
|
+
export async function runInteractiveCli() {
|
|
2957
|
+
// Go straight into the TUI — no pre-prompts
|
|
2958
|
+
console.clear();
|
|
2959
|
+
process.stdout.write("\u001B[?1049h\u001B[H\u001B[?25l");
|
|
2960
|
+
// Show the driver selection screen
|
|
2961
|
+
viewerLines = [
|
|
2962
|
+
` ${logoText()} ${pc.dim("— local data engine")}`,
|
|
2963
|
+
"",
|
|
2964
|
+
pc.dim(" Select an environment to connect."),
|
|
2965
|
+
];
|
|
2966
|
+
draw();
|
|
2967
|
+
const tree = buildTree();
|
|
2968
|
+
const first = tree[cursorIndex];
|
|
2969
|
+
if (first) {
|
|
2970
|
+
scheduleLoad(first);
|
|
2971
|
+
}
|
|
2972
|
+
// Auto-connect to cloud if credentials exist
|
|
2973
|
+
const cloudCfg = readCloudConfig();
|
|
2974
|
+
if (cloudCfg?.userToken ?? cloudCfg?.token) {
|
|
2975
|
+
const cloudUrl = resolveCloudUrl(cloudCfg);
|
|
2976
|
+
if (cloudUrl) {
|
|
2977
|
+
try {
|
|
2978
|
+
const restUrl = deriveRestUrl(cloudUrl);
|
|
2979
|
+
await connectToDriver("cloud", restUrl, restUrl, cloudCfg.userToken ?? cloudCfg.apiKey ?? cloudCfg.token, cloudCfg.instanceSlug);
|
|
2980
|
+
}
|
|
2981
|
+
catch (err) {
|
|
2982
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2983
|
+
viewerLines = [
|
|
2984
|
+
pc.yellow(`Auto-connect failed: ${msg}`),
|
|
2985
|
+
pc.dim("Select an environment to connect, or press 'r' to retry."),
|
|
2986
|
+
];
|
|
2987
|
+
draw();
|
|
2988
|
+
}
|
|
2989
|
+
}
|
|
2990
|
+
}
|
|
2991
|
+
else {
|
|
2992
|
+
viewerLines = [
|
|
2993
|
+
pc.cyan("Not logged in to thingd Cloud."),
|
|
2994
|
+
pc.dim("Press 'c' to connect to a local database, or run thingd cloud login first."),
|
|
2995
|
+
];
|
|
2996
|
+
draw();
|
|
2997
|
+
}
|
|
2998
|
+
setupKeypress();
|
|
2999
|
+
// Background polling loop for real-time updates
|
|
3000
|
+
pollTimer = setInterval(async () => {
|
|
3001
|
+
if (!connected || formState?.active || polling) {
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
polling = true;
|
|
3005
|
+
try {
|
|
3006
|
+
const snapItemId = loadedItemId;
|
|
3007
|
+
const snapshot = JSON.stringify([
|
|
3008
|
+
totalObjects,
|
|
3009
|
+
totalEventsCount,
|
|
3010
|
+
totalActiveJobsCount,
|
|
3011
|
+
totalDeadJobsCount,
|
|
3012
|
+
totalLinksCount,
|
|
3013
|
+
]);
|
|
3014
|
+
await fetchResources();
|
|
3015
|
+
const changed = snapshot !==
|
|
3016
|
+
JSON.stringify([
|
|
3017
|
+
totalObjects,
|
|
3018
|
+
totalEventsCount,
|
|
3019
|
+
totalActiveJobsCount,
|
|
3020
|
+
totalDeadJobsCount,
|
|
3021
|
+
totalLinksCount,
|
|
3022
|
+
]);
|
|
3023
|
+
const tree = buildTree();
|
|
3024
|
+
const n = tree[cursorIndex];
|
|
3025
|
+
if (n && snapItemId === n.id && n.type !== "category") {
|
|
3026
|
+
await loadContent(n).catch(() => { });
|
|
3027
|
+
}
|
|
3028
|
+
if (changed) {
|
|
3029
|
+
draw();
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
catch {
|
|
3033
|
+
// Prevent unhandled rejection from killing the process
|
|
3034
|
+
}
|
|
3035
|
+
finally {
|
|
3036
|
+
polling = false;
|
|
3037
|
+
}
|
|
3038
|
+
}, 10_000);
|
|
3039
|
+
}
|