@narumitw/pi-inspect 0.0.0-stage → 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 +21 -0
- package/README.md +107 -2
- package/dist/app.css +1 -0
- package/dist/app.js +129 -0
- package/dist/index.html +1 -0
- package/docs/REVIEW.md +189 -0
- package/docs/VERIFICATION.md +186 -0
- package/docs/viewer.md +63 -0
- package/package.json +67 -4
- package/src/ancestry.ts +32 -0
- package/src/collector.ts +143 -0
- package/src/context.ts +115 -0
- package/src/correlation.ts +27 -0
- package/src/entry-index.ts +17 -0
- package/src/extension.ts +259 -0
- package/src/feed.ts +145 -0
- package/src/identity.ts +112 -0
- package/src/index.ts +1 -0
- package/src/model.ts +113 -0
- package/src/native-projection.ts +28 -0
- package/src/privacy.ts +136 -0
- package/src/projection.ts +361 -0
- package/src/server.ts +202 -0
- package/src/system-message.ts +117 -0
- package/src/web/api.ts +137 -0
- package/src/web/app.tsx +439 -0
- package/src/web/components.css +261 -0
- package/src/web/components.tsx +289 -0
- package/src/web/composition.css +554 -0
- package/src/web/context-composition.tsx +324 -0
- package/src/web/entry-calls.ts +14 -0
- package/src/web/format.ts +92 -0
- package/src/web/hierarchy.ts +117 -0
- package/src/web/inline-entry.tsx +124 -0
- package/src/web/inspector.tsx +337 -0
- package/src/web/json-tree.tsx +74 -0
- package/src/web/live-drawer.tsx +175 -0
- package/src/web/overview.tsx +67 -0
- package/src/web/panels.css +267 -0
- package/src/web/panes.tsx +123 -0
- package/src/web/search.ts +16 -0
- package/src/web/style.css +332 -0
- package/src/web/styles.d.ts +1 -0
- package/src/web/timeline.tsx +33 -0
- package/src/web/timing.ts +26 -0
- package/src/web/trace.css +410 -0
- package/src/web/trace.tsx +273 -0
- package/src/web/tree.tsx +281 -0
- package/src/web/virtual-context.ts +138 -0
package/src/web/api.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { DetailView } from "../model.js";
|
|
3
|
+
|
|
4
|
+
// Storage is optional reload persistence; a valid fragment must work in restricted profiles.
|
|
5
|
+
function stored(key: string): string | null {
|
|
6
|
+
try {
|
|
7
|
+
return sessionStorage.getItem(key);
|
|
8
|
+
} catch {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function remember(key: string, value: string): void {
|
|
13
|
+
if (!value) return;
|
|
14
|
+
try {
|
|
15
|
+
sessionStorage.setItem(key, value);
|
|
16
|
+
} catch {
|
|
17
|
+
// Keep credentials in memory for this page; fragment-free reloads must reopen from Pi.
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const fragment = location.hash.slice(1);
|
|
21
|
+
const params = new URLSearchParams(fragment);
|
|
22
|
+
// Continue accepting legacy #token=...&generation=... URLs and fragment-free browser reloads.
|
|
23
|
+
const token = /^[\w-]{43}$/.test(fragment) ? fragment : (params.get("token") ?? stored("inspector-token") ?? "");
|
|
24
|
+
const shellGeneration = document.querySelector<HTMLMetaElement>('meta[name="inspector-generation"]')?.content;
|
|
25
|
+
export const generation =
|
|
26
|
+
params.get("generation") ??
|
|
27
|
+
(shellGeneration ? decodeURIComponent(shellGeneration) : (stored("inspector-generation") ?? ""));
|
|
28
|
+
remember("inspector-token", token);
|
|
29
|
+
remember("inspector-generation", generation);
|
|
30
|
+
history.replaceState(null, "", location.pathname);
|
|
31
|
+
export function headers(): HeadersInit {
|
|
32
|
+
return { "X-Inspector-Token": token };
|
|
33
|
+
}
|
|
34
|
+
export class RequestFailure extends Error {
|
|
35
|
+
constructor(
|
|
36
|
+
message: string,
|
|
37
|
+
readonly terminal: boolean,
|
|
38
|
+
) {
|
|
39
|
+
super(message);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export async function request<T>(route: string, signal: AbortSignal): Promise<T> {
|
|
43
|
+
const response = await fetch(
|
|
44
|
+
`/api/${route}${route.includes("?") ? "&" : "?"}generation=${encodeURIComponent(generation)}`,
|
|
45
|
+
{ headers: headers(), signal, cache: "no-store" },
|
|
46
|
+
);
|
|
47
|
+
if (!response.ok)
|
|
48
|
+
throw new RequestFailure(
|
|
49
|
+
[401, 403, 409, 410].includes(response.status)
|
|
50
|
+
? "Session expired; open a new viewer from Pi."
|
|
51
|
+
: "Viewer unavailable; reconnecting requires a running Pi session.",
|
|
52
|
+
[401, 403, 409, 410].includes(response.status),
|
|
53
|
+
);
|
|
54
|
+
return response.json() as Promise<T>;
|
|
55
|
+
}
|
|
56
|
+
interface Job {
|
|
57
|
+
signal: AbortSignal;
|
|
58
|
+
run(): Promise<void>;
|
|
59
|
+
cancel(): void;
|
|
60
|
+
}
|
|
61
|
+
const queue: Job[] = [];
|
|
62
|
+
const cache = new Map<string, DetailView>();
|
|
63
|
+
let active = 0;
|
|
64
|
+
function pump(): void {
|
|
65
|
+
while (active < 4 && queue.length) {
|
|
66
|
+
const job = queue.shift();
|
|
67
|
+
if (!job || job.signal.aborted) continue;
|
|
68
|
+
active++;
|
|
69
|
+
void job.run().finally(() => {
|
|
70
|
+
active--;
|
|
71
|
+
pump();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function loadDetail(id: string, signal: AbortSignal): Promise<DetailView> {
|
|
76
|
+
signal.throwIfAborted();
|
|
77
|
+
const cached = cache.get(id);
|
|
78
|
+
if (cached) return Promise.resolve(cached);
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const job: Job = {
|
|
81
|
+
signal,
|
|
82
|
+
cancel: () => {
|
|
83
|
+
const index = queue.indexOf(job);
|
|
84
|
+
if (index !== -1) queue.splice(index, 1);
|
|
85
|
+
reject(new DOMException("Cancelled", "AbortError"));
|
|
86
|
+
},
|
|
87
|
+
run: async () => {
|
|
88
|
+
try {
|
|
89
|
+
const value = await request<DetailView>(
|
|
90
|
+
`detail?id=${encodeURIComponent(id)}&leaf=${encodeURIComponent(id)}`,
|
|
91
|
+
signal,
|
|
92
|
+
);
|
|
93
|
+
if (signal.aborted) return;
|
|
94
|
+
// Raw entries and their own historical projection are immutable; live calls use the latest snapshot.
|
|
95
|
+
const result = cache.get(id) ?? { ...value, calls: [] };
|
|
96
|
+
cache.set(id, result);
|
|
97
|
+
while (cache.size > 64) {
|
|
98
|
+
const first = cache.keys().next().value;
|
|
99
|
+
if (first === undefined) break;
|
|
100
|
+
cache.delete(first);
|
|
101
|
+
}
|
|
102
|
+
resolve(result);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
reject(error);
|
|
105
|
+
} finally {
|
|
106
|
+
signal.removeEventListener("abort", job.cancel);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
signal.addEventListener("abort", job.cancel, { once: true });
|
|
111
|
+
queue.push(job);
|
|
112
|
+
pump();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export function useDetail(id: string | undefined): { detail?: DetailView; error: string; retry(): void } {
|
|
116
|
+
const [attempt, setAttempt] = useState(0);
|
|
117
|
+
const owner = useRef<{ id: string; attempt: number } | undefined>(undefined);
|
|
118
|
+
const retry = () => setAttempt((value) => value + 1);
|
|
119
|
+
const [state, setState] = useState<{ id?: string; detail?: DetailView; error: string }>({ error: "" });
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!id) return;
|
|
122
|
+
const requestOwner = { id, attempt };
|
|
123
|
+
owner.current = requestOwner;
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
setState({ id, error: "" });
|
|
126
|
+
void loadDetail(id, controller.signal)
|
|
127
|
+
.then((detail) => {
|
|
128
|
+
if (!controller.signal.aborted && owner.current === requestOwner) setState({ id, detail, error: "" });
|
|
129
|
+
})
|
|
130
|
+
.catch(() => {
|
|
131
|
+
if (!controller.signal.aborted && owner.current === requestOwner)
|
|
132
|
+
setState({ id, error: "Could not load entry details." });
|
|
133
|
+
});
|
|
134
|
+
return () => controller.abort();
|
|
135
|
+
}, [id, attempt]);
|
|
136
|
+
return { ...(state.id === id ? state : { error: "" }), retry };
|
|
137
|
+
}
|
package/src/web/app.tsx
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { HamburgerMenuIcon, MoonIcon, ReaderIcon, SunIcon } from "@radix-ui/react-icons";
|
|
2
|
+
import { Badge, Callout, Heading, IconButton, Theme } from "@radix-ui/themes";
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import "@radix-ui/themes/styles.css";
|
|
6
|
+
import "./style.css";
|
|
7
|
+
import "./composition.css";
|
|
8
|
+
import type { BranchView, Snapshot } from "../model.js";
|
|
9
|
+
import { generation, headers, RequestFailure, request, useDetail } from "./api.js";
|
|
10
|
+
import { ContextComposition } from "./context-composition.js";
|
|
11
|
+
import { type Filters, matches } from "./format.js";
|
|
12
|
+
import { Inspector } from "./inspector.js";
|
|
13
|
+
import { LiveDrawer } from "./live-drawer.js";
|
|
14
|
+
import { Overview } from "./overview.js";
|
|
15
|
+
import { PaneDrawer, ResizeHandle, useNarrow } from "./panes.js";
|
|
16
|
+
import { boundedSearch, searchNeedle } from "./search.js";
|
|
17
|
+
import { Trace } from "./trace.js";
|
|
18
|
+
import { Sidebar } from "./tree.js";
|
|
19
|
+
|
|
20
|
+
type Selection = { kind: "entry" | "call"; id: string; serial: number; filterVersion?: number; anchor?: string | null };
|
|
21
|
+
function App() {
|
|
22
|
+
const [appearance, setAppearance] = useState<"dark" | "light">("light");
|
|
23
|
+
const [revision, setRevision] = useState(-1);
|
|
24
|
+
const [snapshot, setSnapshot] = useState<Snapshot>();
|
|
25
|
+
const [branch, setBranch] = useState<BranchView>();
|
|
26
|
+
const [branchError, setBranchError] = useState("");
|
|
27
|
+
const [branchAttempt, setBranchAttempt] = useState(0);
|
|
28
|
+
const branchOwner = useRef<{ entryId: string; attempt: number } | undefined>(undefined);
|
|
29
|
+
const [selection, setSelection] = useState<Selection>();
|
|
30
|
+
const [view, setView] = useState("list");
|
|
31
|
+
const [surface, setSurface] = useState("context");
|
|
32
|
+
const [liveOpen, setLiveOpen] = useState(false);
|
|
33
|
+
const [filters, setFilters] = useState<Filters>({
|
|
34
|
+
query: "",
|
|
35
|
+
kind: "all",
|
|
36
|
+
groups: [],
|
|
37
|
+
errorsOnly: false,
|
|
38
|
+
slowOnly: false,
|
|
39
|
+
});
|
|
40
|
+
const [filterVersion, setFilterVersion] = useState(0);
|
|
41
|
+
const filterEpoch = useRef(0);
|
|
42
|
+
function changeFilters(value: Filters) {
|
|
43
|
+
setFilters({ ...value, query: boundedSearch(value.query) });
|
|
44
|
+
setFilterVersion(++filterEpoch.current);
|
|
45
|
+
}
|
|
46
|
+
const [error, setError] = useState("");
|
|
47
|
+
const [connected, setConnected] = useState(false);
|
|
48
|
+
const lifecycle = useRef<{ terminal: boolean; stream?: AbortController }>({ terminal: false });
|
|
49
|
+
const [tab, setTab] = useState("raw");
|
|
50
|
+
const navTrigger = useRef<HTMLButtonElement>(null);
|
|
51
|
+
const inspectorTrigger = useRef<HTMLButtonElement>(null);
|
|
52
|
+
const [navOpen, setNavOpen] = useState(false);
|
|
53
|
+
const [inspectorOpen, setInspectorOpen] = useState(false);
|
|
54
|
+
const [navDrawer, setNavDrawer] = useState(false);
|
|
55
|
+
const [inspectorDrawer, setInspectorDrawer] = useState(false);
|
|
56
|
+
const [navWidth, setNavWidth] = useState(() => Math.max(200, Math.min(360, window.innerWidth * 0.185)));
|
|
57
|
+
const [inspectorWidth, setInspectorWidth] = useState(() => Math.max(280, Math.min(520, window.innerWidth * 0.235)));
|
|
58
|
+
const narrow = useNarrow();
|
|
59
|
+
const sideBudget = Math.max(0, window.innerWidth - 600);
|
|
60
|
+
const navSize = navOpen ? Math.min(navWidth, inspectorOpen ? sideBudget * 0.4 : sideBudget) : 0;
|
|
61
|
+
const inspectorSize = inspectorOpen ? Math.min(inspectorWidth, navOpen ? sideBudget * 0.6 : sideBudget) : 0;
|
|
62
|
+
const calls = snapshot?.calls ?? [];
|
|
63
|
+
const entries = snapshot?.nodes ?? [];
|
|
64
|
+
const selectedCall =
|
|
65
|
+
selection?.kind === "call" ? calls.find((call) => call.occurrenceId === selection.id) : undefined;
|
|
66
|
+
const represented = useMemo(() => new Set(entries.map((entry) => entry.id)), [entries]);
|
|
67
|
+
const callAnchorAvailable = Boolean(selectedCall?.branchAnchor && represented.has(selectedCall.branchAnchor));
|
|
68
|
+
const entryId =
|
|
69
|
+
selection?.kind === "entry" ? selection.id : callAnchorAvailable ? (selectedCall?.branchAnchor ?? "") : "";
|
|
70
|
+
const {
|
|
71
|
+
detail,
|
|
72
|
+
error: detailError,
|
|
73
|
+
retry: retryDetails,
|
|
74
|
+
} = useDetail(selection?.kind === "entry" ? entryId : undefined);
|
|
75
|
+
const selectedNode = entries.find((node) => node.id === entryId);
|
|
76
|
+
const preparedFilters = useMemo(() => ({ ...filters, query: searchNeedle(filters.query) }), [filters]);
|
|
77
|
+
const matching = useMemo(
|
|
78
|
+
() => new Set(entries.filter((node) => matches(node, preparedFilters, calls)).map((node) => node.id)),
|
|
79
|
+
[entries, calls, preparedFilters],
|
|
80
|
+
);
|
|
81
|
+
function selectEntry(id: string) {
|
|
82
|
+
setSurface("events");
|
|
83
|
+
setSelection((old) => ({ kind: "entry", id, serial: (old?.serial ?? 0) + 1, filterVersion }));
|
|
84
|
+
}
|
|
85
|
+
function selectCall(id: string) {
|
|
86
|
+
if (narrow) setInspectorDrawer(true);
|
|
87
|
+
else setInspectorOpen(true);
|
|
88
|
+
setSelection((old) => ({
|
|
89
|
+
kind: "call",
|
|
90
|
+
id,
|
|
91
|
+
serial: (old?.serial ?? 0) + 1,
|
|
92
|
+
filterVersion,
|
|
93
|
+
anchor: calls.find((call) => call.occurrenceId === id)?.branchAnchor,
|
|
94
|
+
}));
|
|
95
|
+
setLiveOpen(true);
|
|
96
|
+
}
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const controller = new AbortController();
|
|
99
|
+
let retry: ReturnType<typeof setTimeout> | undefined;
|
|
100
|
+
let refresh: ReturnType<typeof setTimeout> | undefined;
|
|
101
|
+
lifecycle.current.stream = controller;
|
|
102
|
+
const stop = (message: string) => {
|
|
103
|
+
lifecycle.current.terminal = true;
|
|
104
|
+
setRevision(-1);
|
|
105
|
+
controller.abort();
|
|
106
|
+
clearTimeout(refresh);
|
|
107
|
+
setConnected(false);
|
|
108
|
+
setError(message);
|
|
109
|
+
};
|
|
110
|
+
const connect = async () => {
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch(`/api/events?generation=${encodeURIComponent(generation)}`, {
|
|
113
|
+
headers: headers(),
|
|
114
|
+
signal: controller.signal,
|
|
115
|
+
});
|
|
116
|
+
if (controller.signal.aborted) return;
|
|
117
|
+
if ([401, 403, 409, 410].includes(response.status)) {
|
|
118
|
+
stop("Session expired or unauthorized; open the viewer again from Pi.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (!response.ok || !response.body) throw new Error("Transient viewer refusal");
|
|
122
|
+
setConnected(true);
|
|
123
|
+
const reader = response.body.getReader();
|
|
124
|
+
const decoder = new TextDecoder();
|
|
125
|
+
let buffer = "";
|
|
126
|
+
try {
|
|
127
|
+
while (!controller.signal.aborted) {
|
|
128
|
+
const { value, done } = await reader.read();
|
|
129
|
+
if (done || controller.signal.aborted) break;
|
|
130
|
+
buffer += decoder.decode(value, { stream: true });
|
|
131
|
+
let end = buffer.indexOf("\n\n");
|
|
132
|
+
while (end !== -1) {
|
|
133
|
+
const frame = buffer.slice(0, end);
|
|
134
|
+
buffer = buffer.slice(end + 2);
|
|
135
|
+
const line = frame.split("\n").find((s) => s.startsWith("data: "));
|
|
136
|
+
if (line) {
|
|
137
|
+
const data = JSON.parse(line.slice(6)) as { protocol: number; generation: string; revision: number };
|
|
138
|
+
if (data.protocol !== 1 || data.generation !== generation) {
|
|
139
|
+
stop("Session changed; open the viewer again from Pi.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (!refresh)
|
|
143
|
+
refresh = setTimeout(() => {
|
|
144
|
+
refresh = undefined;
|
|
145
|
+
setRevision((previous) => previous + 1);
|
|
146
|
+
}, 50);
|
|
147
|
+
}
|
|
148
|
+
end = buffer.indexOf("\n\n");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
await reader.cancel().catch(() => {});
|
|
153
|
+
reader.releaseLock();
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
/* Offline state is visible; reconnect uses a fresh snapshot, not stale replay. */
|
|
157
|
+
}
|
|
158
|
+
if (!controller.signal.aborted) {
|
|
159
|
+
setConnected(false);
|
|
160
|
+
retry = setTimeout(() => void connect(), 1000);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
void connect();
|
|
164
|
+
return () => {
|
|
165
|
+
controller.abort();
|
|
166
|
+
clearTimeout(retry);
|
|
167
|
+
clearTimeout(refresh);
|
|
168
|
+
};
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (revision < 0) return;
|
|
173
|
+
const controller = new AbortController();
|
|
174
|
+
let retry: ReturnType<typeof setTimeout> | undefined;
|
|
175
|
+
const load = () => {
|
|
176
|
+
if (lifecycle.current.terminal || controller.signal.aborted) return;
|
|
177
|
+
void request<Snapshot>("snapshot", controller.signal)
|
|
178
|
+
.then((value) => {
|
|
179
|
+
if (controller.signal.aborted || lifecycle.current.terminal) return;
|
|
180
|
+
setSnapshot(value);
|
|
181
|
+
setError("");
|
|
182
|
+
const represented = new Set(value.nodes.map((node) => node.id));
|
|
183
|
+
const id = value.leafId && represented.has(value.leafId) ? value.leafId : value.nodes[0]?.id;
|
|
184
|
+
setSelection((old) => {
|
|
185
|
+
if (old?.kind === "call" && !value.calls.some((call) => call.occurrenceId === old.id)) {
|
|
186
|
+
const fallback = old.anchor && represented.has(old.anchor) ? old.anchor : id;
|
|
187
|
+
return fallback
|
|
188
|
+
? { kind: "entry", id: fallback, serial: old.serial + 1, filterVersion: filterEpoch.current }
|
|
189
|
+
: undefined;
|
|
190
|
+
}
|
|
191
|
+
if (old?.kind === "entry" && !represented.has(old.id)) {
|
|
192
|
+
return id ? { kind: "entry", id, serial: old.serial + 1, filterVersion: filterEpoch.current } : undefined;
|
|
193
|
+
}
|
|
194
|
+
return old ?? (id ? { kind: "entry", id, serial: 0 } : undefined);
|
|
195
|
+
});
|
|
196
|
+
})
|
|
197
|
+
.catch((error) => {
|
|
198
|
+
if (!controller.signal.aborted && !lifecycle.current.terminal) {
|
|
199
|
+
setError(String(error.message));
|
|
200
|
+
if (error instanceof RequestFailure && error.terminal) {
|
|
201
|
+
lifecycle.current.terminal = true;
|
|
202
|
+
lifecycle.current.stream?.abort();
|
|
203
|
+
setConnected(false);
|
|
204
|
+
setRevision(-1);
|
|
205
|
+
} else retry = setTimeout(load, 1000);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
};
|
|
209
|
+
load();
|
|
210
|
+
return () => {
|
|
211
|
+
controller.abort();
|
|
212
|
+
clearTimeout(retry);
|
|
213
|
+
};
|
|
214
|
+
}, [revision]);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
setBranchError("");
|
|
217
|
+
if (!entryId) {
|
|
218
|
+
setBranch(undefined);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const controller = new AbortController();
|
|
222
|
+
setBranch(undefined);
|
|
223
|
+
const owner = { entryId, attempt: branchAttempt };
|
|
224
|
+
branchOwner.current = owner;
|
|
225
|
+
void request<BranchView>(`branch?leaf=${encodeURIComponent(entryId)}&offset=0`, controller.signal)
|
|
226
|
+
.then((value) => {
|
|
227
|
+
if (!controller.signal.aborted && branchOwner.current === owner) setBranch(value);
|
|
228
|
+
})
|
|
229
|
+
.catch(() => {
|
|
230
|
+
if (!controller.signal.aborted && branchOwner.current === owner)
|
|
231
|
+
setBranchError("Could not load selected branch context.");
|
|
232
|
+
});
|
|
233
|
+
return () => controller.abort();
|
|
234
|
+
}, [entryId, branchAttempt]);
|
|
235
|
+
|
|
236
|
+
const sidebar = (
|
|
237
|
+
<Sidebar
|
|
238
|
+
nodes={entries}
|
|
239
|
+
matches={matching}
|
|
240
|
+
total={snapshot?.totalEntries ?? 0}
|
|
241
|
+
selected={entryId}
|
|
242
|
+
serial={selection?.serial ?? 0}
|
|
243
|
+
filters={filters}
|
|
244
|
+
change={changeFilters}
|
|
245
|
+
revealSelected={selection?.filterVersion === filterVersion}
|
|
246
|
+
choose={selectEntry}
|
|
247
|
+
/>
|
|
248
|
+
);
|
|
249
|
+
const inspector = (
|
|
250
|
+
<Inspector
|
|
251
|
+
selected={selection?.id ?? ""}
|
|
252
|
+
node={selectedNode}
|
|
253
|
+
detail={detail}
|
|
254
|
+
detailError={detailError}
|
|
255
|
+
retryDetails={retryDetails}
|
|
256
|
+
call={selectedCall}
|
|
257
|
+
branch={branch}
|
|
258
|
+
branchError={branchError}
|
|
259
|
+
retryBranch={() => setBranchAttempt((attempt) => attempt + 1)}
|
|
260
|
+
snapshot={snapshot}
|
|
261
|
+
tab={tab}
|
|
262
|
+
changeTab={setTab}
|
|
263
|
+
select={selectEntry}
|
|
264
|
+
selectCall={selectCall}
|
|
265
|
+
/>
|
|
266
|
+
);
|
|
267
|
+
return (
|
|
268
|
+
<Theme
|
|
269
|
+
className="inspector-app"
|
|
270
|
+
appearance={appearance}
|
|
271
|
+
accentColor="blue"
|
|
272
|
+
grayColor="slate"
|
|
273
|
+
radius="medium"
|
|
274
|
+
scaling="100%"
|
|
275
|
+
>
|
|
276
|
+
<header className="app-header">
|
|
277
|
+
<div className="brand">
|
|
278
|
+
<div className="brand-logo" aria-hidden="true">
|
|
279
|
+
π
|
|
280
|
+
</div>
|
|
281
|
+
<Heading size="4">Pi Session Inspector</Heading>
|
|
282
|
+
</div>
|
|
283
|
+
<div className="header-context">
|
|
284
|
+
<span title={snapshot?.nameTruncated ? "Session name truncated to 512 characters" : undefined}>
|
|
285
|
+
{snapshot?.name ?? "Connecting"}
|
|
286
|
+
{snapshot?.nameTruncated && " · truncated"}
|
|
287
|
+
</span>
|
|
288
|
+
<span title={snapshot?.sessionId}>
|
|
289
|
+
Session: <strong>{typeof snapshot?.sessionId === "string" ? snapshot.sessionId.slice(0, 8) : "—"}</strong>
|
|
290
|
+
</span>
|
|
291
|
+
</div>
|
|
292
|
+
<div className="header-actions">
|
|
293
|
+
<fieldset className="surface-switch" aria-label="Inspection source">
|
|
294
|
+
<button type="button" aria-pressed={surface === "context"} onClick={() => setSurface("context")}>
|
|
295
|
+
Context
|
|
296
|
+
</button>
|
|
297
|
+
<button type="button" aria-pressed={surface === "events"} onClick={() => setSurface("events")}>
|
|
298
|
+
Session events
|
|
299
|
+
</button>
|
|
300
|
+
</fieldset>
|
|
301
|
+
<IconButton
|
|
302
|
+
ref={navTrigger}
|
|
303
|
+
aria-label="Toggle navigator"
|
|
304
|
+
aria-expanded={narrow ? navDrawer : navOpen}
|
|
305
|
+
variant="ghost"
|
|
306
|
+
onClick={() => (narrow ? setNavDrawer(!navDrawer) : setNavOpen(!navOpen))}
|
|
307
|
+
>
|
|
308
|
+
<HamburgerMenuIcon />
|
|
309
|
+
</IconButton>
|
|
310
|
+
<IconButton
|
|
311
|
+
ref={inspectorTrigger}
|
|
312
|
+
aria-label="Toggle inspector"
|
|
313
|
+
aria-expanded={narrow ? inspectorDrawer : inspectorOpen}
|
|
314
|
+
variant="ghost"
|
|
315
|
+
onClick={() => (narrow ? setInspectorDrawer(!inspectorDrawer) : setInspectorOpen(!inspectorOpen))}
|
|
316
|
+
>
|
|
317
|
+
<ReaderIcon />
|
|
318
|
+
</IconButton>
|
|
319
|
+
<Badge color={connected ? "green" : "amber"}>{connected ? "Live" : "Disconnected"}</Badge>
|
|
320
|
+
<Badge>Read-only</Badge>
|
|
321
|
+
<IconButton
|
|
322
|
+
aria-label="Toggle appearance"
|
|
323
|
+
variant="ghost"
|
|
324
|
+
onClick={() => setAppearance(appearance === "dark" ? "light" : "dark")}
|
|
325
|
+
>
|
|
326
|
+
{appearance === "dark" ? <SunIcon /> : <MoonIcon />}
|
|
327
|
+
</IconButton>
|
|
328
|
+
</div>
|
|
329
|
+
</header>
|
|
330
|
+
{(error || snapshot?.incomplete) && (
|
|
331
|
+
<div className="app-notices">
|
|
332
|
+
{error && (
|
|
333
|
+
<Callout.Root color="red">
|
|
334
|
+
<Callout.Text>{error}</Callout.Text>
|
|
335
|
+
</Callout.Root>
|
|
336
|
+
)}
|
|
337
|
+
{snapshot?.incomplete && (
|
|
338
|
+
<Callout.Root color="amber">
|
|
339
|
+
<Callout.Text>Indexed data is incomplete; existing entry/capture limits apply.</Callout.Text>
|
|
340
|
+
</Callout.Root>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
)}
|
|
344
|
+
<main
|
|
345
|
+
className={`app-grid ${narrow ? "narrow-layout" : ""}`}
|
|
346
|
+
style={
|
|
347
|
+
narrow
|
|
348
|
+
? undefined
|
|
349
|
+
: {
|
|
350
|
+
gridTemplateColumns: `${navSize}px ${navOpen ? 6 : 0}px minmax(0, 1fr) ${inspectorOpen ? 6 : 0}px ${inspectorSize}px`,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
>
|
|
354
|
+
{!narrow && (
|
|
355
|
+
<>
|
|
356
|
+
<div className="side-slot nav-slot" hidden={!navOpen}>
|
|
357
|
+
{sidebar}
|
|
358
|
+
</div>
|
|
359
|
+
{navOpen && (
|
|
360
|
+
<ResizeHandle name="Resize navigator" value={navWidth} change={setNavWidth} min={180} max={420} />
|
|
361
|
+
)}
|
|
362
|
+
</>
|
|
363
|
+
)}
|
|
364
|
+
<div className="center-column">
|
|
365
|
+
<div className="context-surface" hidden={surface !== "context"}>
|
|
366
|
+
<ContextComposition context={snapshot?.context} payload={snapshot?.providerObservation} />
|
|
367
|
+
</div>
|
|
368
|
+
<div className="events-surface" hidden={surface !== "events"}>
|
|
369
|
+
<Overview snapshot={snapshot} />
|
|
370
|
+
<Trace
|
|
371
|
+
entries={entries}
|
|
372
|
+
matches={matching}
|
|
373
|
+
selected={entryId}
|
|
374
|
+
serial={selection?.serial ?? 0}
|
|
375
|
+
revealSelected={selection?.filterVersion === filterVersion}
|
|
376
|
+
select={selectEntry}
|
|
377
|
+
view={view}
|
|
378
|
+
changeView={setView}
|
|
379
|
+
calls={calls}
|
|
380
|
+
/>
|
|
381
|
+
</div>
|
|
382
|
+
{(calls.length > 0 || surface === "events") && (
|
|
383
|
+
<LiveDrawer
|
|
384
|
+
entries={entries}
|
|
385
|
+
calls={calls}
|
|
386
|
+
selected={selectedCall?.occurrenceId}
|
|
387
|
+
select={selectCall}
|
|
388
|
+
open={liveOpen}
|
|
389
|
+
changeOpen={setLiveOpen}
|
|
390
|
+
view={view}
|
|
391
|
+
dropped={snapshot?.droppedCalls ?? 0}
|
|
392
|
+
invalidEvents={snapshot?.invalidCallEvents ?? 0}
|
|
393
|
+
filters={filters}
|
|
394
|
+
/>
|
|
395
|
+
)}
|
|
396
|
+
</div>
|
|
397
|
+
{!narrow && (
|
|
398
|
+
<>
|
|
399
|
+
{inspectorOpen ? (
|
|
400
|
+
<ResizeHandle
|
|
401
|
+
name="Resize inspector"
|
|
402
|
+
value={inspectorWidth}
|
|
403
|
+
change={setInspectorWidth}
|
|
404
|
+
min={260}
|
|
405
|
+
max={600}
|
|
406
|
+
inverse
|
|
407
|
+
/>
|
|
408
|
+
) : null}
|
|
409
|
+
<div className="side-slot inspector-slot" hidden={!inspectorOpen}>
|
|
410
|
+
{inspector}
|
|
411
|
+
</div>
|
|
412
|
+
</>
|
|
413
|
+
)}
|
|
414
|
+
</main>
|
|
415
|
+
{narrow && (
|
|
416
|
+
<>
|
|
417
|
+
<PaneDrawer trigger={navTrigger} name="Session navigator" side="left" open={navDrawer} change={setNavDrawer}>
|
|
418
|
+
{sidebar}
|
|
419
|
+
</PaneDrawer>
|
|
420
|
+
<PaneDrawer
|
|
421
|
+
trigger={inspectorTrigger}
|
|
422
|
+
name="Event inspector"
|
|
423
|
+
side="right"
|
|
424
|
+
open={inspectorDrawer}
|
|
425
|
+
change={setInspectorDrawer}
|
|
426
|
+
>
|
|
427
|
+
{inspector}
|
|
428
|
+
</PaneDrawer>
|
|
429
|
+
</>
|
|
430
|
+
)}
|
|
431
|
+
<footer className="app-footer">
|
|
432
|
+
<span>{connected ? "Connected · observing live events" : "Disconnected · reopen from Pi"}</span>
|
|
433
|
+
<span>Read-only · bounded display copies · no provider transport spans</span>
|
|
434
|
+
</footer>
|
|
435
|
+
</Theme>
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
const root = document.getElementById("root");
|
|
439
|
+
if (root) createRoot(root).render(<App />);
|