@echothink-ui/developer 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/README.md +5 -0
- package/dist/components/APIExplorer.d.ts +2 -0
- package/dist/components/BranchSelector.d.ts +2 -0
- package/dist/components/CodeBlock.d.ts +2 -0
- package/dist/components/CodeEditor.d.ts +2 -0
- package/dist/components/CommitList.d.ts +2 -0
- package/dist/components/DiffTable.d.ts +2 -0
- package/dist/components/DiffViewer.d.ts +2 -0
- package/dist/components/EventPayloadViewer.d.ts +2 -0
- package/dist/components/GitRepositoryPanel.d.ts +2 -0
- package/dist/components/JSONViewer.d.ts +2 -0
- package/dist/components/LogConsole.d.ts +2 -0
- package/dist/components/PullRequestPanel.d.ts +2 -0
- package/dist/components/RequestResponseViewer.d.ts +2 -0
- package/dist/components/SchemaViewer.d.ts +2 -0
- package/dist/components/TerminalPanel.d.ts +2 -0
- package/dist/components/TraceTimeline.d.ts +2 -0
- package/dist/components/WebhookEventViewer.d.ts +2 -0
- package/dist/components/YAMLViewer.d.ts +2 -0
- package/dist/components/devUtils.d.ts +10 -0
- package/dist/components/types.d.ts +196 -0
- package/dist/index.cjs +2627 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3651 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +2572 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/src/components/APIExplorer.tsx +205 -0
- package/src/components/BranchSelector.tsx +54 -0
- package/src/components/CodeBlock.tsx +127 -0
- package/src/components/CodeEditor.tsx +95 -0
- package/src/components/CommitList.tsx +100 -0
- package/src/components/DiffTable.tsx +288 -0
- package/src/components/DiffViewer.tsx +145 -0
- package/src/components/EventPayloadViewer.tsx +91 -0
- package/src/components/GitRepositoryPanel.tsx +73 -0
- package/src/components/JSONViewer.tsx +189 -0
- package/src/components/LogConsole.tsx +160 -0
- package/src/components/PullRequestPanel.test.tsx +52 -0
- package/src/components/PullRequestPanel.tsx +215 -0
- package/src/components/RequestResponseViewer.test.tsx +45 -0
- package/src/components/RequestResponseViewer.tsx +169 -0
- package/src/components/SchemaViewer.tsx +157 -0
- package/src/components/TerminalPanel.test.tsx +33 -0
- package/src/components/TerminalPanel.tsx +134 -0
- package/src/components/TraceTimeline.test.tsx +63 -0
- package/src/components/TraceTimeline.tsx +207 -0
- package/src/components/WebhookEventViewer.test.tsx +57 -0
- package/src/components/WebhookEventViewer.tsx +184 -0
- package/src/components/YAMLViewer.tsx +207 -0
- package/src/components/devUtils.ts +81 -0
- package/src/components/types.ts +230 -0
- package/src/index.tsx +72 -0
- package/src/styles.css +4296 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import type { RequestResponseViewerProps } from "./types";
|
|
3
|
+
import { formatUnknown } from "./devUtils";
|
|
4
|
+
|
|
5
|
+
type Tab = "request-headers" | "request-body" | "response-headers" | "response-body";
|
|
6
|
+
|
|
7
|
+
const tabs: Array<{ id: Tab; label: string; emptyLabel: string }> = [
|
|
8
|
+
{ id: "request-headers", label: "Request headers", emptyLabel: "No request headers." },
|
|
9
|
+
{ id: "request-body", label: "Request body", emptyLabel: "No request body." },
|
|
10
|
+
{ id: "response-headers", label: "Response headers", emptyLabel: "No response headers." },
|
|
11
|
+
{ id: "response-body", label: "Response body", emptyLabel: "No response body." }
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function RequestResponseViewer({
|
|
15
|
+
request,
|
|
16
|
+
response,
|
|
17
|
+
className,
|
|
18
|
+
"aria-label": ariaLabel,
|
|
19
|
+
...props
|
|
20
|
+
}: RequestResponseViewerProps) {
|
|
21
|
+
const [tab, setTab] = React.useState<Tab>("request-headers");
|
|
22
|
+
const tabRefs = React.useRef<Array<HTMLButtonElement | null>>([]);
|
|
23
|
+
const generatedId = React.useId().replace(/:/g, "");
|
|
24
|
+
const activeIndex = Math.max(
|
|
25
|
+
tabs.findIndex((item) => item.id === tab),
|
|
26
|
+
0
|
|
27
|
+
);
|
|
28
|
+
const regionLabel = `${request.method} ${request.url} ${response.status}`;
|
|
29
|
+
const durationLabel =
|
|
30
|
+
typeof response.durationMs === "number" ? `${response.durationMs} ms` : "Duration unavailable";
|
|
31
|
+
|
|
32
|
+
const selectTab = (nextIndex: number, shouldFocus = false) => {
|
|
33
|
+
const nextTab = tabs[nextIndex] ?? tabs[0]!;
|
|
34
|
+
|
|
35
|
+
setTab(nextTab.id);
|
|
36
|
+
|
|
37
|
+
if (shouldFocus) {
|
|
38
|
+
window.requestAnimationFrame(() => tabRefs.current[nextIndex]?.focus());
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const onTabKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
43
|
+
if (event.key === "ArrowLeft") {
|
|
44
|
+
event.preventDefault();
|
|
45
|
+
selectTab((activeIndex - 1 + tabs.length) % tabs.length, true);
|
|
46
|
+
} else if (event.key === "ArrowRight") {
|
|
47
|
+
event.preventDefault();
|
|
48
|
+
selectTab((activeIndex + 1) % tabs.length, true);
|
|
49
|
+
} else if (event.key === "Home") {
|
|
50
|
+
event.preventDefault();
|
|
51
|
+
selectTab(0, true);
|
|
52
|
+
} else if (event.key === "End") {
|
|
53
|
+
event.preventDefault();
|
|
54
|
+
selectTab(tabs.length - 1, true);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<section
|
|
60
|
+
{...props}
|
|
61
|
+
aria-label={ariaLabel ?? regionLabel}
|
|
62
|
+
className={`eth-dev-request-response-viewer ${className ?? ""}`.trim()}
|
|
63
|
+
data-eth-component="RequestResponseViewer"
|
|
64
|
+
>
|
|
65
|
+
<header className="eth-dev-request-response-viewer__header">
|
|
66
|
+
<div className="eth-dev-request-response-viewer__request">
|
|
67
|
+
<span
|
|
68
|
+
className={`eth-dev-request-response-viewer__method eth-dev-request-response-viewer__method--${tokenize(request.method)}`}
|
|
69
|
+
>
|
|
70
|
+
{request.method}
|
|
71
|
+
</span>
|
|
72
|
+
<code title={request.url}>{request.url}</code>
|
|
73
|
+
</div>
|
|
74
|
+
<dl className="eth-dev-request-response-viewer__meta" aria-label="HTTP response summary">
|
|
75
|
+
<div>
|
|
76
|
+
<dt>Status</dt>
|
|
77
|
+
<dd
|
|
78
|
+
className={`eth-dev-request-response-viewer__status eth-dev-request-response-viewer__status--${statusTone(response.status)}`}
|
|
79
|
+
>
|
|
80
|
+
{response.status}
|
|
81
|
+
</dd>
|
|
82
|
+
</div>
|
|
83
|
+
<div>
|
|
84
|
+
<dt>Time</dt>
|
|
85
|
+
<dd>{durationLabel}</dd>
|
|
86
|
+
</div>
|
|
87
|
+
</dl>
|
|
88
|
+
</header>
|
|
89
|
+
<div
|
|
90
|
+
className="eth-dev-request-response-viewer__tabs"
|
|
91
|
+
role="tablist"
|
|
92
|
+
aria-label="HTTP message sections"
|
|
93
|
+
>
|
|
94
|
+
{tabs.map((item, index) => (
|
|
95
|
+
<button
|
|
96
|
+
key={item.id}
|
|
97
|
+
aria-controls={`${generatedId}-${item.id}-panel`}
|
|
98
|
+
aria-selected={tab === item.id}
|
|
99
|
+
className="eth-dev-request-response-viewer__tab"
|
|
100
|
+
id={`${generatedId}-${item.id}-tab`}
|
|
101
|
+
ref={(node) => {
|
|
102
|
+
tabRefs.current[index] = node;
|
|
103
|
+
}}
|
|
104
|
+
role="tab"
|
|
105
|
+
tabIndex={tab === item.id ? 0 : -1}
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => selectTab(index)}
|
|
108
|
+
onKeyDown={onTabKeyDown}
|
|
109
|
+
>
|
|
110
|
+
{item.label}
|
|
111
|
+
</button>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
{tabs.map((item) => {
|
|
115
|
+
const content = contentFor(item.id, request, response);
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<pre
|
|
119
|
+
key={item.id}
|
|
120
|
+
aria-labelledby={`${generatedId}-${item.id}-tab`}
|
|
121
|
+
className={`eth-dev-request-response-viewer__panel ${
|
|
122
|
+
content ? "" : "eth-dev-request-response-viewer__panel--empty"
|
|
123
|
+
}`.trim()}
|
|
124
|
+
hidden={tab !== item.id}
|
|
125
|
+
id={`${generatedId}-${item.id}-panel`}
|
|
126
|
+
role="tabpanel"
|
|
127
|
+
tabIndex={tab === item.id ? 0 : -1}
|
|
128
|
+
>
|
|
129
|
+
<code>{content || item.emptyLabel}</code>
|
|
130
|
+
</pre>
|
|
131
|
+
);
|
|
132
|
+
})}
|
|
133
|
+
</section>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function contentFor(
|
|
138
|
+
tab: Tab,
|
|
139
|
+
request: RequestResponseViewerProps["request"],
|
|
140
|
+
response: RequestResponseViewerProps["response"]
|
|
141
|
+
) {
|
|
142
|
+
switch (tab) {
|
|
143
|
+
case "request-headers":
|
|
144
|
+
return formatHeaders(request.headers);
|
|
145
|
+
case "request-body":
|
|
146
|
+
return formatUnknown(request.body);
|
|
147
|
+
case "response-headers":
|
|
148
|
+
return formatHeaders(response.headers);
|
|
149
|
+
case "response-body":
|
|
150
|
+
return formatUnknown(response.body);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatHeaders(headers: Record<string, string> | undefined) {
|
|
155
|
+
if (!headers || Object.keys(headers).length === 0) return "";
|
|
156
|
+
return formatUnknown(headers);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function statusTone(status: number) {
|
|
160
|
+
if (status >= 200 && status < 300) return "success";
|
|
161
|
+
if (status >= 300 && status < 400) return "redirect";
|
|
162
|
+
if (status >= 400 && status < 500) return "warning";
|
|
163
|
+
if (status >= 500) return "error";
|
|
164
|
+
return "neutral";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function tokenize(value: string) {
|
|
168
|
+
return value.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
169
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { CheckmarkFilled, ErrorFilled, WarningFilled } from "@carbon/icons-react";
|
|
3
|
+
import type { SchemaViewerProps } from "./types";
|
|
4
|
+
import { JSONViewer } from "./JSONViewer";
|
|
5
|
+
|
|
6
|
+
type ValidationTone = "valid" | "invalid" | "warning" | "unknown";
|
|
7
|
+
|
|
8
|
+
interface SchemaStats {
|
|
9
|
+
nodes: number;
|
|
10
|
+
topLevelKeys: number;
|
|
11
|
+
properties: number;
|
|
12
|
+
required: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function SchemaViewer({
|
|
16
|
+
schema,
|
|
17
|
+
validationState,
|
|
18
|
+
className,
|
|
19
|
+
...props
|
|
20
|
+
}: SchemaViewerProps) {
|
|
21
|
+
const stats = React.useMemo(() => collectSchemaStats(schema), [schema]);
|
|
22
|
+
const tone = normalizeValidationState(validationState);
|
|
23
|
+
const status = getStatusConfig(tone, stats);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<section
|
|
27
|
+
{...props}
|
|
28
|
+
className={`eth-dev-schema-viewer ${className ?? ""}`}
|
|
29
|
+
data-eth-component="SchemaViewer"
|
|
30
|
+
data-validation-state={tone}
|
|
31
|
+
>
|
|
32
|
+
<header className="eth-dev-schema-viewer__status">
|
|
33
|
+
<span className="eth-dev-schema-viewer__status-icon" aria-hidden="true">
|
|
34
|
+
<StatusIcon tone={tone} />
|
|
35
|
+
</span>
|
|
36
|
+
<div className="eth-dev-schema-viewer__status-copy">
|
|
37
|
+
<p className="eth-dev-schema-viewer__status-title">{status.title}</p>
|
|
38
|
+
<p className="eth-dev-schema-viewer__status-description">{status.description}</p>
|
|
39
|
+
</div>
|
|
40
|
+
<dl className="eth-dev-schema-viewer__stats" aria-label="Schema summary">
|
|
41
|
+
<div>
|
|
42
|
+
<dt>Keys</dt>
|
|
43
|
+
<dd>{stats.topLevelKeys}</dd>
|
|
44
|
+
</div>
|
|
45
|
+
<div>
|
|
46
|
+
<dt>Properties</dt>
|
|
47
|
+
<dd>{stats.properties}</dd>
|
|
48
|
+
</div>
|
|
49
|
+
<div>
|
|
50
|
+
<dt>Required</dt>
|
|
51
|
+
<dd>{stats.required}</dd>
|
|
52
|
+
</div>
|
|
53
|
+
</dl>
|
|
54
|
+
</header>
|
|
55
|
+
<JSONViewer className="eth-dev-schema-viewer__json" value={schema} defaultExpandedDepth={2} />
|
|
56
|
+
</section>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeValidationState(validationState: SchemaViewerProps["validationState"]): ValidationTone {
|
|
61
|
+
if (validationState === "valid" || validationState === "invalid" || validationState === "warning") {
|
|
62
|
+
return validationState;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return "unknown";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function getStatusConfig(tone: ValidationTone, stats: SchemaStats) {
|
|
69
|
+
const summary = `${stats.nodes} nodes inspected across ${stats.properties} properties`;
|
|
70
|
+
|
|
71
|
+
switch (tone) {
|
|
72
|
+
case "valid":
|
|
73
|
+
return {
|
|
74
|
+
title: "Schema valid",
|
|
75
|
+
description: summary
|
|
76
|
+
};
|
|
77
|
+
case "invalid":
|
|
78
|
+
return {
|
|
79
|
+
title: "Schema invalid",
|
|
80
|
+
description: "Review schema structure and validation rules before publishing."
|
|
81
|
+
};
|
|
82
|
+
case "warning":
|
|
83
|
+
return {
|
|
84
|
+
title: "Schema warning",
|
|
85
|
+
description: "Schema is readable, but validation reported conditions that need review."
|
|
86
|
+
};
|
|
87
|
+
default:
|
|
88
|
+
return {
|
|
89
|
+
title: "Schema status unknown",
|
|
90
|
+
description: summary
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function collectSchemaStats(schema: object): SchemaStats {
|
|
96
|
+
const stats: SchemaStats = {
|
|
97
|
+
nodes: 0,
|
|
98
|
+
topLevelKeys: isPlainObject(schema)
|
|
99
|
+
? Object.keys(schema).length
|
|
100
|
+
: Array.isArray(schema)
|
|
101
|
+
? schema.length
|
|
102
|
+
: 1,
|
|
103
|
+
properties: 0,
|
|
104
|
+
required: 0
|
|
105
|
+
};
|
|
106
|
+
const seen = new WeakSet<object>();
|
|
107
|
+
|
|
108
|
+
const visit = (node: unknown, key?: string) => {
|
|
109
|
+
stats.nodes += 1;
|
|
110
|
+
|
|
111
|
+
if (Array.isArray(node)) {
|
|
112
|
+
if (seen.has(node)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
seen.add(node);
|
|
117
|
+
|
|
118
|
+
if (key === "required" && node.every((item) => typeof item === "string")) {
|
|
119
|
+
stats.required += node.length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
node.forEach((item) => visit(item));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!isPlainObject(node) || seen.has(node)) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
seen.add(node);
|
|
131
|
+
|
|
132
|
+
if (isPlainObject(node.properties)) {
|
|
133
|
+
stats.properties += Object.keys(node.properties).length;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
Object.entries(node).forEach(([entryKey, entryValue]) => visit(entryValue, entryKey));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
visit(schema);
|
|
140
|
+
return stats;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
144
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function StatusIcon({ tone }: { tone: ValidationTone }) {
|
|
148
|
+
if (tone === "valid") {
|
|
149
|
+
return <CheckmarkFilled size={20} />;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (tone === "invalid") {
|
|
153
|
+
return <ErrorFilled size={20} />;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return <WarningFilled size={20} />;
|
|
157
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { TerminalPanel } from "./TerminalPanel";
|
|
4
|
+
|
|
5
|
+
describe("TerminalPanel", () => {
|
|
6
|
+
it("renders a sandbox terminal region and submits typed commands", () => {
|
|
7
|
+
const onCommand = vi.fn();
|
|
8
|
+
|
|
9
|
+
render(
|
|
10
|
+
<TerminalPanel
|
|
11
|
+
lines={[
|
|
12
|
+
{ id: "1", stream: "stdout", text: "$ ls -la" },
|
|
13
|
+
{ id: "2", stream: "stdout", text: "total 24" },
|
|
14
|
+
{ id: "3", stream: "stderr", text: "warning: untracked file 'tmp.txt'" }
|
|
15
|
+
]}
|
|
16
|
+
onCommand={onCommand}
|
|
17
|
+
/>
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const panel = screen.getByRole("region", { name: "Sandbox terminal" });
|
|
21
|
+
expect(within(panel).getByRole("log", { name: "Terminal output" })).toBeTruthy();
|
|
22
|
+
expect(within(panel).getByText("Sandboxed")).toBeTruthy();
|
|
23
|
+
expect(within(panel).getByText("ERR")).toBeTruthy();
|
|
24
|
+
expect(within(panel).getByText("warning: untracked file 'tmp.txt'")).toBeTruthy();
|
|
25
|
+
|
|
26
|
+
const input = within(panel).getByRole("textbox", { name: "Terminal command" });
|
|
27
|
+
fireEvent.change(input, { target: { value: "pwd" } });
|
|
28
|
+
fireEvent.click(within(panel).getByRole("button", { name: "Run command" }));
|
|
29
|
+
|
|
30
|
+
expect(onCommand).toHaveBeenCalledWith("pwd");
|
|
31
|
+
expect((input as HTMLInputElement).value).toBe("");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Play } from "@carbon/icons-react";
|
|
3
|
+
import { Button } from "@echothink-ui/core";
|
|
4
|
+
import type { TerminalPanelProps } from "./types";
|
|
5
|
+
|
|
6
|
+
export function TerminalPanel({
|
|
7
|
+
lines,
|
|
8
|
+
prompt = "$",
|
|
9
|
+
onCommand,
|
|
10
|
+
disabled,
|
|
11
|
+
className,
|
|
12
|
+
role = "region",
|
|
13
|
+
"aria-label": ariaLabel = "Sandbox terminal",
|
|
14
|
+
"aria-labelledby": ariaLabelledBy,
|
|
15
|
+
...props
|
|
16
|
+
}: TerminalPanelProps) {
|
|
17
|
+
const [command, setCommand] = React.useState("");
|
|
18
|
+
const inputId = React.useId();
|
|
19
|
+
const errorCount = lines.filter((line) => line.stream === "stderr").length;
|
|
20
|
+
const canSubmit = !disabled && command.trim().length > 0;
|
|
21
|
+
const statusLabel = disabled ? "Input disabled" : "Sandboxed";
|
|
22
|
+
const panelClassName = [
|
|
23
|
+
"eth-dev-terminal-panel",
|
|
24
|
+
disabled ? "eth-dev-terminal-panel--disabled" : "",
|
|
25
|
+
className ?? ""
|
|
26
|
+
]
|
|
27
|
+
.filter(Boolean)
|
|
28
|
+
.join(" ");
|
|
29
|
+
|
|
30
|
+
const submit = (event: React.FormEvent) => {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
if (!canSubmit) return;
|
|
33
|
+
onCommand?.(command);
|
|
34
|
+
setCommand("");
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<section
|
|
39
|
+
{...props}
|
|
40
|
+
className={panelClassName}
|
|
41
|
+
data-eth-component="TerminalPanel"
|
|
42
|
+
role={role}
|
|
43
|
+
aria-label={ariaLabelledBy ? undefined : ariaLabel}
|
|
44
|
+
aria-labelledby={ariaLabelledBy}
|
|
45
|
+
>
|
|
46
|
+
<header className="eth-dev-terminal-panel__header">
|
|
47
|
+
<div className="eth-dev-terminal-panel__heading">
|
|
48
|
+
<p className="eth-dev-terminal-panel__eyebrow">Developer terminal</p>
|
|
49
|
+
<h3>Sandbox terminal</h3>
|
|
50
|
+
<p>Review command output and send scoped commands into the configured sandbox.</p>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="eth-dev-terminal-panel__status" aria-live="polite">
|
|
53
|
+
<span className="eth-dev-terminal-panel__status-indicator" aria-hidden="true" />
|
|
54
|
+
<span>{statusLabel}</span>
|
|
55
|
+
</div>
|
|
56
|
+
</header>
|
|
57
|
+
<dl className="eth-dev-terminal-panel__summary" aria-label="Terminal summary">
|
|
58
|
+
<div>
|
|
59
|
+
<dt>Lines</dt>
|
|
60
|
+
<dd>{lines.length}</dd>
|
|
61
|
+
</div>
|
|
62
|
+
<div>
|
|
63
|
+
<dt>Stderr</dt>
|
|
64
|
+
<dd>{errorCount}</dd>
|
|
65
|
+
</div>
|
|
66
|
+
<div>
|
|
67
|
+
<dt>Prompt</dt>
|
|
68
|
+
<dd>{prompt}</dd>
|
|
69
|
+
</div>
|
|
70
|
+
</dl>
|
|
71
|
+
<div className="eth-dev-terminal-panel__viewport">
|
|
72
|
+
{lines.length ? (
|
|
73
|
+
<pre
|
|
74
|
+
className="eth-dev-terminal-panel__output"
|
|
75
|
+
role="log"
|
|
76
|
+
aria-label="Terminal output"
|
|
77
|
+
aria-live="polite"
|
|
78
|
+
aria-relevant="additions text"
|
|
79
|
+
aria-atomic="false"
|
|
80
|
+
>
|
|
81
|
+
<code className="eth-dev-terminal-panel__output-code">
|
|
82
|
+
{lines.map((line) => (
|
|
83
|
+
<span
|
|
84
|
+
key={line.id}
|
|
85
|
+
className={`eth-dev-terminal-panel__line eth-dev-terminal-panel__line--${line.stream}`}
|
|
86
|
+
>
|
|
87
|
+
<span
|
|
88
|
+
className="eth-dev-terminal-panel__stream"
|
|
89
|
+
aria-label={line.stream === "stderr" ? "Standard error" : "Standard output"}
|
|
90
|
+
>
|
|
91
|
+
{line.stream === "stderr" ? "ERR" : "OUT"}
|
|
92
|
+
</span>
|
|
93
|
+
<span className="eth-dev-terminal-panel__line-text">{line.text}</span>
|
|
94
|
+
</span>
|
|
95
|
+
))}
|
|
96
|
+
</code>
|
|
97
|
+
</pre>
|
|
98
|
+
) : (
|
|
99
|
+
<div className="eth-dev-terminal-panel__empty" role="status">
|
|
100
|
+
<h4>No terminal output</h4>
|
|
101
|
+
<p>Command output will appear here when the sandbox emits data.</p>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
<form className="eth-dev-terminal-panel__input" onSubmit={submit}>
|
|
106
|
+
<label className="eth-dev-terminal-panel__visually-hidden" htmlFor={inputId}>
|
|
107
|
+
Terminal command
|
|
108
|
+
</label>
|
|
109
|
+
<span className="eth-dev-terminal-panel__prompt" aria-hidden="true">
|
|
110
|
+
{prompt}
|
|
111
|
+
</span>
|
|
112
|
+
<input
|
|
113
|
+
id={inputId}
|
|
114
|
+
value={command}
|
|
115
|
+
disabled={disabled}
|
|
116
|
+
onChange={(event) => setCommand(event.currentTarget.value)}
|
|
117
|
+
autoComplete="off"
|
|
118
|
+
spellCheck={false}
|
|
119
|
+
placeholder={disabled ? "Input disabled" : "Type a command"}
|
|
120
|
+
/>
|
|
121
|
+
<Button
|
|
122
|
+
type="submit"
|
|
123
|
+
density="compact"
|
|
124
|
+
intent="primary"
|
|
125
|
+
icon={<Play size={16} />}
|
|
126
|
+
disabled={!canSubmit}
|
|
127
|
+
aria-label="Run command"
|
|
128
|
+
>
|
|
129
|
+
Run
|
|
130
|
+
</Button>
|
|
131
|
+
</form>
|
|
132
|
+
</section>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TraceTimeline } from "./TraceTimeline";
|
|
4
|
+
|
|
5
|
+
describe("TraceTimeline", () => {
|
|
6
|
+
it("renders trace summary, axis, and accessible span rows", () => {
|
|
7
|
+
render(
|
|
8
|
+
<TraceTimeline
|
|
9
|
+
spans={[
|
|
10
|
+
{
|
|
11
|
+
id: "s1",
|
|
12
|
+
name: "ingest.batch",
|
|
13
|
+
startMs: 0,
|
|
14
|
+
durationMs: 240,
|
|
15
|
+
service: "ingest",
|
|
16
|
+
status: "succeeded"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: "s2",
|
|
20
|
+
name: "render.frame",
|
|
21
|
+
startMs: 240,
|
|
22
|
+
durationMs: 1800,
|
|
23
|
+
service: "render",
|
|
24
|
+
parentId: "s1",
|
|
25
|
+
status: "running"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "s3",
|
|
29
|
+
name: "send.email",
|
|
30
|
+
startMs: 2040,
|
|
31
|
+
durationMs: 600,
|
|
32
|
+
service: "send",
|
|
33
|
+
parentId: "s1",
|
|
34
|
+
status: "failed"
|
|
35
|
+
}
|
|
36
|
+
]}
|
|
37
|
+
/>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const timeline = screen.getByRole("region", { name: "Trace timeline" });
|
|
41
|
+
expect(within(timeline).getByText("Spans").parentElement?.textContent).toContain("3");
|
|
42
|
+
expect(within(timeline).getByText("2.64 s")).toBeTruthy();
|
|
43
|
+
expect(within(timeline).getByText("1 issue")).toBeTruthy();
|
|
44
|
+
expect(within(timeline).getByText("0 ms")).toBeTruthy();
|
|
45
|
+
|
|
46
|
+
const spans = within(timeline).getByRole("list", { name: "Trace spans" });
|
|
47
|
+
expect(within(spans).getByText("ingest.batch")).toBeTruthy();
|
|
48
|
+
expect(within(spans).getByText("render.frame")).toBeTruthy();
|
|
49
|
+
expect(within(spans).getByText("send.email")).toBeTruthy();
|
|
50
|
+
expect(within(spans).getByText("Failed")).toBeTruthy();
|
|
51
|
+
expect(
|
|
52
|
+
within(spans).getByRole("img", {
|
|
53
|
+
name: "send.email in send, Failed, starts at 2.04 s, duration 600 ms"
|
|
54
|
+
})
|
|
55
|
+
).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders an empty state when no spans are available", () => {
|
|
59
|
+
render(<TraceTimeline spans={[]} />);
|
|
60
|
+
|
|
61
|
+
expect(screen.getByRole("status").textContent).toContain("No spans recorded");
|
|
62
|
+
});
|
|
63
|
+
});
|