@mimicai/explorer 0.7.1
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/.turbo/turbo-build.log +24 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +190 -0
- package/dist/client/assets/index-6jl2Igpx.css +1 -0
- package/dist/client/assets/index-DrcjTHum.js +55 -0
- package/dist/client/index.html +14 -0
- package/dist/server/index.d.ts +10 -0
- package/dist/server/index.js +210 -0
- package/index.html +13 -0
- package/package.json +45 -0
- package/postcss.config.js +6 -0
- package/server/index.ts +285 -0
- package/src/App.tsx +116 -0
- package/src/components/explorer/json-viewer.tsx +241 -0
- package/src/components/layout/header.tsx +57 -0
- package/src/components/layout/sidebar.tsx +132 -0
- package/src/index.css +69 -0
- package/src/lib/api.ts +57 -0
- package/src/lib/group-endpoints.ts +35 -0
- package/src/lib/utils.ts +6 -0
- package/src/main.tsx +10 -0
- package/src/pages/adapter-view.tsx +128 -0
- package/src/pages/dashboard.tsx +134 -0
- package/src/pages/data-view.tsx +202 -0
- package/src/pages/endpoint-tester.tsx +239 -0
- package/tailwind.config.ts +49 -0
- package/tsconfig.json +23 -0
- package/tsup.config.ts +11 -0
- package/vite.config.ts +22 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
type Endpoint = { method: string; path: string; description: string };
|
|
2
|
+
|
|
3
|
+
export interface EndpointGroup {
|
|
4
|
+
label: string;
|
|
5
|
+
endpoints: Endpoint[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Groups endpoints by their top-level resource segment.
|
|
10
|
+
* e.g. /stripe/v1/customers/:id → "customers"
|
|
11
|
+
* /stripe/v1/accounts/:account/bank_accounts → "accounts"
|
|
12
|
+
*/
|
|
13
|
+
export function groupEndpoints(endpoints: Endpoint[], basePath: string): EndpointGroup[] {
|
|
14
|
+
const map = new Map<string, Endpoint[]>();
|
|
15
|
+
|
|
16
|
+
for (const ep of endpoints) {
|
|
17
|
+
// Strip the basePath prefix, then extract the first segment
|
|
18
|
+
let rest = ep.path;
|
|
19
|
+
if (rest.startsWith(basePath)) {
|
|
20
|
+
rest = rest.slice(basePath.length);
|
|
21
|
+
}
|
|
22
|
+
// Remove leading slash and version prefix (e.g. "v1/")
|
|
23
|
+
rest = rest.replace(/^\//, '').replace(/^v\d+\//, '');
|
|
24
|
+
// First segment is the resource group
|
|
25
|
+
const seg = rest.split('/')[0] || 'other';
|
|
26
|
+
const key = seg.replace(/^:/, '');
|
|
27
|
+
|
|
28
|
+
if (!map.has(key)) map.set(key, []);
|
|
29
|
+
map.get(key)!.push(ep);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return Array.from(map.entries())
|
|
33
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
34
|
+
.map(([label, endpoints]) => ({ label, endpoints }));
|
|
35
|
+
}
|
package/src/lib/utils.ts
ADDED
package/src/main.tsx
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
import type { AdapterInfo } from '@/lib/api';
|
|
4
|
+
import type { Page } from '@/App';
|
|
5
|
+
import { groupEndpoints } from '@/lib/group-endpoints';
|
|
6
|
+
|
|
7
|
+
interface AdapterViewProps {
|
|
8
|
+
adapter: AdapterInfo;
|
|
9
|
+
onNavigate: (page: Page) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const METHOD_COLORS: Record<string, string> = {
|
|
13
|
+
GET: 'bg-blue-500/10 text-blue-500',
|
|
14
|
+
POST: 'bg-emerald-500/10 text-emerald-500',
|
|
15
|
+
PUT: 'bg-amber-500/10 text-amber-500',
|
|
16
|
+
PATCH: 'bg-orange-500/10 text-orange-500',
|
|
17
|
+
DELETE: 'bg-red-500/10 text-red-500',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function AdapterView({ adapter, onNavigate }: AdapterViewProps) {
|
|
21
|
+
const groups = useMemo(() => groupEndpoints(adapter.endpoints, adapter.basePath), [adapter]);
|
|
22
|
+
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
|
23
|
+
|
|
24
|
+
const toggle = (group: string) =>
|
|
25
|
+
setCollapsed((prev) => ({ ...prev, [group]: !prev[group] }));
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="space-y-6">
|
|
29
|
+
{/* Header */}
|
|
30
|
+
<div className="flex items-start justify-between">
|
|
31
|
+
<div>
|
|
32
|
+
<h1 className="text-2xl font-bold">{adapter.name}</h1>
|
|
33
|
+
<p className="mt-1 text-muted-foreground">{adapter.description}</p>
|
|
34
|
+
<div className="mt-3 flex items-center gap-4 text-sm text-muted-foreground">
|
|
35
|
+
<span className="font-mono">@mimicai/adapter-{adapter.id}</span>
|
|
36
|
+
{adapter.port && <span>Port: {adapter.port}</span>}
|
|
37
|
+
{adapter.versions.length > 0 && (
|
|
38
|
+
<span>Versions: {adapter.versions.join(', ')}</span>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
<button
|
|
43
|
+
onClick={() => onNavigate({ type: 'tester', adapterId: adapter.id })}
|
|
44
|
+
className={cn(
|
|
45
|
+
'rounded-md border bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
|
|
46
|
+
'hover:bg-primary/90 transition-colors',
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
Test Endpoints
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{/* Endpoints grouped */}
|
|
54
|
+
<div>
|
|
55
|
+
<h2 className="mb-3 text-lg font-semibold">
|
|
56
|
+
Endpoints ({adapter.endpoints.length})
|
|
57
|
+
</h2>
|
|
58
|
+
<div className="space-y-2">
|
|
59
|
+
{groups.map(({ label, endpoints }) => {
|
|
60
|
+
const isCollapsed = collapsed[label] ?? false;
|
|
61
|
+
return (
|
|
62
|
+
<div key={label} className="rounded-lg border">
|
|
63
|
+
<button
|
|
64
|
+
onClick={() => toggle(label)}
|
|
65
|
+
className="flex w-full items-center gap-2 px-4 py-2.5 text-left hover:bg-muted/30 transition-colors"
|
|
66
|
+
>
|
|
67
|
+
<span className={cn(
|
|
68
|
+
'text-muted-foreground transition-transform text-xs',
|
|
69
|
+
isCollapsed ? '' : 'rotate-90',
|
|
70
|
+
)}>
|
|
71
|
+
▶
|
|
72
|
+
</span>
|
|
73
|
+
<span className="font-medium text-sm">{label}</span>
|
|
74
|
+
<span className="ml-auto text-xs text-muted-foreground">
|
|
75
|
+
{endpoints.length} endpoint{endpoints.length !== 1 ? 's' : ''}
|
|
76
|
+
</span>
|
|
77
|
+
</button>
|
|
78
|
+
{!isCollapsed && (
|
|
79
|
+
<table className="w-full">
|
|
80
|
+
<tbody>
|
|
81
|
+
{endpoints.map((ep, i) => (
|
|
82
|
+
<tr
|
|
83
|
+
key={i}
|
|
84
|
+
className={cn(
|
|
85
|
+
'transition-colors hover:bg-muted/30 border-t',
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
<td className="px-4 py-2.5 w-24">
|
|
89
|
+
<span
|
|
90
|
+
className={cn(
|
|
91
|
+
'inline-block rounded px-2 py-0.5 text-xs font-bold',
|
|
92
|
+
METHOD_COLORS[ep.method] ?? 'bg-muted text-muted-foreground',
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
{ep.method}
|
|
96
|
+
</span>
|
|
97
|
+
</td>
|
|
98
|
+
<td className="px-4 py-2.5 font-mono text-sm">{ep.path}</td>
|
|
99
|
+
<td className="px-4 py-2.5 text-sm text-muted-foreground">
|
|
100
|
+
{ep.description}
|
|
101
|
+
</td>
|
|
102
|
+
<td className="px-4 py-2.5 text-right w-20">
|
|
103
|
+
<button
|
|
104
|
+
onClick={() =>
|
|
105
|
+
onNavigate({
|
|
106
|
+
type: 'tester',
|
|
107
|
+
adapterId: adapter.id,
|
|
108
|
+
endpoint: { method: ep.method, path: ep.path },
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
className="text-xs text-primary hover:underline"
|
|
112
|
+
>
|
|
113
|
+
Try it
|
|
114
|
+
</button>
|
|
115
|
+
</td>
|
|
116
|
+
</tr>
|
|
117
|
+
))}
|
|
118
|
+
</tbody>
|
|
119
|
+
</table>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
);
|
|
123
|
+
})}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { cn } from '@/lib/utils';
|
|
2
|
+
import type { ExplorerConfig, AdapterInfo, PersonaDataSummary } from '@/lib/api';
|
|
3
|
+
import type { Page } from '@/App';
|
|
4
|
+
|
|
5
|
+
interface DashboardProps {
|
|
6
|
+
config: ExplorerConfig;
|
|
7
|
+
adapters: AdapterInfo[];
|
|
8
|
+
dataSummary: PersonaDataSummary[];
|
|
9
|
+
onNavigate: (page: Page) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function Dashboard({ config, adapters, dataSummary, onNavigate }: DashboardProps) {
|
|
13
|
+
const totalEndpoints = adapters.reduce((s, a) => s + a.endpoints.length, 0);
|
|
14
|
+
const totalEntities = dataSummary.reduce(
|
|
15
|
+
(s, ps) =>
|
|
16
|
+
s +
|
|
17
|
+
Object.values(ps.tables).reduce((s2, n) => s2 + n, 0) +
|
|
18
|
+
Object.values(ps.apis).reduce(
|
|
19
|
+
(s2, resources) => s2 + Object.values(resources).reduce((s3, n) => s3 + n, 0),
|
|
20
|
+
0,
|
|
21
|
+
),
|
|
22
|
+
0,
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="space-y-8">
|
|
27
|
+
{/* Stats */}
|
|
28
|
+
<div className="grid grid-cols-4 gap-4">
|
|
29
|
+
<StatCard label="Adapters" value={adapters.length} />
|
|
30
|
+
<StatCard label="Endpoints" value={totalEndpoints} />
|
|
31
|
+
<StatCard label="Personas" value={dataSummary.length} />
|
|
32
|
+
<StatCard label="Total Entities" value={totalEntities} />
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
{/* Adapters grid */}
|
|
36
|
+
<div>
|
|
37
|
+
<h2 className="mb-4 text-lg font-semibold">API Adapters</h2>
|
|
38
|
+
<div className="grid grid-cols-3 gap-4">
|
|
39
|
+
{adapters.map((adapter) => (
|
|
40
|
+
<button
|
|
41
|
+
key={adapter.id}
|
|
42
|
+
onClick={() => onNavigate({ type: 'adapter', adapterId: adapter.id })}
|
|
43
|
+
className={cn(
|
|
44
|
+
'rounded-lg border bg-card p-4 text-left transition-colors',
|
|
45
|
+
'hover:border-primary/30 hover:bg-accent',
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<div className="flex items-center justify-between mb-2">
|
|
49
|
+
<span className="font-semibold">{adapter.name}</span>
|
|
50
|
+
<span
|
|
51
|
+
className={cn(
|
|
52
|
+
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
53
|
+
adapter.enabled
|
|
54
|
+
? 'bg-emerald-500/10 text-emerald-500'
|
|
55
|
+
: 'bg-zinc-500/10 text-zinc-500',
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{adapter.enabled ? 'active' : 'disabled'}
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
<p className="text-sm text-muted-foreground line-clamp-2 mb-3">
|
|
62
|
+
{adapter.description}
|
|
63
|
+
</p>
|
|
64
|
+
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
|
65
|
+
<span>{adapter.endpoints.length} endpoints</span>
|
|
66
|
+
{adapter.port && <span>:{adapter.port}</span>}
|
|
67
|
+
</div>
|
|
68
|
+
</button>
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{/* Persona data overview */}
|
|
74
|
+
{dataSummary.length > 0 && (
|
|
75
|
+
<div>
|
|
76
|
+
<h2 className="mb-4 text-lg font-semibold">Persona Data</h2>
|
|
77
|
+
<div className="grid grid-cols-2 gap-4">
|
|
78
|
+
{dataSummary.map((ps) => (
|
|
79
|
+
<button
|
|
80
|
+
key={ps.persona}
|
|
81
|
+
onClick={() => onNavigate({ type: 'data', persona: ps.persona })}
|
|
82
|
+
className={cn(
|
|
83
|
+
'rounded-lg border bg-card p-4 text-left transition-colors',
|
|
84
|
+
'hover:border-primary/30 hover:bg-accent',
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<div className="font-semibold mb-3">{ps.persona}</div>
|
|
88
|
+
|
|
89
|
+
{Object.keys(ps.tables).length > 0 && (
|
|
90
|
+
<div className="mb-2">
|
|
91
|
+
<div className="text-xs font-medium text-muted-foreground mb-1">Database Tables</div>
|
|
92
|
+
<div className="flex flex-wrap gap-2">
|
|
93
|
+
{Object.entries(ps.tables).map(([table, count]) => (
|
|
94
|
+
<span key={table} className="inline-flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs">
|
|
95
|
+
<span className="text-muted-foreground">{table}</span>
|
|
96
|
+
<span className="font-mono font-medium">{count}</span>
|
|
97
|
+
</span>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{Object.keys(ps.apis).length > 0 && (
|
|
104
|
+
<div>
|
|
105
|
+
<div className="text-xs font-medium text-muted-foreground mb-1">API Resources</div>
|
|
106
|
+
<div className="flex flex-wrap gap-2">
|
|
107
|
+
{Object.entries(ps.apis).map(([adapterId, resources]) =>
|
|
108
|
+
Object.entries(resources).map(([resource, count]) => (
|
|
109
|
+
<span key={`${adapterId}-${resource}`} className="inline-flex items-center gap-1 rounded bg-muted px-2 py-0.5 text-xs">
|
|
110
|
+
<span className="text-muted-foreground">{adapterId}/{resource}</span>
|
|
111
|
+
<span className="font-mono font-medium">{count}</span>
|
|
112
|
+
</span>
|
|
113
|
+
)),
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</button>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function StatCard({ label, value }: { label: string; value: number }) {
|
|
128
|
+
return (
|
|
129
|
+
<div className="rounded-lg border bg-card p-4">
|
|
130
|
+
<div className="text-sm text-muted-foreground">{label}</div>
|
|
131
|
+
<div className="mt-1 text-3xl font-bold tracking-tight">{value}</div>
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { cn } from '@/lib/utils';
|
|
3
|
+
import type { PersonaDataSummary, DataResponse } from '@/lib/api';
|
|
4
|
+
import { JsonViewer } from '@/components/explorer/json-viewer';
|
|
5
|
+
|
|
6
|
+
interface DataViewProps {
|
|
7
|
+
persona: string;
|
|
8
|
+
source?: string;
|
|
9
|
+
resource?: string;
|
|
10
|
+
dataSummary: PersonaDataSummary[];
|
|
11
|
+
loadPersonaData: (persona: string) => Promise<DataResponse>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function DataView({ persona, dataSummary, loadPersonaData }: DataViewProps) {
|
|
15
|
+
const [data, setData] = useState<DataResponse | null>(null);
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
|
18
|
+
const [selectedResource, setSelectedResource] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
setLoading(true);
|
|
22
|
+
loadPersonaData(persona).then((d) => {
|
|
23
|
+
setData(d);
|
|
24
|
+
setLoading(false);
|
|
25
|
+
});
|
|
26
|
+
}, [persona]);
|
|
27
|
+
|
|
28
|
+
if (loading) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex items-center justify-center h-64">
|
|
31
|
+
<span className="text-muted-foreground">Loading data...</span>
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (!data) return null;
|
|
37
|
+
|
|
38
|
+
// Build navigation tree
|
|
39
|
+
const sources: Array<{
|
|
40
|
+
id: string;
|
|
41
|
+
label: string;
|
|
42
|
+
type: 'table' | 'api';
|
|
43
|
+
resources: Array<{ id: string; label: string; count: number }>;
|
|
44
|
+
}> = [];
|
|
45
|
+
|
|
46
|
+
// DB tables
|
|
47
|
+
if (Object.keys(data.tables).length > 0) {
|
|
48
|
+
sources.push({
|
|
49
|
+
id: 'database',
|
|
50
|
+
label: 'Database',
|
|
51
|
+
type: 'table',
|
|
52
|
+
resources: Object.entries(data.tables).map(([name, rows]) => ({
|
|
53
|
+
id: name,
|
|
54
|
+
label: name,
|
|
55
|
+
count: (rows as unknown[]).length,
|
|
56
|
+
})),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// API adapters
|
|
61
|
+
for (const [adapterId, responseSet] of Object.entries(data.apiResponses)) {
|
|
62
|
+
const resources = Object.entries(responseSet.responses)
|
|
63
|
+
.filter(([, arr]) => (arr as unknown[]).length > 0)
|
|
64
|
+
.map(([resource, arr]) => ({
|
|
65
|
+
id: resource,
|
|
66
|
+
label: resource,
|
|
67
|
+
count: (arr as unknown[]).length,
|
|
68
|
+
}));
|
|
69
|
+
if (resources.length > 0) {
|
|
70
|
+
sources.push({ id: adapterId, label: adapterId, type: 'api', resources });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const activeSource = selectedSource ?? sources[0]?.id ?? null;
|
|
75
|
+
const sourceObj = sources.find((s) => s.id === activeSource);
|
|
76
|
+
const activeResource = selectedResource ?? sourceObj?.resources[0]?.id ?? null;
|
|
77
|
+
|
|
78
|
+
const getActiveData = (): unknown[] => {
|
|
79
|
+
if (!sourceObj || !activeResource) return [];
|
|
80
|
+
if (sourceObj.type === 'table') {
|
|
81
|
+
return (data.tables[activeResource] as unknown[]) ?? [];
|
|
82
|
+
}
|
|
83
|
+
return (data.apiResponses[activeSource!]?.responses[activeResource] as unknown[]) ?? [];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const activeData = getActiveData();
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="space-y-6">
|
|
90
|
+
<div>
|
|
91
|
+
<h1 className="text-2xl font-bold">{persona}</h1>
|
|
92
|
+
<p className="text-muted-foreground mt-1">
|
|
93
|
+
Generated data for this persona across all configured surfaces.
|
|
94
|
+
</p>
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
{/* Facts */}
|
|
98
|
+
{data.facts && data.facts.length > 0 && (
|
|
99
|
+
<div>
|
|
100
|
+
<h2 className="mb-3 text-lg font-semibold">Facts ({data.facts.length})</h2>
|
|
101
|
+
<div className="grid grid-cols-2 gap-3">
|
|
102
|
+
{data.facts.map((fact) => (
|
|
103
|
+
<div
|
|
104
|
+
key={fact.id}
|
|
105
|
+
className={cn(
|
|
106
|
+
'rounded-lg border p-3',
|
|
107
|
+
fact.severity === 'critical' && 'border-red-500/30 bg-red-500/5',
|
|
108
|
+
fact.severity === 'warn' && 'border-amber-500/30 bg-amber-500/5',
|
|
109
|
+
fact.severity === 'info' && 'border-blue-500/30 bg-blue-500/5',
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
<div className="flex items-center gap-2 mb-1">
|
|
113
|
+
<span
|
|
114
|
+
className={cn(
|
|
115
|
+
'text-xs font-medium rounded px-1.5 py-0.5',
|
|
116
|
+
fact.severity === 'critical' && 'bg-red-500/10 text-red-500',
|
|
117
|
+
fact.severity === 'warn' && 'bg-amber-500/10 text-amber-500',
|
|
118
|
+
fact.severity === 'info' && 'bg-blue-500/10 text-blue-500',
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{fact.severity}
|
|
122
|
+
</span>
|
|
123
|
+
<span className="text-xs text-muted-foreground">{fact.platform}</span>
|
|
124
|
+
<span className="text-xs text-muted-foreground">{fact.type}</span>
|
|
125
|
+
</div>
|
|
126
|
+
<p className="text-sm">{fact.detail}</p>
|
|
127
|
+
</div>
|
|
128
|
+
))}
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
{/* Source / Resource navigation + data viewer */}
|
|
134
|
+
<div className="flex gap-4 min-h-[500px]">
|
|
135
|
+
{/* Source tree */}
|
|
136
|
+
<div className="w-56 shrink-0 space-y-2">
|
|
137
|
+
{sources.map((source) => (
|
|
138
|
+
<div key={source.id}>
|
|
139
|
+
<div
|
|
140
|
+
className={cn(
|
|
141
|
+
'px-2 py-1 text-xs font-semibold uppercase tracking-wider cursor-pointer rounded',
|
|
142
|
+
activeSource === source.id
|
|
143
|
+
? 'text-foreground'
|
|
144
|
+
: 'text-muted-foreground hover:text-foreground',
|
|
145
|
+
)}
|
|
146
|
+
onClick={() => {
|
|
147
|
+
setSelectedSource(source.id);
|
|
148
|
+
setSelectedResource(source.resources[0]?.id ?? null);
|
|
149
|
+
}}
|
|
150
|
+
>
|
|
151
|
+
{source.label}
|
|
152
|
+
<span className="ml-1.5 font-normal normal-case">
|
|
153
|
+
({source.type === 'api' ? 'api' : 'db'})
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
{activeSource === source.id && (
|
|
157
|
+
<div className="ml-2 mt-1 space-y-0.5">
|
|
158
|
+
{source.resources.map((res) => (
|
|
159
|
+
<button
|
|
160
|
+
key={res.id}
|
|
161
|
+
onClick={() => setSelectedResource(res.id)}
|
|
162
|
+
className={cn(
|
|
163
|
+
'flex w-full items-center justify-between rounded px-2 py-1 text-sm transition-colors',
|
|
164
|
+
activeResource === res.id
|
|
165
|
+
? 'bg-accent text-accent-foreground'
|
|
166
|
+
: 'text-muted-foreground hover:bg-accent/50',
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
<span className="truncate">{res.label}</span>
|
|
170
|
+
<span className="text-xs font-mono">{res.count}</span>
|
|
171
|
+
</button>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{/* Data panel */}
|
|
180
|
+
<div className="flex-1 rounded-lg border bg-card overflow-hidden">
|
|
181
|
+
<div className="flex items-center justify-between border-b bg-muted/50 px-4 py-2">
|
|
182
|
+
<span className="text-sm font-medium">
|
|
183
|
+
{activeResource ?? 'Select a resource'}
|
|
184
|
+
</span>
|
|
185
|
+
<span className="text-xs text-muted-foreground">
|
|
186
|
+
{activeData.length} {activeData.length === 1 ? 'record' : 'records'}
|
|
187
|
+
</span>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="overflow-auto max-h-[600px] p-4">
|
|
190
|
+
{activeData.length > 0 ? (
|
|
191
|
+
<JsonViewer data={activeData} />
|
|
192
|
+
) : (
|
|
193
|
+
<div className="text-center text-muted-foreground py-12">
|
|
194
|
+
No data available
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|