@kitlangton/motel 0.2.4 → 0.2.6
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/AGENTS.md +23 -8
- package/README.md +13 -2
- package/package.json +35 -19
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +12 -5
- package/src/StartupGate.tsx +289 -0
- package/src/cli.ts +15 -16
- package/src/config.ts +7 -1
- package/src/daemon.test.ts +332 -51
- package/src/daemon.ts +105 -153
- package/src/httpApi.ts +1 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +9 -2
- package/src/localServer.ts +194 -313
- package/src/mcp.ts +2 -1
- package/src/motel.ts +0 -2
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +20 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/registry.ts +37 -11
- package/src/runtime.ts +2 -6
- package/src/services/AsyncIngest.ts +22 -8
- package/src/services/LogQueryService.ts +13 -27
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +546 -231
- package/src/services/TraceQueryService.ts +22 -56
- package/src/services/ingestRpc.ts +2 -4
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +5 -8
- package/src/startupBench.ts +19 -0
- package/src/storybook/aiChatStory.tsx +1 -1
- package/src/telemetry.test.ts +307 -41
- package/src/ui/AiChatView.tsx +1 -1
- package/src/ui/AttrFilterModal.tsx +1 -1
- package/src/ui/ServiceLogs.tsx +10 -7
- package/src/ui/SpanContentView.tsx +24 -21
- package/src/ui/TraceDetailsPane.tsx +1 -1
- package/src/ui/TraceList.tsx +1 -1
- package/src/ui/aiState.ts +10 -22
- package/src/ui/app/TraceWorkspace.tsx +2 -1
- package/src/ui/app/useAppLayout.ts +1 -1
- package/src/ui/app/useTraceScreenData.ts +35 -23
- package/src/ui/atoms.ts +1 -1
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/loaders.ts +34 -53
- package/src/ui/persistence.ts +3 -3
- package/src/ui/primitives.tsx +1 -1
- package/src/ui/state.ts +2 -0
- package/src/ui/theme.ts +7 -5
- package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
- package/src/ui/traceSortNav.repro.seed.ts +1 -1
- package/src/ui/traceSortNav.repro.test.ts +12 -2
- package/src/ui/useAttrFilterPicker.ts +10 -8
- package/src/ui/useKeyboardNav.ts +28 -5
- package/src/ui/waterfallNav.repro.seed.ts +1 -1
- package/src/ui/waterfallNav.repro.test.ts +16 -8
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-M86tcih5.js +22 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DnyVo03x.js +0 -27
- package/web/dist/assets/index-DzuHNBGV.css +0 -2
package/src/telemetry.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
1
2
|
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
|
2
|
-
import { mkdtempSync, rmSync } from "node:fs"
|
|
3
|
+
import { copyFileSync, mkdtempSync, rmSync } from "node:fs"
|
|
3
4
|
import { tmpdir } from "node:os"
|
|
4
5
|
import { join } from "node:path"
|
|
5
6
|
import { Effect, References } from "effect"
|
|
@@ -8,6 +9,8 @@ import { attributeFiltersFromArgs, attributeContainsFiltersFromArgs, isAttribute
|
|
|
8
9
|
describe("motel telemetry store", () => {
|
|
9
10
|
const tempDir = mkdtempSync(join(tmpdir(), "motel-test-"))
|
|
10
11
|
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
12
|
+
const previousDatabasePath = process.env.MOTEL_OTEL_DB_PATH
|
|
13
|
+
const previousRetentionHours = process.env.MOTEL_OTEL_RETENTION_HOURS
|
|
11
14
|
let storeRuntime: Awaited<typeof import("./runtime.ts")>["storeRuntime"]
|
|
12
15
|
let TelemetryStore: Awaited<typeof import("./services/TelemetryStore.ts")>["TelemetryStore"]
|
|
13
16
|
let motelOpenApiSpec: Awaited<typeof import("./httpApi.ts")>["motelOpenApiSpec"]
|
|
@@ -23,7 +26,7 @@ describe("motel telemetry store", () => {
|
|
|
23
26
|
const nowNanos = BigInt(Date.now()) * 1_000_000n
|
|
24
27
|
const oneSecond = 1_000_000_000n
|
|
25
28
|
|
|
26
|
-
const ingest = Effect.flatMap(TelemetryStore
|
|
29
|
+
const ingest = Effect.flatMap(TelemetryStore, (store) =>
|
|
27
30
|
Effect.flatMap(
|
|
28
31
|
store.ingestTraces({
|
|
29
32
|
resourceSpans: [
|
|
@@ -126,7 +129,7 @@ describe("motel telemetry store", () => {
|
|
|
126
129
|
},
|
|
127
130
|
],
|
|
128
131
|
}),
|
|
129
|
-
).pipe(Effect.flatMap(() => Effect.flatMap(TelemetryStore
|
|
132
|
+
).pipe(Effect.flatMap(() => Effect.flatMap(TelemetryStore, (store) =>
|
|
130
133
|
store.ingestTraces({
|
|
131
134
|
resourceSpans: [{
|
|
132
135
|
resource: { attributes: [{ key: "service.name", value: { stringValue: "test-api" } }] },
|
|
@@ -219,13 +222,192 @@ describe("motel telemetry store", () => {
|
|
|
219
222
|
await storeRuntime.runPromise(ingest.pipe(Effect.provideService(References.MinimumLogLevel, "None")))
|
|
220
223
|
})
|
|
221
224
|
|
|
222
|
-
afterAll(() => {
|
|
225
|
+
afterAll(async () => {
|
|
226
|
+
await storeRuntime.dispose()
|
|
223
227
|
rmSync(tempDir, { recursive: true, force: true })
|
|
228
|
+
if (previousDatabasePath === undefined) delete process.env.MOTEL_OTEL_DB_PATH
|
|
229
|
+
else process.env.MOTEL_OTEL_DB_PATH = previousDatabasePath
|
|
230
|
+
if (previousRetentionHours === undefined) delete process.env.MOTEL_OTEL_RETENTION_HOURS
|
|
231
|
+
else process.env.MOTEL_OTEL_RETENTION_HOURS = previousRetentionHours
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("creates fresh DBs with auto_vacuum=INCREMENTAL", () => {
|
|
235
|
+
// Headline regression test: PRAGMA auto_vacuum is a header-level
|
|
236
|
+
// setting that only takes effect when set BEFORE the first CREATE
|
|
237
|
+
// TABLE, or after a full VACUUM. The previous code set it AFTER
|
|
238
|
+
// schema init, so every motel DB ever created had auto_vacuum=NONE
|
|
239
|
+
// and incremental_vacuum was silently a no-op — the documented
|
|
240
|
+
// mechanism behind the 17GB telemetry.sqlite this test exists to
|
|
241
|
+
// prevent.
|
|
242
|
+
const probe = new Database(dbPath, { readonly: true })
|
|
243
|
+
try {
|
|
244
|
+
const mode = (probe.query(`PRAGMA auto_vacuum`).get() as { auto_vacuum: number }).auto_vacuum
|
|
245
|
+
expect(mode).toBe(2) // 2 = INCREMENTAL
|
|
246
|
+
} finally {
|
|
247
|
+
probe.close()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it("incremental_vacuum reclaims pages back to the OS after deletes", () => {
|
|
252
|
+
// Proves the full reclaim chain works on the real schema: DELETE
|
|
253
|
+
// → wal_checkpoint → incremental_vacuum → page_count drops. With
|
|
254
|
+
// the previous auto_vacuum=NONE bug, page_count would not change
|
|
255
|
+
// no matter how many incremental_vacuum calls were made.
|
|
256
|
+
// Operate on a copy of the seed DB so we don't destroy state that
|
|
257
|
+
// later tests rely on. Checkpoint first so the copy is consistent.
|
|
258
|
+
const sourceProbe = new Database(dbPath)
|
|
259
|
+
try { sourceProbe.exec(`PRAGMA wal_checkpoint(TRUNCATE);`) } finally { sourceProbe.close() }
|
|
260
|
+
const clonePath = join(tempDir, "telemetry-vacuum-clone.sqlite")
|
|
261
|
+
copyFileSync(dbPath, clonePath)
|
|
262
|
+
const probe = new Database(clonePath)
|
|
263
|
+
try {
|
|
264
|
+
probe.exec(`PRAGMA busy_timeout = 5000;`)
|
|
265
|
+
|
|
266
|
+
// Bulk-insert filler so we have enough pages to make truncation
|
|
267
|
+
// observable. SQLite frees pages whole-page-at-a-time, so a tiny
|
|
268
|
+
// fixture (a handful of partial pages) won't yield a measurable
|
|
269
|
+
// page_count delta. 1000 rows × ~600 bytes = ~600KB ≈ 150 pages.
|
|
270
|
+
const stmt = probe.prepare(
|
|
271
|
+
`INSERT INTO spans (trace_id, span_id, parent_span_id, service_name, scope_name, operation_name, kind, start_time_ms, end_time_ms, duration_ms, status, attributes_json, resource_json, events_json) VALUES (?, ?, NULL, 'vac', 'scope', 'op', 'INTERNAL', 0, 0, 0, 'OK', '{}', '{}', '[]')`,
|
|
272
|
+
)
|
|
273
|
+
probe.exec(`BEGIN IMMEDIATE;`)
|
|
274
|
+
const filler = "x".repeat(512)
|
|
275
|
+
for (let i = 0; i < 1000; i++) {
|
|
276
|
+
stmt.run(`v${i.toString().padStart(8, "0")}-${filler.slice(0, 40)}`, `s${i.toString(16).padStart(15, "0")}`)
|
|
277
|
+
}
|
|
278
|
+
probe.exec(`COMMIT;`)
|
|
279
|
+
|
|
280
|
+
const pageCountBefore = (probe.query(`PRAGMA page_count`).get() as { page_count: number }).page_count
|
|
281
|
+
expect(pageCountBefore).toBeGreaterThan(50)
|
|
282
|
+
|
|
283
|
+
probe.exec(`DELETE FROM spans WHERE service_name = 'vac';`)
|
|
284
|
+
|
|
285
|
+
const freelistAfterDelete = (probe.query(`PRAGMA freelist_count`).get() as { freelist_count: number }).freelist_count
|
|
286
|
+
expect(freelistAfterDelete).toBeGreaterThan(0)
|
|
287
|
+
|
|
288
|
+
probe.exec(`PRAGMA wal_checkpoint(RESTART);`)
|
|
289
|
+
probe.exec(`PRAGMA incremental_vacuum;`)
|
|
290
|
+
probe.exec(`PRAGMA wal_checkpoint(TRUNCATE);`)
|
|
291
|
+
|
|
292
|
+
const pageCountAfter = (probe.query(`PRAGMA page_count`).get() as { page_count: number }).page_count
|
|
293
|
+
const freelistAfter = (probe.query(`PRAGMA freelist_count`).get() as { freelist_count: number }).freelist_count
|
|
294
|
+
expect(pageCountAfter).toBeLessThan(pageCountBefore)
|
|
295
|
+
expect(freelistAfter).toBeLessThan(freelistAfterDelete)
|
|
296
|
+
} finally {
|
|
297
|
+
probe.close()
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it("retention prunes old correlated orphan logs and preserves recent logs", async () => {
|
|
302
|
+
const oldNanos = BigInt(Date.now() - 48 * 60 * 60 * 1000) * 1_000_000n
|
|
303
|
+
const recentNanos = BigInt(Date.now()) * 1_000_000n
|
|
304
|
+
await storeRuntime.runPromise(
|
|
305
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
306
|
+
Effect.andThen(
|
|
307
|
+
store.ingestLogs({
|
|
308
|
+
resourceLogs: [{
|
|
309
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "retention-test" } }] },
|
|
310
|
+
scopeLogs: [{ logRecords: [{ traceId: "missing-old-trace", timeUnixNano: String(oldNanos), body: { stringValue: "expired-correlated-log" } }, { traceId: "missing-recent-trace", timeUnixNano: String(recentNanos), body: { stringValue: "recent-correlated-log" } }] }],
|
|
311
|
+
}],
|
|
312
|
+
}),
|
|
313
|
+
store.runRetentionNow,
|
|
314
|
+
),
|
|
315
|
+
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
const probe = new Database(dbPath, { readonly: true })
|
|
319
|
+
try {
|
|
320
|
+
const oldCount = (probe.query(`SELECT COUNT(*) AS c FROM logs WHERE body = 'expired-correlated-log'`).get() as { c: number }).c
|
|
321
|
+
const recentCount = (probe.query(`SELECT COUNT(*) AS c FROM logs WHERE body = 'recent-correlated-log'`).get() as { c: number }).c
|
|
322
|
+
expect(oldCount).toBe(0)
|
|
323
|
+
expect(recentCount).toBe(1)
|
|
324
|
+
} finally {
|
|
325
|
+
probe.close()
|
|
326
|
+
}
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it("retention cleans orphan log indexes even when no logs are deleted", async () => {
|
|
330
|
+
const probe = new Database(dbPath)
|
|
331
|
+
try {
|
|
332
|
+
probe.query(`INSERT INTO log_attributes(log_id, key, value) VALUES (?, 'orphan', 'value')`).run(9_999_999)
|
|
333
|
+
probe.query(`INSERT INTO log_body_fts(log_id, body) VALUES (?, 'orphan body')`).run("9999999")
|
|
334
|
+
} finally {
|
|
335
|
+
probe.close()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
await storeRuntime.runPromise(
|
|
339
|
+
Effect.flatMap(TelemetryStore, (store) => store.runRetentionNow).pipe(
|
|
340
|
+
Effect.provideService(References.MinimumLogLevel, "None"),
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
const check = new Database(dbPath, { readonly: true })
|
|
345
|
+
try {
|
|
346
|
+
expect((check.query(`SELECT COUNT(*) AS c FROM log_attributes WHERE log_id = 9999999`).get() as { c: number }).c).toBe(0)
|
|
347
|
+
expect((check.query(`SELECT COUNT(*) AS c FROM log_body_fts WHERE log_id = '9999999'`).get() as { c: number }).c).toBe(0)
|
|
348
|
+
} finally {
|
|
349
|
+
check.close()
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
it("does not rewrite legacy auto_vacuum databases on startup", async () => {
|
|
354
|
+
const legacyPath = join(tempDir, "legacy-no-vacuum.sqlite")
|
|
355
|
+
const legacy = new Database(legacyPath)
|
|
356
|
+
legacy.exec(`PRAGMA auto_vacuum = NONE; CREATE TABLE legacy(value TEXT); INSERT INTO legacy VALUES ('kept');`)
|
|
357
|
+
legacy.close()
|
|
358
|
+
|
|
359
|
+
const open = Bun.spawn({
|
|
360
|
+
cmd: [process.execPath, "-e", `const { Effect } = await import('effect'); const { storeRuntime } = await import('./src/runtime.ts'); const { TelemetryStore } = await import('./src/services/TelemetryStore.ts'); await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.listServices)); await storeRuntime.dispose()`],
|
|
361
|
+
cwd: process.cwd(),
|
|
362
|
+
env: { ...process.env, MOTEL_OTEL_DB_PATH: legacyPath },
|
|
363
|
+
stdout: "ignore",
|
|
364
|
+
stderr: "pipe",
|
|
365
|
+
})
|
|
366
|
+
expect(await open.exited).toBe(0)
|
|
367
|
+
|
|
368
|
+
const probe = new Database(legacyPath, { readonly: true })
|
|
369
|
+
try {
|
|
370
|
+
expect((probe.query(`PRAGMA auto_vacuum`).get() as { auto_vacuum: number }).auto_vacuum).toBe(0)
|
|
371
|
+
expect((probe.query(`SELECT value FROM legacy`).get() as { value: string }).value).toBe("kept")
|
|
372
|
+
} finally {
|
|
373
|
+
probe.close()
|
|
374
|
+
}
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
it("incrementally backfills historical AI FTS content", async () => {
|
|
378
|
+
const historicalPath = join(tempDir, "historical-fts.sqlite")
|
|
379
|
+
const seedScript = `
|
|
380
|
+
const { Effect } = await import('effect')
|
|
381
|
+
const { storeRuntime } = await import('./src/runtime.ts')
|
|
382
|
+
const { TelemetryStore } = await import('./src/services/TelemetryStore.ts')
|
|
383
|
+
await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({ resourceSpans: [{ resource: { attributes: [{ key: 'service.name', value: { stringValue: 'historical-fts' } }] }, scopeSpans: [{ spans: [{ traceId: 'historical-trace', spanId: 'historical-span', name: 'ai.generateText', startTimeUnixNano: '1', endTimeUnixNano: '2', attributes: [{ key: 'ai.response.text', value: { stringValue: 'historical-backfill-token' } }] }] }] }] })))
|
|
384
|
+
await storeRuntime.dispose()
|
|
385
|
+
`
|
|
386
|
+
const seed = Bun.spawn({ cmd: [process.execPath, "-e", seedScript], cwd: process.cwd(), env: { ...process.env, MOTEL_OTEL_DB_PATH: historicalPath }, stdout: "ignore", stderr: "pipe" })
|
|
387
|
+
expect(await seed.exited).toBe(0)
|
|
388
|
+
|
|
389
|
+
const damage = new Database(historicalPath)
|
|
390
|
+
damage.query(`INSERT INTO span_attr_fts(span_attr_fts) VALUES ('delete-all')`).run()
|
|
391
|
+
damage.query(`DELETE FROM motel_maintenance WHERE key = 'span_attr_fts_v1'`).run()
|
|
392
|
+
damage.close()
|
|
393
|
+
|
|
394
|
+
const repair = Bun.spawn({ cmd: [process.execPath, "-e", `const { Effect } = await import('effect'); const { storeRuntime } = await import('./src/runtime.ts'); const { TelemetryStore } = await import('./src/services/TelemetryStore.ts'); await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.listServices)); await Bun.sleep(500); await storeRuntime.dispose()`], cwd: process.cwd(), env: { ...process.env, MOTEL_OTEL_DB_PATH: historicalPath }, stdout: "ignore", stderr: "pipe" })
|
|
395
|
+
expect(await repair.exited).toBe(0)
|
|
396
|
+
|
|
397
|
+
const probe = new Database(historicalPath, { readonly: true })
|
|
398
|
+
try {
|
|
399
|
+
const match = (probe.query(`SELECT COUNT(*) AS c FROM span_attr_fts WHERE span_attr_fts MATCH 'historical'`).get() as { c: number }).c
|
|
400
|
+
const marker = (probe.query(`SELECT value FROM motel_maintenance WHERE key = 'span_attr_fts_v1'`).get() as { value: string }).value
|
|
401
|
+
expect(match).toBe(1)
|
|
402
|
+
expect(marker).toBe("complete")
|
|
403
|
+
} finally {
|
|
404
|
+
probe.close()
|
|
405
|
+
}
|
|
224
406
|
})
|
|
225
407
|
|
|
226
408
|
it("filters traces by attr.* fields", async () => {
|
|
227
409
|
const result = await storeRuntime.runPromise(
|
|
228
|
-
Effect.flatMap(TelemetryStore
|
|
410
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
229
411
|
store.searchTraces({
|
|
230
412
|
serviceName: "test-api",
|
|
231
413
|
attributeFilters: {
|
|
@@ -242,7 +424,7 @@ describe("motel telemetry store", () => {
|
|
|
242
424
|
|
|
243
425
|
it("looks up a span directly by spanId", async () => {
|
|
244
426
|
const result = await storeRuntime.runPromise(
|
|
245
|
-
Effect.flatMap(TelemetryStore
|
|
427
|
+
Effect.flatMap(TelemetryStore, (store) => store.getSpan("child-1")).pipe(
|
|
246
428
|
Effect.provideService(References.MinimumLogLevel, "None"),
|
|
247
429
|
),
|
|
248
430
|
)
|
|
@@ -253,9 +435,57 @@ describe("motel telemetry store", () => {
|
|
|
253
435
|
expect(result?.span.depth).toBe(1)
|
|
254
436
|
})
|
|
255
437
|
|
|
438
|
+
it("returns trace details through the query worker when spans share empty event arrays", async () => {
|
|
439
|
+
const workerDbPath = join(tempDir, "query-worker-shared-events.sqlite")
|
|
440
|
+
const script = `
|
|
441
|
+
const { Effect, ManagedRuntime } = await import("effect")
|
|
442
|
+
const { storeRuntime } = await import("./src/runtime.ts")
|
|
443
|
+
const { TelemetryStore, TelemetryStoreReadonly } = await import("./src/services/TelemetryStore.ts")
|
|
444
|
+
await storeRuntime.runPromise(Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({
|
|
445
|
+
resourceSpans: [{
|
|
446
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "worker-repro" } }] },
|
|
447
|
+
scopeSpans: [{ spans: [
|
|
448
|
+
{ traceId: "shared-trace", spanId: "root", name: "root", startTimeUnixNano: "1", endTimeUnixNano: "3" },
|
|
449
|
+
{ traceId: "shared-trace", spanId: "child", parentSpanId: "root", name: "child", startTimeUnixNano: "2", endTimeUnixNano: "3" },
|
|
450
|
+
] }],
|
|
451
|
+
}],
|
|
452
|
+
})))
|
|
453
|
+
await storeRuntime.dispose()
|
|
454
|
+
const { TelemetryQueryLive } = await import("./src/services/TelemetryQuery.ts")
|
|
455
|
+
const queryRuntime = ManagedRuntime.make(TelemetryQueryLive)
|
|
456
|
+
try {
|
|
457
|
+
const trace = await queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (store) => store.getTrace("shared-trace")))
|
|
458
|
+
if (trace?.spans.length !== 2) throw new Error("Expected two spans")
|
|
459
|
+
} finally {
|
|
460
|
+
await queryRuntime.dispose()
|
|
461
|
+
}
|
|
462
|
+
`
|
|
463
|
+
const child = Bun.spawn({
|
|
464
|
+
cmd: [process.execPath, "-e", script],
|
|
465
|
+
cwd: process.cwd(),
|
|
466
|
+
env: { ...process.env, MOTEL_OTEL_DB_PATH: workerDbPath },
|
|
467
|
+
stdout: "ignore",
|
|
468
|
+
stderr: "pipe",
|
|
469
|
+
})
|
|
470
|
+
const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()])
|
|
471
|
+
expect(exitCode, stderr).toBe(0)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it("uses the canonical earliest root when directly looking up a later root span", async () => {
|
|
475
|
+
const result = await storeRuntime.runPromise(
|
|
476
|
+
Effect.flatMap(TelemetryStore, (store) => store.getSpan("ai-stream-2")).pipe(
|
|
477
|
+
Effect.provideService(References.MinimumLogLevel, "None"),
|
|
478
|
+
),
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
expect(result?.traceId).toBe("trace-ai")
|
|
482
|
+
expect(result?.span.operationName).toBe("ai.generateText")
|
|
483
|
+
expect(result?.rootOperationName).toBe("ai.streamText")
|
|
484
|
+
})
|
|
485
|
+
|
|
256
486
|
it("filters logs by spanId", async () => {
|
|
257
487
|
const result = await storeRuntime.runPromise(
|
|
258
|
-
Effect.flatMap(TelemetryStore
|
|
488
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
259
489
|
store.searchLogs({
|
|
260
490
|
spanId: "child-1",
|
|
261
491
|
}),
|
|
@@ -268,7 +498,7 @@ describe("motel telemetry store", () => {
|
|
|
268
498
|
|
|
269
499
|
it("searches spans by operation, parent operation, and attr filters", async () => {
|
|
270
500
|
const result = await storeRuntime.runPromise(
|
|
271
|
-
Effect.flatMap(TelemetryStore
|
|
501
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
272
502
|
store.searchSpans({
|
|
273
503
|
serviceName: "test-api",
|
|
274
504
|
operation: "tool.call",
|
|
@@ -288,7 +518,7 @@ describe("motel telemetry store", () => {
|
|
|
288
518
|
|
|
289
519
|
it("lists spans for a trace", async () => {
|
|
290
520
|
const result = await storeRuntime.runPromise(
|
|
291
|
-
Effect.flatMap(TelemetryStore
|
|
521
|
+
Effect.flatMap(TelemetryStore, (store) => store.listTraceSpans("trace-1")).pipe(
|
|
292
522
|
Effect.provideService(References.MinimumLogLevel, "None"),
|
|
293
523
|
),
|
|
294
524
|
)
|
|
@@ -303,7 +533,7 @@ describe("motel telemetry store", () => {
|
|
|
303
533
|
|
|
304
534
|
it("aggregates trace stats by operation", async () => {
|
|
305
535
|
const result = await storeRuntime.runPromise(
|
|
306
|
-
Effect.flatMap(TelemetryStore
|
|
536
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
307
537
|
store.traceStats({
|
|
308
538
|
groupBy: "operation",
|
|
309
539
|
agg: "avg_duration",
|
|
@@ -321,7 +551,7 @@ describe("motel telemetry store", () => {
|
|
|
321
551
|
|
|
322
552
|
it("aggregates log stats by severity", async () => {
|
|
323
553
|
const result = await storeRuntime.runPromise(
|
|
324
|
-
Effect.flatMap(TelemetryStore
|
|
554
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
325
555
|
store.logStats({
|
|
326
556
|
groupBy: "severity",
|
|
327
557
|
agg: "count",
|
|
@@ -347,7 +577,7 @@ describe("motel telemetry store", () => {
|
|
|
347
577
|
|
|
348
578
|
it("lists trace summaries without loading spans", async () => {
|
|
349
579
|
const result = await storeRuntime.runPromise(
|
|
350
|
-
Effect.flatMap(TelemetryStore
|
|
580
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
351
581
|
store.listTraceSummaries(null),
|
|
352
582
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
353
583
|
)
|
|
@@ -365,7 +595,7 @@ describe("motel telemetry store", () => {
|
|
|
365
595
|
|
|
366
596
|
it("lists trace summaries filtered by service", async () => {
|
|
367
597
|
const result = await storeRuntime.runPromise(
|
|
368
|
-
Effect.flatMap(TelemetryStore
|
|
598
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
369
599
|
store.listTraceSummaries("test-api"),
|
|
370
600
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
371
601
|
)
|
|
@@ -375,7 +605,7 @@ describe("motel telemetry store", () => {
|
|
|
375
605
|
|
|
376
606
|
it("searches trace summaries with status filter", async () => {
|
|
377
607
|
const result = await storeRuntime.runPromise(
|
|
378
|
-
Effect.flatMap(TelemetryStore
|
|
608
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
379
609
|
store.searchTraceSummaries({
|
|
380
610
|
serviceName: "test-api",
|
|
381
611
|
status: "error",
|
|
@@ -389,7 +619,7 @@ describe("motel telemetry store", () => {
|
|
|
389
619
|
|
|
390
620
|
it("searches trace summaries with attribute filters", async () => {
|
|
391
621
|
const result = await storeRuntime.runPromise(
|
|
392
|
-
Effect.flatMap(TelemetryStore
|
|
622
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
393
623
|
store.searchTraceSummaries({
|
|
394
624
|
serviceName: "test-api",
|
|
395
625
|
attributeFilters: { sessionID: "session-1" },
|
|
@@ -403,7 +633,7 @@ describe("motel telemetry store", () => {
|
|
|
403
633
|
|
|
404
634
|
it("searches trace summaries with operation filter", async () => {
|
|
405
635
|
const result = await storeRuntime.runPromise(
|
|
406
|
-
Effect.flatMap(TelemetryStore
|
|
636
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
407
637
|
store.searchTraceSummaries({
|
|
408
638
|
serviceName: "test-api",
|
|
409
639
|
operation: "tool.call",
|
|
@@ -417,7 +647,7 @@ describe("motel telemetry store", () => {
|
|
|
417
647
|
|
|
418
648
|
it("filters logs by severity", async () => {
|
|
419
649
|
const result = await storeRuntime.runPromise(
|
|
420
|
-
Effect.flatMap(TelemetryStore
|
|
650
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
421
651
|
store.searchLogs({ serviceName: "test-api", severity: "ERROR" }),
|
|
422
652
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
423
653
|
)
|
|
@@ -429,7 +659,7 @@ describe("motel telemetry store", () => {
|
|
|
429
659
|
|
|
430
660
|
it("filters logs by severity case-insensitively", async () => {
|
|
431
661
|
const result = await storeRuntime.runPromise(
|
|
432
|
-
Effect.flatMap(TelemetryStore
|
|
662
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
433
663
|
store.searchLogs({ serviceName: "test-api", severity: "error" }),
|
|
434
664
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
435
665
|
)
|
|
@@ -440,7 +670,7 @@ describe("motel telemetry store", () => {
|
|
|
440
670
|
|
|
441
671
|
it("searches log body case-insensitively", async () => {
|
|
442
672
|
const result = await storeRuntime.runPromise(
|
|
443
|
-
Effect.flatMap(TelemetryStore
|
|
673
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
444
674
|
store.searchLogs({ serviceName: "test-api", body: "STREAM FAILED" }),
|
|
445
675
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
446
676
|
)
|
|
@@ -451,7 +681,7 @@ describe("motel telemetry store", () => {
|
|
|
451
681
|
|
|
452
682
|
it("searches spans by traceId", async () => {
|
|
453
683
|
const result = await storeRuntime.runPromise(
|
|
454
|
-
Effect.flatMap(TelemetryStore
|
|
684
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
455
685
|
store.searchSpans({ traceId: "trace-1" }),
|
|
456
686
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
457
687
|
)
|
|
@@ -462,7 +692,7 @@ describe("motel telemetry store", () => {
|
|
|
462
692
|
|
|
463
693
|
it("searches spans with attrContains substring filter", async () => {
|
|
464
694
|
const result = await storeRuntime.runPromise(
|
|
465
|
-
Effect.flatMap(TelemetryStore
|
|
695
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
466
696
|
store.searchSpans({
|
|
467
697
|
serviceName: "test-api",
|
|
468
698
|
attributeContainsFilters: { sessionID: "session" },
|
|
@@ -476,7 +706,7 @@ describe("motel telemetry store", () => {
|
|
|
476
706
|
|
|
477
707
|
it("searches spans with attrContains case-insensitively", async () => {
|
|
478
708
|
const result = await storeRuntime.runPromise(
|
|
479
|
-
Effect.flatMap(TelemetryStore
|
|
709
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
480
710
|
store.searchSpans({
|
|
481
711
|
serviceName: "test-api",
|
|
482
712
|
attributeContainsFilters: { modelID: "GPT" },
|
|
@@ -490,7 +720,7 @@ describe("motel telemetry store", () => {
|
|
|
490
720
|
|
|
491
721
|
it("searches logs with attrContains substring filter", async () => {
|
|
492
722
|
const result = await storeRuntime.runPromise(
|
|
493
|
-
Effect.flatMap(TelemetryStore
|
|
723
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
494
724
|
store.searchLogs({
|
|
495
725
|
serviceName: "test-api",
|
|
496
726
|
attributeContainsFilters: { tool: "sea" },
|
|
@@ -504,7 +734,7 @@ describe("motel telemetry store", () => {
|
|
|
504
734
|
|
|
505
735
|
it("combines severity and body filters", async () => {
|
|
506
736
|
const result = await storeRuntime.runPromise(
|
|
507
|
-
Effect.flatMap(TelemetryStore
|
|
737
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
508
738
|
store.searchLogs({ serviceName: "test-api", severity: "INFO", body: "tool" }),
|
|
509
739
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
510
740
|
)
|
|
@@ -515,7 +745,7 @@ describe("motel telemetry store", () => {
|
|
|
515
745
|
|
|
516
746
|
it("computes facet status without N+1 queries", async () => {
|
|
517
747
|
const result = await storeRuntime.runPromise(
|
|
518
|
-
Effect.flatMap(TelemetryStore
|
|
748
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
519
749
|
store.listFacets({
|
|
520
750
|
type: "traces",
|
|
521
751
|
field: "status",
|
|
@@ -532,7 +762,7 @@ describe("motel telemetry store", () => {
|
|
|
532
762
|
|
|
533
763
|
it("computes logStats with SQL aggregation", async () => {
|
|
534
764
|
const result = await storeRuntime.runPromise(
|
|
535
|
-
Effect.flatMap(TelemetryStore
|
|
765
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
536
766
|
store.logStats({
|
|
537
767
|
groupBy: "service",
|
|
538
768
|
agg: "count",
|
|
@@ -548,7 +778,7 @@ describe("motel telemetry store", () => {
|
|
|
548
778
|
|
|
549
779
|
it("computes traceStats count via SQL", async () => {
|
|
550
780
|
const result = await storeRuntime.runPromise(
|
|
551
|
-
Effect.flatMap(TelemetryStore
|
|
781
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
552
782
|
store.traceStats({
|
|
553
783
|
groupBy: "status",
|
|
554
784
|
agg: "count",
|
|
@@ -566,7 +796,7 @@ describe("motel telemetry store", () => {
|
|
|
566
796
|
|
|
567
797
|
it("computes traceStats error_rate via SQL", async () => {
|
|
568
798
|
const result = await storeRuntime.runPromise(
|
|
569
|
-
Effect.flatMap(TelemetryStore
|
|
799
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
570
800
|
store.traceStats({
|
|
571
801
|
groupBy: "service",
|
|
572
802
|
agg: "error_rate",
|
|
@@ -613,7 +843,7 @@ describe("motel telemetry store", () => {
|
|
|
613
843
|
|
|
614
844
|
it("searches AI calls and returns compact summaries", async () => {
|
|
615
845
|
const result = await storeRuntime.runPromise(
|
|
616
|
-
Effect.flatMap(TelemetryStore
|
|
846
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
617
847
|
store.searchAiCalls({}),
|
|
618
848
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
619
849
|
)
|
|
@@ -636,7 +866,7 @@ describe("motel telemetry store", () => {
|
|
|
636
866
|
|
|
637
867
|
it("dedupes nested doStream spans from AI summaries", async () => {
|
|
638
868
|
const result = await storeRuntime.runPromise(
|
|
639
|
-
Effect.flatMap(TelemetryStore
|
|
869
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
640
870
|
store.searchAiCalls({ sessionId: "ses_test123" }),
|
|
641
871
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
642
872
|
)
|
|
@@ -647,7 +877,7 @@ describe("motel telemetry store", () => {
|
|
|
647
877
|
|
|
648
878
|
it("filters AI calls by model", async () => {
|
|
649
879
|
const result = await storeRuntime.runPromise(
|
|
650
|
-
Effect.flatMap(TelemetryStore
|
|
880
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
651
881
|
store.searchAiCalls({ model: "claude-opus-4" }),
|
|
652
882
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
653
883
|
)
|
|
@@ -659,7 +889,7 @@ describe("motel telemetry store", () => {
|
|
|
659
889
|
|
|
660
890
|
it("filters AI calls by sessionId", async () => {
|
|
661
891
|
const result = await storeRuntime.runPromise(
|
|
662
|
-
Effect.flatMap(TelemetryStore
|
|
892
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
663
893
|
store.searchAiCalls({ sessionId: "ses_test123" }),
|
|
664
894
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
665
895
|
)
|
|
@@ -670,7 +900,7 @@ describe("motel telemetry store", () => {
|
|
|
670
900
|
|
|
671
901
|
it("searches AI calls by text content", async () => {
|
|
672
902
|
const result = await storeRuntime.runPromise(
|
|
673
|
-
Effect.flatMap(TelemetryStore
|
|
903
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
674
904
|
store.searchAiCalls({ text: "joke about programming" }),
|
|
675
905
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
676
906
|
)
|
|
@@ -683,7 +913,7 @@ describe("motel telemetry store", () => {
|
|
|
683
913
|
// Verifies FTS indexes ai.response.text, not just ai.prompt*. The
|
|
684
914
|
// seeded ai-stream-2 has response "Error: rate limited".
|
|
685
915
|
const result = await storeRuntime.runPromise(
|
|
686
|
-
Effect.flatMap(TelemetryStore
|
|
916
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
687
917
|
store.searchAiCalls({ text: "rate limited" }),
|
|
688
918
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
689
919
|
)
|
|
@@ -694,7 +924,7 @@ describe("motel telemetry store", () => {
|
|
|
694
924
|
// unicode61 tokenizer is case-insensitive by default; prefix `*`
|
|
695
925
|
// handles partial terms like `"PROG"` matching `"programming"`.
|
|
696
926
|
const result = await storeRuntime.runPromise(
|
|
697
|
-
Effect.flatMap(TelemetryStore
|
|
927
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
698
928
|
store.searchAiCalls({ text: "PROG" }),
|
|
699
929
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
700
930
|
)
|
|
@@ -705,7 +935,7 @@ describe("motel telemetry store", () => {
|
|
|
705
935
|
// FTS5 treats `"`, `*`, `-`, `:` as operators; toFtsQuery must
|
|
706
936
|
// strip them so raw user input never crashes the query.
|
|
707
937
|
const result = await storeRuntime.runPromise(
|
|
708
|
-
Effect.flatMap(TelemetryStore
|
|
938
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
709
939
|
store.searchAiCalls({ text: `"joke" - about:programming*` }),
|
|
710
940
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
711
941
|
)
|
|
@@ -714,7 +944,7 @@ describe("motel telemetry store", () => {
|
|
|
714
944
|
|
|
715
945
|
it("filters AI calls by operation type", async () => {
|
|
716
946
|
const result = await storeRuntime.runPromise(
|
|
717
|
-
Effect.flatMap(TelemetryStore
|
|
947
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
718
948
|
store.searchAiCalls({ operation: "generateText" }),
|
|
719
949
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
720
950
|
)
|
|
@@ -725,7 +955,7 @@ describe("motel telemetry store", () => {
|
|
|
725
955
|
|
|
726
956
|
it("gets AI call detail with full payloads", async () => {
|
|
727
957
|
const result = await storeRuntime.runPromise(
|
|
728
|
-
Effect.flatMap(TelemetryStore
|
|
958
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
729
959
|
store.getAiCall("ai-stream-1"),
|
|
730
960
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
731
961
|
)
|
|
@@ -743,7 +973,7 @@ describe("motel telemetry store", () => {
|
|
|
743
973
|
|
|
744
974
|
it("returns null for non-AI span in getAiCall", async () => {
|
|
745
975
|
const result = await storeRuntime.runPromise(
|
|
746
|
-
Effect.flatMap(TelemetryStore
|
|
976
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
747
977
|
store.getAiCall("root-1"),
|
|
748
978
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
749
979
|
)
|
|
@@ -753,7 +983,7 @@ describe("motel telemetry store", () => {
|
|
|
753
983
|
|
|
754
984
|
it("aggregates AI call stats by model", async () => {
|
|
755
985
|
const result = await storeRuntime.runPromise(
|
|
756
|
-
Effect.flatMap(TelemetryStore
|
|
986
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
757
987
|
store.aiCallStats({ groupBy: "model", agg: "count" }),
|
|
758
988
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
759
989
|
)
|
|
@@ -767,7 +997,7 @@ describe("motel telemetry store", () => {
|
|
|
767
997
|
|
|
768
998
|
it("aggregates AI call stats by status", async () => {
|
|
769
999
|
const result = await storeRuntime.runPromise(
|
|
770
|
-
Effect.flatMap(TelemetryStore
|
|
1000
|
+
Effect.flatMap(TelemetryStore, (store) =>
|
|
771
1001
|
store.aiCallStats({ groupBy: "status", agg: "count" }),
|
|
772
1002
|
).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
773
1003
|
)
|
|
@@ -784,4 +1014,40 @@ describe("motel telemetry store", () => {
|
|
|
784
1014
|
expect(motelOpenApiSpec.paths["/api/ai/calls/{spanId}"]).toBeDefined()
|
|
785
1015
|
expect(motelOpenApiSpec.paths["/api/ai/stats"]).toBeDefined()
|
|
786
1016
|
})
|
|
1017
|
+
|
|
1018
|
+
it("lists services with recent child-span activity even when the root started earlier", async () => {
|
|
1019
|
+
const nowNanos = BigInt(Date.now()) * 1_000_000n
|
|
1020
|
+
const oldRootNanos = nowNanos - 2n * 24n * 60n * 60n * 1_000_000_000n
|
|
1021
|
+
await storeRuntime.runPromise(
|
|
1022
|
+
Effect.flatMap(TelemetryStore, (store) => store.ingestTraces({
|
|
1023
|
+
resourceSpans: [{
|
|
1024
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: "active-child-only" } }] },
|
|
1025
|
+
scopeSpans: [{
|
|
1026
|
+
spans: [{
|
|
1027
|
+
traceId: "trace-active-child",
|
|
1028
|
+
spanId: "old-root",
|
|
1029
|
+
name: "old.root",
|
|
1030
|
+
startTimeUnixNano: String(oldRootNanos),
|
|
1031
|
+
endTimeUnixNano: String(oldRootNanos + 1_000_000n),
|
|
1032
|
+
}, {
|
|
1033
|
+
traceId: "trace-active-child",
|
|
1034
|
+
spanId: "recent-child",
|
|
1035
|
+
parentSpanId: "old-root",
|
|
1036
|
+
name: "recent.child",
|
|
1037
|
+
startTimeUnixNano: String(nowNanos),
|
|
1038
|
+
endTimeUnixNano: String(nowNanos + 1_000_000n),
|
|
1039
|
+
}],
|
|
1040
|
+
}],
|
|
1041
|
+
}],
|
|
1042
|
+
})).pipe(Effect.provideService(References.MinimumLogLevel, "None")),
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
const services = await storeRuntime.runPromise(
|
|
1046
|
+
Effect.flatMap(TelemetryStore, (store) => store.listServices).pipe(
|
|
1047
|
+
Effect.provideService(References.MinimumLogLevel, "None"),
|
|
1048
|
+
),
|
|
1049
|
+
)
|
|
1050
|
+
|
|
1051
|
+
expect(services).toContain("active-child-only")
|
|
1052
|
+
})
|
|
787
1053
|
})
|
package/src/ui/AiChatView.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./aiChatModel.ts"
|
|
12
12
|
import { formatDuration, truncateText } from "./format.ts"
|
|
13
13
|
import { AlignedHeaderLine, BlankRow, Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
14
|
-
import type { AiCallDetailState } from "./
|
|
14
|
+
import type { AiCallDetailState } from "./aiState.ts"
|
|
15
15
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
16
16
|
|
|
17
17
|
export const AI_CHAT_HEADER_ROWS = 4
|
|
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
|
|
|
2
2
|
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
3
3
|
import { colors } from "./theme.ts"
|
|
4
4
|
import { fitCell, truncateText } from "./format.ts"
|
|
5
|
-
import type { AttrFacetState, AttrPickerMode } from "./
|
|
5
|
+
import type { AttrFacetState, AttrPickerMode } from "./atoms.ts"
|
|
6
6
|
|
|
7
7
|
export interface AttrFilterModalProps {
|
|
8
8
|
readonly width: number
|