@telorun/debug-ui 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -14
- package/app-dist/assets/index-Bye-pLmT.js +104 -0
- package/app-dist/assets/index-CK8ArLzP.css +1 -0
- package/app-dist/index.html +2 -2
- package/app-single/index.html +68 -13
- package/package.json +5 -1
- package/src/ansi.ts +11 -0
- package/src/components/DebugPanel.tsx +133 -0
- package/src/components/DebugWatcher.tsx +49 -43
- package/src/components/EndpointLinks.tsx +42 -0
- package/src/components/FilterBar.tsx +5 -22
- package/src/components/LogView.tsx +33 -0
- package/src/components/ThemeToggle.tsx +34 -0
- package/src/components/index.ts +3 -0
- package/src/components/styles.css +115 -5
- package/src/endpoints.ts +35 -0
- package/src/index.ts +8 -1
- package/src/sse-client.ts +4 -3
- package/src/theme.ts +50 -0
- package/src/wire.ts +4 -77
- package/app-dist/assets/index-npnCjha3.css +0 -1
- package/app-dist/assets/index-tx0habRA.js +0 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/debug-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"app-single/**",
|
|
43
43
|
"src/**"
|
|
44
44
|
],
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"lucide-react": "^1.8.0",
|
|
47
|
+
"@telorun/debug-wire": "0.1.0"
|
|
48
|
+
},
|
|
45
49
|
"peerDependencies": {
|
|
46
50
|
"react": ">=18",
|
|
47
51
|
"react-dom": ">=18"
|
package/src/ansi.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Strip ANSI escape sequences for plain-text display. The raw line (escapes
|
|
2
|
+
// intact) is preserved on the wire; richer in-place rendering can layer on later.
|
|
3
|
+
// Built from string source so the ESC/CSI control bytes stay unambiguous.
|
|
4
|
+
const ANSI = new RegExp(
|
|
5
|
+
"[\\u001b\\u009b][[\\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-PR-TZcf-ntqry=><]",
|
|
6
|
+
"g",
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export function stripAnsi(text: string): string {
|
|
10
|
+
return text.replace(ANSI, "");
|
|
11
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type ReactNode, useMemo, useState } from "react";
|
|
2
|
+
import type { AppEndpoint } from "../endpoints.js";
|
|
3
|
+
import { distinctSuffixes, type EventFilter, matchesFilter } from "../filter.js";
|
|
4
|
+
import { type DebugTheme, loadStoredTheme, storeTheme, useResolvedTheme } from "../theme.js";
|
|
5
|
+
import { type DebugEvent, type DebugFrame, isLogFrame } from "../wire.js";
|
|
6
|
+
import { EndpointLinks } from "./EndpointLinks.js";
|
|
7
|
+
import { EventTable } from "./EventTable.js";
|
|
8
|
+
import { FilterBar } from "./FilterBar.js";
|
|
9
|
+
import { LogView } from "./LogView.js";
|
|
10
|
+
import { ThemeToggle } from "./ThemeToggle.js";
|
|
11
|
+
import "./styles.css";
|
|
12
|
+
|
|
13
|
+
export interface DebugPanelProps {
|
|
14
|
+
/** The frame buffer (events + logs) in arrival order. */
|
|
15
|
+
frames: readonly DebugFrame[];
|
|
16
|
+
/** Re-render signal: bump when `frames` mutates in place. Unneeded when the
|
|
17
|
+
* parent passes a fresh array reference on every change. */
|
|
18
|
+
revision?: number;
|
|
19
|
+
status: "connecting" | "open" | "closed";
|
|
20
|
+
paused: boolean;
|
|
21
|
+
onTogglePause: () => void;
|
|
22
|
+
onClear: () => void;
|
|
23
|
+
/** Resolve a blob pointer (path relative to the producer origin) to a URL. */
|
|
24
|
+
resolveBlobUrl: (rel: string) => string;
|
|
25
|
+
/** When provided, the Logs tab renders this instead of the built-in read-only
|
|
26
|
+
* {@link LogView} — e.g. the editor injects its interactive terminal here so
|
|
27
|
+
* the panel is writable. Standalone hosts omit it and get the log line view. */
|
|
28
|
+
logsSlot?: ReactNode;
|
|
29
|
+
/** Which tab is shown first. Default `"events"`; the editor opens on `"logs"`
|
|
30
|
+
* so its interactive terminal is front-and-centre. */
|
|
31
|
+
defaultTab?: Tab;
|
|
32
|
+
/** Exposed addresses of the running application, rendered as links in the
|
|
33
|
+
* header. The standalone {@link DebugWatcher} sources these from the producer's
|
|
34
|
+
* handshake; the editor passes its resolved run endpoints. */
|
|
35
|
+
endpoints?: readonly AppEndpoint[];
|
|
36
|
+
/** Color theme. Defaults to `"system"` (follows the OS); an embedding host
|
|
37
|
+
* (e.g. the editor) passes its own resolved `"light"` / `"dark"`. */
|
|
38
|
+
theme?: DebugTheme;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Tab = "events" | "logs";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The presentation half of the debug watcher: a Logs / Events tab split over one
|
|
45
|
+
* frame stream, with event filtering. Fully controlled — the standalone
|
|
46
|
+
* {@link DebugWatcher} drives it from an SSE buffer; the editor drives it from
|
|
47
|
+
* frames relayed over the run session. Only the source and `resolveBlobUrl` differ.
|
|
48
|
+
*/
|
|
49
|
+
export function DebugPanel({
|
|
50
|
+
frames,
|
|
51
|
+
revision,
|
|
52
|
+
status,
|
|
53
|
+
paused,
|
|
54
|
+
onTogglePause,
|
|
55
|
+
onClear,
|
|
56
|
+
resolveBlobUrl,
|
|
57
|
+
logsSlot,
|
|
58
|
+
defaultTab = "events",
|
|
59
|
+
endpoints,
|
|
60
|
+
theme,
|
|
61
|
+
}: DebugPanelProps) {
|
|
62
|
+
const [tab, setTab] = useState<Tab>(defaultTab);
|
|
63
|
+
const [filter, setFilter] = useState<EventFilter>({});
|
|
64
|
+
// Controlled when the host supplies a theme; otherwise the panel owns its mode
|
|
65
|
+
// (shows the toggle, persists the choice across reloads). `"system"` resolves
|
|
66
|
+
// against the OS, live.
|
|
67
|
+
const controlled = theme !== undefined;
|
|
68
|
+
const [ownTheme, setOwnTheme] = useState<DebugTheme>(loadStoredTheme);
|
|
69
|
+
const changeOwnTheme = (next: DebugTheme): void => {
|
|
70
|
+
setOwnTheme(next);
|
|
71
|
+
storeTheme(next);
|
|
72
|
+
};
|
|
73
|
+
const resolvedTheme = useResolvedTheme(controlled ? theme : ownTheme);
|
|
74
|
+
|
|
75
|
+
// `revision` is the in-place-mutation signal; `frames` covers the fresh-array
|
|
76
|
+
// pattern. Either changing recomputes the split.
|
|
77
|
+
const events = useMemo(
|
|
78
|
+
() => frames.filter((f): f is DebugEvent => !isLogFrame(f)),
|
|
79
|
+
[frames, revision],
|
|
80
|
+
);
|
|
81
|
+
const logs = useMemo(() => frames.filter(isLogFrame), [frames, revision]);
|
|
82
|
+
const suffixes = useMemo(() => distinctSuffixes(events), [events]);
|
|
83
|
+
const visible = useMemo(
|
|
84
|
+
() => events.filter((e) => matchesFilter(e, filter)),
|
|
85
|
+
[events, filter],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="tdbg-root" data-theme={resolvedTheme}>
|
|
90
|
+
<div className="tdbg-tabs">
|
|
91
|
+
<span className={`tdbg-status tdbg-status-${status}`} title={`stream ${status}`} />
|
|
92
|
+
<button
|
|
93
|
+
className={`tdbg-tab${tab === "events" ? " tdbg-tab-on" : ""}`}
|
|
94
|
+
onClick={() => setTab("events")}
|
|
95
|
+
>
|
|
96
|
+
Events <span className="tdbg-tabcount">{events.length}</span>
|
|
97
|
+
</button>
|
|
98
|
+
<button
|
|
99
|
+
className={`tdbg-tab${tab === "logs" ? " tdbg-tab-on" : ""}`}
|
|
100
|
+
onClick={() => setTab("logs")}
|
|
101
|
+
>
|
|
102
|
+
Logs{logsSlot ? null : <span className="tdbg-tabcount">{logs.length}</span>}
|
|
103
|
+
</button>
|
|
104
|
+
<span className="tdbg-tabs-spacer" />
|
|
105
|
+
{endpoints && endpoints.length > 0 && <EndpointLinks endpoints={endpoints} />}
|
|
106
|
+
<button className="tdbg-btn" onClick={onTogglePause}>
|
|
107
|
+
{paused ? "Resume" : "Pause"}
|
|
108
|
+
</button>
|
|
109
|
+
<button className="tdbg-btn" onClick={onClear}>
|
|
110
|
+
Clear
|
|
111
|
+
</button>
|
|
112
|
+
{!controlled && <ThemeToggle value={ownTheme} onChange={changeOwnTheme} />}
|
|
113
|
+
</div>
|
|
114
|
+
{tab === "events" ? (
|
|
115
|
+
<>
|
|
116
|
+
<FilterBar
|
|
117
|
+
filter={filter}
|
|
118
|
+
onChange={setFilter}
|
|
119
|
+
suffixes={suffixes}
|
|
120
|
+
total={events.length}
|
|
121
|
+
shown={visible.length}
|
|
122
|
+
paused={paused}
|
|
123
|
+
/>
|
|
124
|
+
<EventTable events={visible} resolveUrl={resolveBlobUrl} />
|
|
125
|
+
</>
|
|
126
|
+
) : logsSlot ? (
|
|
127
|
+
<div className="tdbg-slot">{logsSlot}</div>
|
|
128
|
+
) : (
|
|
129
|
+
<LogView logs={logs} />
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -1,47 +1,65 @@
|
|
|
1
|
-
import { useEffect,
|
|
2
|
-
import
|
|
1
|
+
import { useEffect, useReducer, useRef, useState } from "react";
|
|
2
|
+
import type { AppEndpoint } from "../endpoints.js";
|
|
3
3
|
import { connectDebugStream } from "../sse-client.js";
|
|
4
|
-
import type {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import "./styles.css";
|
|
4
|
+
import type { DebugTheme } from "../theme.js";
|
|
5
|
+
import type { DebugFrame } from "../wire.js";
|
|
6
|
+
import { DebugPanel } from "./DebugPanel.js";
|
|
8
7
|
|
|
9
8
|
export interface DebugWatcherProps {
|
|
10
9
|
/** The producer's SSE endpoint, e.g. `http://localhost:9230/events`. */
|
|
11
10
|
url: string;
|
|
12
|
-
/** Ring-buffer cap; oldest
|
|
11
|
+
/** Ring-buffer cap; oldest frames drop past this. Default 5000. */
|
|
13
12
|
maxEvents?: number;
|
|
13
|
+
/** Color theme. Defaults to `"system"` (follows the OS). */
|
|
14
|
+
theme?: DebugTheme;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
|
-
* The
|
|
18
|
-
* and renders
|
|
19
|
-
* the *view* while still collecting into the buffer, so nothing
|
|
20
|
-
*
|
|
21
|
-
* Self-contained and prop-driven — the standalone app and the editor panel both
|
|
22
|
-
* mount this; only the `url` differs.
|
|
18
|
+
* The standalone debug watcher: connects to `url`, retains a bounded ring buffer
|
|
19
|
+
* of frames (events + logs), and renders the Logs / Events {@link DebugPanel}.
|
|
20
|
+
* Pausing freezes the *view* while still collecting into the buffer, so nothing
|
|
21
|
+
* is lost on resume.
|
|
23
22
|
*/
|
|
24
|
-
export function DebugWatcher({ url, maxEvents = 5000 }: DebugWatcherProps) {
|
|
25
|
-
const bufferRef = useRef<
|
|
23
|
+
export function DebugWatcher({ url, maxEvents = 5000, theme }: DebugWatcherProps) {
|
|
24
|
+
const bufferRef = useRef<DebugFrame[]>([]);
|
|
26
25
|
const pausedRef = useRef(false);
|
|
27
|
-
const [, bump] = useReducer((n: number) => n + 1, 0);
|
|
28
|
-
const [filter, setFilter] = useState<EventFilter>({});
|
|
26
|
+
const [rev, bump] = useReducer((n: number) => n + 1, 0);
|
|
29
27
|
const [status, setStatus] = useState<"connecting" | "open" | "closed">("connecting");
|
|
30
28
|
const [paused, setPaused] = useState(false);
|
|
29
|
+
const [endpoints, setEndpoints] = useState<AppEndpoint[]>([]);
|
|
31
30
|
|
|
32
31
|
useEffect(() => {
|
|
33
32
|
bufferRef.current = [];
|
|
34
33
|
return connectDebugStream(url, {
|
|
35
34
|
onStatus: setStatus,
|
|
36
|
-
|
|
35
|
+
onFrame: (f) => {
|
|
37
36
|
const buf = bufferRef.current;
|
|
38
|
-
buf.push(
|
|
37
|
+
buf.push(f);
|
|
39
38
|
if (buf.length > maxEvents) buf.splice(0, buf.length - maxEvents);
|
|
40
39
|
if (!pausedRef.current) bump();
|
|
41
40
|
},
|
|
42
41
|
});
|
|
43
42
|
}, [url, maxEvents]);
|
|
44
43
|
|
|
44
|
+
// The producer advertises the running app's exposed addresses in its discovery
|
|
45
|
+
// handshake. Endpoints whose host the producer couldn't know (loopback default)
|
|
46
|
+
// arrive blank — fill them from the page origin so the link points where the
|
|
47
|
+
// viewer actually reached the server (localhost locally, the bound host remotely).
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
const origin = new URL(url).hostname;
|
|
51
|
+
fetch(new URL("json/version", url).toString())
|
|
52
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
53
|
+
.then((info: { appEndpoints?: AppEndpoint[] } | null) => {
|
|
54
|
+
if (cancelled || !info?.appEndpoints) return;
|
|
55
|
+
setEndpoints(info.appEndpoints.map((e) => ({ ...e, host: e.host || origin })));
|
|
56
|
+
})
|
|
57
|
+
.catch(() => {});
|
|
58
|
+
return () => {
|
|
59
|
+
cancelled = true;
|
|
60
|
+
};
|
|
61
|
+
}, [url]);
|
|
62
|
+
|
|
45
63
|
function togglePause() {
|
|
46
64
|
const next = !pausedRef.current;
|
|
47
65
|
pausedRef.current = next;
|
|
@@ -56,31 +74,19 @@ export function DebugWatcher({ url, maxEvents = 5000 }: DebugWatcherProps) {
|
|
|
56
74
|
|
|
57
75
|
// Blob pointers are relative to the producer origin; resolve them against the
|
|
58
76
|
// SSE URL so the standalone page and the editor (cross-origin) both work.
|
|
59
|
-
const
|
|
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
|
-
);
|
|
77
|
+
const resolveBlobUrl = (rel: string) => new URL(rel, url).toString();
|
|
69
78
|
|
|
70
79
|
return (
|
|
71
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
/>
|
|
83
|
-
<EventTable events={visible} resolveUrl={resolveUrl} />
|
|
84
|
-
</div>
|
|
80
|
+
<DebugPanel
|
|
81
|
+
frames={bufferRef.current}
|
|
82
|
+
revision={rev}
|
|
83
|
+
status={status}
|
|
84
|
+
paused={paused}
|
|
85
|
+
onTogglePause={togglePause}
|
|
86
|
+
onClear={clear}
|
|
87
|
+
resolveBlobUrl={resolveBlobUrl}
|
|
88
|
+
endpoints={endpoints}
|
|
89
|
+
theme={theme}
|
|
90
|
+
/>
|
|
85
91
|
);
|
|
86
92
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ExternalLink } from "lucide-react";
|
|
2
|
+
import { type AppEndpoint, endpointHref, endpointLabel } from "../endpoints.js";
|
|
3
|
+
|
|
4
|
+
export interface EndpointLinksProps {
|
|
5
|
+
endpoints: readonly AppEndpoint[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** The running application's exposed addresses, rendered in the panel header.
|
|
9
|
+
* tcp endpoints are clickable links to the app; udp endpoints show as a plain
|
|
10
|
+
* label (not reachable over http). Renders nothing when there are no endpoints. */
|
|
11
|
+
export function EndpointLinks({ endpoints }: EndpointLinksProps) {
|
|
12
|
+
if (endpoints.length === 0) return null;
|
|
13
|
+
return (
|
|
14
|
+
<span className="tdbg-endpoints">
|
|
15
|
+
{endpoints.map((endpoint) => {
|
|
16
|
+
const label = endpointLabel(endpoint);
|
|
17
|
+
const href = endpointHref(endpoint);
|
|
18
|
+
const key = `${endpoint.host}:${endpoint.port}/${endpoint.protocol}`;
|
|
19
|
+
if (!href) {
|
|
20
|
+
return (
|
|
21
|
+
<span key={key} className="tdbg-endpoint">
|
|
22
|
+
{label}
|
|
23
|
+
</span>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
return (
|
|
27
|
+
<a
|
|
28
|
+
key={key}
|
|
29
|
+
className="tdbg-endpoint tdbg-endpoint-link"
|
|
30
|
+
href={href}
|
|
31
|
+
target="_blank"
|
|
32
|
+
rel="noreferrer"
|
|
33
|
+
title={`Open ${href}`}
|
|
34
|
+
>
|
|
35
|
+
<ExternalLink size={11} aria-hidden />
|
|
36
|
+
{label}
|
|
37
|
+
</a>
|
|
38
|
+
);
|
|
39
|
+
})}
|
|
40
|
+
</span>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -4,25 +4,15 @@ export interface FilterBarProps {
|
|
|
4
4
|
filter: EventFilter;
|
|
5
5
|
onChange: (filter: EventFilter) => void;
|
|
6
6
|
suffixes: string[];
|
|
7
|
-
status: "connecting" | "open" | "closed";
|
|
8
|
-
paused: boolean;
|
|
9
|
-
onTogglePause: () => void;
|
|
10
|
-
onClear: () => void;
|
|
11
7
|
total: number;
|
|
12
8
|
shown: number;
|
|
9
|
+
paused: boolean;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
status,
|
|
20
|
-
paused,
|
|
21
|
-
onTogglePause,
|
|
22
|
-
onClear,
|
|
23
|
-
total,
|
|
24
|
-
shown,
|
|
25
|
-
}: FilterBarProps) {
|
|
12
|
+
/** Event-filtering controls for the Events tab: free-text search, kind/resource
|
|
13
|
+
* substring, and suffix facets. Connection status and pause/clear live on the
|
|
14
|
+
* shared {@link DebugPanel} tab bar, not here. */
|
|
15
|
+
export function FilterBar({ filter, onChange, suffixes, total, shown, paused }: FilterBarProps) {
|
|
26
16
|
const active = new Set(filter.suffixes ?? []);
|
|
27
17
|
|
|
28
18
|
function toggleSuffix(suffix: string) {
|
|
@@ -35,7 +25,6 @@ export function FilterBar({
|
|
|
35
25
|
return (
|
|
36
26
|
<div className="tdbg-bar">
|
|
37
27
|
<div className="tdbg-bar-row">
|
|
38
|
-
<span className={`tdbg-status tdbg-status-${status}`} title={`stream ${status}`} />
|
|
39
28
|
<input
|
|
40
29
|
className="tdbg-search"
|
|
41
30
|
placeholder="Search event name or payload…"
|
|
@@ -48,12 +37,6 @@ export function FilterBar({
|
|
|
48
37
|
value={filter.kind ?? ""}
|
|
49
38
|
onChange={(e) => onChange({ ...filter, kind: e.target.value || undefined })}
|
|
50
39
|
/>
|
|
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
40
|
<span className="tdbg-counts">
|
|
58
41
|
{shown === total ? `${total}` : `${shown} / ${total}`} events
|
|
59
42
|
{paused ? " · paused" : ""}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { stripAnsi } from "../ansi.js";
|
|
3
|
+
import type { DebugLog } from "../wire.js";
|
|
4
|
+
|
|
5
|
+
export interface LogViewProps {
|
|
6
|
+
logs: readonly DebugLog[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Chronological stdout/stderr view (oldest first), autoscrolled to the tail.
|
|
10
|
+
* stderr lines are tinted. ANSI escapes are stripped for display. */
|
|
11
|
+
export function LogView({ logs }: LogViewProps) {
|
|
12
|
+
const endRef = useRef<HTMLDivElement>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
endRef.current?.scrollIntoView({ block: "end" });
|
|
16
|
+
}, [logs.length]);
|
|
17
|
+
|
|
18
|
+
if (logs.length === 0) {
|
|
19
|
+
return <div className="tdbg-empty">No output yet — waiting for the stream…</div>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="tdbg-logs" role="log" aria-live="polite">
|
|
24
|
+
{logs.map((l, i) => (
|
|
25
|
+
<div className={`tdbg-logline tdbg-log-${l.stream}`} key={i}>
|
|
26
|
+
<span className="tdbg-time">{l.timestamp.slice(11, 23)}</span>
|
|
27
|
+
<span className="tdbg-logtext">{stripAnsi(l.line)}</span>
|
|
28
|
+
</div>
|
|
29
|
+
))}
|
|
30
|
+
<div ref={endRef} />
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Monitor, Moon, Sun } from "lucide-react";
|
|
2
|
+
import type { DebugTheme } from "../theme.js";
|
|
3
|
+
|
|
4
|
+
const NEXT: Record<DebugTheme, DebugTheme> = {
|
|
5
|
+
system: "light",
|
|
6
|
+
light: "dark",
|
|
7
|
+
dark: "system",
|
|
8
|
+
};
|
|
9
|
+
const ICON: Record<DebugTheme, typeof Monitor> = {
|
|
10
|
+
system: Monitor,
|
|
11
|
+
light: Sun,
|
|
12
|
+
dark: Moon,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ThemeToggleProps {
|
|
16
|
+
value: DebugTheme;
|
|
17
|
+
onChange: (theme: DebugTheme) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Cycles system → light → dark. Shown only when the host hasn't supplied a
|
|
21
|
+
* `theme` (i.e. the panel owns its own mode). */
|
|
22
|
+
export function ThemeToggle({ value, onChange }: ThemeToggleProps) {
|
|
23
|
+
const Icon = ICON[value];
|
|
24
|
+
return (
|
|
25
|
+
<button
|
|
26
|
+
className="tdbg-btn tdbg-icon-btn"
|
|
27
|
+
onClick={() => onChange(NEXT[value])}
|
|
28
|
+
title={`Theme: ${value} (click to change)`}
|
|
29
|
+
aria-label={`Theme: ${value}`}
|
|
30
|
+
>
|
|
31
|
+
<Icon size={14} aria-hidden />
|
|
32
|
+
</button>
|
|
33
|
+
);
|
|
34
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// React component surface, consumed by the editor panel and the standalone app.
|
|
2
2
|
export { DebugWatcher, type DebugWatcherProps } from "./DebugWatcher.js";
|
|
3
|
+
export { DebugPanel, type DebugPanelProps } from "./DebugPanel.js";
|
|
3
4
|
export { EventTable } from "./EventTable.js";
|
|
4
5
|
export { FilterBar, type FilterBarProps } from "./FilterBar.js";
|
|
6
|
+
export { LogView, type LogViewProps } from "./LogView.js";
|
|
5
7
|
export { PayloadInspector } from "./PayloadInspector.js";
|
|
8
|
+
export type { DebugTheme } from "../theme.js";
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
.tdbg-root {
|
|
2
|
+
/* Dark theme (default; also applied for `data-theme="dark"`). */
|
|
2
3
|
--tdbg-bg: #0f1115;
|
|
3
4
|
--tdbg-panel: #161a21;
|
|
4
5
|
--tdbg-line: #232a34;
|
|
5
6
|
--tdbg-text: #d7dce3;
|
|
6
7
|
--tdbg-muted: #7a8493;
|
|
7
8
|
--tdbg-accent: #4f9cf0;
|
|
9
|
+
--tdbg-bad: #ff8079;
|
|
10
|
+
--tdbg-bad-bg: rgba(248, 81, 73, 0.2);
|
|
11
|
+
--tdbg-good: #5fd673;
|
|
12
|
+
--tdbg-good-bg: rgba(63, 185, 80, 0.18);
|
|
8
13
|
display: flex;
|
|
9
14
|
flex-direction: column;
|
|
10
15
|
height: 100%;
|
|
@@ -13,6 +18,76 @@
|
|
|
13
18
|
color: var(--tdbg-text);
|
|
14
19
|
background: var(--tdbg-bg);
|
|
15
20
|
}
|
|
21
|
+
.tdbg-root[data-theme="light"] {
|
|
22
|
+
--tdbg-bg: #ffffff;
|
|
23
|
+
--tdbg-panel: #f6f7f9;
|
|
24
|
+
--tdbg-line: #e2e5e9;
|
|
25
|
+
--tdbg-text: #1f2328;
|
|
26
|
+
--tdbg-muted: #656d76;
|
|
27
|
+
--tdbg-accent: #2f6feb;
|
|
28
|
+
--tdbg-bad: #cf222e;
|
|
29
|
+
--tdbg-bad-bg: rgba(207, 34, 46, 0.1);
|
|
30
|
+
--tdbg-good: #1a7f37;
|
|
31
|
+
--tdbg-good-bg: rgba(26, 127, 55, 0.1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.tdbg-tabs {
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 8px;
|
|
38
|
+
padding: 6px 10px;
|
|
39
|
+
background: var(--tdbg-panel);
|
|
40
|
+
border-bottom: 1px solid var(--tdbg-line);
|
|
41
|
+
}
|
|
42
|
+
.tdbg-tab {
|
|
43
|
+
padding: 4px 10px;
|
|
44
|
+
color: var(--tdbg-muted);
|
|
45
|
+
background: none;
|
|
46
|
+
border: 1px solid transparent;
|
|
47
|
+
border-radius: 6px;
|
|
48
|
+
cursor: pointer;
|
|
49
|
+
font: inherit;
|
|
50
|
+
}
|
|
51
|
+
.tdbg-tab:hover {
|
|
52
|
+
color: var(--tdbg-text);
|
|
53
|
+
}
|
|
54
|
+
.tdbg-tab-on {
|
|
55
|
+
color: var(--tdbg-text);
|
|
56
|
+
background: var(--tdbg-bg);
|
|
57
|
+
border-color: var(--tdbg-line);
|
|
58
|
+
}
|
|
59
|
+
.tdbg-tabcount {
|
|
60
|
+
margin-left: 4px;
|
|
61
|
+
color: var(--tdbg-muted);
|
|
62
|
+
font-size: 11px;
|
|
63
|
+
}
|
|
64
|
+
.tdbg-tabs-spacer {
|
|
65
|
+
flex: 1;
|
|
66
|
+
}
|
|
67
|
+
.tdbg-endpoints {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 6px;
|
|
71
|
+
}
|
|
72
|
+
.tdbg-endpoint {
|
|
73
|
+
display: inline-flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
gap: 4px;
|
|
76
|
+
padding: 2px 8px;
|
|
77
|
+
color: var(--tdbg-muted);
|
|
78
|
+
background: var(--tdbg-bg);
|
|
79
|
+
border: 1px solid var(--tdbg-line);
|
|
80
|
+
border-radius: 999px;
|
|
81
|
+
font-size: 11px;
|
|
82
|
+
white-space: nowrap;
|
|
83
|
+
}
|
|
84
|
+
.tdbg-endpoint-link {
|
|
85
|
+
color: var(--tdbg-accent);
|
|
86
|
+
text-decoration: none;
|
|
87
|
+
}
|
|
88
|
+
.tdbg-endpoint-link:hover {
|
|
89
|
+
border-color: var(--tdbg-accent);
|
|
90
|
+
}
|
|
16
91
|
|
|
17
92
|
.tdbg-bar {
|
|
18
93
|
position: sticky;
|
|
@@ -54,6 +129,12 @@
|
|
|
54
129
|
.tdbg-btn:hover {
|
|
55
130
|
border-color: var(--tdbg-accent);
|
|
56
131
|
}
|
|
132
|
+
.tdbg-icon-btn {
|
|
133
|
+
display: inline-flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
padding: 5px 7px;
|
|
137
|
+
}
|
|
57
138
|
.tdbg-counts {
|
|
58
139
|
color: var(--tdbg-muted);
|
|
59
140
|
white-space: nowrap;
|
|
@@ -91,7 +172,7 @@
|
|
|
91
172
|
font: inherit;
|
|
92
173
|
}
|
|
93
174
|
.tdbg-facet-on {
|
|
94
|
-
color:
|
|
175
|
+
color: var(--tdbg-text);
|
|
95
176
|
border-color: var(--tdbg-accent);
|
|
96
177
|
background: rgba(79, 156, 240, 0.18);
|
|
97
178
|
}
|
|
@@ -143,15 +224,15 @@
|
|
|
143
224
|
.tdbg-suffix-failed,
|
|
144
225
|
.tdbg-suffix-rejected,
|
|
145
226
|
.tdbg-suffix-cancelled {
|
|
146
|
-
background:
|
|
147
|
-
color:
|
|
227
|
+
background: var(--tdbg-bad-bg);
|
|
228
|
+
color: var(--tdbg-bad);
|
|
148
229
|
}
|
|
149
230
|
.tdbg-suffix-invoked,
|
|
150
231
|
.tdbg-suffix-listening,
|
|
151
232
|
.tdbg-suffix-started,
|
|
152
233
|
.tdbg-suffix-initialized {
|
|
153
|
-
background:
|
|
154
|
-
color:
|
|
234
|
+
background: var(--tdbg-good-bg);
|
|
235
|
+
color: var(--tdbg-good);
|
|
155
236
|
}
|
|
156
237
|
.tdbg-name {
|
|
157
238
|
white-space: nowrap;
|
|
@@ -217,3 +298,32 @@
|
|
|
217
298
|
.tdbg-ref {
|
|
218
299
|
color: var(--tdbg-accent);
|
|
219
300
|
}
|
|
301
|
+
|
|
302
|
+
.tdbg-slot {
|
|
303
|
+
display: flex;
|
|
304
|
+
flex: 1;
|
|
305
|
+
min-height: 0;
|
|
306
|
+
overflow: hidden;
|
|
307
|
+
}
|
|
308
|
+
.tdbg-logs {
|
|
309
|
+
flex: 1;
|
|
310
|
+
min-height: 0;
|
|
311
|
+
overflow: auto;
|
|
312
|
+
padding: 4px 0;
|
|
313
|
+
}
|
|
314
|
+
.tdbg-logline {
|
|
315
|
+
display: flex;
|
|
316
|
+
gap: 8px;
|
|
317
|
+
padding: 1px 10px;
|
|
318
|
+
white-space: pre-wrap;
|
|
319
|
+
word-break: break-word;
|
|
320
|
+
}
|
|
321
|
+
.tdbg-logline:hover {
|
|
322
|
+
background: var(--tdbg-panel);
|
|
323
|
+
}
|
|
324
|
+
.tdbg-logtext {
|
|
325
|
+
flex: 1;
|
|
326
|
+
}
|
|
327
|
+
.tdbg-log-stderr .tdbg-logtext {
|
|
328
|
+
color: var(--tdbg-bad);
|
|
329
|
+
}
|
package/src/endpoints.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** A reachable address of the running application, surfaced as a link in the
|
|
2
|
+
* debug panel header. Structurally matches the editor's `RunnerEndpoint` and the
|
|
3
|
+
* runner contract's endpoint, but kept local to debug-ui so this package stays
|
|
4
|
+
* browser-safe with no dependency on the Node-only runner / kernel packages. */
|
|
5
|
+
export interface AppEndpoint {
|
|
6
|
+
/** Reachable host. May be empty when the producer can't know which hostname the
|
|
7
|
+
* viewer used; {@link DebugWatcher} fills it from the page origin. */
|
|
8
|
+
host: string;
|
|
9
|
+
port: number;
|
|
10
|
+
protocol: "tcp" | "udp";
|
|
11
|
+
/** Fully-qualified URL when the producer already knows it (proxy / ingress);
|
|
12
|
+
* preferred over deriving `http://host:port`. */
|
|
13
|
+
url?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The link a tcp endpoint resolves to, or `null` for a non-linkable (udp)
|
|
17
|
+
* endpoint. Prefers an explicit `url`; otherwise derives `http://host:port`. */
|
|
18
|
+
export function endpointHref(endpoint: AppEndpoint): string | null {
|
|
19
|
+
if (endpoint.url) return endpoint.url;
|
|
20
|
+
if (endpoint.protocol !== "tcp") return null;
|
|
21
|
+
return `http://${formatHost(endpoint.host)}:${endpoint.port}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Display label for an endpoint, e.g. `localhost:8080` or `:9000/udp`. */
|
|
25
|
+
export function endpointLabel(endpoint: AppEndpoint): string {
|
|
26
|
+
const host = endpoint.host ? formatHost(endpoint.host) : "";
|
|
27
|
+
const base = `${host}:${endpoint.port}`;
|
|
28
|
+
return endpoint.protocol === "tcp" ? base : `${base}/udp`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** IPv6 literals need bracketing when paired with a port. */
|
|
32
|
+
function formatHost(host: string): string {
|
|
33
|
+
if (host.includes(":") && !host.startsWith("[")) return `[${host}]`;
|
|
34
|
+
return host;
|
|
35
|
+
}
|