khotan-data 0.0.1 → 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/AGENTS.md +54 -0
- package/README.md +62 -0
- package/dist/cli.js +2585 -0
- package/dist/factory.cjs +2319 -0
- package/dist/factory.cjs.map +1 -0
- package/dist/factory.d.cts +475 -0
- package/dist/factory.d.ts +475 -0
- package/dist/factory.js +2311 -0
- package/dist/factory.js.map +1 -0
- package/dist/plug-client.cjs +99 -0
- package/dist/plug-client.cjs.map +1 -0
- package/dist/plug-client.d.cts +71 -0
- package/dist/plug-client.d.ts +71 -0
- package/dist/plug-client.js +96 -0
- package/dist/plug-client.js.map +1 -0
- package/dist/templates/agent-skill.md +73 -0
- package/dist/templates/agents.md +41 -0
- package/dist/templates/catch.example.ts +36 -0
- package/dist/templates/catch.ts +107 -0
- package/dist/templates/config-page.tsx +20 -0
- package/dist/templates/debug-index-page.tsx +101 -0
- package/dist/templates/debug-page.tsx +48 -0
- package/dist/templates/graph-page.tsx +11 -0
- package/dist/templates/hub.tsx +450 -0
- package/dist/templates/inflow.example.ts +61 -0
- package/dist/templates/inflow.ts +99 -0
- package/dist/templates/khotan-config.ts +40 -0
- package/dist/templates/khotan-route.ts +13 -0
- package/dist/templates/logs-page.tsx +9 -0
- package/dist/templates/logs.tsx +20 -0
- package/dist/templates/outflow.example.ts +52 -0
- package/dist/templates/outflow.ts +90 -0
- package/dist/templates/pass.example.ts +51 -0
- package/dist/templates/pass.ts +124 -0
- package/dist/templates/plug-debugger.tsx +1185 -0
- package/dist/templates/plug.example.ts +93 -0
- package/dist/templates/plug.ts +806 -0
- package/dist/templates/relay.example.ts +61 -0
- package/dist/templates/relay.ts +95 -0
- package/dist/templates/runs-table.tsx +592 -0
- package/dist/templates/schema.ts +424 -0
- package/dist/templates/skill-dashboard.md +144 -0
- package/dist/templates/skill-plug.md +193 -0
- package/dist/templates/skill-setup.md +119 -0
- package/dist/templates/skill-webhook.md +196 -0
- package/dist/templates/topology-canvas.tsx +1406 -0
- package/dist/templates/var-panel.tsx +276 -0
- package/dist/templates/webhook-events-table.tsx +241 -0
- package/dist/templates/wire-panel.tsx +216 -0
- package/dist/templates/wire.ts +155 -0
- package/package.json +46 -5
|
@@ -0,0 +1,1185 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import React, { useEffect, useState, useCallback, useRef } from "react";
|
|
4
|
+
import { Badge } from "@/components/ui/badge";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { Label } from "@/components/ui/label";
|
|
8
|
+
|
|
9
|
+
// ============================================================================
|
|
10
|
+
// Plug Debugger — Lightweight Postman for your plugs
|
|
11
|
+
// Generated by khotan CLI · https://github.com/khotan-data
|
|
12
|
+
//
|
|
13
|
+
// Fires requests through the real plug code path (auth, retry, hooks).
|
|
14
|
+
// Gated behind KHOTAN_DEBUG env var — hidden in production.
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
interface EndpointMeta {
|
|
18
|
+
method: string;
|
|
19
|
+
path: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
body?: Record<string, unknown> | null;
|
|
22
|
+
query?: Record<string, unknown> | null;
|
|
23
|
+
responses?: Record<string, Record<string, unknown> | null> | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Endpoint {
|
|
27
|
+
name: string;
|
|
28
|
+
method: string;
|
|
29
|
+
path: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
body?: Record<string, unknown> | null;
|
|
32
|
+
query?: Record<string, unknown> | null;
|
|
33
|
+
responses?: Record<string, Record<string, unknown> | null> | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface VarField {
|
|
37
|
+
key: string;
|
|
38
|
+
label: string;
|
|
39
|
+
type: string;
|
|
40
|
+
secret?: boolean;
|
|
41
|
+
defaultValue?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PlugMeta {
|
|
45
|
+
name: string;
|
|
46
|
+
baseUrl: string;
|
|
47
|
+
authType: string;
|
|
48
|
+
endpoints: Record<string, EndpointMeta> | null;
|
|
49
|
+
vars: {
|
|
50
|
+
fields: VarField[];
|
|
51
|
+
configured: boolean;
|
|
52
|
+
values?: Record<string, string>;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface DebugResponse {
|
|
57
|
+
status: number;
|
|
58
|
+
statusText: string;
|
|
59
|
+
headers: Record<string, string>;
|
|
60
|
+
body: unknown;
|
|
61
|
+
timing: number;
|
|
62
|
+
endpoint?: { name: string; method: string; path: string };
|
|
63
|
+
error?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface HistoryEntry {
|
|
67
|
+
id: string;
|
|
68
|
+
method: string;
|
|
69
|
+
path: string;
|
|
70
|
+
status: number;
|
|
71
|
+
timing: number;
|
|
72
|
+
timestamp: number;
|
|
73
|
+
body?: string;
|
|
74
|
+
params?: string;
|
|
75
|
+
headers?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface PlugDebuggerProps {
|
|
79
|
+
plugName: string;
|
|
80
|
+
basePath?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"] as const;
|
|
84
|
+
|
|
85
|
+
const METHOD_COLORS: Record<string, string> = {
|
|
86
|
+
GET: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20",
|
|
87
|
+
POST: "bg-amber-500/10 text-amber-700 border-amber-500/20",
|
|
88
|
+
PUT: "bg-blue-500/10 text-blue-700 border-blue-500/20",
|
|
89
|
+
PATCH: "bg-purple-500/10 text-purple-700 border-purple-500/20",
|
|
90
|
+
DELETE: "bg-red-500/10 text-red-700 border-red-500/20",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
94
|
+
"2": "text-emerald-600",
|
|
95
|
+
"4": "text-red-600",
|
|
96
|
+
"5": "text-red-600",
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
function getStatusColor(status: number): string {
|
|
100
|
+
return STATUS_COLORS[String(status)[0] ?? ""] ?? "text-amber-600";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Path param detection — extracts :param segments from a path template
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
function extractPathParams(pathTemplate: string): string[] {
|
|
108
|
+
return (pathTemplate.match(/:([a-zA-Z_]\w*)/g) ?? []).map((p) => p.slice(1));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function interpolatePath(
|
|
112
|
+
pathTemplate: string,
|
|
113
|
+
values: Record<string, string>,
|
|
114
|
+
): string {
|
|
115
|
+
let result = pathTemplate;
|
|
116
|
+
for (const [key, val] of Object.entries(values)) {
|
|
117
|
+
if (val) result = result.replace(`:${key}`, encodeURIComponent(val));
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// JSON path builder — builds dot-notation paths for nested objects
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
interface DiffInfo {
|
|
127
|
+
matched: Set<string>;
|
|
128
|
+
unexpected: Set<string>;
|
|
129
|
+
missing: string[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildJsonTree(
|
|
133
|
+
obj: unknown,
|
|
134
|
+
prefix: string,
|
|
135
|
+
onCopy: (path: string) => void,
|
|
136
|
+
diff?: DiffInfo,
|
|
137
|
+
depth = 0,
|
|
138
|
+
): React.ReactNode {
|
|
139
|
+
if (obj === null || obj === undefined) {
|
|
140
|
+
return <span className="text-muted-foreground">null</span>;
|
|
141
|
+
}
|
|
142
|
+
if (typeof obj !== "object") {
|
|
143
|
+
return <span className="text-foreground">{JSON.stringify(obj)}</span>;
|
|
144
|
+
}
|
|
145
|
+
if (Array.isArray(obj)) {
|
|
146
|
+
if (obj.length === 0)
|
|
147
|
+
return <span className="text-muted-foreground">[]</span>;
|
|
148
|
+
return (
|
|
149
|
+
<div className="pl-3">
|
|
150
|
+
{obj.slice(0, 20).map((item, i) => (
|
|
151
|
+
<div key={i} className="border-l border-border/50 pl-2 my-0.5">
|
|
152
|
+
<button
|
|
153
|
+
onClick={() => onCopy(`${prefix}[${i}]`)}
|
|
154
|
+
className="text-[10px] text-muted-foreground/60 hover:text-foreground mr-1"
|
|
155
|
+
title={`Copy path: ${prefix}[${i}]`}
|
|
156
|
+
>
|
|
157
|
+
[{i}]
|
|
158
|
+
</button>
|
|
159
|
+
{buildJsonTree(
|
|
160
|
+
item,
|
|
161
|
+
`${prefix}[${i}]`,
|
|
162
|
+
onCopy,
|
|
163
|
+
undefined,
|
|
164
|
+
depth + 1,
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
))}
|
|
168
|
+
{obj.length > 20 && (
|
|
169
|
+
<p className="text-[10px] text-muted-foreground italic">
|
|
170
|
+
...{obj.length - 20} more items
|
|
171
|
+
</p>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const entries = Object.entries(obj as Record<string, unknown>);
|
|
178
|
+
return (
|
|
179
|
+
<div className={depth > 0 ? "pl-3" : ""}>
|
|
180
|
+
{/* Missing fields at top (only at depth 0) */}
|
|
181
|
+
{diff &&
|
|
182
|
+
depth === 0 &&
|
|
183
|
+
diff.missing.map((key) => (
|
|
184
|
+
<div
|
|
185
|
+
key={`missing-${key}`}
|
|
186
|
+
className="my-0.5 -mx-1 px-1 rounded bg-red-500/10"
|
|
187
|
+
>
|
|
188
|
+
<span className="select-none inline-block w-4 text-[10px] text-red-500">
|
|
189
|
+
−
|
|
190
|
+
</span>
|
|
191
|
+
<span className="text-xs font-mono text-red-400 line-through">
|
|
192
|
+
{key}
|
|
193
|
+
</span>
|
|
194
|
+
<span className="text-red-400/60 text-xs">: ...</span>
|
|
195
|
+
</div>
|
|
196
|
+
))}
|
|
197
|
+
{entries.map(([key, val]) => {
|
|
198
|
+
const fullPath = prefix ? `${prefix}.${key}` : key;
|
|
199
|
+
const isObject = val !== null && typeof val === "object";
|
|
200
|
+
let bgClass = "";
|
|
201
|
+
let prefixChar = " ";
|
|
202
|
+
if (diff && depth === 0) {
|
|
203
|
+
if (diff.matched.has(key)) {
|
|
204
|
+
bgClass = "bg-emerald-500/10 -mx-1 px-1 rounded";
|
|
205
|
+
} else if (diff.unexpected.has(key)) {
|
|
206
|
+
bgClass = "bg-amber-500/10 -mx-1 px-1 rounded";
|
|
207
|
+
prefixChar = "+";
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return (
|
|
211
|
+
<div key={key} className={`my-0.5 ${bgClass}`}>
|
|
212
|
+
{diff && depth === 0 && (
|
|
213
|
+
<span
|
|
214
|
+
className={`select-none inline-block w-4 text-[10px] ${prefixChar === "+" ? "text-amber-600" : "text-transparent"}`}
|
|
215
|
+
>
|
|
216
|
+
{prefixChar}
|
|
217
|
+
</span>
|
|
218
|
+
)}
|
|
219
|
+
<button
|
|
220
|
+
onClick={() => onCopy(fullPath)}
|
|
221
|
+
className="text-xs font-mono font-medium text-foreground hover:text-blue-600 transition-colors"
|
|
222
|
+
title={`Copy path: ${fullPath}`}
|
|
223
|
+
>
|
|
224
|
+
{key}
|
|
225
|
+
</button>
|
|
226
|
+
<span className="text-muted-foreground text-xs">: </span>
|
|
227
|
+
{isObject ? (
|
|
228
|
+
buildJsonTree(val, fullPath, onCopy, undefined, depth + 1)
|
|
229
|
+
) : (
|
|
230
|
+
<span className="text-xs font-mono text-foreground">
|
|
231
|
+
{JSON.stringify(val)}
|
|
232
|
+
</span>
|
|
233
|
+
)}
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
})}
|
|
237
|
+
</div>
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Schema block display
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
function SchemaBlock({
|
|
246
|
+
title,
|
|
247
|
+
schema,
|
|
248
|
+
}: {
|
|
249
|
+
title: string;
|
|
250
|
+
schema: Record<string, unknown>;
|
|
251
|
+
}) {
|
|
252
|
+
if (schema._type === "array" && schema.items) {
|
|
253
|
+
return (
|
|
254
|
+
<div className="space-y-1">
|
|
255
|
+
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
256
|
+
{title}
|
|
257
|
+
</p>
|
|
258
|
+
<div className="rounded bg-muted/50 p-2 font-mono text-[11px]">
|
|
259
|
+
<span className="text-muted-foreground">Array of:</span>
|
|
260
|
+
<div className="pl-2 mt-0.5">
|
|
261
|
+
{Object.entries(schema.items as Record<string, unknown>).map(
|
|
262
|
+
([key, val]) => (
|
|
263
|
+
<div key={key}>
|
|
264
|
+
<span className="text-foreground">{key}</span>
|
|
265
|
+
<span className="text-muted-foreground">: {String(val)}</span>
|
|
266
|
+
</div>
|
|
267
|
+
),
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const entries = Object.entries(schema).filter(([k]) => k !== "_type");
|
|
276
|
+
if (entries.length === 0) return null;
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
<div className="space-y-1">
|
|
280
|
+
<p className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
|
281
|
+
{title}
|
|
282
|
+
</p>
|
|
283
|
+
<div className="rounded bg-muted/50 p-2 font-mono text-[11px] space-y-0.5">
|
|
284
|
+
{entries.map(([key, val]) => (
|
|
285
|
+
<div key={key}>
|
|
286
|
+
<span className="text-foreground">{key}</span>
|
|
287
|
+
<span className="text-muted-foreground">
|
|
288
|
+
: {typeof val === "object" && val ? "object" : String(val)}
|
|
289
|
+
</span>
|
|
290
|
+
</div>
|
|
291
|
+
))}
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---------------------------------------------------------------------------
|
|
298
|
+
// History helpers
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
const HISTORY_KEY = "khotan-debug-history";
|
|
302
|
+
const MAX_HISTORY = 20;
|
|
303
|
+
|
|
304
|
+
function loadHistory(plugName: string): HistoryEntry[] {
|
|
305
|
+
try {
|
|
306
|
+
const raw = sessionStorage.getItem(`${HISTORY_KEY}:${plugName}`);
|
|
307
|
+
return raw ? JSON.parse(raw) : [];
|
|
308
|
+
} catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function saveHistory(plugName: string, entries: HistoryEntry[]) {
|
|
314
|
+
try {
|
|
315
|
+
sessionStorage.setItem(
|
|
316
|
+
`${HISTORY_KEY}:${plugName}`,
|
|
317
|
+
JSON.stringify(entries.slice(0, MAX_HISTORY)),
|
|
318
|
+
);
|
|
319
|
+
} catch {
|
|
320
|
+
/* quota exceeded */
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ===========================================================================
|
|
325
|
+
// Main component
|
|
326
|
+
// ===========================================================================
|
|
327
|
+
|
|
328
|
+
export function PlugDebugger({
|
|
329
|
+
plugName,
|
|
330
|
+
basePath = "/api/khotan",
|
|
331
|
+
}: PlugDebuggerProps) {
|
|
332
|
+
const [meta, setMeta] = useState<PlugMeta | null>(null);
|
|
333
|
+
const [loading, setLoading] = useState(true);
|
|
334
|
+
|
|
335
|
+
const [method, setMethod] = useState<string>("GET");
|
|
336
|
+
const [path, setPath] = useState("");
|
|
337
|
+
const [pathParams, setPathParams] = useState<Record<string, string>>({});
|
|
338
|
+
const [body, setBody] = useState("");
|
|
339
|
+
const [params, setParams] = useState("");
|
|
340
|
+
const [headers, setHeaders] = useState("");
|
|
341
|
+
const [activeTab, setActiveTab] = useState<"body" | "params" | "headers">(
|
|
342
|
+
"params",
|
|
343
|
+
);
|
|
344
|
+
|
|
345
|
+
const [sending, setSending] = useState(false);
|
|
346
|
+
const [response, setResponse] = useState<DebugResponse | null>(null);
|
|
347
|
+
const [responseTab, setResponseTab] = useState<"body" | "headers">("body");
|
|
348
|
+
const [responseView, setResponseView] = useState<"raw" | "tree">("raw");
|
|
349
|
+
|
|
350
|
+
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
|
351
|
+
const [copiedPath, setCopiedPath] = useState<string | null>(null);
|
|
352
|
+
|
|
353
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
354
|
+
|
|
355
|
+
const fetchMeta = useCallback(async () => {
|
|
356
|
+
try {
|
|
357
|
+
const res = await fetch(`${basePath}/debug/${plugName}`);
|
|
358
|
+
if (!res.ok) {
|
|
359
|
+
setMeta(null);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
setMeta(await res.json());
|
|
363
|
+
} catch {
|
|
364
|
+
setMeta(null);
|
|
365
|
+
} finally {
|
|
366
|
+
setLoading(false);
|
|
367
|
+
}
|
|
368
|
+
}, [basePath, plugName]);
|
|
369
|
+
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
fetchMeta();
|
|
372
|
+
}, [fetchMeta]);
|
|
373
|
+
useEffect(() => {
|
|
374
|
+
setHistory(loadHistory(plugName));
|
|
375
|
+
}, [plugName]);
|
|
376
|
+
|
|
377
|
+
// Cmd+Enter global shortcut
|
|
378
|
+
useEffect(() => {
|
|
379
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
380
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
|
|
381
|
+
e.preventDefault();
|
|
382
|
+
if (path && !sending) fireRequest();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const el = containerRef.current;
|
|
386
|
+
el?.addEventListener("keydown", handleKeyDown);
|
|
387
|
+
return () => el?.removeEventListener("keydown", handleKeyDown);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Detect path params when path changes
|
|
391
|
+
const detectedParams = extractPathParams(path);
|
|
392
|
+
const resolvedPath =
|
|
393
|
+
detectedParams.length > 0 ? interpolatePath(path, pathParams) : path;
|
|
394
|
+
|
|
395
|
+
if (loading) {
|
|
396
|
+
return (
|
|
397
|
+
<div className="space-y-4">
|
|
398
|
+
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
|
|
399
|
+
<div className="h-64 animate-pulse rounded bg-muted" />
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (!meta) {
|
|
405
|
+
return (
|
|
406
|
+
<div className="rounded-lg border border-border p-6 text-center">
|
|
407
|
+
<p className="text-muted-foreground">
|
|
408
|
+
Debug mode is not available for this plug.
|
|
409
|
+
</p>
|
|
410
|
+
</div>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const endpointList: Endpoint[] = meta.endpoints
|
|
415
|
+
? Object.entries(meta.endpoints).map(([name, ep]) => ({
|
|
416
|
+
name,
|
|
417
|
+
method: ep.method.toUpperCase(),
|
|
418
|
+
path: ep.path,
|
|
419
|
+
description: ep.description,
|
|
420
|
+
body: ep.body,
|
|
421
|
+
query: ep.query,
|
|
422
|
+
responses: ep.responses,
|
|
423
|
+
}))
|
|
424
|
+
: [];
|
|
425
|
+
|
|
426
|
+
const selectedEndpoint = endpointList.find(
|
|
427
|
+
(ep) => ep.method === method && ep.path === path,
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
function selectEndpoint(ep: Endpoint) {
|
|
431
|
+
setMethod(ep.method);
|
|
432
|
+
setPath(ep.path);
|
|
433
|
+
setPathParams({});
|
|
434
|
+
setResponse(null);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function loadFromHistory(entry: HistoryEntry) {
|
|
438
|
+
setMethod(entry.method);
|
|
439
|
+
setPath(entry.path);
|
|
440
|
+
setBody(entry.body ?? "");
|
|
441
|
+
setParams(entry.params ?? "");
|
|
442
|
+
setHeaders(entry.headers ?? "");
|
|
443
|
+
setPathParams({});
|
|
444
|
+
setResponse(null);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function fireRequest() {
|
|
448
|
+
const finalPath = resolvedPath || path;
|
|
449
|
+
if (!finalPath) return;
|
|
450
|
+
setSending(true);
|
|
451
|
+
setResponse(null);
|
|
452
|
+
try {
|
|
453
|
+
const payload: Record<string, unknown> = { method, path: finalPath };
|
|
454
|
+
|
|
455
|
+
if (body.trim()) {
|
|
456
|
+
try {
|
|
457
|
+
payload.body = JSON.parse(body);
|
|
458
|
+
} catch {
|
|
459
|
+
payload.body = body;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (params.trim()) {
|
|
463
|
+
try {
|
|
464
|
+
payload.params = JSON.parse(params);
|
|
465
|
+
} catch {
|
|
466
|
+
/* skip */
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
if (headers.trim()) {
|
|
470
|
+
try {
|
|
471
|
+
payload.headers = JSON.parse(headers);
|
|
472
|
+
} catch {
|
|
473
|
+
/* skip */
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const res = await fetch(`${basePath}/debug/${plugName}`, {
|
|
478
|
+
method: "POST",
|
|
479
|
+
headers: { "Content-Type": "application/json" },
|
|
480
|
+
body: JSON.stringify(payload),
|
|
481
|
+
});
|
|
482
|
+
const data: DebugResponse = await res.json();
|
|
483
|
+
setResponse(data);
|
|
484
|
+
|
|
485
|
+
const entry: HistoryEntry = {
|
|
486
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
487
|
+
method,
|
|
488
|
+
path: finalPath,
|
|
489
|
+
status: data.status,
|
|
490
|
+
timing: data.timing,
|
|
491
|
+
timestamp: Date.now(),
|
|
492
|
+
body: body || undefined,
|
|
493
|
+
params: params || undefined,
|
|
494
|
+
headers: headers || undefined,
|
|
495
|
+
};
|
|
496
|
+
const updated = [entry, ...history].slice(0, MAX_HISTORY);
|
|
497
|
+
setHistory(updated);
|
|
498
|
+
saveHistory(plugName, updated);
|
|
499
|
+
} catch (err) {
|
|
500
|
+
setResponse({
|
|
501
|
+
status: 0,
|
|
502
|
+
statusText: "Network Error",
|
|
503
|
+
headers: {},
|
|
504
|
+
body: null,
|
|
505
|
+
timing: 0,
|
|
506
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
507
|
+
});
|
|
508
|
+
} finally {
|
|
509
|
+
setSending(false);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function handleBodyBlur() {
|
|
514
|
+
if (!body.trim()) return;
|
|
515
|
+
try {
|
|
516
|
+
const parsed = JSON.parse(body);
|
|
517
|
+
const formatted = JSON.stringify(parsed, null, 2);
|
|
518
|
+
if (formatted !== body) setBody(formatted);
|
|
519
|
+
} catch {
|
|
520
|
+
/* not valid JSON, leave as-is */
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function copyJsonPath(jsonPath: string) {
|
|
525
|
+
navigator.clipboard.writeText(jsonPath);
|
|
526
|
+
setCopiedPath(jsonPath);
|
|
527
|
+
setTimeout(() => setCopiedPath(null), 1500);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const statusColor = response ? getStatusColor(response.status) : "";
|
|
531
|
+
|
|
532
|
+
// Compute response size
|
|
533
|
+
const responseRaw =
|
|
534
|
+
response?.body != null
|
|
535
|
+
? typeof response.body === "string"
|
|
536
|
+
? response.body
|
|
537
|
+
: JSON.stringify(response.body)
|
|
538
|
+
: "";
|
|
539
|
+
const responseSizeKB = responseRaw
|
|
540
|
+
? (new Blob([responseRaw]).size / 1024).toFixed(1)
|
|
541
|
+
: "0";
|
|
542
|
+
|
|
543
|
+
// Schema diff computation
|
|
544
|
+
const schemaDiff = (() => {
|
|
545
|
+
if (
|
|
546
|
+
!selectedEndpoint?.responses ||
|
|
547
|
+
!response?.body ||
|
|
548
|
+
typeof response.body !== "object"
|
|
549
|
+
)
|
|
550
|
+
return null;
|
|
551
|
+
const schemaForStatus = selectedEndpoint.responses[String(response.status)];
|
|
552
|
+
if (!schemaForStatus) return null;
|
|
553
|
+
const expectedKeys = new Set(
|
|
554
|
+
Object.keys(schemaForStatus).filter((k) => k !== "_type"),
|
|
555
|
+
);
|
|
556
|
+
const actualKeys = new Set(
|
|
557
|
+
Object.keys(response.body as Record<string, unknown>),
|
|
558
|
+
);
|
|
559
|
+
const matched = [...expectedKeys].filter((k) => actualKeys.has(k));
|
|
560
|
+
const unexpected = [...actualKeys].filter((k) => !expectedKeys.has(k));
|
|
561
|
+
const missing = [...expectedKeys].filter((k) => !actualKeys.has(k));
|
|
562
|
+
return { matched, unexpected, missing, expectedKeys };
|
|
563
|
+
})();
|
|
564
|
+
|
|
565
|
+
return (
|
|
566
|
+
<div ref={containerRef} className="flex gap-6" tabIndex={-1}>
|
|
567
|
+
{/* Sidebar */}
|
|
568
|
+
<div className="w-56 shrink-0 space-y-4">
|
|
569
|
+
{/* Plug info */}
|
|
570
|
+
<div className="rounded-lg border border-border p-3 space-y-2">
|
|
571
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
572
|
+
{meta.name}
|
|
573
|
+
</p>
|
|
574
|
+
<p
|
|
575
|
+
className="text-xs text-muted-foreground truncate"
|
|
576
|
+
title={meta.baseUrl}
|
|
577
|
+
>
|
|
578
|
+
{meta.baseUrl}
|
|
579
|
+
</p>
|
|
580
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
581
|
+
{meta.authType && meta.authType !== "none" && (
|
|
582
|
+
<Badge variant="outline" className="text-[10px]">
|
|
583
|
+
{meta.authType}
|
|
584
|
+
</Badge>
|
|
585
|
+
)}
|
|
586
|
+
<Badge
|
|
587
|
+
variant={meta.vars.configured ? "default" : "secondary"}
|
|
588
|
+
className="text-[10px]"
|
|
589
|
+
>
|
|
590
|
+
{meta.vars.configured ? "Vars ✓" : "No vars"}
|
|
591
|
+
</Badge>
|
|
592
|
+
</div>
|
|
593
|
+
{meta.vars.fields.length > 0 && (
|
|
594
|
+
<div className="pt-1 space-y-1">
|
|
595
|
+
{meta.vars.fields.map((f) => {
|
|
596
|
+
const value = meta.vars.values?.[f.key];
|
|
597
|
+
return (
|
|
598
|
+
<div key={f.key} className="text-[10px]">
|
|
599
|
+
<span className="text-muted-foreground">
|
|
600
|
+
{f.label || f.key}
|
|
601
|
+
</span>
|
|
602
|
+
{value && (
|
|
603
|
+
<span className="ml-1.5 font-mono text-foreground">
|
|
604
|
+
{f.secret ? "••••••••" : value}
|
|
605
|
+
</span>
|
|
606
|
+
)}
|
|
607
|
+
{!value && (
|
|
608
|
+
<span className="ml-1.5 text-muted-foreground/50 italic">
|
|
609
|
+
not set
|
|
610
|
+
</span>
|
|
611
|
+
)}
|
|
612
|
+
</div>
|
|
613
|
+
);
|
|
614
|
+
})}
|
|
615
|
+
</div>
|
|
616
|
+
)}
|
|
617
|
+
</div>
|
|
618
|
+
|
|
619
|
+
{/* New request */}
|
|
620
|
+
<div className="space-y-1">
|
|
621
|
+
<button
|
|
622
|
+
onClick={() => {
|
|
623
|
+
setMethod("GET");
|
|
624
|
+
setPath("");
|
|
625
|
+
setBody("");
|
|
626
|
+
setParams("");
|
|
627
|
+
setHeaders("");
|
|
628
|
+
setPathParams({});
|
|
629
|
+
setResponse(null);
|
|
630
|
+
}}
|
|
631
|
+
className={`w-full text-left rounded-md px-2 py-1.5 text-xs font-medium transition-colors hover:bg-muted ${
|
|
632
|
+
!selectedEndpoint && !path ? "bg-muted" : ""
|
|
633
|
+
}`}
|
|
634
|
+
>
|
|
635
|
+
+ New Request
|
|
636
|
+
</button>
|
|
637
|
+
</div>
|
|
638
|
+
|
|
639
|
+
{/* Endpoints */}
|
|
640
|
+
{endpointList.length > 0 && (
|
|
641
|
+
<div className="space-y-1">
|
|
642
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-1">
|
|
643
|
+
Endpoints
|
|
644
|
+
</p>
|
|
645
|
+
{endpointList.map((ep) => (
|
|
646
|
+
<button
|
|
647
|
+
key={ep.name}
|
|
648
|
+
onClick={() => selectEndpoint(ep)}
|
|
649
|
+
className={`w-full text-left rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-muted ${
|
|
650
|
+
method === ep.method && path === ep.path
|
|
651
|
+
? "bg-muted font-medium"
|
|
652
|
+
: ""
|
|
653
|
+
}`}
|
|
654
|
+
>
|
|
655
|
+
<span
|
|
656
|
+
className={`inline-block w-12 font-mono text-[10px] font-semibold ${METHOD_COLORS[ep.method]?.split(" ")[1] ?? ""}`}
|
|
657
|
+
>
|
|
658
|
+
{ep.method}
|
|
659
|
+
</span>
|
|
660
|
+
<span className="text-muted-foreground ml-1">{ep.path}</span>
|
|
661
|
+
<span className="block text-[10px] text-muted-foreground/60 pl-[52px]">
|
|
662
|
+
{ep.name}
|
|
663
|
+
</span>
|
|
664
|
+
</button>
|
|
665
|
+
))}
|
|
666
|
+
</div>
|
|
667
|
+
)}
|
|
668
|
+
|
|
669
|
+
{endpointList.length === 0 && (
|
|
670
|
+
<div className="rounded-lg border border-dashed border-border p-3 text-center">
|
|
671
|
+
<p className="text-[10px] text-muted-foreground">
|
|
672
|
+
No typed endpoints registered. Add{" "}
|
|
673
|
+
<code className="bg-muted px-1 rounded">endpoints</code> to your
|
|
674
|
+
plug config to see them here.
|
|
675
|
+
</p>
|
|
676
|
+
</div>
|
|
677
|
+
)}
|
|
678
|
+
|
|
679
|
+
{/* History */}
|
|
680
|
+
{history.length > 0 && (
|
|
681
|
+
<div className="space-y-1">
|
|
682
|
+
<div className="flex items-center justify-between px-1">
|
|
683
|
+
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
|
684
|
+
History
|
|
685
|
+
</p>
|
|
686
|
+
<button
|
|
687
|
+
onClick={() => {
|
|
688
|
+
setHistory([]);
|
|
689
|
+
saveHistory(plugName, []);
|
|
690
|
+
}}
|
|
691
|
+
className="text-[10px] text-muted-foreground hover:text-foreground"
|
|
692
|
+
>
|
|
693
|
+
Clear
|
|
694
|
+
</button>
|
|
695
|
+
</div>
|
|
696
|
+
{history.slice(0, 8).map((entry) => (
|
|
697
|
+
<button
|
|
698
|
+
key={entry.id}
|
|
699
|
+
onClick={() => loadFromHistory(entry)}
|
|
700
|
+
className="w-full text-left rounded-md px-2 py-1 text-[10px] transition-colors hover:bg-muted"
|
|
701
|
+
>
|
|
702
|
+
<span
|
|
703
|
+
className={`inline-block w-10 font-mono font-semibold ${METHOD_COLORS[entry.method]?.split(" ")[1] ?? ""}`}
|
|
704
|
+
>
|
|
705
|
+
{entry.method}
|
|
706
|
+
</span>
|
|
707
|
+
<span className="text-muted-foreground ml-1 truncate">
|
|
708
|
+
{entry.path}
|
|
709
|
+
</span>
|
|
710
|
+
<span className="float-right">
|
|
711
|
+
<span className={`font-mono ${getStatusColor(entry.status)}`}>
|
|
712
|
+
{entry.status}
|
|
713
|
+
</span>
|
|
714
|
+
<span className="text-muted-foreground/50 ml-1">
|
|
715
|
+
{entry.timing}ms
|
|
716
|
+
</span>
|
|
717
|
+
</span>
|
|
718
|
+
</button>
|
|
719
|
+
))}
|
|
720
|
+
</div>
|
|
721
|
+
)}
|
|
722
|
+
</div>
|
|
723
|
+
|
|
724
|
+
{/* Main pane */}
|
|
725
|
+
<div className="flex-1 min-w-0 space-y-4">
|
|
726
|
+
{/* URL bar */}
|
|
727
|
+
<div className="flex gap-2">
|
|
728
|
+
<select
|
|
729
|
+
className={`w-24 rounded-md border px-2 py-2 text-xs font-mono font-semibold ${METHOD_COLORS[method] ?? ""}`}
|
|
730
|
+
value={method}
|
|
731
|
+
onChange={(e) => setMethod(e.target.value)}
|
|
732
|
+
>
|
|
733
|
+
{METHODS.map((m) => (
|
|
734
|
+
<option key={m} value={m}>
|
|
735
|
+
{m}
|
|
736
|
+
</option>
|
|
737
|
+
))}
|
|
738
|
+
</select>
|
|
739
|
+
<div className="flex-1 relative">
|
|
740
|
+
<Input
|
|
741
|
+
className="font-mono text-sm pr-20"
|
|
742
|
+
placeholder="/products"
|
|
743
|
+
value={path}
|
|
744
|
+
onChange={(e) => setPath(e.target.value)}
|
|
745
|
+
onKeyDown={(e) => {
|
|
746
|
+
if (e.key === "Enter" && path) fireRequest();
|
|
747
|
+
}}
|
|
748
|
+
/>
|
|
749
|
+
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-muted-foreground pointer-events-none">
|
|
750
|
+
⌘↵
|
|
751
|
+
</span>
|
|
752
|
+
</div>
|
|
753
|
+
<Button
|
|
754
|
+
onClick={fireRequest}
|
|
755
|
+
disabled={sending || !path}
|
|
756
|
+
size="sm"
|
|
757
|
+
className="px-4"
|
|
758
|
+
>
|
|
759
|
+
{sending ? "..." : "Send"}
|
|
760
|
+
</Button>
|
|
761
|
+
</div>
|
|
762
|
+
|
|
763
|
+
{/* Path params interpolation */}
|
|
764
|
+
{detectedParams.length > 0 && (
|
|
765
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
766
|
+
{detectedParams.map((param) => (
|
|
767
|
+
<div key={param} className="flex items-center gap-1.5">
|
|
768
|
+
<Label className="text-[10px] text-muted-foreground font-mono">
|
|
769
|
+
:{param}
|
|
770
|
+
</Label>
|
|
771
|
+
<Input
|
|
772
|
+
className="w-32 h-7 text-xs font-mono"
|
|
773
|
+
placeholder={param}
|
|
774
|
+
value={pathParams[param] ?? ""}
|
|
775
|
+
onChange={(e) =>
|
|
776
|
+
setPathParams((prev) => ({
|
|
777
|
+
...prev,
|
|
778
|
+
[param]: e.target.value,
|
|
779
|
+
}))
|
|
780
|
+
}
|
|
781
|
+
/>
|
|
782
|
+
</div>
|
|
783
|
+
))}
|
|
784
|
+
{Object.values(pathParams).some(Boolean) && (
|
|
785
|
+
<span className="text-[10px] text-muted-foreground font-mono">
|
|
786
|
+
→ {resolvedPath}
|
|
787
|
+
</span>
|
|
788
|
+
)}
|
|
789
|
+
</div>
|
|
790
|
+
)}
|
|
791
|
+
|
|
792
|
+
{/* Request tabs */}
|
|
793
|
+
<div className="rounded-lg border border-border">
|
|
794
|
+
<div className="flex border-b border-border">
|
|
795
|
+
{(["params", "body", "headers"] as const).map((tab) => (
|
|
796
|
+
<button
|
|
797
|
+
key={tab}
|
|
798
|
+
onClick={() => setActiveTab(tab)}
|
|
799
|
+
className={`px-4 py-2 text-xs font-medium transition-colors ${
|
|
800
|
+
activeTab === tab
|
|
801
|
+
? "border-b-2 border-foreground text-foreground"
|
|
802
|
+
: "text-muted-foreground hover:text-foreground"
|
|
803
|
+
}`}
|
|
804
|
+
>
|
|
805
|
+
{tab === "body"
|
|
806
|
+
? "Body"
|
|
807
|
+
: tab === "params"
|
|
808
|
+
? "Params"
|
|
809
|
+
: "Headers"}
|
|
810
|
+
</button>
|
|
811
|
+
))}
|
|
812
|
+
</div>
|
|
813
|
+
<div className="p-3">
|
|
814
|
+
{activeTab === "params" && (
|
|
815
|
+
<div className="space-y-1">
|
|
816
|
+
<Label className="text-[10px] text-muted-foreground">
|
|
817
|
+
Query parameters as JSON
|
|
818
|
+
</Label>
|
|
819
|
+
<Input
|
|
820
|
+
className="font-mono text-xs"
|
|
821
|
+
placeholder='{ "limit": "10", "page": "1" }'
|
|
822
|
+
value={params}
|
|
823
|
+
onChange={(e) => setParams(e.target.value)}
|
|
824
|
+
/>
|
|
825
|
+
</div>
|
|
826
|
+
)}
|
|
827
|
+
{activeTab === "body" && (
|
|
828
|
+
<div className="space-y-1">
|
|
829
|
+
<Label className="text-[10px] text-muted-foreground">
|
|
830
|
+
Request body (JSON) — auto-formats on blur
|
|
831
|
+
</Label>
|
|
832
|
+
<textarea
|
|
833
|
+
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs min-h-[120px] resize-y"
|
|
834
|
+
placeholder='{ "key": "value" }'
|
|
835
|
+
value={body}
|
|
836
|
+
onChange={(e) => setBody(e.target.value)}
|
|
837
|
+
onBlur={handleBodyBlur}
|
|
838
|
+
/>
|
|
839
|
+
</div>
|
|
840
|
+
)}
|
|
841
|
+
{activeTab === "headers" && (
|
|
842
|
+
<div className="space-y-1">
|
|
843
|
+
<Label className="text-[10px] text-muted-foreground">
|
|
844
|
+
Extra headers as JSON (auth headers are added automatically)
|
|
845
|
+
</Label>
|
|
846
|
+
<Input
|
|
847
|
+
className="font-mono text-xs"
|
|
848
|
+
placeholder='{ "X-Custom-Header": "value" }'
|
|
849
|
+
value={headers}
|
|
850
|
+
onChange={(e) => setHeaders(e.target.value)}
|
|
851
|
+
/>
|
|
852
|
+
</div>
|
|
853
|
+
)}
|
|
854
|
+
</div>
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
{/* Schema info for selected endpoint */}
|
|
858
|
+
{selectedEndpoint &&
|
|
859
|
+
(selectedEndpoint.body ||
|
|
860
|
+
selectedEndpoint.query ||
|
|
861
|
+
selectedEndpoint.responses) && (
|
|
862
|
+
<div className="rounded-lg border border-border">
|
|
863
|
+
<div className="px-4 py-2 border-b border-border">
|
|
864
|
+
<p className="text-xs font-medium">
|
|
865
|
+
{selectedEndpoint.description || selectedEndpoint.name}
|
|
866
|
+
</p>
|
|
867
|
+
</div>
|
|
868
|
+
<div className="p-3 grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
869
|
+
{selectedEndpoint.body && (
|
|
870
|
+
<SchemaBlock
|
|
871
|
+
title="Request Body"
|
|
872
|
+
schema={selectedEndpoint.body}
|
|
873
|
+
/>
|
|
874
|
+
)}
|
|
875
|
+
{selectedEndpoint.query && (
|
|
876
|
+
<SchemaBlock
|
|
877
|
+
title="Query Params"
|
|
878
|
+
schema={selectedEndpoint.query}
|
|
879
|
+
/>
|
|
880
|
+
)}
|
|
881
|
+
{selectedEndpoint.responses &&
|
|
882
|
+
Object.entries(selectedEndpoint.responses).map(
|
|
883
|
+
([code, schema]) =>
|
|
884
|
+
schema ? (
|
|
885
|
+
<SchemaBlock
|
|
886
|
+
key={code}
|
|
887
|
+
title={`Response ${code}`}
|
|
888
|
+
schema={schema}
|
|
889
|
+
/>
|
|
890
|
+
) : null,
|
|
891
|
+
)}
|
|
892
|
+
</div>
|
|
893
|
+
</div>
|
|
894
|
+
)}
|
|
895
|
+
|
|
896
|
+
{/* Response */}
|
|
897
|
+
{response && (
|
|
898
|
+
<div className="rounded-lg border border-border">
|
|
899
|
+
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
|
900
|
+
<div className="flex items-center gap-3">
|
|
901
|
+
<span className={`font-mono text-sm font-bold ${statusColor}`}>
|
|
902
|
+
{response.status}
|
|
903
|
+
</span>
|
|
904
|
+
<span className="text-xs text-muted-foreground">
|
|
905
|
+
{response.statusText}
|
|
906
|
+
</span>
|
|
907
|
+
{response.endpoint && (
|
|
908
|
+
<Badge variant="outline" className="text-[10px]">
|
|
909
|
+
{response.endpoint.name}
|
|
910
|
+
</Badge>
|
|
911
|
+
)}
|
|
912
|
+
</div>
|
|
913
|
+
<div className="flex items-center gap-2 text-xs font-mono text-muted-foreground">
|
|
914
|
+
<span>{response.timing}ms</span>
|
|
915
|
+
<span className="text-muted-foreground/40">·</span>
|
|
916
|
+
<span>{responseSizeKB} KB</span>
|
|
917
|
+
</div>
|
|
918
|
+
</div>
|
|
919
|
+
|
|
920
|
+
{response.error && (
|
|
921
|
+
<div className="px-4 py-2 border-b border-border bg-red-500/5">
|
|
922
|
+
<p className="text-xs text-red-600">{response.error}</p>
|
|
923
|
+
</div>
|
|
924
|
+
)}
|
|
925
|
+
|
|
926
|
+
{/* Schema diff summary */}
|
|
927
|
+
{schemaDiff &&
|
|
928
|
+
(schemaDiff.unexpected.length > 0 ||
|
|
929
|
+
schemaDiff.missing.length > 0) && (
|
|
930
|
+
<div className="px-4 py-2 border-b border-border bg-muted/30">
|
|
931
|
+
<div className="flex flex-wrap gap-2 text-[10px]">
|
|
932
|
+
{schemaDiff.matched.length > 0 && (
|
|
933
|
+
<span className="text-emerald-600">
|
|
934
|
+
✓ {schemaDiff.matched.length} matched
|
|
935
|
+
</span>
|
|
936
|
+
)}
|
|
937
|
+
{schemaDiff.unexpected.length > 0 && (
|
|
938
|
+
<span className="text-amber-600">
|
|
939
|
+
+ {schemaDiff.unexpected.length} undocumented:{" "}
|
|
940
|
+
<span className="font-mono">
|
|
941
|
+
{schemaDiff.unexpected.join(", ")}
|
|
942
|
+
</span>
|
|
943
|
+
</span>
|
|
944
|
+
)}
|
|
945
|
+
{schemaDiff.missing.length > 0 && (
|
|
946
|
+
<span className="text-red-600">
|
|
947
|
+
− {schemaDiff.missing.length} missing:{" "}
|
|
948
|
+
<span className="font-mono">
|
|
949
|
+
{schemaDiff.missing.join(", ")}
|
|
950
|
+
</span>
|
|
951
|
+
</span>
|
|
952
|
+
)}
|
|
953
|
+
</div>
|
|
954
|
+
</div>
|
|
955
|
+
)}
|
|
956
|
+
|
|
957
|
+
<div className="flex items-center border-b border-border">
|
|
958
|
+
<div className="flex flex-1">
|
|
959
|
+
{(["body", "headers"] as const).map((tab) => (
|
|
960
|
+
<button
|
|
961
|
+
key={tab}
|
|
962
|
+
onClick={() => setResponseTab(tab)}
|
|
963
|
+
className={`px-4 py-1.5 text-xs font-medium transition-colors ${
|
|
964
|
+
responseTab === tab
|
|
965
|
+
? "border-b-2 border-foreground text-foreground"
|
|
966
|
+
: "text-muted-foreground hover:text-foreground"
|
|
967
|
+
}`}
|
|
968
|
+
>
|
|
969
|
+
{tab === "body"
|
|
970
|
+
? "Body"
|
|
971
|
+
: `Headers (${Object.keys(response.headers).length})`}
|
|
972
|
+
</button>
|
|
973
|
+
))}
|
|
974
|
+
{responseTab === "body" && (
|
|
975
|
+
<div className="flex items-center gap-1 ml-2">
|
|
976
|
+
<button
|
|
977
|
+
onClick={() => setResponseView("raw")}
|
|
978
|
+
className={`px-2 py-0.5 text-[10px] rounded transition-colors ${responseView === "raw" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
|
979
|
+
>
|
|
980
|
+
Raw
|
|
981
|
+
</button>
|
|
982
|
+
<button
|
|
983
|
+
onClick={() => setResponseView("tree")}
|
|
984
|
+
className={`px-2 py-0.5 text-[10px] rounded transition-colors ${responseView === "tree" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
|
985
|
+
>
|
|
986
|
+
Tree
|
|
987
|
+
</button>
|
|
988
|
+
</div>
|
|
989
|
+
)}
|
|
990
|
+
</div>
|
|
991
|
+
<div className="flex items-center gap-1">
|
|
992
|
+
{copiedPath && (
|
|
993
|
+
<span className="text-[10px] text-emerald-600 mr-1">
|
|
994
|
+
{copiedPath}
|
|
995
|
+
</span>
|
|
996
|
+
)}
|
|
997
|
+
{responseTab === "body" && (
|
|
998
|
+
<button
|
|
999
|
+
onClick={() => {
|
|
1000
|
+
const text =
|
|
1001
|
+
typeof response.body === "string"
|
|
1002
|
+
? response.body
|
|
1003
|
+
: JSON.stringify(response.body, null, 2);
|
|
1004
|
+
navigator.clipboard.writeText(text);
|
|
1005
|
+
}}
|
|
1006
|
+
className="px-3 py-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
|
1007
|
+
title="Copy response body"
|
|
1008
|
+
>
|
|
1009
|
+
Copy
|
|
1010
|
+
</button>
|
|
1011
|
+
)}
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
|
|
1015
|
+
<div className="p-4 max-h-[480px] overflow-auto">
|
|
1016
|
+
{responseTab === "body" &&
|
|
1017
|
+
responseView === "raw" &&
|
|
1018
|
+
(() => {
|
|
1019
|
+
const raw =
|
|
1020
|
+
typeof response.body === "string"
|
|
1021
|
+
? response.body
|
|
1022
|
+
: JSON.stringify(response.body, null, 2);
|
|
1023
|
+
const MAX_DISPLAY = 50_000;
|
|
1024
|
+
const truncated = raw.length > MAX_DISPLAY;
|
|
1025
|
+
const displayRaw = truncated
|
|
1026
|
+
? raw.slice(0, MAX_DISPLAY)
|
|
1027
|
+
: raw;
|
|
1028
|
+
|
|
1029
|
+
if (
|
|
1030
|
+
!schemaDiff ||
|
|
1031
|
+
typeof response.body !== "object" ||
|
|
1032
|
+
response.body === null
|
|
1033
|
+
) {
|
|
1034
|
+
return (
|
|
1035
|
+
<>
|
|
1036
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
|
|
1037
|
+
{displayRaw}
|
|
1038
|
+
</pre>
|
|
1039
|
+
{truncated && (
|
|
1040
|
+
<div className="mt-3 rounded bg-muted/50 px-3 py-2 text-center">
|
|
1041
|
+
<p className="text-[10px] text-muted-foreground">
|
|
1042
|
+
Response truncated (
|
|
1043
|
+
{(raw.length / 1024).toFixed(0)} KB total). Copy
|
|
1044
|
+
to see full output.
|
|
1045
|
+
</p>
|
|
1046
|
+
</div>
|
|
1047
|
+
)}
|
|
1048
|
+
</>
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const lines = displayRaw.split("\n");
|
|
1053
|
+
const topLevelKeys = new Set([
|
|
1054
|
+
...schemaDiff.matched,
|
|
1055
|
+
...schemaDiff.unexpected,
|
|
1056
|
+
]);
|
|
1057
|
+
const keyLineMap = new Map<
|
|
1058
|
+
string,
|
|
1059
|
+
"matched" | "unexpected"
|
|
1060
|
+
>();
|
|
1061
|
+
for (const k of schemaDiff.matched)
|
|
1062
|
+
keyLineMap.set(k, "matched");
|
|
1063
|
+
for (const k of schemaDiff.unexpected)
|
|
1064
|
+
keyLineMap.set(k, "unexpected");
|
|
1065
|
+
|
|
1066
|
+
const missingLines = schemaDiff.missing.map((key) => ({
|
|
1067
|
+
key,
|
|
1068
|
+
line: ` "${key}": ...`,
|
|
1069
|
+
}));
|
|
1070
|
+
|
|
1071
|
+
return (
|
|
1072
|
+
<>
|
|
1073
|
+
<div className="text-xs font-mono whitespace-pre-wrap break-words">
|
|
1074
|
+
{lines.map((line, i) => {
|
|
1075
|
+
const isOpeningBrace = line.trim() === "{";
|
|
1076
|
+
|
|
1077
|
+
const keyMatch = line.match(/^\s+"([^"]+)":/);
|
|
1078
|
+
let bg = "";
|
|
1079
|
+
let prefix = " ";
|
|
1080
|
+
if (keyMatch && topLevelKeys.has(keyMatch[1])) {
|
|
1081
|
+
const status = keyLineMap.get(keyMatch[1]);
|
|
1082
|
+
if (status === "matched") {
|
|
1083
|
+
bg = "bg-emerald-500/10";
|
|
1084
|
+
} else if (status === "unexpected") {
|
|
1085
|
+
bg = "bg-amber-500/10";
|
|
1086
|
+
prefix = "+";
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return (
|
|
1091
|
+
<React.Fragment key={i}>
|
|
1092
|
+
<div className={`px-1 -mx-1 rounded-sm ${bg}`}>
|
|
1093
|
+
<span
|
|
1094
|
+
className={`select-none inline-block w-4 text-[10px] ${prefix === "+" ? "text-amber-600" : "text-transparent"}`}
|
|
1095
|
+
>
|
|
1096
|
+
{prefix}
|
|
1097
|
+
</span>
|
|
1098
|
+
{line}
|
|
1099
|
+
</div>
|
|
1100
|
+
{isOpeningBrace &&
|
|
1101
|
+
missingLines.length > 0 &&
|
|
1102
|
+
missingLines.map((m) => (
|
|
1103
|
+
<div
|
|
1104
|
+
key={m.key}
|
|
1105
|
+
className="px-1 -mx-1 rounded-sm bg-red-500/10"
|
|
1106
|
+
>
|
|
1107
|
+
<span className="select-none inline-block w-4 text-[10px] text-red-500">
|
|
1108
|
+
−
|
|
1109
|
+
</span>
|
|
1110
|
+
<span className="text-red-400 line-through">
|
|
1111
|
+
{m.line}
|
|
1112
|
+
</span>
|
|
1113
|
+
</div>
|
|
1114
|
+
))}
|
|
1115
|
+
</React.Fragment>
|
|
1116
|
+
);
|
|
1117
|
+
})}
|
|
1118
|
+
</div>
|
|
1119
|
+
{truncated && (
|
|
1120
|
+
<div className="mt-3 rounded bg-muted/50 px-3 py-2 text-center">
|
|
1121
|
+
<p className="text-[10px] text-muted-foreground">
|
|
1122
|
+
Response truncated ({(raw.length / 1024).toFixed(0)}{" "}
|
|
1123
|
+
KB total). Copy to see full output.
|
|
1124
|
+
</p>
|
|
1125
|
+
</div>
|
|
1126
|
+
)}
|
|
1127
|
+
</>
|
|
1128
|
+
);
|
|
1129
|
+
})()}
|
|
1130
|
+
{responseTab === "body" && responseView === "tree" && (
|
|
1131
|
+
<div className="text-xs">
|
|
1132
|
+
{buildJsonTree(
|
|
1133
|
+
response.body,
|
|
1134
|
+
"",
|
|
1135
|
+
copyJsonPath,
|
|
1136
|
+
schemaDiff
|
|
1137
|
+
? {
|
|
1138
|
+
matched: new Set(schemaDiff.matched),
|
|
1139
|
+
unexpected: new Set(schemaDiff.unexpected),
|
|
1140
|
+
missing: schemaDiff.missing,
|
|
1141
|
+
}
|
|
1142
|
+
: undefined,
|
|
1143
|
+
)}
|
|
1144
|
+
</div>
|
|
1145
|
+
)}
|
|
1146
|
+
{responseTab === "headers" && (
|
|
1147
|
+
<div className="space-y-1">
|
|
1148
|
+
{Object.entries(response.headers).map(([key, value]) => (
|
|
1149
|
+
<div key={key} className="flex gap-2 text-xs">
|
|
1150
|
+
<span className="font-mono font-medium text-foreground">
|
|
1151
|
+
{key}:
|
|
1152
|
+
</span>
|
|
1153
|
+
<span className="text-muted-foreground break-all">
|
|
1154
|
+
{value}
|
|
1155
|
+
</span>
|
|
1156
|
+
</div>
|
|
1157
|
+
))}
|
|
1158
|
+
{Object.keys(response.headers).length === 0 && (
|
|
1159
|
+
<p className="text-xs text-muted-foreground">
|
|
1160
|
+
No headers returned.
|
|
1161
|
+
</p>
|
|
1162
|
+
)}
|
|
1163
|
+
</div>
|
|
1164
|
+
)}
|
|
1165
|
+
</div>
|
|
1166
|
+
</div>
|
|
1167
|
+
)}
|
|
1168
|
+
|
|
1169
|
+
{!response && !sending && (
|
|
1170
|
+
<div className="rounded-lg border border-dashed border-border p-12 text-center">
|
|
1171
|
+
<p className="text-sm text-muted-foreground">
|
|
1172
|
+
{endpointList.length > 0
|
|
1173
|
+
? "Select an endpoint or enter a path and hit Send."
|
|
1174
|
+
: "Enter a path and hit Send to fire a request through this plug."}
|
|
1175
|
+
</p>
|
|
1176
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
1177
|
+
Requests flow through the real plug code path — auth, retry, and
|
|
1178
|
+
hooks all apply.
|
|
1179
|
+
</p>
|
|
1180
|
+
</div>
|
|
1181
|
+
)}
|
|
1182
|
+
</div>
|
|
1183
|
+
</div>
|
|
1184
|
+
);
|
|
1185
|
+
}
|