@parhelia/alpaca 0.3.12831 → 0.3.12833
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tracing/components/CaptureCompleteness.d.ts +6 -0
- package/dist/tracing/components/CaptureCompleteness.d.ts.map +1 -0
- package/dist/tracing/components/CaptureCompleteness.js +26 -0
- package/dist/tracing/components/CaptureFilterDialog.d.ts +6 -0
- package/dist/tracing/components/CaptureFilterDialog.d.ts.map +1 -0
- package/dist/tracing/components/CaptureFilterDialog.js +19 -0
- package/dist/tracing/components/CaptureFilterForm.d.ts +13 -0
- package/dist/tracing/components/CaptureFilterForm.d.ts.map +1 -0
- package/dist/tracing/components/CaptureFilterForm.js +190 -0
- package/dist/tracing/components/CountersTab.d.ts +6 -0
- package/dist/tracing/components/CountersTab.d.ts.map +1 -0
- package/dist/tracing/components/CountersTab.js +47 -0
- package/dist/tracing/components/InstanceSwitcher.d.ts +11 -0
- package/dist/tracing/components/InstanceSwitcher.d.ts.map +1 -0
- package/dist/tracing/components/InstanceSwitcher.js +37 -0
- package/dist/tracing/components/LogMessage.d.ts +6 -0
- package/dist/tracing/components/LogMessage.d.ts.map +1 -0
- package/dist/tracing/components/LogMessage.js +50 -0
- package/dist/tracing/components/LogsTab.d.ts +12 -0
- package/dist/tracing/components/LogsTab.d.ts.map +1 -0
- package/dist/tracing/components/LogsTab.js +120 -0
- package/dist/tracing/components/StackTracePopover.d.ts +19 -0
- package/dist/tracing/components/StackTracePopover.d.ts.map +1 -0
- package/dist/tracing/components/StackTracePopover.js +45 -0
- package/dist/tracing/components/StoriesPane.d.ts +2 -0
- package/dist/tracing/components/StoriesPane.d.ts.map +1 -0
- package/dist/tracing/components/StoriesPane.js +204 -0
- package/dist/tracing/components/StoryDetail.d.ts +2 -0
- package/dist/tracing/components/StoryDetail.d.ts.map +1 -0
- package/dist/tracing/components/StoryDetail.js +81 -0
- package/dist/tracing/components/TimelineTab.d.ts +7 -0
- package/dist/tracing/components/TimelineTab.d.ts.map +1 -0
- package/dist/tracing/components/TimelineTab.js +59 -0
- package/dist/tracing/components/TimersTab.d.ts +6 -0
- package/dist/tracing/components/TimersTab.d.ts.map +1 -0
- package/dist/tracing/components/TimersTab.js +61 -0
- package/dist/tracing/components/TraceSourcesSidebar.d.ts +2 -0
- package/dist/tracing/components/TraceSourcesSidebar.d.ts.map +1 -0
- package/dist/tracing/components/TraceSourcesSidebar.js +76 -0
- package/dist/tracing/components/TracingTitlebar.d.ts +15 -0
- package/dist/tracing/components/TracingTitlebar.d.ts.map +1 -0
- package/dist/tracing/components/TracingTitlebar.js +98 -0
- package/dist/tracing/components/TracingWorkspace.d.ts +2 -0
- package/dist/tracing/components/TracingWorkspace.d.ts.map +1 -0
- package/dist/tracing/components/TracingWorkspace.js +38 -0
- package/dist/tracing/components/shared.d.ts +38 -0
- package/dist/tracing/components/shared.d.ts.map +1 -0
- package/dist/tracing/components/shared.js +95 -0
- package/dist/tracing/format.d.ts +18 -0
- package/dist/tracing/format.d.ts.map +1 -0
- package/dist/tracing/format.js +140 -0
- package/dist/tracing/messageTags.d.ts +20 -0
- package/dist/tracing/messageTags.d.ts.map +1 -0
- package/dist/tracing/messageTags.js +46 -0
- package/dist/tracing/traceStore.d.ts +123 -0
- package/dist/tracing/traceStore.d.ts.map +1 -0
- package/dist/tracing/traceStore.js +572 -0
- package/dist/tracing/tracingService.d.ts +131 -0
- package/dist/tracing/tracingService.d.ts.map +1 -0
- package/dist/tracing/tracingService.js +151 -0
- package/dist/tracing/types.d.ts +292 -0
- package/dist/tracing/types.d.ts.map +1 -0
- package/dist/tracing/types.js +16 -0
- package/dist/tracing/useTraceUpdates.d.ts +8 -0
- package/dist/tracing/useTraceUpdates.d.ts.map +1 -0
- package/dist/tracing/useTraceUpdates.js +31 -0
- package/package.json +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Button, CopyButton, SearchInput, Select } from "@parhelia/core";
|
|
4
|
+
import { formatRelativeMs, formatTime } from "../format";
|
|
5
|
+
import { TracingApiError, forTracingInstance } from "../tracingService";
|
|
6
|
+
import { traceActions, useTraceStore } from "../traceStore";
|
|
7
|
+
import { LOG_LEVELS, } from "../types";
|
|
8
|
+
import { LogMessage } from "./LogMessage";
|
|
9
|
+
import { EmptyHint, InlineError, LevelBadge, cls } from "./shared";
|
|
10
|
+
import { StackTracePopover } from "./StackTracePopover";
|
|
11
|
+
const PAGE = 500;
|
|
12
|
+
/**
|
|
13
|
+
* Log entries of one story, or — with `story === null` — of the whole source merged by sequence
|
|
14
|
+
* (then a Story column links each row to its story).
|
|
15
|
+
*/
|
|
16
|
+
export function LogsTab({ sessionId, story, jumpMs, onJumpConsumed, }) {
|
|
17
|
+
const requestContext = useTraceStore();
|
|
18
|
+
const tracingService = forTracingInstance(requestContext.selectedInstance, requestContext.generation, requestContext.status?.globalRecording?.recordingId);
|
|
19
|
+
const traceState = useTraceStore();
|
|
20
|
+
const generation = traceState.generation;
|
|
21
|
+
const sessionMode = story === null;
|
|
22
|
+
const storyId = story?.id ?? null;
|
|
23
|
+
// Reload trigger: the story's revision, or the source's change cursor in session mode.
|
|
24
|
+
const contentVersion = story ? story.revision : traceState.changeCursor;
|
|
25
|
+
// Levels live in the store so the error / warning counts elsewhere can narrow this view.
|
|
26
|
+
const levels = traceState.logLevels;
|
|
27
|
+
const [scopePrefix, setScopePrefix] = useState("");
|
|
28
|
+
const [search, setSearch] = useState("");
|
|
29
|
+
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
30
|
+
const [relative, setRelative] = useState(!sessionMode);
|
|
31
|
+
const [anchor, setAnchor] = useState({ kind: "start" });
|
|
32
|
+
const [page, setPage] = useState(null);
|
|
33
|
+
const [error, setError] = useState(null);
|
|
34
|
+
const [loading, setLoading] = useState(false);
|
|
35
|
+
const focusRef = useRef(null);
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const handle = setTimeout(() => setDebouncedSearch(search), 300);
|
|
38
|
+
return () => clearTimeout(handle);
|
|
39
|
+
}, [search]);
|
|
40
|
+
// Reset paging when the story or filters change.
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
setAnchor({ kind: "start" });
|
|
43
|
+
}, [storyId, levels, scopePrefix, debouncedSearch]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (jumpMs !== null) {
|
|
46
|
+
setAnchor({ kind: "around", ms: jumpMs });
|
|
47
|
+
onJumpConsumed();
|
|
48
|
+
}
|
|
49
|
+
}, [jumpMs, onJumpConsumed]);
|
|
50
|
+
const load = useCallback(async () => {
|
|
51
|
+
setLoading(true);
|
|
52
|
+
try {
|
|
53
|
+
const result = await tracingService.logs(sessionId, storyId, generation, {
|
|
54
|
+
levels,
|
|
55
|
+
scopePrefix: scopePrefix || null,
|
|
56
|
+
search: debouncedSearch || null,
|
|
57
|
+
afterSequence: anchor.kind === "after" ? anchor.sequence : null,
|
|
58
|
+
beforeSequence: anchor.kind === "before" ? anchor.sequence : null,
|
|
59
|
+
aroundMs: anchor.kind === "around" ? anchor.ms : null,
|
|
60
|
+
limit: PAGE,
|
|
61
|
+
});
|
|
62
|
+
setPage(result);
|
|
63
|
+
setError(null);
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
if (e instanceof TracingApiError && e.notFound)
|
|
67
|
+
setError("No longer in memory.");
|
|
68
|
+
else
|
|
69
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
setLoading(false);
|
|
73
|
+
}
|
|
74
|
+
}, [requestContext.selectedInstance, sessionId, storyId, generation, levels, scopePrefix, debouncedSearch, anchor]);
|
|
75
|
+
// Load on every relevant change, including new content of a running story or source.
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
void load();
|
|
78
|
+
}, [load, contentVersion]);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (page?.focusSequence && focusRef.current) {
|
|
81
|
+
focusRef.current.scrollIntoView({ block: "center" });
|
|
82
|
+
}
|
|
83
|
+
}, [page?.focusSequence, page?.entries]);
|
|
84
|
+
const scopeOptions = useMemo(() => {
|
|
85
|
+
const options = [{ value: "", label: "All scopes" }];
|
|
86
|
+
for (const scope of page?.scopeTree ?? []) {
|
|
87
|
+
const depth = scope.prefix.split(".").length - 1;
|
|
88
|
+
options.push({ value: scope.prefix, label: `${" ".repeat(depth)}${scope.prefix.split(".").pop()} (${scope.count})` });
|
|
89
|
+
}
|
|
90
|
+
return options;
|
|
91
|
+
}, [page?.scopeTree]);
|
|
92
|
+
const toggleLevel = (level) => traceActions.setLogLevels(levels.includes(level) ? levels.filter((l) => l !== level) : [...levels, level]);
|
|
93
|
+
const entries = page?.entries ?? [];
|
|
94
|
+
const copyAll = entries
|
|
95
|
+
.map((e) => [relative ? formatRelativeMs(e.relativeMs) : e.time, sessionMode ? e.storyPath ?? e.storyName ?? "" : null, e.level, e.scope ?? "", e.message]
|
|
96
|
+
.filter((part) => part !== null)
|
|
97
|
+
.join("\t"))
|
|
98
|
+
.join("\n");
|
|
99
|
+
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col", "data-testid": "alpaca-trace-logs", "data-mode": sessionMode ? "session" : "story", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-2 border-b border-border-default px-3 py-2", children: [_jsx("div", { className: "flex items-center gap-1", "data-testid": "alpaca-trace-level-chips", children: LOG_LEVELS.map((level) => (_jsx("button", { type: "button", onClick: () => toggleLevel(level), className: cls("rounded border px-1.5 py-0.5 text-2xs uppercase tracking-wide", levels.includes(level)
|
|
100
|
+
? "border-highlight-100 bg-highlight-10 text-neutral-grey-100"
|
|
101
|
+
: "border-border-default text-neutral-grey-50 hover:bg-neutral-grey-5"), "data-testid": `alpaca-trace-level-${level}`, children: level }, level))) }), _jsx(Select, { size: "xs", options: scopeOptions, value: scopePrefix, onValueChange: setScopePrefix, maxWidth: 260, searchable: true, "data-testid": "alpaca-trace-scope-select" }), _jsx(SearchInput, { value: search, onChange: setSearch, placeholder: "Search messages", size: "sm", className: "min-w-[140px] flex-1", testId: "alpaca-trace-logs-search" }), _jsx(Button, { variant: "outline", size: "xs", onClick: () => setRelative((v) => !v), "data-testid": "alpaca-trace-time-mode", children: relative ? "+relative" : "absolute" }), _jsx(CopyButton, { textToCopy: copyAll, iconOnly: true })] }), page?.evicted && (_jsxs("div", { className: "border-b border-feedback-orange/20 bg-feedback-orange-light px-3 py-1 text-2xs text-feedback-orange", "data-testid": "alpaca-trace-evicted", children: [page.evictedCount, " entries were lost to the rolling buffer. Persist sessions you want to keep."] })), _jsx(InlineError, { message: error }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: entries.length === 0 ? (_jsx(EmptyHint, { title: loading ? "Loading…" : "No log entries", children: !loading && (levels.length < LOG_LEVELS.length || scopePrefix || debouncedSearch
|
|
102
|
+
? "Nothing matches the current filters."
|
|
103
|
+
: story && !story.available.logs
|
|
104
|
+
? "Logs were not persisted for this story."
|
|
105
|
+
: sessionMode
|
|
106
|
+
? "This source has no entries in memory."
|
|
107
|
+
: "This story has no entries in memory.") })) : (_jsxs("table", { className: "w-full table-fixed text-left text-2xs", children: [_jsxs("colgroup", { children: [_jsx("col", { className: "w-[84px]" }), sessionMode && _jsx("col", { className: "w-[200px]" }), _jsx("col", { className: "w-[220px]" }), _jsx("col", { className: "w-[58px]" }), _jsx("col", {})] }), _jsxs("tbody", { children: [page?.hasMoreBefore && (_jsx("tr", { children: _jsx("td", { colSpan: sessionMode ? 5 : 4, className: "p-1 text-center", children: _jsx(Button, { variant: "outline", size: "xxs", onClick: () => entries[0] && setAnchor({ kind: "before", sequence: entries[0].sequence }), children: "Load earlier entries" }) }) })), entries.map((entry, index) => {
|
|
108
|
+
const previous = entries[index - 1];
|
|
109
|
+
const isFocus = page?.focusSequence === entry.sequence;
|
|
110
|
+
return (_jsx(LogRow, { ref: isFocus ? focusRef : undefined, entry: entry, relative: relative, sessionMode: sessionMode, repeatedScope: previous?.scope === entry.scope, repeatedStory: previous?.storyId === entry.storyId, focus: isFocus, highlight: debouncedSearch || null, sessionId: sessionId, storyId: entry.storyId ?? storyId, generation: generation, onFilterScope: () => entry.scope && setScopePrefix(entry.scope), onOpenStory: () => entry.storyId && traceActions.selectStory(entry.storyId, { manual: true }) }, entry.sequence));
|
|
111
|
+
}), page?.hasMoreAfter && (_jsx("tr", { children: _jsx("td", { colSpan: sessionMode ? 5 : 4, className: "p-1 text-center", children: _jsx(Button, { variant: "outline", size: "xxs", onClick: () => {
|
|
112
|
+
const last = entries[entries.length - 1];
|
|
113
|
+
if (last)
|
|
114
|
+
setAnchor({ kind: "after", sequence: last.sequence });
|
|
115
|
+
}, children: "Load later entries" }) }) }))] })] })) }), page && (_jsxs("div", { className: "border-t border-border-default px-3 py-1 text-2xs text-neutral-grey-50", children: [entries.length, " of ", page.total, " entries", sessionMode ? " across all stories of this source" : ""] }))] }));
|
|
116
|
+
}
|
|
117
|
+
const LogRow = forwardRef(function LogRow({ entry, relative, sessionMode, repeatedScope, repeatedStory, focus, highlight, sessionId, storyId, generation, onFilterScope, onOpenStory }, ref) {
|
|
118
|
+
const storyLabel = entry.storyPath ?? entry.storyName ?? "";
|
|
119
|
+
return (_jsxs("tr", { ref: ref, className: cls("border-b border-border-default align-top hover:bg-neutral-grey-5", focus && "bg-highlight-10", entry.level === "error" && "bg-feedback-red-light/40"), "data-testid": "alpaca-trace-log-row", "data-sequence": entry.sequence, children: [_jsx("td", { className: "px-2 py-1 font-mono text-neutral-grey-50", title: entry.time, children: relative ? formatRelativeMs(entry.relativeMs) : formatTime(entry.time) }), sessionMode && (_jsx("td", { className: "px-2 py-1", children: _jsx("button", { type: "button", className: cls("max-w-full truncate text-left hover:underline", repeatedStory ? "text-neutral-grey-15" : "text-neutral-grey-100"), title: `${entry.storyName ?? ""}\nClick to open this story`, onClick: onOpenStory, "data-testid": "alpaca-trace-log-story-link", children: storyLabel }) })), _jsx("td", { className: "px-2 py-1", children: _jsx("button", { type: "button", className: cls("max-w-full truncate text-left hover:underline", repeatedScope ? "text-neutral-grey-15" : "text-neutral-grey-50"), title: `${entry.scope ?? ""}\nClick to filter by this scope`, onClick: onFilterScope, children: entry.scope }) }), _jsx("td", { className: "px-2 py-1", children: _jsx(LevelBadge, { level: entry.level }) }), _jsx("td", { className: "px-2 py-1", children: _jsxs("div", { className: "flex items-start gap-1", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(LogMessage, { message: entry.message, truncated: entry.truncated, highlight: highlight }) }), entry.hasStackTrace && storyId && (_jsx(StackTracePopover, { sessionId: sessionId, storyId: storyId, sequence: entry.sequence, generation: generation }))] }) })] }));
|
|
120
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "stack" button of a log row. Opens the captured stack trace in a popover instead of expanding the row,
|
|
3
|
+
* so the log list keeps its rhythm; the trace is fetched on first open and offered with a copy button.
|
|
4
|
+
*/
|
|
5
|
+
export declare function StackTracePopover({ sessionId, storyId, sequence, generation, }: {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
storyId: string;
|
|
8
|
+
sequence: number;
|
|
9
|
+
generation: string | null;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
11
|
+
type Frame = {
|
|
12
|
+
text: string;
|
|
13
|
+
own: boolean;
|
|
14
|
+
tracing: boolean;
|
|
15
|
+
};
|
|
16
|
+
/** One frame per line; the "at " prefix goes, own (Parhelia/Alpaca) frames are marked, tracing plumbing is dimmed. */
|
|
17
|
+
export declare function parseFrames(text: string): Frame[];
|
|
18
|
+
export {};
|
|
19
|
+
//# sourceMappingURL=StackTracePopover.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"StackTracePopover.d.ts","sourceRoot":"","sources":["../../../src/tracing/components/StackTracePopover.tsx"],"names":[],"mappings":"AASA;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,SAAS,EACT,OAAO,EACP,QAAQ,EACR,UAAU,GACX,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B,+BAyFA;AAED,KAAK,KAAK,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;AAE9D,sHAAsH;AACtH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,KAAK,EAAE,CAWjD"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useTraceStore } from "../traceStore";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { CopyButton, Popover, PopoverContent, PopoverTrigger } from "@parhelia/core";
|
|
5
|
+
import { Layers } from "lucide-react";
|
|
6
|
+
import { forTracingInstance } from "../tracingService";
|
|
7
|
+
import { cls } from "./shared";
|
|
8
|
+
/**
|
|
9
|
+
* "stack" button of a log row. Opens the captured stack trace in a popover instead of expanding the row,
|
|
10
|
+
* so the log list keeps its rhythm; the trace is fetched on first open and offered with a copy button.
|
|
11
|
+
*/
|
|
12
|
+
export function StackTracePopover({ sessionId, storyId, sequence, generation, }) {
|
|
13
|
+
const requestContext = useTraceStore();
|
|
14
|
+
const tracingService = forTracingInstance(requestContext.selectedInstance, requestContext.generation, requestContext.status?.globalRecording?.recordingId);
|
|
15
|
+
const [open, setOpen] = useState(false);
|
|
16
|
+
const [stack, setStack] = useState({ kind: "idle" });
|
|
17
|
+
const [ownOnly, setOwnOnly] = useState(false);
|
|
18
|
+
const onOpenChange = (next) => {
|
|
19
|
+
setOpen(next);
|
|
20
|
+
if (!next || stack.kind === "ready" || stack.kind === "loading")
|
|
21
|
+
return;
|
|
22
|
+
setStack({ kind: "loading" });
|
|
23
|
+
tracingService
|
|
24
|
+
.stackTrace(sessionId, storyId, sequence, generation)
|
|
25
|
+
.then((result) => setStack({ kind: "ready", text: result.stackTrace ?? "", truncated: result.truncated }))
|
|
26
|
+
.catch((e) => setStack({ kind: "error", message: e instanceof Error ? e.message : String(e) }));
|
|
27
|
+
};
|
|
28
|
+
const frames = stack.kind === "ready" ? parseFrames(stack.text) : [];
|
|
29
|
+
const ownCount = frames.filter((f) => f.own).length;
|
|
30
|
+
const visible = ownOnly ? frames.filter((f) => f.own) : frames;
|
|
31
|
+
return (_jsxs(Popover, { open: open, onOpenChange: onOpenChange, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: cls("inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-2xs text-neutral-grey-50 hover:bg-neutral-grey-10 hover:text-neutral-grey-100", open && "bg-neutral-grey-10 text-neutral-grey-100"), title: "Show the captured stack trace", "data-testid": "alpaca-trace-stack-toggle", children: [_jsx(Layers, { className: "h-3 w-3", strokeWidth: 1.5 }), "stack"] }) }), _jsxs(PopoverContent, { align: "end", collisionPadding: 12, className: "flex w-[min(92vw,820px)] flex-col gap-0 p-0", "data-testid": "alpaca-trace-stack-popover", children: [_jsxs("div", { className: "flex items-center gap-2 border-b border-border-default px-3 py-2", children: [_jsx("span", { className: "text-xs font-medium text-neutral-grey-100", children: "Stack trace" }), stack.kind === "ready" && (_jsxs("span", { className: "text-2xs text-neutral-grey-50", children: [frames.length, " frames", ownCount > 0 ? `, ${ownCount} own` : "", stack.truncated ? " · truncated at 64 KB" : ""] })), _jsx("span", { className: "flex-1" }), ownCount > 0 && ownCount < frames.length && (_jsxs("label", { className: "flex items-center gap-1 text-2xs text-neutral-grey-50", children: [_jsx("input", { type: "checkbox", checked: ownOnly, onChange: (e) => setOwnOnly(e.target.checked), className: "h-3 w-3" }), "Own frames only"] })), stack.kind === "ready" && (_jsx(CopyButton, { textToCopy: ownOnly ? visible.map((f) => f.text).join("\n") : stack.text, className: "inline-flex h-6 shrink-0 cursor-pointer items-center gap-1 whitespace-nowrap rounded border border-border-default bg-background px-2 text-2xs text-neutral-grey-100 hover:bg-neutral-grey-5", "data-testid": "alpaca-trace-stack-copy" }))] }), _jsxs("div", { className: "max-h-[min(60vh,520px)] overflow-auto px-3 py-2", children: [stack.kind === "loading" && _jsx("div", { className: "text-2xs text-neutral-grey-50", children: "Loading\u2026" }), stack.kind === "error" && _jsxs("div", { className: "text-2xs text-feedback-red", children: ["Stack trace unavailable: ", stack.message] }), stack.kind === "ready" && frames.length === 0 && _jsx("div", { className: "text-2xs text-neutral-grey-50", children: "No stack was captured for this entry." }), stack.kind === "ready" && frames.length > 0 && (_jsx("ol", { className: "m-0 list-none p-0 font-mono text-2xs leading-snug", "data-testid": "alpaca-trace-stack-frames", children: visible.map((frame, index) => (_jsx("li", { className: cls("whitespace-pre", frame.own ? "font-medium text-neutral-grey-100" : frame.tracing ? "text-neutral-grey-15" : "text-neutral-grey-50"), children: frame.text }, index))) }))] })] })] }));
|
|
32
|
+
}
|
|
33
|
+
/** One frame per line; the "at " prefix goes, own (Parhelia/Alpaca) frames are marked, tracing plumbing is dimmed. */
|
|
34
|
+
export function parseFrames(text) {
|
|
35
|
+
return text
|
|
36
|
+
.split("\n")
|
|
37
|
+
.map((line) => line.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((line) => {
|
|
40
|
+
const clean = line.replace(/^at\s+/, "");
|
|
41
|
+
const tracing = /^(Alpaca\.Tracing|System\.Environment\.(Get|get_)StackTrace)/.test(clean);
|
|
42
|
+
const own = !tracing && /\b(Parhelia|Alpaca)\./.test(clean);
|
|
43
|
+
return { text: clean, own, tracing };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"StoriesPane.d.ts","sourceRoot":"","sources":["../../../src/tracing/components/StoriesPane.tsx"],"names":[],"mappings":"AAqNA,wBAAgB,WAAW,gCA0O1B"}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Button, Checkbox, Popover, PopoverContent, PopoverTrigger, SearchInput, Select, SimpleTable, useEditContext, } from "@parhelia/core";
|
|
4
|
+
import { Activity, Archive, ChevronDown, Globe, PanelLeftOpen, Settings2, User } from "lucide-react";
|
|
5
|
+
import { formatDuration, formatTime, storyDisplayName, storyDurationLabel, } from "../format";
|
|
6
|
+
import { forTracingInstance } from "../tracingService";
|
|
7
|
+
import { hasInstanceChoice, selectedSource, traceActions, useTraceStore } from "../traceStore";
|
|
8
|
+
import { InstanceSwitcher } from "./InstanceSwitcher";
|
|
9
|
+
/** Tooltip of "Hide noise": what the read-side noise filter drops. */
|
|
10
|
+
function noiseTitle(prefixes) {
|
|
11
|
+
const lines = [
|
|
12
|
+
"Noise is infrastructure traffic that rarely matters when reading a trace:",
|
|
13
|
+
"Sitecore shell, SPEAK and service requests, plus Parhelia's own polling, realtime and asset requests.",
|
|
14
|
+
];
|
|
15
|
+
if (prefixes && prefixes.length)
|
|
16
|
+
lines.push("", "Request path prefixes hidden:", ...prefixes.map((p) => " " + p));
|
|
17
|
+
lines.push("", "Hiding only affects this list; the stories are still recorded.");
|
|
18
|
+
return lines.join("\n");
|
|
19
|
+
}
|
|
20
|
+
/** One-line description of a source's capture filter, refreshed when the source changes or the filter dialog closes. */
|
|
21
|
+
function CaptureFilterSummary({ sessionId }) {
|
|
22
|
+
const requestContext = useTraceStore();
|
|
23
|
+
const tracingService = forTracingInstance(requestContext.selectedInstance, requestContext.generation, requestContext.status?.globalRecording?.recordingId);
|
|
24
|
+
const dialogOpen = useTraceStore().captureFilterSourceId !== null;
|
|
25
|
+
const [filter, setFilter] = useState(null);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
let cancelled = false;
|
|
28
|
+
tracingService
|
|
29
|
+
.filters(sessionId)
|
|
30
|
+
.then((r) => !cancelled && setFilter(r.filter))
|
|
31
|
+
.catch(() => !cancelled && setFilter(null));
|
|
32
|
+
return () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
};
|
|
35
|
+
}, [requestContext.selectedInstance, sessionId, dialogOpen]);
|
|
36
|
+
if (!filter)
|
|
37
|
+
return _jsx("span", { children: "capture filter" });
|
|
38
|
+
const parts = [
|
|
39
|
+
filter.includeScopes.length ? `${filter.includeScopes.length} included` : null,
|
|
40
|
+
filter.includeScopesTraceLevel.length ? `${filter.includeScopesTraceLevel.length} at trace` : null,
|
|
41
|
+
filter.excludeScopes.length ? `${filter.excludeScopes.length} excluded` : null,
|
|
42
|
+
filter.stackTraceScopes.length ? `${filter.stackTraceScopes.length} with stacks` : null,
|
|
43
|
+
filter.disabledCaches.length ? `${filter.disabledCaches.length} caches off` : null,
|
|
44
|
+
filter.enabledFeatures.length ? `${filter.enabledFeatures.length} feature previews` : null,
|
|
45
|
+
].filter(Boolean);
|
|
46
|
+
return _jsxs("span", { children: ["capture: ", parts.length ? parts.join(" · ") : "default"] });
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Switches the source from the main pane, so it does not depend on the Sources sidebar being open.
|
|
50
|
+
* Lists every session and stream, marks what is recording, and offers to open the sidebar.
|
|
51
|
+
*/
|
|
52
|
+
function SourceSwitcher({ current }) {
|
|
53
|
+
const state = useTraceStore();
|
|
54
|
+
const editContext = useEditContext();
|
|
55
|
+
const [open, setOpen] = useState(false);
|
|
56
|
+
const status = state.status;
|
|
57
|
+
const sources = status?.sources ?? [];
|
|
58
|
+
const groups = [
|
|
59
|
+
{ title: "Sessions", items: sources.filter((s) => s.kind === "personal" || s.kind === "global" || s.kind === "capture").sort((a, b) => (a.kind === "global" ? -1 : b.kind === "global" ? 1 : a.name.localeCompare(b.name))) },
|
|
60
|
+
{ title: "Streams", items: sources.filter((s) => s.kind === "stream").sort((a, b) => (b.lastActivity ?? "").localeCompare(a.lastActivity ?? "")) },
|
|
61
|
+
{ title: "Restored", items: sources.filter((s) => s.kind === "restored") },
|
|
62
|
+
].filter((g) => g.items.length > 0);
|
|
63
|
+
const isRecording = (s) => s.kind === "global"
|
|
64
|
+
? !!status?.globalEnabled
|
|
65
|
+
: !!status && !status.browserAttachment.stale && status.browserAttachment.sessionId === s.id;
|
|
66
|
+
const isMine = (s) => s.kind === "personal" && !!status?.userName && s.owner === status.userName;
|
|
67
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: "inline-flex min-w-0 items-center gap-1 rounded px-1 py-0.5 text-2xs hover:bg-neutral-grey-10 hover:text-neutral-grey-100", title: "Switch source", "data-testid": "alpaca-trace-source-switcher", children: [current ? (_jsxs(_Fragment, { children: [_jsx(KindIcon, { kind: current.kind, className: "h-3 w-3" }), _jsx("span", { className: "truncate", children: current.name })] })) : (_jsx("span", { children: "Choose a source" })), _jsx(ChevronDown, { className: "h-3 w-3 shrink-0", strokeWidth: 1.5 })] }) }), _jsxs(PopoverContent, { align: "start", className: "w-80 p-1", "data-testid": "alpaca-trace-source-switcher-menu", children: [hasInstanceChoice(state) && _jsxs("div", { className: "border-b border-border-default px-2 pt-1 pb-2", children: [_jsx("div", { className: "pb-1 text-2xs font-medium uppercase tracking-wide text-neutral-grey-50", children: "Instance" }), _jsx(InstanceSwitcher, {})] }), sources.length === 0 && _jsx("div", { className: "px-2 py-1.5 text-2xs text-neutral-grey-50", children: "No sources yet." }), groups.map((group) => (_jsxs("div", { className: "py-1", children: [_jsx("div", { className: "px-2 pb-0.5 text-2xs font-medium uppercase tracking-wide text-neutral-grey-50", children: group.title }), group.items.map((s) => (_jsxs("button", { type: "button", className: cls("flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs hover:bg-neutral-grey-5", s.id === current?.id && "bg-highlight-10"), onClick: () => {
|
|
68
|
+
traceActions.selectSource(s.id);
|
|
69
|
+
setOpen(false);
|
|
70
|
+
}, "data-source-id": s.id, children: [_jsxs("span", { className: "relative shrink-0", children: [_jsx(KindIcon, { kind: s.kind, className: "text-neutral-grey-50" }), isRecording(s) && _jsx("span", { className: "absolute -right-0.5 -bottom-0.5 h-2 w-2 rounded-full bg-feedback-green ring-2 ring-background" })] }), _jsxs("span", { className: "min-w-0 flex-1 truncate", children: [s.name, isMine(s) && _jsx("span", { className: "text-neutral-grey-50", children: " (this login)" })] }), _jsxs("span", { className: "shrink-0 text-2xs text-neutral-grey-50", children: [s.storyCount, " ", s.storyCount === 1 ? "story" : "stories"] })] }, s.id)))] }, group.title))), !status?.globalEnabled && (_jsxs("button", { type: "button", className: "flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs text-neutral-grey-100 hover:bg-neutral-grey-5", onClick: () => {
|
|
71
|
+
setOpen(false);
|
|
72
|
+
editContext?.confirm({
|
|
73
|
+
header: "Enable global recording",
|
|
74
|
+
message: "Record every request and background job on this instance for all users. This is memory heavy; disable it when you are done.",
|
|
75
|
+
acceptLabel: "Enable",
|
|
76
|
+
accept: () => {
|
|
77
|
+
void traceActions.enableGlobal();
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}, children: [_jsx(Globe, { className: "h-4 w-4 text-neutral-grey-50", strokeWidth: 1.5 }), "Enable global recording\u2026"] })), _jsx("div", { className: "mt-1 border-t border-border-default pt-1", children: _jsxs("button", { type: "button", className: "flex w-full items-center gap-2 rounded px-2 py-1 text-left text-2xs text-neutral-grey-50 hover:bg-neutral-grey-5", onClick: () => {
|
|
81
|
+
setOpen(false);
|
|
82
|
+
editContext?.openSidebar("alpaca-trace-sources", { preserveOpenSidebars: true });
|
|
83
|
+
}, children: [_jsx(PanelLeftOpen, { className: "h-3.5 w-3.5", strokeWidth: 1.5 }), "Show the Sources sidebar"] }) })] })] }));
|
|
84
|
+
}
|
|
85
|
+
/** Esc while no input has focus clears the selected story (back to the whole source). */
|
|
86
|
+
function useEscapeDeselects() {
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const onKey = (event) => {
|
|
89
|
+
if (event.key !== "Escape")
|
|
90
|
+
return;
|
|
91
|
+
const target = event.target;
|
|
92
|
+
if (target && /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))
|
|
93
|
+
return;
|
|
94
|
+
if (target?.isContentEditable)
|
|
95
|
+
return;
|
|
96
|
+
traceActions.selectStory(null);
|
|
97
|
+
};
|
|
98
|
+
window.addEventListener("keydown", onKey);
|
|
99
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
100
|
+
}, []);
|
|
101
|
+
}
|
|
102
|
+
import { EmptyHint, InlineError, KindIcon, ProblemCounts, ResultBadge, StateDot, cls } from "./shared";
|
|
103
|
+
const problemOptions = [
|
|
104
|
+
{ value: "all", label: "All stories" },
|
|
105
|
+
{ value: "any", label: "Any problem" },
|
|
106
|
+
{ value: "errors", label: "With errors" },
|
|
107
|
+
{ value: "warnings", label: "With warnings" },
|
|
108
|
+
{ value: "failed", label: "Failed requests" },
|
|
109
|
+
];
|
|
110
|
+
const durationOptions = [
|
|
111
|
+
{ value: "0", label: "Any duration" },
|
|
112
|
+
{ value: "100", label: "≥ 100 ms" },
|
|
113
|
+
{ value: "500", label: "≥ 500 ms" },
|
|
114
|
+
{ value: "1000", label: "≥ 1 s" },
|
|
115
|
+
{ value: "5000", label: "≥ 5 s" },
|
|
116
|
+
];
|
|
117
|
+
export function StoriesPane() {
|
|
118
|
+
const requestContext = useTraceStore();
|
|
119
|
+
const tracingService = forTracingInstance(requestContext.selectedInstance, requestContext.generation, requestContext.status?.globalRecording?.recordingId);
|
|
120
|
+
const state = useTraceStore();
|
|
121
|
+
const source = selectedSource(state);
|
|
122
|
+
const [search, setSearch] = useState(state.filters.search);
|
|
123
|
+
const [now, setNow] = useState(() => Date.now());
|
|
124
|
+
useEscapeDeselects();
|
|
125
|
+
// Debounced search.
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const handle = setTimeout(() => {
|
|
128
|
+
if (search !== state.filters.search)
|
|
129
|
+
traceActions.setFilters({ search });
|
|
130
|
+
}, 300);
|
|
131
|
+
return () => clearTimeout(handle);
|
|
132
|
+
}, [search, state.filters.search]);
|
|
133
|
+
// Running rows show elapsed time; tick once a second while any is running.
|
|
134
|
+
const hasRunning = state.stories.some((r) => r.state === "running");
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (!hasRunning)
|
|
137
|
+
return;
|
|
138
|
+
const handle = setInterval(() => setNow(Date.now()), 1000);
|
|
139
|
+
return () => clearInterval(handle);
|
|
140
|
+
}, [hasRunning]);
|
|
141
|
+
if (state.status && state.status.sources.length === 0 && !state.selectedSourceId) {
|
|
142
|
+
return _jsx(NothingRecorded, {});
|
|
143
|
+
}
|
|
144
|
+
if (state.sessionMissing) {
|
|
145
|
+
return _jsx(SessionMissingCard, {});
|
|
146
|
+
}
|
|
147
|
+
if (!state.selectedSourceId) {
|
|
148
|
+
return (_jsx("div", { className: "flex h-full flex-col bg-background", children: state.status && !state.status.globalEnabled && (!state.status.browserAttachment.sessionId || state.status.browserAttachment.stale) ? (_jsx(NothingRecorded, {})) : (_jsx(EmptyHint, { title: "Select a source", icon: _jsx(Activity, { className: "h-8 w-8", strokeWidth: 1 }), children: _jsxs("div", { className: "flex flex-col items-center gap-2", children: [_jsx("span", { children: "Pick a session or stream to see its stories." }), _jsx(SourceSwitcher, { current: null })] }) })) }));
|
|
149
|
+
}
|
|
150
|
+
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col bg-background", "data-testid": "alpaca-trace-stories", children: [_jsxs("div", { className: "flex flex-nowrap items-center gap-2 border-b border-border-default px-3 py-2", children: [_jsx(SearchInput, { value: search, onChange: setSearch, placeholder: "Search stories", size: "sm", className: "min-w-[160px] flex-1", testId: "alpaca-trace-stories-search" }), _jsx("div", { className: "shrink-0", title: "Problems: errors and warnings in the logs, or a 4xx / 5xx response", children: _jsx(Select, { size: "xs", options: problemOptions, value: state.filters.problems ?? "all", onValueChange: (value) => traceActions.setFilters({ problems: value === "all" ? null : value }), maxWidth: 136, "data-testid": "alpaca-trace-problem-filter" }) }), _jsxs("label", { className: "flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs text-neutral-grey-100", title: noiseTitle(state.status?.noisePathPrefixes), children: [_jsx(Checkbox, { checked: state.filters.hideNoise, onCheckedChange: (checked) => traceActions.setFilters({ hideNoise: checked === true }), "data-testid": "alpaca-trace-hide-noise" }), "Hide noise"] }), _jsx("div", { className: "shrink-0", title: "Minimum story duration", children: _jsx(Select, { size: "xs", options: durationOptions, value: String(state.filters.minDurationMs ?? 0), onValueChange: (value) => traceActions.setFilters({ minDurationMs: value === "0" ? null : Number(value) }), maxWidth: 128, "data-testid": "alpaca-trace-min-duration" }) })] }), source && (_jsxs("div", { className: "flex items-center gap-2 border-b border-border-default bg-neutral-grey-5 px-3 py-1 text-2xs text-neutral-grey-50", children: [source.kind === "global" ? (_jsx(Globe, { className: "h-3 w-3", strokeWidth: 1.5 })) : source.kind === "restored" ? (_jsx(Archive, { className: "h-3 w-3", strokeWidth: 1.5 })) : (_jsx(User, { className: "h-3 w-3", strokeWidth: 1.5 })), _jsx(SourceSwitcher, { current: source }), _jsx("span", { children: "\u00B7" }), _jsxs("span", { className: "shrink-0", children: [state.storiesTotal, " ", state.storiesTotal === 1 ? "story" : "stories", source.storyCount > state.storiesTotal ? ` of ${source.storyCount}` : ""] }), source.kind === "restored" && _jsx("span", { className: "shrink-0", children: "\u00B7 restored, read-only" }), _jsx("span", { className: "flex-1" }), source.kind !== "restored" && (_jsxs("button", { type: "button", className: "inline-flex shrink-0 items-center gap-1 rounded px-1.5 py-0.5 text-2xs text-neutral-grey-50 hover:bg-neutral-grey-10 hover:text-neutral-grey-100", title: "Capture filter of this source (what gets recorded)", onClick: () => traceActions.openCaptureFilter(source.id), "data-testid": "alpaca-trace-open-capture-filter", children: [_jsx(Settings2, { className: "h-3 w-3", strokeWidth: 1.5 }), _jsx(CaptureFilterSummary, { sessionId: source.id })] }))] })), _jsx(InlineError, { message: state.storiesError }), _jsxs("div", { className: "min-h-0 flex-1 overflow-auto", children: [state.stories.length === 0 ? (_jsx(EmptyHint, { title: state.storiesLoading ? "Loading stories…" : "No stories", children: !state.storiesLoading &&
|
|
151
|
+
(state.filters.problems || state.filters.search || state.filters.minDurationMs
|
|
152
|
+
? "Nothing matches the current filters."
|
|
153
|
+
: source?.isRecordable
|
|
154
|
+
? "Nothing recorded yet. Use the editor in another tab while this session records, and stories appear here."
|
|
155
|
+
: "This source has no stories in memory.") })) : (_jsx(SimpleTable, { items: state.stories, rowKey: (row) => row.id, rowTestId: (row) => `alpaca-trace-story-${row.id}`, rowClassName: (row) => cls(row.id === state.selectedStoryId && "bg-highlight-10"), onRowClick: ({ item, event }) => {
|
|
156
|
+
if (event.ctrlKey || event.metaKey) {
|
|
157
|
+
const url = new URL(window.location.href);
|
|
158
|
+
url.searchParams.set("workspace", "alpaca-tracing");
|
|
159
|
+
url.searchParams.set("traceSession", item.sessionId);
|
|
160
|
+
url.searchParams.set("traceStory", item.id);
|
|
161
|
+
window.open(url.toString(), "_blank", "noopener");
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
traceActions.toggleStory(item.id);
|
|
165
|
+
}, "data-testid": "alpaca-trace-stories-table", columns: [
|
|
166
|
+
{
|
|
167
|
+
header: "Time",
|
|
168
|
+
className: "w-[70px] whitespace-nowrap font-mono text-2xs text-neutral-grey-50",
|
|
169
|
+
body: (row) => (_jsx("span", { title: new Date(row.timeCreated).toLocaleString(), children: formatTime(row.timeCreated) })),
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
header: "Story",
|
|
173
|
+
// max-w-0 stops a long request path from stretching the table (which pushed Result,
|
|
174
|
+
// Duration and Logs out of view); the name wraps instead.
|
|
175
|
+
className: "w-full max-w-0",
|
|
176
|
+
body: (row) => (_jsxs("span", { className: "flex min-w-0 items-start gap-1.5", title: row.host ? `${row.host}\n${row.name}` : row.name, children: [_jsx(StateDot, { state: row.state, className: "mt-1" }), _jsx("span", { className: "min-w-0 [overflow-wrap:anywhere]", children: storyDisplayName(row) }), row.origin === "restored" && (_jsx(Archive, { className: "h-3 w-3 shrink-0 text-neutral-grey-50", strokeWidth: 1.5 })), row.evictedCount > 0 && (_jsxs("span", { className: "shrink-0 text-2xs text-feedback-orange", title: `${row.evictedCount} entries lost to the buffer`, children: ["\u2212", row.evictedCount] }))] })),
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
header: "Result",
|
|
180
|
+
className: "w-[60px] whitespace-nowrap",
|
|
181
|
+
body: (row) => _jsx(ResultBadge, { row: row }),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
header: "Duration",
|
|
185
|
+
className: "w-[80px] whitespace-nowrap text-right font-mono text-2xs",
|
|
186
|
+
align: "right",
|
|
187
|
+
body: (row) => (_jsx("span", { title: row.sqlMs > 0 ? `SQL ${formatDuration(row.sqlMs)}` : undefined, children: storyDurationLabel(row, now) })),
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
header: "Logs",
|
|
191
|
+
className: "w-[80px] whitespace-nowrap text-right text-2xs",
|
|
192
|
+
align: "right",
|
|
193
|
+
body: (row) => (_jsxs("span", { className: "inline-flex items-center justify-end gap-2", children: [_jsx("span", { className: "tabular-nums text-neutral-grey-50", children: row.logCount }), _jsx(ProblemCounts, { errorCount: row.errorCount, warnCount: row.warnCount, onErrorClick: () => traceActions.focusProblems("error", { sourceId: row.sessionId, storyId: row.id }), onWarnClick: () => traceActions.focusProblems("warn", { sourceId: row.sessionId, storyId: row.id }) })] })),
|
|
194
|
+
},
|
|
195
|
+
] })), state.nextCursor && (_jsx("div", { className: "flex justify-center p-2", children: _jsx(Button, { variant: "outline", size: "xs", disabled: state.storiesLoading, onClick: () => void traceActions.loadOlder(), "data-testid": "alpaca-trace-load-older", children: "Load older stories" }) }))] })] }));
|
|
196
|
+
}
|
|
197
|
+
function NothingRecorded() {
|
|
198
|
+
return (_jsx("div", { className: "flex h-full items-center justify-center bg-background p-6", "data-testid": "alpaca-trace-nothing-recorded", children: _jsxs("div", { className: "max-w-md rounded border border-border-default bg-neutral-grey-5 p-6", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-neutral-grey-100", children: [_jsx(Activity, { className: "h-5 w-5 text-highlight-100", strokeWidth: 1 }), _jsx("span", { className: "text-sm font-medium", children: "Nothing is being recorded" })] }), _jsxs("p", { className: "mb-4 text-xs text-neutral-grey-50", children: ["Alpaca tracing records every request as a ", _jsx("em", { children: "story" }), " with its logs, timers and counters. Recording is opt-in and lives in memory on this instance only."] }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs(Button, { size: "sm", onClick: () => void traceActions.recordMySession(), "data-testid": "alpaca-trace-empty-record", children: [_jsx(User, { className: "h-3.5 w-3.5", strokeWidth: 1.5 }), "Record my session"] }), _jsx("p", { className: "text-2xs text-neutral-grey-50", children: "Only requests from this browser are traced. Use the editor in another tab and watch the stories arrive." }), _jsxs(Button, { size: "sm", variant: "outline", onClick: () => void traceActions.enableGlobal(), "data-testid": "alpaca-trace-empty-global", children: [_jsx(Globe, { className: "h-3.5 w-3.5", strokeWidth: 1.5 }), "Record everything (global)"] }), _jsx("p", { className: "text-2xs text-neutral-grey-50", children: "Traces all requests and background jobs of this instance for every user. Memory heavy; disable when done." })] })] }) }));
|
|
199
|
+
}
|
|
200
|
+
function SessionMissingCard() {
|
|
201
|
+
const state = useTraceStore();
|
|
202
|
+
const missing = state.sessionMissing;
|
|
203
|
+
return (_jsx("div", { className: "flex h-full items-center justify-center bg-background p-6", "data-testid": "alpaca-trace-session-missing", children: _jsxs("div", { className: "max-w-md rounded border border-border-default bg-neutral-grey-5 p-6", children: [_jsxs("div", { className: "mb-2 flex items-center gap-2 text-neutral-grey-100", children: [_jsx(Archive, { className: "h-5 w-5 text-neutral-grey-50", strokeWidth: 1 }), _jsx("span", { className: "text-sm font-medium", children: "This session is not in memory on this instance" })] }), missing.restorable ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "mb-4 text-xs text-neutral-grey-50", children: "Trace storage holds a persisted copy. Restoring loads it read-only; results, timers or counters may be missing depending on when it was saved." }), _jsx(Button, { size: "sm", disabled: missing.restoring, onClick: () => void traceActions.restoreSession(missing.sessionId), "data-testid": "alpaca-trace-restore", children: missing.restoring ? "Restoring…" : "Restore from storage" })] })) : (_jsx("p", { className: "text-xs text-neutral-grey-50", children: "It was never persisted, has been deleted, or was recorded on another instance of the farm. Traces are held per instance." })), _jsx("div", { className: "mt-3", children: _jsx(Button, { size: "xs", variant: "outline", onClick: () => traceActions.selectSource(null), children: "Back to sources" }) })] }) }));
|
|
204
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"StoryDetail.d.ts","sourceRoot":"","sources":["../../../src/tracing/components/StoryDetail.tsx"],"names":[],"mappings":"AAWA,wBAAgB,WAAW,gCAqK1B"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { SimpleIconButton, SimpleTabs } from "@parhelia/core";
|
|
4
|
+
import { Activity, Archive, Check, Link2, X } from "lucide-react";
|
|
5
|
+
import { formatDateTime, formatDuration, storyDisplayName, storyDurationLabel } from "../format";
|
|
6
|
+
import { buildDeepLink, selectedSource, traceActions, useTraceStore } from "../traceStore";
|
|
7
|
+
import { CountersTab } from "./CountersTab";
|
|
8
|
+
import { LogsTab } from "./LogsTab";
|
|
9
|
+
import { EmptyHint, ProblemCounts, ResultBadge, StateDot } from "./shared";
|
|
10
|
+
import { TimelineTab } from "./TimelineTab";
|
|
11
|
+
import { TimersTab } from "./TimersTab";
|
|
12
|
+
export function StoryDetail() {
|
|
13
|
+
const state = useTraceStore();
|
|
14
|
+
const source = selectedSource(state);
|
|
15
|
+
const story = state.stories.find((row) => row.id === state.selectedStoryId) ?? null;
|
|
16
|
+
const [jumpMs, setJumpMs] = useState(null);
|
|
17
|
+
const [now, setNow] = useState(() => Date.now());
|
|
18
|
+
const [copied, setCopied] = useState(false);
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (story?.state !== "running")
|
|
21
|
+
return;
|
|
22
|
+
setNow(Date.now());
|
|
23
|
+
const handle = setInterval(() => setNow(Date.now()), 1000);
|
|
24
|
+
return () => clearInterval(handle);
|
|
25
|
+
}, [story?.state, story?.id]);
|
|
26
|
+
// A jump from the timeline switches to the Logs tab.
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (jumpMs !== null)
|
|
29
|
+
traceActions.setStoryTab(0);
|
|
30
|
+
}, [jumpMs]);
|
|
31
|
+
if (!source) {
|
|
32
|
+
return (_jsx("div", { className: "h-full bg-neutral-grey-5", children: _jsx(EmptyHint, { title: "No source selected", icon: _jsx(Activity, { className: "h-8 w-8", strokeWidth: 1 }) }) }));
|
|
33
|
+
}
|
|
34
|
+
if (!story) {
|
|
35
|
+
// Session view: everything that spans the whole source. Logs merges all stories by sequence.
|
|
36
|
+
// (The capture filter is configuration, not data: it opens as a dialog from the gear / source menu.)
|
|
37
|
+
const sessionTabs = [
|
|
38
|
+
{ id: "logs", label: "Logs", content: _jsx(LogsTab, { sessionId: source.id, story: null, jumpMs: null, onJumpConsumed: () => undefined }) },
|
|
39
|
+
{ id: "timers", label: "Timers", content: _jsx(TimersTab, { sessionId: source.id, storyId: null, available: true }) },
|
|
40
|
+
{ id: "counters", label: "Counters", content: _jsx(CountersTab, { sessionId: source.id, storyId: null, available: true }) },
|
|
41
|
+
];
|
|
42
|
+
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col bg-background", "data-testid": "alpaca-trace-session-detail", children: [_jsxs("div", { className: "border-b border-border-default px-4 py-3", children: [_jsx("div", { className: "text-sm font-medium text-neutral-grey-100", children: source.name }), _jsx("div", { className: "text-2xs text-neutral-grey-50", children: state.selectedStoryId
|
|
43
|
+
? "The selected story is no longer listed."
|
|
44
|
+
: "Whole source: all log entries, timers and counters across its stories. Select a story for its own logs and timeline." })] }), _jsx(SimpleTabs, { tabs: sessionTabs, activeTab: state.sessionTab, setActiveTab: (index) => traceActions.setSessionTab(index) })] }));
|
|
45
|
+
}
|
|
46
|
+
const tabs = [
|
|
47
|
+
{
|
|
48
|
+
id: "logs",
|
|
49
|
+
label: "Logs",
|
|
50
|
+
badge: story.logCount > 0 ? _jsx("span", { className: "text-2xs text-neutral-grey-50", children: story.logCount }) : undefined,
|
|
51
|
+
content: (_jsx(LogsTab, { sessionId: source.id, story: story, jumpMs: jumpMs, onJumpConsumed: () => setJumpMs(null) })),
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: "timeline",
|
|
55
|
+
label: "Timeline",
|
|
56
|
+
content: _jsx(TimelineTab, { sessionId: source.id, story: story, onJump: (ms) => setJumpMs(ms) }),
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: "timers",
|
|
60
|
+
label: "Timers",
|
|
61
|
+
content: _jsx(TimersTab, { sessionId: source.id, storyId: story.id, available: story.available.timers }),
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "counters",
|
|
65
|
+
label: "Counters",
|
|
66
|
+
content: _jsx(CountersTab, { sessionId: source.id, storyId: story.id, available: story.available.counters }),
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col bg-background", "data-testid": "alpaca-trace-story-detail", "data-story-id": story.id, children: [_jsxs("div", { className: "border-b border-border-default px-4 py-3", children: [_jsxs("div", { className: "flex items-start gap-2", children: [_jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(StateDot, { state: story.state }), _jsx("span", { className: "truncate text-sm font-medium text-neutral-grey-100", title: story.name, children: storyDisplayName(story) }), _jsx(ResultBadge, { row: story }), story.origin === "restored" && (_jsxs("span", { className: "inline-flex items-center gap-1 text-2xs text-neutral-grey-50", title: "Restored from storage", children: [_jsx(Archive, { className: "h-3 w-3", strokeWidth: 1.5 }), " restored"] }))] }), _jsxs("div", { className: "mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-2xs text-neutral-grey-50", children: [_jsx("span", { title: "Started", children: formatDateTime(story.timeCreated) }), _jsx("span", { className: "font-mono", children: storyDurationLabel(story, now) }), story.state === "running" && now - new Date(story.timeCreated).getTime() > 10 * 60_000 && (_jsx("span", { title: "No End() was recorded for this story", children: "not ended" })), story.sqlMs > 0 && _jsxs("span", { children: ["SQL ", formatDuration(story.sqlMs)] }), _jsxs("span", { children: [story.logCount, " log entries"] }), _jsx(ProblemCounts, { errorCount: story.errorCount, warnCount: story.warnCount, onErrorClick: () => traceActions.focusProblems("error", { sourceId: source.id, storyId: story.id }), onWarnClick: () => traceActions.focusProblems("warn", { sourceId: source.id, storyId: story.id }) }), story.evictedCount > 0 && (_jsxs("span", { className: "text-feedback-orange", children: [story.evictedCount, " entries lost to the buffer"] })), story.host && _jsx("span", { className: "truncate", children: story.host })] })] }), _jsxs("div", { className: "flex shrink-0 items-center gap-1", children: [_jsx(SimpleIconButton, { icon: copied ? _jsx(Check, { className: "h-4 w-4 text-feedback-green", strokeWidth: 1.5 }) : _jsx(Link2, { className: "h-4 w-4", strokeWidth: 1.5 }), label: copied ? "Link copied" : "Copy link to this story", size: "small", onClick: () => {
|
|
70
|
+
void navigator.clipboard?.writeText(buildDeepLink(source.id, story.id)).then(() => {
|
|
71
|
+
setCopied(true);
|
|
72
|
+
setTimeout(() => setCopied(false), 1500);
|
|
73
|
+
});
|
|
74
|
+
}, "data-testid": "alpaca-trace-story-copy-link" }), _jsx(SimpleIconButton, { icon: _jsx(X, { className: "h-4 w-4", strokeWidth: 1.5 }), label: "Back to the whole source (Esc)", size: "small", onClick: () => traceActions.selectStory(null), "data-testid": "alpaca-trace-story-close" })] })] }), !story.available.timers && story.origin === "restored" && (_jsxs("div", { className: "mt-2 rounded bg-neutral-grey-5 px-2 py-1 text-2xs text-neutral-grey-50", children: ["Restored story: ", [
|
|
75
|
+
!story.available.results && "results",
|
|
76
|
+
!story.available.timers && "timers",
|
|
77
|
+
!story.available.counters && "counters",
|
|
78
|
+
]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(", "), " ", "were not persisted."] }))] }), _jsx(SimpleTabs, { tabs: tabs, activeTab: state.storyTab, setActiveTab: (index) => traceActions.setStoryTab(index) })] }));
|
|
81
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TimelineTab.d.ts","sourceRoot":"","sources":["../../../src/tracing/components/TimelineTab.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,QAAQ,EAAoB,MAAM,UAAU,CAAC;AAG3D,wBAAgB,WAAW,CAAC,EAC1B,SAAS,EACT,KAAK,EACL,MAAM,GACP,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,QAAQ,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B,+BA0EA"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useTraceStore } from "../traceStore";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { formatDuration } from "../format";
|
|
5
|
+
import { TracingApiError, forTracingInstance } from "../tracingService";
|
|
6
|
+
import { EmptyHint, InlineError, cls } from "./shared";
|
|
7
|
+
export function TimelineTab({ sessionId, story, onJump, }) {
|
|
8
|
+
const requestContext = useTraceStore();
|
|
9
|
+
const tracingService = forTracingInstance(requestContext.selectedInstance, requestContext.generation, requestContext.status?.globalRecording?.recordingId);
|
|
10
|
+
const [data, setData] = useState(null);
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
let cancelled = false;
|
|
14
|
+
tracingService
|
|
15
|
+
.timeline(sessionId, story.id)
|
|
16
|
+
.then((result) => {
|
|
17
|
+
if (!cancelled) {
|
|
18
|
+
setData(result);
|
|
19
|
+
setError(null);
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
.catch((e) => {
|
|
23
|
+
if (cancelled)
|
|
24
|
+
return;
|
|
25
|
+
setError(e instanceof TracingApiError && e.notFound ? "This story is no longer in memory." : e instanceof Error ? e.message : String(e));
|
|
26
|
+
});
|
|
27
|
+
return () => {
|
|
28
|
+
cancelled = true;
|
|
29
|
+
};
|
|
30
|
+
}, [requestContext.selectedInstance, sessionId, story.id, story.revision]);
|
|
31
|
+
if (!story.available.timers) {
|
|
32
|
+
return _jsx(EmptyHint, { title: "Timers were not persisted for this story" });
|
|
33
|
+
}
|
|
34
|
+
if (error)
|
|
35
|
+
return _jsx("div", { className: "p-3", children: _jsx(InlineError, { message: error }) });
|
|
36
|
+
if (!data)
|
|
37
|
+
return _jsx(EmptyHint, { title: "Loading\u2026" });
|
|
38
|
+
const segments = data.lanes.flatMap((l) => l.rows.flatMap((r) => r.segments));
|
|
39
|
+
if (segments.length === 0) {
|
|
40
|
+
return (_jsxs(EmptyHint, { title: "No timer entries", children: ["Timers appear when code measures with ", _jsx("code", { children: "TraceLog.CreateTimer" }), "; SQL load timers need the tracing data provider."] }));
|
|
41
|
+
}
|
|
42
|
+
const maxEnd = Math.max(...segments.map((s) => s.startMs + s.durationMs));
|
|
43
|
+
const total = Math.max(data.durationMs ?? 0, maxEnd, 1);
|
|
44
|
+
const ticks = [0, 0.25, 0.5, 0.75, 1];
|
|
45
|
+
return (_jsxs("div", { className: "h-full overflow-auto p-3", "data-testid": "alpaca-trace-timeline", children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-2xs text-neutral-grey-50", children: [_jsxs("span", { children: [data.lanes.length, " lane", data.lanes.length === 1 ? "" : "s", ", ", segments.length, " segment", segments.length === 1 ? "" : "s", " \u00B7 click a segment to jump to its log entries"] }), _jsx("span", { className: "font-mono", children: formatDuration(total) })] }), _jsxs("div", { className: "grid grid-cols-[minmax(120px,200px)_minmax(0,1fr)] gap-x-2", children: [_jsx("div", {}), _jsx("div", { className: "relative h-4 border-b border-border-default text-2xs text-neutral-grey-50", children: ticks.map((t) => (_jsx("span", { className: cls("absolute whitespace-nowrap", t === 0 ? "" : t === 1 ? "-translate-x-full" : "-translate-x-1/2"), style: { left: `${t * 100}%` }, children: formatDuration(total * t) }, t))) }), data.lanes.map((lane) => (_jsx(LaneRows, { lane: lane, total: total, onJump: onJump }, lane.scope)))] })] }));
|
|
46
|
+
}
|
|
47
|
+
function LaneRows({ lane, total, onJump, }) {
|
|
48
|
+
const tone = lane.scope === "Global"
|
|
49
|
+
? "bg-highlight-100"
|
|
50
|
+
: lane.scope.startsWith("SQL-")
|
|
51
|
+
? "bg-feedback-orange"
|
|
52
|
+
: "bg-feedback-blue";
|
|
53
|
+
// One grid row per lane: the label cell and a track cell that stacks the lane's sub-rows.
|
|
54
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "min-w-0 truncate py-1 text-2xs text-neutral-grey-100", title: `${lane.scope}\n${lane.rows.reduce((n, r) => n + r.segments.length, 0)} segments`, children: lane.scope }), _jsx("div", { className: "flex min-w-0 flex-col gap-0.5 py-1", children: lane.rows.map((row, rowIndex) => (_jsx("div", { className: "relative h-4 rounded bg-neutral-grey-5", children: row.segments.map((segment) => {
|
|
55
|
+
const left = (segment.startMs / total) * 100;
|
|
56
|
+
const width = Math.max((segment.durationMs / total) * 100, 0.25);
|
|
57
|
+
return (_jsx("button", { type: "button", className: cls("absolute top-0 h-4 rounded-sm opacity-80 hover:opacity-100", tone), style: { left: `${left}%`, width: `${width}%` }, title: `${segment.label ?? ""}\nstart ${formatDuration(segment.startMs)}, duration ${formatDuration(segment.durationMs)}`, onClick: () => onJump(segment.startMs), "data-testid": "alpaca-trace-timeline-segment", children: width > 8 && (_jsx("span", { className: "block truncate px-1 text-left text-2xs leading-4 text-white", children: segment.label })) }, segment.sequence));
|
|
58
|
+
}) }, rowIndex))) })] }));
|
|
59
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TimersTab.d.ts","sourceRoot":"","sources":["../../../src/tracing/components/TimersTab.tsx"],"names":[],"mappings":"AAQA,wBAAgB,SAAS,CAAC,EACxB,SAAS,EACT,OAAO,EACP,SAAS,GACV,EAAE;IACD,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,SAAS,EAAE,OAAO,CAAC;CACpB,+BA8EA"}
|