Skip to content

Commit 447d7bf

Browse files
authored
Merge pull request #11 from sourcemeta-research/feature/registry-health-bundle-rdf-stats-locations
Integrate registry health, bundling, RDF, stats, and locations endpoints
2 parents 5b6de69 + cdee86d commit 447d7bf

8 files changed

Lines changed: 385 additions & 18 deletions

File tree

‎src/api/one.ts‎

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import type {
44
EvaluationResult,
55
HealthReport,
66
ProblemDetails,
7+
SchemaLocations,
78
SchemaMetadata,
89
SchemaPositions,
10+
SchemaStats,
911
SearchResult,
1012
TraceResult,
1113
} from "../types/one";
@@ -38,6 +40,19 @@ const request = async <T>(
3840
return response.json() as Promise<T>;
3941
};
4042

43+
// GET /self/v1/health has an empty body and only exists to be pinged, so it
44+
// resolves to a bool rather than parsing a response.
45+
export const checkRegistryHealth = async (
46+
registryUrl: string
47+
): Promise<boolean> => {
48+
try {
49+
const response = await fetch(`${normaliseBase(registryUrl)}/self/v1/health`);
50+
return response.ok;
51+
} catch {
52+
return false;
53+
}
54+
};
55+
4156
export const listDirectory = (
4257
registryUrl: string,
4358
path = ""
@@ -61,17 +76,21 @@ export const getSchemaMetadata = (
6176

6277
export const getSchemaContent = async (
6378
registryUrl: string,
64-
schemaPath: string
79+
schemaPath: string,
80+
options?: { bundle?: boolean }
6581
): Promise<string> => {
66-
const response = await fetch(`${normaliseBase(registryUrl)}${schemaPath}.json`);
82+
const query = options?.bundle ? "?bundle=1" : "";
83+
const response = await fetch(
84+
`${normaliseBase(registryUrl)}${schemaPath}.json${query}`
85+
);
6786
if (!response.ok) {
6887
throw new Error(`Request failed with status ${response.status}`);
6988
}
7089
// Return the server's exact original text rather than re-serializing via
7190
// JSON.parse/stringify: /schemas/positions is computed against that exact
72-
// text, and reformatting (e.g. reflowing inline arrays to multiple lines)
73-
// shifts every line number after the first difference, breaking every
74-
// trace highlight that follows.
91+
// text (for the unbundled case), and reformatting (e.g. reflowing inline
92+
// arrays to multiple lines) shifts every line number after the first
93+
// difference, breaking every trace highlight that follows.
7594
return response.text();
7695
};
7796

@@ -110,6 +129,36 @@ export const getSchemaPositions = (
110129
): Promise<SchemaPositions> =>
111130
request(registryUrl, `/self/v1/api/schemas/positions${schemaPath}`);
112131

132+
export const getSchemaStats = (
133+
registryUrl: string,
134+
schemaPath: string
135+
): Promise<SchemaStats> =>
136+
request(registryUrl, `/self/v1/api/schemas/stats${schemaPath}`);
137+
138+
export const getSchemaLocations = (
139+
registryUrl: string,
140+
schemaPath: string
141+
): Promise<SchemaLocations> =>
142+
request(registryUrl, `/self/v1/api/schemas/locations${schemaPath}`);
143+
144+
// Unlike evaluate/trace, the request body wraps the instance in an object
145+
// alongside optional JSON-LD flattening/compaction options.
146+
export const promoteToRdf = (
147+
registryUrl: string,
148+
schemaPath: string,
149+
instance: unknown,
150+
options?: { flatten?: boolean; context?: object }
151+
): Promise<unknown> =>
152+
request(registryUrl, `/self/v1/api/schemas/rdf${schemaPath}`, {
153+
method: "POST",
154+
headers: { "Content-Type": "application/json" },
155+
body: JSON.stringify({
156+
instance,
157+
flatten: options?.flatten,
158+
context: options?.context,
159+
}),
160+
});
161+
113162
export const traceSchema = (
114163
registryUrl: string,
115164
schemaPath: string,

‎src/components/DetailPanel.tsx‎

Lines changed: 129 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,33 @@ const TabButton = ({
3636
};
3737

3838
const DetailPanel = () => {
39-
const { detailTab, dependencies, dependents, healthReport, detailLoading } =
40-
useContext(AppContext);
39+
const {
40+
detailTab,
41+
dependencies,
42+
dependents,
43+
healthReport,
44+
schemaStats,
45+
schemaLocations,
46+
detailLoading,
47+
} = useContext(AppContext);
48+
49+
const statsRows = Object.entries(schemaStats ?? {}).flatMap(
50+
([vocabulary, keywords]) =>
51+
Object.entries(keywords).map(([keyword, count]) => ({
52+
vocabulary,
53+
keyword,
54+
count,
55+
}))
56+
);
57+
statsRows.sort((a, b) => b.count - a.count);
58+
const maxCount = Math.max(1, ...statsRows.map((row) => row.count));
59+
60+
const staticLocations = Object.entries(schemaLocations?.static ?? {});
61+
const dynamicLocations = Object.entries(schemaLocations?.dynamic ?? {});
4162

4263
return (
4364
<div className="flex flex-col">
44-
<div className="flex border-b border-[var(--border)]">
65+
<div className="flex border-b border-[var(--border)] overflow-x-auto">
4566
<TabButton
4667
tab="dependencies"
4768
label="Dependencies"
@@ -57,6 +78,12 @@ const DetailPanel = () => {
5778
label="Lint"
5879
count={healthReport?.errors.length ?? 0}
5980
/>
81+
<TabButton tab="stats" label="Stats" count={statsRows.length} />
82+
<TabButton
83+
tab="locations"
84+
label="Locations"
85+
count={staticLocations.length + dynamicLocations.length}
86+
/>
6087
</div>
6188

6289
<div className="p-2 h-48 min-h-32 max-h-128 resize-y overflow-y-auto">
@@ -153,6 +180,105 @@ const DetailPanel = () => {
153180
))}
154181
</div>
155182
)}
183+
184+
{!detailLoading && detailTab === "stats" && (
185+
<div className="flex flex-col gap-1.5">
186+
{statsRows.length === 0 && (
187+
<p className="text-xs text-[var(--text-secondary)] opacity-60">
188+
No keyword usage recorded
189+
</p>
190+
)}
191+
{statsRows.map((row, i) => (
192+
<div key={i} className="text-xs">
193+
<div className="flex items-center justify-between gap-2">
194+
<span className="font-mono text-[var(--text-nav)] truncate">
195+
{row.keyword}
196+
</span>
197+
<span className="text-[var(--text-secondary)] shrink-0">
198+
{row.count}
199+
</span>
200+
</div>
201+
<div className="h-1 mt-0.5 rounded-full bg-[var(--bg-inset)] overflow-hidden">
202+
<div
203+
className="h-full bg-[var(--info)]"
204+
style={{ width: `${(row.count / maxCount) * 100}%` }}
205+
/>
206+
</div>
207+
<div
208+
className="text-[10px] text-[var(--text-secondary)] opacity-60 truncate mt-0.5"
209+
title={row.vocabulary}
210+
>
211+
{row.vocabulary}
212+
</div>
213+
</div>
214+
))}
215+
</div>
216+
)}
217+
218+
{!detailLoading && detailTab === "locations" && (
219+
<div className="flex flex-col gap-3">
220+
{staticLocations.length === 0 && dynamicLocations.length === 0 && (
221+
<p className="text-xs text-[var(--text-secondary)] opacity-60">
222+
No locations found
223+
</p>
224+
)}
225+
{staticLocations.length > 0 && (
226+
<div>
227+
<div className="text-xs text-[var(--text-secondary)] opacity-70 mb-1">
228+
Static ({staticLocations.length})
229+
</div>
230+
<div className="flex flex-col gap-1">
231+
{staticLocations.map(([uri, entry]) => (
232+
<div
233+
key={uri}
234+
className="text-xs border border-[var(--border)] rounded-[var(--radius-sm)] px-2 py-1 bg-[var(--bg-inset)]/50"
235+
title={uri}
236+
>
237+
<div className="flex items-center justify-between gap-2">
238+
<span className="font-mono text-[var(--text-nav)] truncate">
239+
{entry.pointer || "/"}
240+
</span>
241+
<span className="text-[var(--text-secondary)] shrink-0">
242+
{entry.type}
243+
</span>
244+
</div>
245+
{entry.orphan && (
246+
<div className="text-[10px] text-[var(--warning)] mt-0.5">
247+
orphan (never referenced)
248+
</div>
249+
)}
250+
</div>
251+
))}
252+
</div>
253+
</div>
254+
)}
255+
{dynamicLocations.length > 0 && (
256+
<div>
257+
<div className="text-xs text-[var(--text-secondary)] opacity-70 mb-1">
258+
Dynamic ({dynamicLocations.length})
259+
</div>
260+
<div className="flex flex-col gap-1">
261+
{dynamicLocations.map(([uri, entry]) => (
262+
<div
263+
key={uri}
264+
className="text-xs border border-[var(--border)] rounded-[var(--radius-sm)] px-2 py-1 bg-[var(--bg-inset)]/50"
265+
title={uri}
266+
>
267+
<div className="flex items-center justify-between gap-2">
268+
<span className="font-mono text-[var(--text-nav)] truncate">
269+
{entry.pointer || "/"}
270+
</span>
271+
<span className="text-[var(--text-secondary)] shrink-0">
272+
{entry.type}
273+
</span>
274+
</div>
275+
</div>
276+
))}
277+
</div>
278+
</div>
279+
)}
280+
</div>
281+
)}
156282
</div>
157283
</div>
158284
);

‎src/components/InstanceEditor.tsx‎

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import { useContext } from "react";
1+
import { useContext, useEffect, useState } from "react";
22
import Editor from "@monaco-editor/react";
33
import { AppContext } from "../contexts/AppContext";
44
import MetadataTable from "./MetadataTable";
55
import DetailPanel from "./DetailPanel";
66
import { defineMonacoTheme, ONE_UI_EDITOR_FONT_OPTIONS, ONE_UI_MONACO_THEME } from "../utils/monacoTheme";
77
import IdleState from "./IdleState";
8+
import { getSchemaContent } from "../api/one";
89

910
const InstanceEditor = () => {
1011
const {
12+
registryUrl,
1113
selectedSchemaPath,
1214
schemaMetadata,
1315
metadataLoading,
@@ -21,9 +23,47 @@ const InstanceEditor = () => {
2123
setInstanceText,
2224
runEvaluate,
2325
runTrace,
26+
runRdf,
2427
resultLoading,
2528
} = useContext(AppContext);
2629

30+
// Bundled view is fetched separately from the plain schemaContent used
31+
// elsewhere (e.g. the Trace Debugger's highlighting, which relies on
32+
// /positions being computed against the unbundled text) so toggling it
33+
// here can't desync anything else that reads schemaContent.
34+
const [bundled, setBundled] = useState(false);
35+
const [bundledContent, setBundledContent] = useState<string | null>(null);
36+
const [bundledLoading, setBundledLoading] = useState(false);
37+
const [bundledError, setBundledError] = useState<string | null>(null);
38+
39+
useEffect(() => {
40+
setBundled(false);
41+
setBundledContent(null);
42+
setBundledError(null);
43+
}, [selectedSchemaPath]);
44+
45+
useEffect(() => {
46+
if (!bundled || !selectedSchemaPath) return;
47+
let cancelled = false;
48+
setBundledLoading(true);
49+
setBundledError(null);
50+
getSchemaContent(registryUrl, selectedSchemaPath, { bundle: true })
51+
.then((content) => {
52+
if (!cancelled) setBundledContent(content);
53+
})
54+
.catch((error: unknown) => {
55+
if (!cancelled) {
56+
setBundledError(error instanceof Error ? error.message : String(error));
57+
}
58+
})
59+
.finally(() => {
60+
if (!cancelled) setBundledLoading(false);
61+
});
62+
return () => {
63+
cancelled = true;
64+
};
65+
}, [bundled, registryUrl, selectedSchemaPath]);
66+
2767
if (!selectedSchemaPath) {
2868
return <IdleState />;
2969
}
@@ -66,12 +106,19 @@ const InstanceEditor = () => {
66106
>
67107
Trace
68108
</button>
109+
<button
110+
onClick={runRdf}
111+
disabled={resultLoading}
112+
className="h-8 px-3 text-sm rounded-[var(--radius-sm)] border border-[var(--info)]/50 bg-[var(--info)]/12 text-[var(--info)] hover:bg-[var(--info)]/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
113+
>
114+
RDF
115+
</button>
69116
</div>
70117
</div>
71118

72119
<MetadataTable />
73120

74-
<div className="flex border-b border-[var(--border)]">
121+
<div className="flex items-center border-b border-[var(--border)]">
75122
<button
76123
onClick={() => setActiveTab("schema")}
77124
className={`px-3 py-1.5 text-xs border-r border-[var(--border)] ${
@@ -92,10 +139,30 @@ const InstanceEditor = () => {
92139
>
93140
Instance
94141
</button>
142+
{activeTab === "schema" && (
143+
<label
144+
title="Show the schema with $ref keywords inlined via JSON Schema Bundling"
145+
className="ml-auto mr-2 flex items-center gap-1.5 text-xs text-[var(--text-secondary)] cursor-pointer select-none"
146+
>
147+
<input
148+
type="checkbox"
149+
checked={bundled}
150+
onChange={(e) => setBundled(e.target.checked)}
151+
className="accent-[var(--accent)]"
152+
/>
153+
Bundled
154+
</label>
155+
)}
95156
</div>
96157

97158
<div className="h-72 shrink-0 border-b border-[var(--border)]">
98-
{activeTab === "schema" && schemaContentLoading ? (
159+
{activeTab === "schema" && bundled && bundledLoading ? (
160+
<p className="text-sm text-[var(--text-secondary)] p-3">
161+
Bundling schema…
162+
</p>
163+
) : activeTab === "schema" && bundled && bundledError ? (
164+
<p className="text-sm text-[var(--danger)] p-3">{bundledError}</p>
165+
) : activeTab === "schema" && schemaContentLoading ? (
99166
<p className="text-sm text-[var(--text-secondary)] p-3">
100167
Loading schema…
101168
</p>
@@ -105,11 +172,17 @@ const InstanceEditor = () => {
105172
</p>
106173
) : (
107174
<Editor
108-
key={activeTab}
175+
key={activeTab === "schema" && bundled ? "schema-bundled" : activeTab}
109176
language="json"
110177
theme={ONE_UI_MONACO_THEME}
111178
beforeMount={defineMonacoTheme}
112-
value={activeTab === "schema" ? schemaContent ?? "" : instanceText}
179+
value={
180+
activeTab === "schema"
181+
? bundled
182+
? bundledContent ?? ""
183+
: schemaContent ?? ""
184+
: instanceText
185+
}
113186
onChange={
114187
activeTab === "instance"
115188
? (value) => setInstanceText(value ?? "")

0 commit comments

Comments
 (0)