@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,207 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge, StatusDot, statusLabel } from "@echothink-ui/core";
|
|
3
|
+
import type { TraceSpan, TraceTimelineProps } from "./types";
|
|
4
|
+
|
|
5
|
+
interface TimelineItem {
|
|
6
|
+
span: TraceSpan;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
depth: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function TraceTimeline({
|
|
13
|
+
spans,
|
|
14
|
+
className,
|
|
15
|
+
role = "region",
|
|
16
|
+
"aria-label": ariaLabel = "Trace timeline",
|
|
17
|
+
"aria-labelledby": ariaLabelledBy,
|
|
18
|
+
...props
|
|
19
|
+
}: TraceTimelineProps) {
|
|
20
|
+
const timeline = React.useMemo(() => buildTimeline(spans), [spans]);
|
|
21
|
+
const issueCount = spans.filter((span) => isIssueStatus(getSpanStatus(span))).length;
|
|
22
|
+
const activeCount = spans.filter((span) => isActiveStatus(getSpanStatus(span))).length;
|
|
23
|
+
const services = new Set(spans.flatMap((span) => (span.service ? [span.service] : [])));
|
|
24
|
+
const serviceCount = services.size;
|
|
25
|
+
const describedServiceCount = spans.length ? serviceCount || 1 : 0;
|
|
26
|
+
const traceDescription = spans.length
|
|
27
|
+
? `${spans.length} spans across ${describedServiceCount} service${
|
|
28
|
+
describedServiceCount === 1 ? "" : "s"
|
|
29
|
+
}.`
|
|
30
|
+
: "No spans have been recorded for this trace window.";
|
|
31
|
+
const healthLabel = issueCount
|
|
32
|
+
? `${issueCount} ${issueCount === 1 ? "issue" : "issues"}`
|
|
33
|
+
: activeCount
|
|
34
|
+
? `${activeCount} active`
|
|
35
|
+
: "All complete";
|
|
36
|
+
const healthSeverity = issueCount ? "danger" : activeCount ? "info" : "success";
|
|
37
|
+
const rootClassName = ["eth-dev-trace-timeline", className].filter(Boolean).join(" ");
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<section
|
|
41
|
+
{...props}
|
|
42
|
+
aria-label={ariaLabelledBy ? undefined : ariaLabel}
|
|
43
|
+
aria-labelledby={ariaLabelledBy}
|
|
44
|
+
className={rootClassName}
|
|
45
|
+
data-eth-component="TraceTimeline"
|
|
46
|
+
role={role}
|
|
47
|
+
>
|
|
48
|
+
<header className="eth-dev-trace-timeline__header">
|
|
49
|
+
<div className="eth-dev-trace-timeline__heading">
|
|
50
|
+
<p className="eth-dev-trace-timeline__eyebrow">Distributed trace</p>
|
|
51
|
+
<h3>Trace timeline</h3>
|
|
52
|
+
<p>{traceDescription}</p>
|
|
53
|
+
</div>
|
|
54
|
+
<Badge severity={healthSeverity}>{healthLabel}</Badge>
|
|
55
|
+
</header>
|
|
56
|
+
|
|
57
|
+
<dl className="eth-dev-trace-timeline__summary" aria-label="Trace summary">
|
|
58
|
+
<div>
|
|
59
|
+
<dt>Spans</dt>
|
|
60
|
+
<dd>{spans.length}</dd>
|
|
61
|
+
</div>
|
|
62
|
+
<div>
|
|
63
|
+
<dt>Window</dt>
|
|
64
|
+
<dd>{formatDuration(timeline.total)}</dd>
|
|
65
|
+
</div>
|
|
66
|
+
<div>
|
|
67
|
+
<dt>Issues</dt>
|
|
68
|
+
<dd>
|
|
69
|
+
{issueCount ? `${issueCount} ${issueCount === 1 ? "issue" : "issues"}` : "0"}
|
|
70
|
+
</dd>
|
|
71
|
+
</div>
|
|
72
|
+
<div>
|
|
73
|
+
<dt>Services</dt>
|
|
74
|
+
<dd>{serviceCount}</dd>
|
|
75
|
+
</div>
|
|
76
|
+
</dl>
|
|
77
|
+
|
|
78
|
+
{spans.length ? (
|
|
79
|
+
<div className="eth-dev-trace-timeline__viewport">
|
|
80
|
+
<div className="eth-dev-trace-timeline__axis" aria-hidden="true">
|
|
81
|
+
<span>Span</span>
|
|
82
|
+
<div className="eth-dev-trace-timeline__axis-scale">
|
|
83
|
+
<span>0 ms</span>
|
|
84
|
+
<span>{formatDuration(timeline.total / 2)}</span>
|
|
85
|
+
<span>{formatDuration(timeline.total)}</span>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div className="eth-dev-trace-timeline__rows" role="list" aria-label="Trace spans">
|
|
90
|
+
{timeline.items.map(({ span, start, end, depth }) => {
|
|
91
|
+
const status = getSpanStatus(span);
|
|
92
|
+
const startOffset = start - timeline.min;
|
|
93
|
+
const duration = end - start;
|
|
94
|
+
const left = (startOffset / timeline.total) * 100;
|
|
95
|
+
const width = Math.max(1, (duration / timeline.total) * 100);
|
|
96
|
+
const spanLabel = formatSpanLabel(span, status, startOffset, duration);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<article
|
|
100
|
+
key={span.id}
|
|
101
|
+
className={`eth-dev-trace-timeline__row eth-dev-trace-timeline__row--${status}`}
|
|
102
|
+
role="listitem"
|
|
103
|
+
style={
|
|
104
|
+
{
|
|
105
|
+
"--eth-dev-trace-indent": `${depth}rem`,
|
|
106
|
+
"--eth-dev-trace-left": `${left}%`,
|
|
107
|
+
"--eth-dev-trace-width": `${width}%`
|
|
108
|
+
} as React.CSSProperties
|
|
109
|
+
}
|
|
110
|
+
>
|
|
111
|
+
<div className="eth-dev-trace-timeline__label">
|
|
112
|
+
<div className="eth-dev-trace-timeline__span-title">
|
|
113
|
+
<StatusDot status={status} label={statusLabel(status)} />
|
|
114
|
+
<strong>{span.name}</strong>
|
|
115
|
+
</div>
|
|
116
|
+
<div className="eth-dev-trace-timeline__span-meta">
|
|
117
|
+
{span.service ? <span>{span.service}</span> : null}
|
|
118
|
+
{span.parentId ? <span>Parent {span.parentId}</span> : null}
|
|
119
|
+
<span>{formatDuration(duration)}</span>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="eth-dev-trace-timeline__track" role="img" aria-label={spanLabel}>
|
|
123
|
+
<span
|
|
124
|
+
aria-hidden="true"
|
|
125
|
+
className={`eth-dev-trace-timeline__bar eth-dev-trace-timeline__bar--${status}`}
|
|
126
|
+
title={spanLabel}
|
|
127
|
+
/>
|
|
128
|
+
</div>
|
|
129
|
+
</article>
|
|
130
|
+
);
|
|
131
|
+
})}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
<div className="eth-dev-trace-timeline__empty" role="status">
|
|
136
|
+
<h4>No spans recorded</h4>
|
|
137
|
+
<p>Trace spans will appear here when instrumentation emits timing data.</p>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</section>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildTimeline(spans: TraceSpan[]) {
|
|
145
|
+
if (!spans.length) return { items: [] as TimelineItem[], min: 0, total: 0 };
|
|
146
|
+
|
|
147
|
+
const spansById = new Map(spans.map((span) => [span.id, span]));
|
|
148
|
+
const depthById = new Map<string, number>();
|
|
149
|
+
const depthFor = (span: TraceSpan, seen = new Set<string>()): number => {
|
|
150
|
+
if (depthById.has(span.id)) return depthById.get(span.id) ?? 0;
|
|
151
|
+
if (!span.parentId || seen.has(span.id)) {
|
|
152
|
+
depthById.set(span.id, 0);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const parent = spansById.get(span.parentId);
|
|
157
|
+
const depth = parent ? depthFor(parent, new Set(seen).add(span.id)) + 1 : 0;
|
|
158
|
+
depthById.set(span.id, depth);
|
|
159
|
+
return depth;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const items = spans
|
|
163
|
+
.map((span, index) => {
|
|
164
|
+
const start = Number.isFinite(span.startMs) ? span.startMs : index * 10;
|
|
165
|
+
const duration = Number.isFinite(span.durationMs) ? Math.max(span.durationMs, 1) : 1;
|
|
166
|
+
return { span, start, end: start + duration, depth: depthFor(span) };
|
|
167
|
+
})
|
|
168
|
+
.sort((a, b) => a.start - b.start || a.span.name.localeCompare(b.span.name));
|
|
169
|
+
|
|
170
|
+
const min = Math.min(...items.map((item) => item.start));
|
|
171
|
+
const max = Math.max(...items.map((item) => item.end));
|
|
172
|
+
return { items, min, total: Math.max(max - min, 1) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function getSpanStatus(span: TraceSpan): NonNullable<TraceSpan["status"]> {
|
|
176
|
+
return span.status ?? "running";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isActiveStatus(status: NonNullable<TraceSpan["status"]>) {
|
|
180
|
+
return status === "running" || status === "in-progress";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isIssueStatus(status: NonNullable<TraceSpan["status"]>) {
|
|
184
|
+
return (
|
|
185
|
+
status === "failed" || status === "blocked" || status === "warning" || status === "stale"
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function formatSpanLabel(
|
|
190
|
+
span: TraceSpan,
|
|
191
|
+
status: NonNullable<TraceSpan["status"]>,
|
|
192
|
+
startOffset: number,
|
|
193
|
+
duration: number
|
|
194
|
+
) {
|
|
195
|
+
return `${span.name}${span.service ? ` in ${span.service}` : ""}, ${statusLabel(
|
|
196
|
+
status
|
|
197
|
+
)}, starts at ${formatDuration(startOffset)}, duration ${formatDuration(duration)}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function formatDuration(ms: number) {
|
|
201
|
+
if (!Number.isFinite(ms) || ms <= 0) return "0 ms";
|
|
202
|
+
if (ms < 1000) return `${Math.round(ms)} ms`;
|
|
203
|
+
|
|
204
|
+
const seconds = ms / 1000;
|
|
205
|
+
const precision = seconds < 10 ? 2 : 1;
|
|
206
|
+
return `${seconds.toFixed(precision).replace(/\.?0+$/, "")} s`;
|
|
207
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { fireEvent, render, screen, within } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { WebhookEventViewer } from "./WebhookEventViewer";
|
|
4
|
+
|
|
5
|
+
const events = [
|
|
6
|
+
{
|
|
7
|
+
id: "evt_1001",
|
|
8
|
+
url: "https://hooks.example.com/tasks",
|
|
9
|
+
status: "delivered",
|
|
10
|
+
retries: 0,
|
|
11
|
+
deliveredAt: "10:42",
|
|
12
|
+
payload: { event: "task.created" }
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: "evt_1002",
|
|
16
|
+
url: "https://hooks.example.com/approvals",
|
|
17
|
+
status: "failed",
|
|
18
|
+
retries: 3,
|
|
19
|
+
payload: { event: "approval.granted" }
|
|
20
|
+
}
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
describe("WebhookEventViewer", () => {
|
|
24
|
+
it("renders webhook delivery status, summary metrics, and retry action", () => {
|
|
25
|
+
const onRetry = vi.fn();
|
|
26
|
+
|
|
27
|
+
render(<WebhookEventViewer events={events} onRetry={onRetry} />);
|
|
28
|
+
|
|
29
|
+
const viewer = screen.getByRole("region", { name: "Webhook deliveries" });
|
|
30
|
+
|
|
31
|
+
expect(within(viewer).getByText("Webhook deliveries")).toBeTruthy();
|
|
32
|
+
expect(within(viewer).getByText("2 events")).toBeTruthy();
|
|
33
|
+
expect(within(viewer).getByText("1 failed")).toBeTruthy();
|
|
34
|
+
expect(within(viewer).getByText("3 retries")).toBeTruthy();
|
|
35
|
+
expect(within(viewer).getByText("task.created")).toBeTruthy();
|
|
36
|
+
expect(within(viewer).getByText("approval.granted")).toBeTruthy();
|
|
37
|
+
expect(within(viewer).getByText("Not delivered")).toBeTruthy();
|
|
38
|
+
|
|
39
|
+
const deliveredBadge = within(viewer)
|
|
40
|
+
.getAllByText("Delivered")
|
|
41
|
+
.find((node) => node.closest(".eth-badge"))
|
|
42
|
+
?.closest(".eth-badge");
|
|
43
|
+
const failedBadge = within(viewer)
|
|
44
|
+
.getAllByText("Failed")
|
|
45
|
+
.find((node) => node.closest(".eth-badge"))
|
|
46
|
+
?.closest(".eth-badge");
|
|
47
|
+
|
|
48
|
+
expect(deliveredBadge?.className).toContain("eth-badge--success");
|
|
49
|
+
expect(failedBadge?.className).toContain("eth-badge--danger");
|
|
50
|
+
|
|
51
|
+
const retryButton = within(viewer).getByRole("button", { name: "Retry" });
|
|
52
|
+
|
|
53
|
+
fireEvent.click(retryButton);
|
|
54
|
+
|
|
55
|
+
expect(onRetry).toHaveBeenCalledWith("evt_1002");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge, EmptyState, type EthSeverity } from "@echothink-ui/core";
|
|
3
|
+
import { DataTable, type DataColumn } from "@echothink-ui/data";
|
|
4
|
+
import type { WebhookEvent, WebhookEventViewerProps } from "./types";
|
|
5
|
+
|
|
6
|
+
export function WebhookEventViewer({
|
|
7
|
+
events,
|
|
8
|
+
onRetry,
|
|
9
|
+
className,
|
|
10
|
+
"aria-label": ariaLabel,
|
|
11
|
+
...props
|
|
12
|
+
}: WebhookEventViewerProps) {
|
|
13
|
+
const failedCount = events.filter(
|
|
14
|
+
(event) => webhookStatusTone(event.status) === "danger"
|
|
15
|
+
).length;
|
|
16
|
+
const retryCount = events.reduce((total, event) => total + event.retries, 0);
|
|
17
|
+
const columns: DataColumn<WebhookEvent>[] = [
|
|
18
|
+
{
|
|
19
|
+
key: "id",
|
|
20
|
+
header: "Event",
|
|
21
|
+
width: "16rem",
|
|
22
|
+
render: (row) => (
|
|
23
|
+
<span className="eth-dev-webhook-event-viewer__event">
|
|
24
|
+
<code title={row.id}>{row.id}</code>
|
|
25
|
+
<span>{payloadEventLabel(row.payload)}</span>
|
|
26
|
+
</span>
|
|
27
|
+
)
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
key: "url",
|
|
31
|
+
header: "Endpoint",
|
|
32
|
+
render: (row) => (
|
|
33
|
+
<code className="eth-dev-webhook-event-viewer__url" title={row.url}>
|
|
34
|
+
{row.url}
|
|
35
|
+
</code>
|
|
36
|
+
)
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: "status",
|
|
40
|
+
header: "Status",
|
|
41
|
+
width: "9rem",
|
|
42
|
+
render: (row) => (
|
|
43
|
+
<Badge severity={webhookStatusTone(row.status)}>
|
|
44
|
+
{formatWebhookStatus(row.status)}
|
|
45
|
+
</Badge>
|
|
46
|
+
)
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "retries",
|
|
50
|
+
header: "Retries",
|
|
51
|
+
align: "end",
|
|
52
|
+
width: "6.5rem",
|
|
53
|
+
render: (row) => (
|
|
54
|
+
<span className="eth-dev-webhook-event-viewer__count">{row.retries}</span>
|
|
55
|
+
)
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
key: "deliveredAt",
|
|
59
|
+
header: "Delivered",
|
|
60
|
+
width: "10rem",
|
|
61
|
+
render: (row) =>
|
|
62
|
+
row.deliveredAt ? (
|
|
63
|
+
<time className="eth-dev-webhook-event-viewer__time">{row.deliveredAt}</time>
|
|
64
|
+
) : (
|
|
65
|
+
<span className="eth-dev-webhook-event-viewer__missing">Not delivered</span>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<section
|
|
72
|
+
{...props}
|
|
73
|
+
aria-label={ariaLabel ?? "Webhook deliveries"}
|
|
74
|
+
className={`eth-dev-webhook-event-viewer ${className ?? ""}`.trim()}
|
|
75
|
+
data-eth-component="WebhookEventViewer"
|
|
76
|
+
>
|
|
77
|
+
<header className="eth-dev-webhook-event-viewer__header">
|
|
78
|
+
<div className="eth-dev-webhook-event-viewer__heading">
|
|
79
|
+
<p>Webhook events</p>
|
|
80
|
+
<h3>Webhook deliveries</h3>
|
|
81
|
+
</div>
|
|
82
|
+
<dl
|
|
83
|
+
className="eth-dev-webhook-event-viewer__summary"
|
|
84
|
+
aria-label="Webhook delivery summary"
|
|
85
|
+
>
|
|
86
|
+
<div>
|
|
87
|
+
<dt>Events</dt>
|
|
88
|
+
<dd>{pluralize(events.length, "event")}</dd>
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<dt>Failed</dt>
|
|
92
|
+
<dd>{pluralize(failedCount, "failed", "failed")}</dd>
|
|
93
|
+
</div>
|
|
94
|
+
<div>
|
|
95
|
+
<dt>Retries</dt>
|
|
96
|
+
<dd>{pluralize(retryCount, "retry", "retries")}</dd>
|
|
97
|
+
</div>
|
|
98
|
+
</dl>
|
|
99
|
+
</header>
|
|
100
|
+
<DataTable
|
|
101
|
+
rows={events}
|
|
102
|
+
columns={columns}
|
|
103
|
+
rowKey="id"
|
|
104
|
+
density="compact"
|
|
105
|
+
className="eth-dev-webhook-event-viewer__table"
|
|
106
|
+
aria-label="Webhook delivery events"
|
|
107
|
+
rowActions={
|
|
108
|
+
onRetry
|
|
109
|
+
? (row) =>
|
|
110
|
+
isRetryableWebhookStatus(row.status)
|
|
111
|
+
? [
|
|
112
|
+
{
|
|
113
|
+
id: `retry-${row.id}`,
|
|
114
|
+
label: "Retry",
|
|
115
|
+
onSelect: () => onRetry(row.id)
|
|
116
|
+
}
|
|
117
|
+
]
|
|
118
|
+
: []
|
|
119
|
+
: undefined
|
|
120
|
+
}
|
|
121
|
+
emptyState={
|
|
122
|
+
<EmptyState
|
|
123
|
+
title="No webhook events"
|
|
124
|
+
description="Delivery attempts and retry outcomes will appear here when webhooks fire."
|
|
125
|
+
/>
|
|
126
|
+
}
|
|
127
|
+
/>
|
|
128
|
+
</section>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function webhookStatusTone(status: string): EthSeverity {
|
|
133
|
+
const token = normalizeWebhookStatus(status);
|
|
134
|
+
|
|
135
|
+
if (["delivered", "success", "succeeded", "completed", "ok"].includes(token)) {
|
|
136
|
+
return "success";
|
|
137
|
+
}
|
|
138
|
+
if (isRetryableWebhookStatus(token)) return "danger";
|
|
139
|
+
if (["retrying", "queued", "pending", "running", "in-progress"].includes(token)) {
|
|
140
|
+
return "info";
|
|
141
|
+
}
|
|
142
|
+
if (["warning", "throttled", "rate-limited"].includes(token)) {
|
|
143
|
+
return "warning";
|
|
144
|
+
}
|
|
145
|
+
return "neutral";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isRetryableWebhookStatus(status: string) {
|
|
149
|
+
return ["failed", "error", "timeout", "timed-out", "undelivered"].includes(
|
|
150
|
+
normalizeWebhookStatus(status)
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function normalizeWebhookStatus(status: string) {
|
|
155
|
+
return status.trim().toLowerCase().replace(/_/g, "-");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function formatWebhookStatus(status: string) {
|
|
159
|
+
const normalized = normalizeWebhookStatus(status);
|
|
160
|
+
|
|
161
|
+
if (!normalized) return "Unknown";
|
|
162
|
+
if (normalized === "ok") return "OK";
|
|
163
|
+
|
|
164
|
+
return normalized
|
|
165
|
+
.split("-")
|
|
166
|
+
.filter(Boolean)
|
|
167
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
168
|
+
.join(" ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function payloadEventLabel(payload: unknown) {
|
|
172
|
+
if (payload && typeof payload === "object") {
|
|
173
|
+
const record = payload as Record<string, unknown>;
|
|
174
|
+
const eventName = record.event ?? record.type ?? record.name;
|
|
175
|
+
|
|
176
|
+
if (typeof eventName === "string" && eventName.trim()) return eventName;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return "Webhook event";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function pluralize(count: number, singular: string, plural = `${singular}s`) {
|
|
183
|
+
return `${count} ${count === 1 ? singular : plural}`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Checkmark, Copy } from "@carbon/icons-react";
|
|
3
|
+
import { IconButton } from "@echothink-ui/core";
|
|
4
|
+
import type { YAMLViewerProps } from "./types";
|
|
5
|
+
import { CodeEditor } from "./CodeEditor";
|
|
6
|
+
import { splitLines } from "./devUtils";
|
|
7
|
+
|
|
8
|
+
function countYamlEntries(lines: string[]) {
|
|
9
|
+
return lines.filter(
|
|
10
|
+
(line) => /^\s*(?:-\s+)?[\w.-]+\s*:/.test(line) || /^\s*-\s+\S/.test(line)
|
|
11
|
+
).length;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getScalarClass(value: string) {
|
|
15
|
+
if (/^["'].*["']$/.test(value)) return "string";
|
|
16
|
+
if (/^(?:true|false)$/i.test(value)) return "boolean";
|
|
17
|
+
if (/^(?:null|~)$/i.test(value)) return "null";
|
|
18
|
+
if (/^-?\d+(?:\.\d+)?$/.test(value)) return "number";
|
|
19
|
+
return "plain";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderScalar(value: string) {
|
|
23
|
+
if (!value) return null;
|
|
24
|
+
|
|
25
|
+
const commentIndex = value.search(/(^|\s)#/);
|
|
26
|
+
const scalar = commentIndex >= 0 ? value.slice(0, commentIndex) : value;
|
|
27
|
+
const comment = commentIndex >= 0 ? value.slice(commentIndex) : "";
|
|
28
|
+
const scalarClass = getScalarClass(scalar.trim());
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<>
|
|
32
|
+
{scalar ? (
|
|
33
|
+
<span
|
|
34
|
+
className={`eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--${scalarClass}`}
|
|
35
|
+
>
|
|
36
|
+
{scalar}
|
|
37
|
+
</span>
|
|
38
|
+
) : null}
|
|
39
|
+
{comment ? (
|
|
40
|
+
<span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--comment">
|
|
41
|
+
{comment}
|
|
42
|
+
</span>
|
|
43
|
+
) : null}
|
|
44
|
+
</>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderYamlLine(line: string) {
|
|
49
|
+
if (!line.trim()) return line;
|
|
50
|
+
|
|
51
|
+
if (/^\s*#/.test(line)) {
|
|
52
|
+
return (
|
|
53
|
+
<span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--comment">
|
|
54
|
+
{line}
|
|
55
|
+
</span>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const keyMatch = line.match(/^(\s*)(-\s+)?([\w.-]+)(\s*:)(.*)$/);
|
|
60
|
+
if (keyMatch) {
|
|
61
|
+
const [, indent, listMarker = "", key, separator, rest] = keyMatch;
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
{indent}
|
|
65
|
+
{listMarker ? (
|
|
66
|
+
<span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--marker">
|
|
67
|
+
{listMarker}
|
|
68
|
+
</span>
|
|
69
|
+
) : null}
|
|
70
|
+
<span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--key">
|
|
71
|
+
{key}
|
|
72
|
+
</span>
|
|
73
|
+
<span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--punctuation">
|
|
74
|
+
{separator}
|
|
75
|
+
</span>
|
|
76
|
+
{renderScalar(rest)}
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const listMatch = line.match(/^(\s*)(-\s+)(.*)$/);
|
|
82
|
+
if (listMatch) {
|
|
83
|
+
const [, indent, marker, rest] = listMatch;
|
|
84
|
+
return (
|
|
85
|
+
<>
|
|
86
|
+
{indent}
|
|
87
|
+
<span className="eth-dev-yaml-viewer__token eth-dev-yaml-viewer__token--marker">
|
|
88
|
+
{marker}
|
|
89
|
+
</span>
|
|
90
|
+
{renderScalar(rest)}
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return renderScalar(line) ?? line;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function YAMLViewer({
|
|
99
|
+
value,
|
|
100
|
+
editable = false,
|
|
101
|
+
onChange,
|
|
102
|
+
className,
|
|
103
|
+
"aria-label": ariaLabel,
|
|
104
|
+
...props
|
|
105
|
+
}: YAMLViewerProps) {
|
|
106
|
+
const [copied, setCopied] = React.useState(false);
|
|
107
|
+
const resetTimer = React.useRef<number | undefined>(undefined);
|
|
108
|
+
const lines = splitLines(value);
|
|
109
|
+
const lineCount = lines.length;
|
|
110
|
+
const entryCount = countYamlEntries(lines);
|
|
111
|
+
const modeLabel = editable ? "Editable" : "Read-only";
|
|
112
|
+
const viewerLabel = ariaLabel ?? (editable ? "YAML editor" : "YAML document");
|
|
113
|
+
|
|
114
|
+
React.useEffect(() => {
|
|
115
|
+
return () => {
|
|
116
|
+
if (resetTimer.current !== undefined && typeof window !== "undefined") {
|
|
117
|
+
window.clearTimeout(resetTimer.current);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}, []);
|
|
121
|
+
|
|
122
|
+
const copy = React.useCallback(() => {
|
|
123
|
+
if (typeof navigator === "undefined" || !navigator.clipboard) return;
|
|
124
|
+
|
|
125
|
+
void navigator.clipboard
|
|
126
|
+
.writeText(value)
|
|
127
|
+
.then(() => {
|
|
128
|
+
if (typeof window !== "undefined") {
|
|
129
|
+
setCopied(true);
|
|
130
|
+
if (resetTimer.current !== undefined) {
|
|
131
|
+
window.clearTimeout(resetTimer.current);
|
|
132
|
+
}
|
|
133
|
+
resetTimer.current = window.setTimeout(() => setCopied(false), 1200);
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
.catch(() => {
|
|
137
|
+
setCopied(false);
|
|
138
|
+
});
|
|
139
|
+
}, [value]);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<section
|
|
143
|
+
{...props}
|
|
144
|
+
aria-label={viewerLabel}
|
|
145
|
+
className={`eth-dev-yaml-viewer ${className ?? ""}`.trim()}
|
|
146
|
+
data-eth-component="YAMLViewer"
|
|
147
|
+
data-editable={editable ? "true" : undefined}
|
|
148
|
+
>
|
|
149
|
+
<header className="eth-dev-yaml-viewer__header">
|
|
150
|
+
<div className="eth-dev-yaml-viewer__heading">
|
|
151
|
+
<span className="eth-dev-yaml-viewer__eyebrow">YAML</span>
|
|
152
|
+
<span className="eth-dev-yaml-viewer__title">
|
|
153
|
+
{editable ? "YAML editor" : "YAML document"}
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div
|
|
157
|
+
className="eth-dev-yaml-viewer__meta"
|
|
158
|
+
role="group"
|
|
159
|
+
aria-label="YAML document metadata"
|
|
160
|
+
>
|
|
161
|
+
<span className="eth-dev-yaml-viewer__badge">{modeLabel}</span>
|
|
162
|
+
<span className="eth-dev-yaml-viewer__badge">
|
|
163
|
+
{lineCount} {lineCount === 1 ? "line" : "lines"}
|
|
164
|
+
</span>
|
|
165
|
+
<span className="eth-dev-yaml-viewer__badge">
|
|
166
|
+
{entryCount} {entryCount === 1 ? "entry" : "entries"}
|
|
167
|
+
</span>
|
|
168
|
+
<IconButton
|
|
169
|
+
className="eth-dev-yaml-viewer__copy-button"
|
|
170
|
+
label={copied ? "Copied YAML" : "Copy YAML"}
|
|
171
|
+
intent="ghost"
|
|
172
|
+
density="compact"
|
|
173
|
+
icon={copied ? <Checkmark size={16} /> : <Copy size={16} />}
|
|
174
|
+
onClick={copy}
|
|
175
|
+
/>
|
|
176
|
+
<span className="eth-dev-yaml-viewer__copy-status" aria-live="polite">
|
|
177
|
+
{copied ? "YAML copied" : ""}
|
|
178
|
+
</span>
|
|
179
|
+
</div>
|
|
180
|
+
</header>
|
|
181
|
+
{editable ? (
|
|
182
|
+
<CodeEditor
|
|
183
|
+
value={value}
|
|
184
|
+
onChange={onChange}
|
|
185
|
+
language="yaml"
|
|
186
|
+
className="eth-dev-yaml-viewer__editor"
|
|
187
|
+
aria-label={viewerLabel}
|
|
188
|
+
/>
|
|
189
|
+
) : (
|
|
190
|
+
<pre className="eth-dev-yaml-viewer__pre" aria-label={viewerLabel} tabIndex={0}>
|
|
191
|
+
<code className="eth-dev-yaml-viewer__code">
|
|
192
|
+
{lines.map((line, index) => (
|
|
193
|
+
<span key={index} className="eth-dev-yaml-viewer__line">
|
|
194
|
+
<span className="eth-dev-yaml-viewer__gutter" aria-hidden="true">
|
|
195
|
+
{index + 1}
|
|
196
|
+
</span>
|
|
197
|
+
<span className="eth-dev-yaml-viewer__line-code">
|
|
198
|
+
{renderYamlLine(line)}
|
|
199
|
+
</span>
|
|
200
|
+
</span>
|
|
201
|
+
))}
|
|
202
|
+
</code>
|
|
203
|
+
</pre>
|
|
204
|
+
)}
|
|
205
|
+
</section>
|
|
206
|
+
);
|
|
207
|
+
}
|