@telorun/debug-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/README.md +96 -0
- package/app-dist/assets/index-npnCjha3.css +1 -0
- package/app-dist/assets/index-tx0habRA.js +49 -0
- package/app-dist/index.html +22 -0
- package/app-single/index.html +70 -0
- package/package.json +68 -0
- package/src/App.tsx +10 -0
- package/src/components/DebugWatcher.tsx +86 -0
- package/src/components/EventTable.tsx +65 -0
- package/src/components/FilterBar.tsx +77 -0
- package/src/components/PayloadInspector.tsx +71 -0
- package/src/components/index.ts +5 -0
- package/src/components/styles.css +219 -0
- package/src/css.d.ts +3 -0
- package/src/filter.test.ts +53 -0
- package/src/filter.ts +52 -0
- package/src/index.ts +14 -0
- package/src/main.tsx +9 -0
- package/src/media.test.ts +39 -0
- package/src/media.ts +55 -0
- package/src/sse-client.ts +39 -0
- package/src/wire.ts +77 -0
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@telorun/debug-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Browser-safe debug event UI for Telo: wire types, filtering, SSE client, and React components for watching a running app's event stream.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telo",
|
|
7
|
+
"debug",
|
|
8
|
+
"events",
|
|
9
|
+
"sse"
|
|
10
|
+
],
|
|
11
|
+
"author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
|
|
12
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/telorun/telo.git",
|
|
16
|
+
"directory": "packages/debug-ui"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"source": "./src/index.ts",
|
|
23
|
+
"types": "./src/index.ts",
|
|
24
|
+
"bun": "./src/index.ts",
|
|
25
|
+
"import": "./dist/index.js",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./components": {
|
|
29
|
+
"source": "./src/components/index.ts",
|
|
30
|
+
"types": "./src/components/index.ts",
|
|
31
|
+
"bun": "./src/components/index.ts",
|
|
32
|
+
"import": "./dist/components/index.js",
|
|
33
|
+
"default": "./dist/components/index.js"
|
|
34
|
+
},
|
|
35
|
+
"./app-dist/*": "./app-dist/*",
|
|
36
|
+
"./app-single/*": "./app-single/*",
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist/**",
|
|
41
|
+
"app-dist/**",
|
|
42
|
+
"app-single/**",
|
|
43
|
+
"src/**"
|
|
44
|
+
],
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": ">=18",
|
|
47
|
+
"react-dom": ">=18"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^20.0.0",
|
|
51
|
+
"@types/react": "^19",
|
|
52
|
+
"@types/react-dom": "^19",
|
|
53
|
+
"@vitejs/plugin-react": "^4",
|
|
54
|
+
"react": "19.2.3",
|
|
55
|
+
"react-dom": "19.2.3",
|
|
56
|
+
"typescript": "^5.0.0",
|
|
57
|
+
"vite": "^6",
|
|
58
|
+
"vite-plugin-singlefile": "^2.3.3",
|
|
59
|
+
"vitest": "^2.1.8"
|
|
60
|
+
},
|
|
61
|
+
"scripts": {
|
|
62
|
+
"build": "vite build && vite build --config vite.single.config.ts",
|
|
63
|
+
"build:lib": "tsc -p tsconfig.lib.json",
|
|
64
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"test:watch": "vitest"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { DebugWatcher } from "./components/DebugWatcher.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Standalone debug-watcher page. Served by a Telo runtime's debug server at `/`,
|
|
5
|
+
* so the SSE endpoint is `/events` on the same origin — no configuration needed.
|
|
6
|
+
*/
|
|
7
|
+
export function App() {
|
|
8
|
+
const url = new URL("events", window.location.href).toString();
|
|
9
|
+
return <DebugWatcher url={url} />;
|
|
10
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
|
|
2
|
+
import { distinctSuffixes, type EventFilter, matchesFilter } from "../filter.js";
|
|
3
|
+
import { connectDebugStream } from "../sse-client.js";
|
|
4
|
+
import type { DebugEvent } from "../wire.js";
|
|
5
|
+
import { EventTable } from "./EventTable.js";
|
|
6
|
+
import { FilterBar } from "./FilterBar.js";
|
|
7
|
+
import "./styles.css";
|
|
8
|
+
|
|
9
|
+
export interface DebugWatcherProps {
|
|
10
|
+
/** The producer's SSE endpoint, e.g. `http://localhost:9230/events`. */
|
|
11
|
+
url: string;
|
|
12
|
+
/** Ring-buffer cap; oldest events drop past this. Default 5000. */
|
|
13
|
+
maxEvents?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The full debug-watcher view: connects to `url`, retains a bounded ring buffer,
|
|
18
|
+
* and renders a filterable, pausable, newest-first event stream. Pausing freezes
|
|
19
|
+
* the *view* while still collecting into the buffer, so nothing is lost on resume.
|
|
20
|
+
*
|
|
21
|
+
* Self-contained and prop-driven — the standalone app and the editor panel both
|
|
22
|
+
* mount this; only the `url` differs.
|
|
23
|
+
*/
|
|
24
|
+
export function DebugWatcher({ url, maxEvents = 5000 }: DebugWatcherProps) {
|
|
25
|
+
const bufferRef = useRef<DebugEvent[]>([]);
|
|
26
|
+
const pausedRef = useRef(false);
|
|
27
|
+
const [, bump] = useReducer((n: number) => n + 1, 0);
|
|
28
|
+
const [filter, setFilter] = useState<EventFilter>({});
|
|
29
|
+
const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");
|
|
30
|
+
const [paused, setPaused] = useState(false);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
bufferRef.current = [];
|
|
34
|
+
return connectDebugStream(url, {
|
|
35
|
+
onStatus: setStatus,
|
|
36
|
+
onEvent: (e) => {
|
|
37
|
+
const buf = bufferRef.current;
|
|
38
|
+
buf.push(e);
|
|
39
|
+
if (buf.length > maxEvents) buf.splice(0, buf.length - maxEvents);
|
|
40
|
+
if (!pausedRef.current) bump();
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}, [url, maxEvents]);
|
|
44
|
+
|
|
45
|
+
function togglePause() {
|
|
46
|
+
const next = !pausedRef.current;
|
|
47
|
+
pausedRef.current = next;
|
|
48
|
+
setPaused(next);
|
|
49
|
+
if (!next) bump();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clear() {
|
|
53
|
+
bufferRef.current = [];
|
|
54
|
+
bump();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Blob pointers are relative to the producer origin; resolve them against the
|
|
58
|
+
// SSE URL so the standalone page and the editor (cross-origin) both work.
|
|
59
|
+
const resolveUrl = (rel: string) => new URL(rel, url).toString();
|
|
60
|
+
|
|
61
|
+
const all = bufferRef.current;
|
|
62
|
+
// `all.length` + `paused` are the render signal (the ref array is mutated in
|
|
63
|
+
// place); recompute the derived views against the current buffer snapshot.
|
|
64
|
+
const suffixes = useMemo(() => distinctSuffixes(all), [all.length, paused]);
|
|
65
|
+
const visible = useMemo(
|
|
66
|
+
() => all.filter((e) => matchesFilter(e, filter)),
|
|
67
|
+
[all.length, filter, paused],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<div className="tdbg-root">
|
|
72
|
+
<FilterBar
|
|
73
|
+
filter={filter}
|
|
74
|
+
onChange={setFilter}
|
|
75
|
+
suffixes={suffixes}
|
|
76
|
+
status={status}
|
|
77
|
+
paused={paused}
|
|
78
|
+
onTogglePause={togglePause}
|
|
79
|
+
onClear={clear}
|
|
80
|
+
total={all.length}
|
|
81
|
+
shown={visible.length}
|
|
82
|
+
/>
|
|
83
|
+
<EventTable events={visible} resolveUrl={resolveUrl} />
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { type DebugEvent, eventSuffix } from "../wire.js";
|
|
3
|
+
import { PayloadInspector } from "./PayloadInspector.js";
|
|
4
|
+
|
|
5
|
+
export interface EventTableProps {
|
|
6
|
+
events: readonly DebugEvent[];
|
|
7
|
+
resolveUrl: (rel: string) => string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Newest-first list of events; each row expands to its payload. Expansion is
|
|
11
|
+
* keyed by event identity so it survives re-renders as new events stream in. */
|
|
12
|
+
export function EventTable({ events, resolveUrl }: EventTableProps) {
|
|
13
|
+
const [open, setOpen] = useState<Set<DebugEvent>>(new Set());
|
|
14
|
+
|
|
15
|
+
function toggle(e: DebugEvent) {
|
|
16
|
+
setOpen((prev) => {
|
|
17
|
+
const next = new Set(prev);
|
|
18
|
+
if (next.has(e)) next.delete(e);
|
|
19
|
+
else next.add(e);
|
|
20
|
+
return next;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (events.length === 0) {
|
|
25
|
+
return <div className="tdbg-empty">No events match — waiting for the stream…</div>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="tdbg-table" role="log" aria-live="polite">
|
|
30
|
+
{events
|
|
31
|
+
.map((e, i) => ({ e, i }))
|
|
32
|
+
.reverse()
|
|
33
|
+
.map(({ e, i }) => {
|
|
34
|
+
const suffix = eventSuffix(e.event);
|
|
35
|
+
return (
|
|
36
|
+
<div className="tdbg-row" key={i}>
|
|
37
|
+
<button
|
|
38
|
+
className="tdbg-rowhead"
|
|
39
|
+
onClick={() => toggle(e)}
|
|
40
|
+
aria-expanded={open.has(e)}
|
|
41
|
+
>
|
|
42
|
+
<span className="tdbg-caret">{open.has(e) ? "▾" : "▸"}</span>
|
|
43
|
+
<span className="tdbg-time">{e.timestamp.slice(11, 23)}</span>
|
|
44
|
+
<span className={`tdbg-badge tdbg-suffix-${suffix.toLowerCase()}`}>{suffix}</span>
|
|
45
|
+
<span className="tdbg-name">{e.event}</span>
|
|
46
|
+
<span className="tdbg-preview">{preview(e.payload)}</span>
|
|
47
|
+
</button>
|
|
48
|
+
{open.has(e) && <PayloadInspector value={e.payload} resolveUrl={resolveUrl} />}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
})}
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function preview(payload: unknown): string {
|
|
57
|
+
if (payload == null) return "";
|
|
58
|
+
let text: string;
|
|
59
|
+
try {
|
|
60
|
+
text = typeof payload === "string" ? payload : JSON.stringify(payload);
|
|
61
|
+
} catch {
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
return text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
|
65
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { EventFilter } from "../filter.js";
|
|
2
|
+
|
|
3
|
+
export interface FilterBarProps {
|
|
4
|
+
filter: EventFilter;
|
|
5
|
+
onChange: (filter: EventFilter) => void;
|
|
6
|
+
suffixes: string[];
|
|
7
|
+
status: "connecting" | "open" | "closed";
|
|
8
|
+
paused: boolean;
|
|
9
|
+
onTogglePause: () => void;
|
|
10
|
+
onClear: () => void;
|
|
11
|
+
total: number;
|
|
12
|
+
shown: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function FilterBar({
|
|
16
|
+
filter,
|
|
17
|
+
onChange,
|
|
18
|
+
suffixes,
|
|
19
|
+
status,
|
|
20
|
+
paused,
|
|
21
|
+
onTogglePause,
|
|
22
|
+
onClear,
|
|
23
|
+
total,
|
|
24
|
+
shown,
|
|
25
|
+
}: FilterBarProps) {
|
|
26
|
+
const active = new Set(filter.suffixes ?? []);
|
|
27
|
+
|
|
28
|
+
function toggleSuffix(suffix: string) {
|
|
29
|
+
const next = new Set(active);
|
|
30
|
+
if (next.has(suffix)) next.delete(suffix);
|
|
31
|
+
else next.add(suffix);
|
|
32
|
+
onChange({ ...filter, suffixes: next.size ? [...next] : undefined });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="tdbg-bar">
|
|
37
|
+
<div className="tdbg-bar-row">
|
|
38
|
+
<span className={`tdbg-status tdbg-status-${status}`} title={`stream ${status}`} />
|
|
39
|
+
<input
|
|
40
|
+
className="tdbg-search"
|
|
41
|
+
placeholder="Search event name or payload…"
|
|
42
|
+
value={filter.text ?? ""}
|
|
43
|
+
onChange={(e) => onChange({ ...filter, text: e.target.value || undefined })}
|
|
44
|
+
/>
|
|
45
|
+
<input
|
|
46
|
+
className="tdbg-kind"
|
|
47
|
+
placeholder="kind / resource…"
|
|
48
|
+
value={filter.kind ?? ""}
|
|
49
|
+
onChange={(e) => onChange({ ...filter, kind: e.target.value || undefined })}
|
|
50
|
+
/>
|
|
51
|
+
<button className="tdbg-btn" onClick={onTogglePause}>
|
|
52
|
+
{paused ? "Resume" : "Pause"}
|
|
53
|
+
</button>
|
|
54
|
+
<button className="tdbg-btn" onClick={onClear}>
|
|
55
|
+
Clear
|
|
56
|
+
</button>
|
|
57
|
+
<span className="tdbg-counts">
|
|
58
|
+
{shown === total ? `${total}` : `${shown} / ${total}`} events
|
|
59
|
+
{paused ? " · paused" : ""}
|
|
60
|
+
</span>
|
|
61
|
+
</div>
|
|
62
|
+
{suffixes.length > 0 && (
|
|
63
|
+
<div className="tdbg-facets">
|
|
64
|
+
{suffixes.map((s) => (
|
|
65
|
+
<button
|
|
66
|
+
key={s}
|
|
67
|
+
className={`tdbg-facet${active.has(s) ? " tdbg-facet-on" : ""}`}
|
|
68
|
+
onClick={() => toggleSuffix(s)}
|
|
69
|
+
>
|
|
70
|
+
{s}
|
|
71
|
+
</button>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { blobDimensions, collectBlobs, formatBytes, type FoundBlob } from "../media.js";
|
|
2
|
+
import { isWireRef } from "../wire.js";
|
|
3
|
+
|
|
4
|
+
export interface PayloadInspectorProps {
|
|
5
|
+
value: unknown;
|
|
6
|
+
/** Turns a relative blob path (`blobs/<id>`) into a fetchable URL against the
|
|
7
|
+
* producer origin. */
|
|
8
|
+
resolveUrl: (rel: string) => string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Pretty-prints an event payload. Binary lives in the blob store, not the
|
|
12
|
+
* payload — any `{ $blob }` pointers are rendered as media (images inline, other
|
|
13
|
+
* files as download links) above the JSON, which itself only carries the small
|
|
14
|
+
* pointer descriptors. */
|
|
15
|
+
export function PayloadInspector({ value, resolveUrl }: PayloadInspectorProps) {
|
|
16
|
+
if (value === undefined) {
|
|
17
|
+
return <div className="tdbg-payload tdbg-muted">(no payload)</div>;
|
|
18
|
+
}
|
|
19
|
+
if (isWireRef(value)) {
|
|
20
|
+
return (
|
|
21
|
+
<div className="tdbg-payload">
|
|
22
|
+
<span className="tdbg-ref">
|
|
23
|
+
{value.kind} <span className="tdbg-muted">·</span> {value.name}
|
|
24
|
+
</span>
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const blobs = collectBlobs(value);
|
|
30
|
+
let text: string;
|
|
31
|
+
try {
|
|
32
|
+
text = JSON.stringify(value, null, 2);
|
|
33
|
+
} catch {
|
|
34
|
+
text = String(value);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="tdbg-payload-wrap">
|
|
39
|
+
{blobs.length > 0 && (
|
|
40
|
+
<div className="tdbg-media">
|
|
41
|
+
{blobs.map((b, i) => (
|
|
42
|
+
<BlobView key={i} found={b} resolveUrl={resolveUrl} />
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
<pre className="tdbg-payload">{text}</pre>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function BlobView({ found, resolveUrl }: { found: FoundBlob; resolveUrl: (rel: string) => string }) {
|
|
52
|
+
const { path, blob, parent } = found;
|
|
53
|
+
const src = resolveUrl(blob.$blob);
|
|
54
|
+
const dims = blobDimensions(parent);
|
|
55
|
+
const caption = [path, blob.mediaType, formatBytes(blob.byteLength), dims].filter(Boolean).join(" · ");
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<figure className="tdbg-blob">
|
|
59
|
+
{blob.mediaType.startsWith("image/") ? (
|
|
60
|
+
<a href={src} target="_blank" rel="noreferrer">
|
|
61
|
+
<img className="tdbg-blob-img" src={src} alt={path} loading="lazy" />
|
|
62
|
+
</a>
|
|
63
|
+
) : (
|
|
64
|
+
<a className="tdbg-blob-file" href={src} target="_blank" rel="noreferrer" download>
|
|
65
|
+
⬇ {blob.mediaType}
|
|
66
|
+
</a>
|
|
67
|
+
)}
|
|
68
|
+
<figcaption className="tdbg-blob-cap">{caption}</figcaption>
|
|
69
|
+
</figure>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// React component surface, consumed by the editor panel and the standalone app.
|
|
2
|
+
export { DebugWatcher, type DebugWatcherProps } from "./DebugWatcher.js";
|
|
3
|
+
export { EventTable } from "./EventTable.js";
|
|
4
|
+
export { FilterBar, type FilterBarProps } from "./FilterBar.js";
|
|
5
|
+
export { PayloadInspector } from "./PayloadInspector.js";
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
.tdbg-root {
|
|
2
|
+
--tdbg-bg: #0f1115;
|
|
3
|
+
--tdbg-panel: #161a21;
|
|
4
|
+
--tdbg-line: #232a34;
|
|
5
|
+
--tdbg-text: #d7dce3;
|
|
6
|
+
--tdbg-muted: #7a8493;
|
|
7
|
+
--tdbg-accent: #4f9cf0;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
height: 100%;
|
|
11
|
+
min-height: 0;
|
|
12
|
+
font: 13px/1.5 ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
13
|
+
color: var(--tdbg-text);
|
|
14
|
+
background: var(--tdbg-bg);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.tdbg-bar {
|
|
18
|
+
position: sticky;
|
|
19
|
+
top: 0;
|
|
20
|
+
z-index: 1;
|
|
21
|
+
padding: 8px 10px;
|
|
22
|
+
background: var(--tdbg-panel);
|
|
23
|
+
border-bottom: 1px solid var(--tdbg-line);
|
|
24
|
+
}
|
|
25
|
+
.tdbg-bar-row {
|
|
26
|
+
display: flex;
|
|
27
|
+
align-items: center;
|
|
28
|
+
gap: 8px;
|
|
29
|
+
}
|
|
30
|
+
.tdbg-search {
|
|
31
|
+
flex: 1;
|
|
32
|
+
}
|
|
33
|
+
.tdbg-search,
|
|
34
|
+
.tdbg-kind {
|
|
35
|
+
padding: 5px 8px;
|
|
36
|
+
color: var(--tdbg-text);
|
|
37
|
+
background: var(--tdbg-bg);
|
|
38
|
+
border: 1px solid var(--tdbg-line);
|
|
39
|
+
border-radius: 5px;
|
|
40
|
+
font: inherit;
|
|
41
|
+
}
|
|
42
|
+
.tdbg-kind {
|
|
43
|
+
width: 140px;
|
|
44
|
+
}
|
|
45
|
+
.tdbg-btn {
|
|
46
|
+
padding: 5px 10px;
|
|
47
|
+
color: var(--tdbg-text);
|
|
48
|
+
background: var(--tdbg-bg);
|
|
49
|
+
border: 1px solid var(--tdbg-line);
|
|
50
|
+
border-radius: 5px;
|
|
51
|
+
cursor: pointer;
|
|
52
|
+
font: inherit;
|
|
53
|
+
}
|
|
54
|
+
.tdbg-btn:hover {
|
|
55
|
+
border-color: var(--tdbg-accent);
|
|
56
|
+
}
|
|
57
|
+
.tdbg-counts {
|
|
58
|
+
color: var(--tdbg-muted);
|
|
59
|
+
white-space: nowrap;
|
|
60
|
+
}
|
|
61
|
+
.tdbg-status {
|
|
62
|
+
width: 9px;
|
|
63
|
+
height: 9px;
|
|
64
|
+
border-radius: 50%;
|
|
65
|
+
background: var(--tdbg-muted);
|
|
66
|
+
flex: 0 0 auto;
|
|
67
|
+
}
|
|
68
|
+
.tdbg-status-open {
|
|
69
|
+
background: #3fb950;
|
|
70
|
+
}
|
|
71
|
+
.tdbg-status-connecting {
|
|
72
|
+
background: #d29922;
|
|
73
|
+
}
|
|
74
|
+
.tdbg-status-closed {
|
|
75
|
+
background: #f85149;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.tdbg-facets {
|
|
79
|
+
display: flex;
|
|
80
|
+
flex-wrap: wrap;
|
|
81
|
+
gap: 6px;
|
|
82
|
+
margin-top: 8px;
|
|
83
|
+
}
|
|
84
|
+
.tdbg-facet {
|
|
85
|
+
padding: 2px 8px;
|
|
86
|
+
color: var(--tdbg-muted);
|
|
87
|
+
background: var(--tdbg-bg);
|
|
88
|
+
border: 1px solid var(--tdbg-line);
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
font: inherit;
|
|
92
|
+
}
|
|
93
|
+
.tdbg-facet-on {
|
|
94
|
+
color: #fff;
|
|
95
|
+
border-color: var(--tdbg-accent);
|
|
96
|
+
background: rgba(79, 156, 240, 0.18);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.tdbg-table {
|
|
100
|
+
flex: 1;
|
|
101
|
+
min-height: 0;
|
|
102
|
+
overflow: auto;
|
|
103
|
+
}
|
|
104
|
+
.tdbg-empty {
|
|
105
|
+
padding: 24px;
|
|
106
|
+
color: var(--tdbg-muted);
|
|
107
|
+
text-align: center;
|
|
108
|
+
}
|
|
109
|
+
.tdbg-row {
|
|
110
|
+
border-bottom: 1px solid var(--tdbg-line);
|
|
111
|
+
}
|
|
112
|
+
.tdbg-rowhead {
|
|
113
|
+
display: flex;
|
|
114
|
+
align-items: baseline;
|
|
115
|
+
gap: 8px;
|
|
116
|
+
width: 100%;
|
|
117
|
+
padding: 4px 10px;
|
|
118
|
+
background: none;
|
|
119
|
+
border: none;
|
|
120
|
+
color: inherit;
|
|
121
|
+
cursor: pointer;
|
|
122
|
+
text-align: left;
|
|
123
|
+
font: inherit;
|
|
124
|
+
}
|
|
125
|
+
.tdbg-rowhead:hover {
|
|
126
|
+
background: var(--tdbg-panel);
|
|
127
|
+
}
|
|
128
|
+
.tdbg-caret {
|
|
129
|
+
width: 12px;
|
|
130
|
+
color: var(--tdbg-muted);
|
|
131
|
+
}
|
|
132
|
+
.tdbg-time {
|
|
133
|
+
color: var(--tdbg-muted);
|
|
134
|
+
white-space: nowrap;
|
|
135
|
+
}
|
|
136
|
+
.tdbg-badge {
|
|
137
|
+
padding: 1px 6px;
|
|
138
|
+
border-radius: 4px;
|
|
139
|
+
background: var(--tdbg-line);
|
|
140
|
+
color: var(--tdbg-text);
|
|
141
|
+
white-space: nowrap;
|
|
142
|
+
}
|
|
143
|
+
.tdbg-suffix-failed,
|
|
144
|
+
.tdbg-suffix-rejected,
|
|
145
|
+
.tdbg-suffix-cancelled {
|
|
146
|
+
background: rgba(248, 81, 73, 0.2);
|
|
147
|
+
color: #ff8079;
|
|
148
|
+
}
|
|
149
|
+
.tdbg-suffix-invoked,
|
|
150
|
+
.tdbg-suffix-listening,
|
|
151
|
+
.tdbg-suffix-started,
|
|
152
|
+
.tdbg-suffix-initialized {
|
|
153
|
+
background: rgba(63, 185, 80, 0.18);
|
|
154
|
+
color: #5fd673;
|
|
155
|
+
}
|
|
156
|
+
.tdbg-name {
|
|
157
|
+
white-space: nowrap;
|
|
158
|
+
}
|
|
159
|
+
.tdbg-preview {
|
|
160
|
+
color: var(--tdbg-muted);
|
|
161
|
+
overflow: hidden;
|
|
162
|
+
text-overflow: ellipsis;
|
|
163
|
+
white-space: nowrap;
|
|
164
|
+
}
|
|
165
|
+
.tdbg-payload-wrap {
|
|
166
|
+
background: var(--tdbg-panel);
|
|
167
|
+
}
|
|
168
|
+
.tdbg-payload {
|
|
169
|
+
margin: 0;
|
|
170
|
+
padding: 8px 10px 10px 30px;
|
|
171
|
+
background: var(--tdbg-panel);
|
|
172
|
+
overflow: auto;
|
|
173
|
+
white-space: pre-wrap;
|
|
174
|
+
word-break: break-word;
|
|
175
|
+
}
|
|
176
|
+
.tdbg-media {
|
|
177
|
+
display: flex;
|
|
178
|
+
flex-wrap: wrap;
|
|
179
|
+
gap: 12px;
|
|
180
|
+
padding: 10px 10px 4px 30px;
|
|
181
|
+
}
|
|
182
|
+
.tdbg-blob {
|
|
183
|
+
margin: 0;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
gap: 4px;
|
|
187
|
+
max-width: 320px;
|
|
188
|
+
}
|
|
189
|
+
.tdbg-blob-img {
|
|
190
|
+
max-width: 320px;
|
|
191
|
+
max-height: 240px;
|
|
192
|
+
border: 1px solid var(--tdbg-line);
|
|
193
|
+
border-radius: 6px;
|
|
194
|
+
background:
|
|
195
|
+
repeating-conic-gradient(#1b202840 0% 25%, transparent 0% 50%) 50% / 16px 16px;
|
|
196
|
+
object-fit: contain;
|
|
197
|
+
}
|
|
198
|
+
.tdbg-blob-file {
|
|
199
|
+
display: inline-block;
|
|
200
|
+
padding: 8px 12px;
|
|
201
|
+
color: var(--tdbg-accent);
|
|
202
|
+
border: 1px solid var(--tdbg-line);
|
|
203
|
+
border-radius: 6px;
|
|
204
|
+
text-decoration: none;
|
|
205
|
+
}
|
|
206
|
+
.tdbg-blob-file:hover {
|
|
207
|
+
border-color: var(--tdbg-accent);
|
|
208
|
+
}
|
|
209
|
+
.tdbg-blob-cap {
|
|
210
|
+
color: var(--tdbg-muted);
|
|
211
|
+
font-size: 11px;
|
|
212
|
+
word-break: break-all;
|
|
213
|
+
}
|
|
214
|
+
.tdbg-muted {
|
|
215
|
+
color: var(--tdbg-muted);
|
|
216
|
+
}
|
|
217
|
+
.tdbg-ref {
|
|
218
|
+
color: var(--tdbg-accent);
|
|
219
|
+
}
|
package/src/css.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { distinctSuffixes, matchesFilter } from "./filter.js";
|
|
3
|
+
import type { DebugEvent } from "./wire.js";
|
|
4
|
+
|
|
5
|
+
const ev = (event: string, payload?: unknown): DebugEvent => ({
|
|
6
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
7
|
+
event,
|
|
8
|
+
payload,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("matchesFilter", () => {
|
|
12
|
+
it("empty filter matches everything", () => {
|
|
13
|
+
expect(matchesFilter(ev("A.B.Invoked"), {})).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("filters by suffix", () => {
|
|
17
|
+
const f = { suffixes: ["Failed"] };
|
|
18
|
+
expect(matchesFilter(ev("A.B.Failed"), f)).toBe(true);
|
|
19
|
+
expect(matchesFilter(ev("A.B.Invoked"), f)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("filters by kind substring (case-insensitive)", () => {
|
|
23
|
+
expect(matchesFilter(ev("Http.Server.Listening"), { kind: "server" })).toBe(true);
|
|
24
|
+
expect(matchesFilter(ev("Sql.Query.Invoked"), { kind: "server" })).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("text searches name and payload", () => {
|
|
28
|
+
expect(matchesFilter(ev("A.Invoked", { port: 5599 }), { text: "5599" })).toBe(true);
|
|
29
|
+
expect(matchesFilter(ev("A.Invoked", { port: 1 }), { text: "listening" })).toBe(false);
|
|
30
|
+
expect(matchesFilter(ev("Server.Listening"), { text: "listening" })).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("AND-combines fields", () => {
|
|
34
|
+
const f = { suffixes: ["Invoked"], text: "5599" };
|
|
35
|
+
expect(matchesFilter(ev("A.Invoked", { port: 5599 }), f)).toBe(true);
|
|
36
|
+
expect(matchesFilter(ev("A.Failed", { port: 5599 }), f)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("tolerates a non-serializable payload in text search", () => {
|
|
40
|
+
const cyclic: Record<string, unknown> = {};
|
|
41
|
+
cyclic.self = cyclic;
|
|
42
|
+
expect(matchesFilter(ev("A.Invoked", cyclic), { text: "A" })).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("distinctSuffixes", () => {
|
|
47
|
+
it("returns sorted distinct suffixes", () => {
|
|
48
|
+
expect(distinctSuffixes([ev("A.Invoked"), ev("B.Failed"), ev("C.Invoked")])).toEqual([
|
|
49
|
+
"Failed",
|
|
50
|
+
"Invoked",
|
|
51
|
+
]);
|
|
52
|
+
});
|
|
53
|
+
});
|