Embed EpiphanyGraph in Hugin
This commit is contained in:
parent
8631f0ab62
commit
e5c4c9dd50
9 changed files with 661 additions and 242 deletions
|
|
@ -57,8 +57,9 @@ npm run dist:inspector
|
|||
```
|
||||
|
||||
Hugin is read-only. Drop a `.cc`, `.msgpack`, or `.mpack` file onto the window to
|
||||
inspect the snapshot header, schema catalog, records, and decoded MessagePack
|
||||
payload previews without registering application schemas.
|
||||
inspect the snapshot header, schema catalog, records, decoded MessagePack
|
||||
payload previews, and an EpiphanyGraph view of the file's structured data tree
|
||||
without registering application schemas.
|
||||
|
||||
## Example
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,6 @@
|
|||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,239 +0,0 @@
|
|||
import { inspectCultCacheBytes, type CultCacheInspection, type InspectedCatalogEntry, type InspectedRecord } from "../../src/cult-cache-inspector";
|
||||
import "./styles.css";
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app");
|
||||
if (!app) {
|
||||
throw new Error("Missing inspector app root.");
|
||||
}
|
||||
const appRoot = app;
|
||||
|
||||
type Selection = {
|
||||
record: number;
|
||||
catalog: number;
|
||||
};
|
||||
|
||||
let inspection: CultCacheInspection | undefined;
|
||||
let selection: Selection = { record: 0, catalog: 0 };
|
||||
let errorMessage = "";
|
||||
let dragging = false;
|
||||
|
||||
appRoot.addEventListener("dragover", (event) => {
|
||||
event.preventDefault();
|
||||
dragging = true;
|
||||
render();
|
||||
});
|
||||
|
||||
appRoot.addEventListener("dragleave", (event) => {
|
||||
if (event.target === appRoot) {
|
||||
dragging = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
appRoot.addEventListener("drop", (event) => {
|
||||
event.preventDefault();
|
||||
dragging = false;
|
||||
const file = event.dataTransfer?.files.item(0);
|
||||
if (file) {
|
||||
void inspectFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
render();
|
||||
|
||||
function render(): void {
|
||||
appRoot.innerHTML = `
|
||||
<main class="workspace ${dragging ? "is-dragging" : ""}">
|
||||
<section class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="/hugin-64.png" alt="" width="64" height="64" />
|
||||
<div>
|
||||
<h1>Hugin</h1>
|
||||
<p>CultCache state inspection for <code>.cc</code> files.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="dropzone">
|
||||
<input id="file-input" type="file" accept=".cc,.msgpack,.mpack,application/octet-stream" />
|
||||
<span class="drop-title">Drop CultCache file</span>
|
||||
<span class="drop-copy">or choose one from disk</span>
|
||||
</label>
|
||||
${errorMessage ? `<div class="error">${escapeHtml(errorMessage)}</div>` : ""}
|
||||
${inspection ? renderSummary(inspection) : renderEmptyState()}
|
||||
</section>
|
||||
<section class="content">
|
||||
${inspection ? renderInspection(inspection, selection) : renderNoFile()}
|
||||
</section>
|
||||
</main>
|
||||
`;
|
||||
|
||||
appRoot.querySelector<HTMLInputElement>("#file-input")?.addEventListener("change", (event) => {
|
||||
const file = (event.currentTarget as HTMLInputElement).files?.item(0);
|
||||
if (file) {
|
||||
void inspectFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
appRoot.querySelectorAll<HTMLButtonElement>("[data-record]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
selection = { ...selection, record: Number(button.dataset.record) };
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
appRoot.querySelectorAll<HTMLButtonElement>("[data-catalog]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
selection = { ...selection, catalog: Number(button.dataset.catalog) };
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function inspectFile(file: File): Promise<void> {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
inspection = inspectCultCacheBytes(file.name, bytes, file.size);
|
||||
selection = { record: 0, catalog: 0 };
|
||||
errorMessage = "";
|
||||
} catch (error) {
|
||||
inspection = undefined;
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
function renderSummary(model: CultCacheInspection): string {
|
||||
return `
|
||||
<div class="summary">
|
||||
${metric("Format", model.format)}
|
||||
${metric("Records", model.records.length.toString())}
|
||||
${metric("Schemas", model.catalog.length.toString())}
|
||||
${metric("Bytes", model.fileSizeBytes.toString())}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderEmptyState(): string {
|
||||
return `
|
||||
<div class="summary">
|
||||
${metric("Format", ".cc")}
|
||||
${metric("Mode", "Read-only")}
|
||||
${metric("Payload", "MessagePack")}
|
||||
${metric("Runtime", "Vite")}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNoFile(): string {
|
||||
return `
|
||||
<div class="hero">
|
||||
<img src="/hugin.png" alt="" />
|
||||
<h2>No state loaded</h2>
|
||||
<p>Drag a CultCache <code>.cc</code> store into the window to inspect the snapshot header, schema catalog, records, and decoded MessagePack payloads.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderInspection(model: CultCacheInspection, current: Selection): string {
|
||||
const record = model.records[current.record];
|
||||
const catalogEntry = model.catalog[current.catalog];
|
||||
return `
|
||||
<div class="columns">
|
||||
<section class="panel">
|
||||
<header><h2>Records</h2><span>${model.records.length}</span></header>
|
||||
<div class="list">
|
||||
${model.records.map((entry, index) => renderRecordButton(entry, index, index === current.record)).join("") || `<div class="empty">No records</div>`}
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel detail">
|
||||
<header><h2>Record Detail</h2><span>${escapeHtml(model.filePath)}</span></header>
|
||||
${record ? renderRecordDetail(record) : `<div class="empty">No record selected</div>`}
|
||||
</section>
|
||||
</div>
|
||||
<div class="columns bottom">
|
||||
<section class="panel">
|
||||
<header><h2>Schema Catalog</h2><span>${model.catalog.length}</span></header>
|
||||
<div class="list">
|
||||
${model.catalog.map((entry, index) => renderCatalogButton(entry, index, index === current.catalog)).join("") || `<div class="empty">No schema catalog</div>`}
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel detail">
|
||||
<header><h2>Catalog Entry</h2><span>${catalogEntry ? escapeHtml(catalogEntry.schemaVersion) : ""}</span></header>
|
||||
${catalogEntry ? renderCatalogDetail(catalogEntry) : `<div class="empty">No catalog entry selected</div>`}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecordButton(record: InspectedRecord, index: number, selected: boolean): string {
|
||||
return `
|
||||
<button class="row" type="button" data-record="${index}" aria-selected="${selected}">
|
||||
<strong>${escapeHtml(record.key)}</strong>
|
||||
<span>${escapeHtml(record.schemaName)} · ${record.payloadBytes} bytes</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCatalogButton(entry: InspectedCatalogEntry, index: number, selected: boolean): string {
|
||||
return `
|
||||
<button class="row" type="button" data-catalog="${index}" aria-selected="${selected}">
|
||||
<strong>${escapeHtml(entry.schemaName)}</strong>
|
||||
<span>${escapeHtml(entry.schemaId)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRecordDetail(record: InspectedRecord): string {
|
||||
return `
|
||||
${facts([
|
||||
["Key", record.key],
|
||||
["Schema", record.schemaName],
|
||||
["Schema ID", record.schemaId],
|
||||
["Stored", record.storedAt],
|
||||
["Payload", `${record.payloadBytes} bytes`],
|
||||
])}
|
||||
${record.payloadDecodeError ? `<div class="error">${escapeHtml(record.payloadDecodeError)}</div>` : ""}
|
||||
<pre>${escapeHtml(JSON.stringify(record.payloadPreview, null, 2))}</pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderCatalogDetail(entry: InspectedCatalogEntry): string {
|
||||
return `
|
||||
${facts([
|
||||
["Schema", entry.schemaName],
|
||||
["Version", entry.schemaVersion],
|
||||
["Schema ID", entry.schemaId],
|
||||
["Hash", entry.contentHash],
|
||||
["Compatible", entry.compatibleSchemaIds.join(", ")],
|
||||
])}
|
||||
<pre>${escapeHtml(JSON.stringify({
|
||||
members: entry.members,
|
||||
canonicalSchemaJson: parseJson(entry.canonicalSchemaJson),
|
||||
}, null, 2))}</pre>
|
||||
`;
|
||||
}
|
||||
|
||||
function facts(rows: Array<[string, string]>): string {
|
||||
return `<dl>${rows.map(([name, value]) => `<dt>${escapeHtml(name)}</dt><dd>${escapeHtml(value)}</dd>`).join("")}</dl>`;
|
||||
}
|
||||
|
||||
function metric(label: string, value: string): string {
|
||||
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
519
inspector/src/main.tsx
Normal file
519
inspector/src/main.tsx
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
import { EpiphanyGraphViewer, type EpiphanyGraphEdge, type EpiphanyGraphNode, type EpiphanyGraphsState } from "@epiphanygraph/epiphany-graph-viewer";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { useMemo, useState, type DragEvent } from "react";
|
||||
|
||||
import { inspectCultCacheBytes, type CultCacheInspection, type InspectedCatalogEntry, type InspectedRecord } from "../../src/cult-cache-inspector";
|
||||
import "./styles.css";
|
||||
|
||||
type Selection = {
|
||||
record: number;
|
||||
catalog: number;
|
||||
};
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app");
|
||||
if (!app) {
|
||||
throw new Error("Missing inspector app root.");
|
||||
}
|
||||
|
||||
createRoot(app).render(<HuginApp />);
|
||||
|
||||
function HuginApp() {
|
||||
const [inspection, setInspection] = useState<CultCacheInspection | undefined>();
|
||||
const [selection, setSelection] = useState<Selection>({ record: 0, catalog: 0 });
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const graphState = useMemo(
|
||||
() => inspection ? buildGraphState(inspection) : emptyGraphState(),
|
||||
[inspection],
|
||||
);
|
||||
|
||||
async function inspectFile(file: File): Promise<void> {
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
setInspection(inspectCultCacheBytes(file.name, bytes, file.size));
|
||||
setSelection({ record: 0, catalog: 0 });
|
||||
setErrorMessage("");
|
||||
} catch (error) {
|
||||
setInspection(undefined);
|
||||
setErrorMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent<HTMLElement>): void {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
const file = event.dataTransfer.files.item(0);
|
||||
if (file) {
|
||||
void inspectFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`workspace ${dragging ? "is-dragging" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setDragging(false);
|
||||
}
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<section className="sidebar">
|
||||
<div className="brand">
|
||||
<img src="/hugin-64.png" alt="" width="64" height="64" />
|
||||
<div>
|
||||
<h1>Hugin</h1>
|
||||
<p>CultCache state inspection for <code>.cc</code> files.</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="dropzone">
|
||||
<input
|
||||
type="file"
|
||||
accept=".cc,.msgpack,.mpack,application/octet-stream"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.item(0);
|
||||
if (file) {
|
||||
void inspectFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="drop-title">Drop CultCache file</span>
|
||||
<span className="drop-copy">or choose one from disk</span>
|
||||
</label>
|
||||
{errorMessage ? <div className="error">{errorMessage}</div> : null}
|
||||
{inspection ? <Summary inspection={inspection} /> : <EmptySummary />}
|
||||
</section>
|
||||
<section className="content">
|
||||
{inspection
|
||||
? <InspectionView inspection={inspection} graphState={graphState} selection={selection} setSelection={setSelection} />
|
||||
: <NoFile />}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ inspection }: { inspection: CultCacheInspection }) {
|
||||
return (
|
||||
<div className="summary">
|
||||
<Metric label="Format" value={inspection.format} />
|
||||
<Metric label="Records" value={inspection.records.length.toString()} />
|
||||
<Metric label="Schemas" value={inspection.catalog.length.toString()} />
|
||||
<Metric label="Bytes" value={inspection.fileSizeBytes.toString()} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySummary() {
|
||||
return (
|
||||
<div className="summary">
|
||||
<Metric label="Format" value=".cc" />
|
||||
<Metric label="Mode" value="Read-only" />
|
||||
<Metric label="Payload" value="MessagePack" />
|
||||
<Metric label="Runtime" value="Vite" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="metric">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoFile() {
|
||||
return (
|
||||
<div className="hero">
|
||||
<img src="/hugin.png" alt="" />
|
||||
<h2>No state loaded</h2>
|
||||
<p>Drag a CultCache <code>.cc</code> store into the window to inspect the snapshot header, schema catalog, records, and decoded MessagePack payloads.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InspectionView({
|
||||
inspection,
|
||||
graphState,
|
||||
selection,
|
||||
setSelection,
|
||||
}: {
|
||||
inspection: CultCacheInspection;
|
||||
graphState: EpiphanyGraphsState;
|
||||
selection: Selection;
|
||||
setSelection: (selection: Selection) => void;
|
||||
}) {
|
||||
const record = inspection.records[selection.record];
|
||||
const catalogEntry = inspection.catalog[selection.catalog];
|
||||
|
||||
return (
|
||||
<div className="inspector-stack">
|
||||
<section className="graph-panel">
|
||||
<EpiphanyGraphViewer
|
||||
state={graphState}
|
||||
initialGraph="dataflow"
|
||||
title="CultCache Structure"
|
||||
style={{ minHeight: 620 }}
|
||||
/>
|
||||
</section>
|
||||
<div className="columns">
|
||||
<section className="panel">
|
||||
<header><h2>Records</h2><span>{inspection.records.length}</span></header>
|
||||
<div className="list">
|
||||
{inspection.records.length
|
||||
? inspection.records.map((entry, index) => (
|
||||
<RecordButton
|
||||
key={`${entry.schemaId}:${entry.key}`}
|
||||
record={entry}
|
||||
index={index}
|
||||
selected={index === selection.record}
|
||||
onSelect={() => setSelection({ ...selection, record: index })}
|
||||
/>
|
||||
))
|
||||
: <div className="empty">No records</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel detail">
|
||||
<header><h2>Record Detail</h2><span>{inspection.filePath}</span></header>
|
||||
{record ? <RecordDetail record={record} /> : <div className="empty">No record selected</div>}
|
||||
</section>
|
||||
</div>
|
||||
<div className="columns bottom">
|
||||
<section className="panel">
|
||||
<header><h2>Schema Catalog</h2><span>{inspection.catalog.length}</span></header>
|
||||
<div className="list">
|
||||
{inspection.catalog.length
|
||||
? inspection.catalog.map((entry, index) => (
|
||||
<CatalogButton
|
||||
key={entry.schemaId}
|
||||
entry={entry}
|
||||
index={index}
|
||||
selected={index === selection.catalog}
|
||||
onSelect={() => setSelection({ ...selection, catalog: index })}
|
||||
/>
|
||||
))
|
||||
: <div className="empty">No schema catalog</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="panel detail">
|
||||
<header><h2>Catalog Entry</h2><span>{catalogEntry?.schemaVersion ?? ""}</span></header>
|
||||
{catalogEntry ? <CatalogDetail entry={catalogEntry} /> : <div className="empty">No catalog entry selected</div>}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordButton({
|
||||
record,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
record: InspectedRecord;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="row" type="button" data-record={index} aria-selected={selected} onClick={onSelect}>
|
||||
<strong>{record.key}</strong>
|
||||
<span>{record.schemaName} · {record.payloadBytes} bytes</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogButton({
|
||||
entry,
|
||||
index,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
entry: InspectedCatalogEntry;
|
||||
index: number;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button className="row" type="button" data-catalog={index} aria-selected={selected} onClick={onSelect}>
|
||||
<strong>{entry.schemaName}</strong>
|
||||
<span>{entry.schemaId}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordDetail({ record }: { record: InspectedRecord }) {
|
||||
return (
|
||||
<>
|
||||
<Facts rows={[
|
||||
["Key", record.key],
|
||||
["Schema", record.schemaName],
|
||||
["Schema ID", record.schemaId],
|
||||
["Stored", record.storedAt],
|
||||
["Payload", `${record.payloadBytes} bytes`],
|
||||
]} />
|
||||
{record.payloadDecodeError ? <div className="error">{record.payloadDecodeError}</div> : null}
|
||||
<pre>{JSON.stringify(record.payloadPreview, null, 2)}</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogDetail({ entry }: { entry: InspectedCatalogEntry }) {
|
||||
return (
|
||||
<>
|
||||
<Facts rows={[
|
||||
["Schema", entry.schemaName],
|
||||
["Version", entry.schemaVersion],
|
||||
["Schema ID", entry.schemaId],
|
||||
["Hash", entry.contentHash],
|
||||
["Compatible", entry.compatibleSchemaIds.join(", ")],
|
||||
]} />
|
||||
<pre>{JSON.stringify({
|
||||
members: entry.members,
|
||||
canonicalSchemaJson: parseJson(entry.canonicalSchemaJson),
|
||||
}, null, 2)}</pre>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Facts({ rows }: { rows: Array<[string, string]> }) {
|
||||
return (
|
||||
<dl>
|
||||
{rows.map(([name, value]) => (
|
||||
<div className="fact-row" key={name}>
|
||||
<dt>{name}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function buildGraphState(inspection: CultCacheInspection): EpiphanyGraphsState {
|
||||
const dataflowNodes: EpiphanyGraphNode[] = [];
|
||||
const dataflowEdges: EpiphanyGraphEdge[] = [];
|
||||
const architectureNodes: EpiphanyGraphNode[] = [
|
||||
{
|
||||
id: "store",
|
||||
title: ".cc Store",
|
||||
purpose: `${inspection.format} file with ${inspection.records.length} record(s) and ${inspection.catalog.length} schema catalog entr${inspection.catalog.length === 1 ? "y" : "ies"}.`,
|
||||
mechanism: inspection.filePath,
|
||||
status: "source",
|
||||
},
|
||||
{
|
||||
id: "catalog",
|
||||
title: "Schema Catalog",
|
||||
purpose: "Maps persisted schema ids to schema names, versions, compatible ids, and slot metadata.",
|
||||
status: `${inspection.catalog.length} schemas`,
|
||||
},
|
||||
{
|
||||
id: "records",
|
||||
title: "Records",
|
||||
purpose: "Persisted document entries carrying key, schema id, timestamp, and raw MessagePack payload bytes.",
|
||||
status: `${inspection.records.length} records`,
|
||||
},
|
||||
];
|
||||
const architectureEdges: EpiphanyGraphEdge[] = [
|
||||
{
|
||||
id: "store-catalog",
|
||||
source_id: "store",
|
||||
target_id: "catalog",
|
||||
kind: "contains",
|
||||
label: "schema catalog",
|
||||
},
|
||||
{
|
||||
id: "store-records",
|
||||
source_id: "store",
|
||||
target_id: "records",
|
||||
kind: "contains",
|
||||
label: "record set",
|
||||
},
|
||||
];
|
||||
|
||||
dataflowNodes.push({
|
||||
id: "store",
|
||||
title: ".cc Store",
|
||||
purpose: `Root ${inspection.format} snapshot.`,
|
||||
mechanism: inspection.filePath,
|
||||
status: `${inspection.fileSizeBytes} bytes`,
|
||||
});
|
||||
|
||||
for (const entry of inspection.catalog) {
|
||||
const schemaNodeId = nodeId("schema", entry.schemaId);
|
||||
dataflowNodes.push({
|
||||
id: schemaNodeId,
|
||||
title: entry.schemaName,
|
||||
purpose: `Schema ${entry.schemaVersion} with ${entry.members.length} declared slot(s).`,
|
||||
mechanism: entry.contentHash,
|
||||
status: "schema",
|
||||
});
|
||||
dataflowEdges.push({
|
||||
id: edgeId("store", schemaNodeId, "catalogs"),
|
||||
source_id: "store",
|
||||
target_id: schemaNodeId,
|
||||
kind: "catalogs",
|
||||
label: "catalog",
|
||||
});
|
||||
}
|
||||
|
||||
inspection.records.forEach((record, recordIndex) => {
|
||||
const recordNodeId = nodeId("record", `${record.schemaId}:${record.key}:${recordIndex}`);
|
||||
const schemaNodeId = nodeId("schema", record.schemaId);
|
||||
dataflowNodes.push({
|
||||
id: recordNodeId,
|
||||
title: record.key,
|
||||
purpose: `Document record for ${record.schemaName}.`,
|
||||
mechanism: `${record.payloadBytes} payload bytes stored at ${record.storedAt}`,
|
||||
status: "record",
|
||||
});
|
||||
dataflowEdges.push({
|
||||
id: edgeId("store", recordNodeId, "contains"),
|
||||
source_id: "store",
|
||||
target_id: recordNodeId,
|
||||
kind: "contains",
|
||||
label: "record",
|
||||
});
|
||||
if (dataflowNodes.some((node) => node.id === schemaNodeId)) {
|
||||
dataflowEdges.push({
|
||||
id: edgeId(recordNodeId, schemaNodeId, "uses-schema"),
|
||||
source_id: recordNodeId,
|
||||
target_id: schemaNodeId,
|
||||
kind: "uses-schema",
|
||||
label: "schema",
|
||||
});
|
||||
}
|
||||
|
||||
appendValueTree({
|
||||
nodes: dataflowNodes,
|
||||
edges: dataflowEdges,
|
||||
parentId: recordNodeId,
|
||||
value: record.payloadPreview,
|
||||
path: "payload",
|
||||
depth: 0,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
architecture: { nodes: architectureNodes, edges: architectureEdges },
|
||||
dataflow: { nodes: dataflowNodes, edges: dataflowEdges },
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
|
||||
function appendValueTree({
|
||||
nodes,
|
||||
edges,
|
||||
parentId,
|
||||
value,
|
||||
path,
|
||||
depth,
|
||||
}: {
|
||||
nodes: EpiphanyGraphNode[];
|
||||
edges: EpiphanyGraphEdge[];
|
||||
parentId: string;
|
||||
value: unknown;
|
||||
path: string;
|
||||
depth: number;
|
||||
}): void {
|
||||
if (depth > 5) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item, index) => {
|
||||
const childPath = `${path}[${index}]`;
|
||||
const childId = nodeId("value", `${parentId}:${childPath}`);
|
||||
nodes.push(valueNode(childId, `[${index}]`, item, childPath));
|
||||
edges.push(valueEdge(parentId, childId, "slot", index.toString()));
|
||||
appendValueTree({ nodes, edges, parentId: childId, value: item, path: childPath, depth: depth + 1 });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPlainObject(value)) {
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
const childPath = `${path}.${key}`;
|
||||
const childId = nodeId("value", `${parentId}:${childPath}`);
|
||||
nodes.push(valueNode(childId, key, item, childPath));
|
||||
edges.push(valueEdge(parentId, childId, "field", key));
|
||||
appendValueTree({ nodes, edges, parentId: childId, value: item, path: childPath, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function valueNode(id: string, title: string, value: unknown, path: string): EpiphanyGraphNode {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
purpose: valuePurpose(value),
|
||||
mechanism: path,
|
||||
status: valueStatus(value),
|
||||
};
|
||||
}
|
||||
|
||||
function valueEdge(sourceId: string, targetId: string, kind: string, label: string): EpiphanyGraphEdge {
|
||||
return {
|
||||
id: edgeId(sourceId, targetId, kind),
|
||||
source_id: sourceId,
|
||||
target_id: targetId,
|
||||
kind,
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
function valuePurpose(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `Array with ${value.length} item(s).`;
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return `Object with ${Object.keys(value).length} field(s).`;
|
||||
}
|
||||
if (value === null) {
|
||||
return "Null payload value.";
|
||||
}
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
}
|
||||
|
||||
function valueStatus(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return "array";
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
return "object";
|
||||
}
|
||||
return typeof value;
|
||||
}
|
||||
|
||||
function emptyGraphState(): EpiphanyGraphsState {
|
||||
return {
|
||||
architecture: { nodes: [], edges: [] },
|
||||
dataflow: { nodes: [], edges: [] },
|
||||
links: [],
|
||||
};
|
||||
}
|
||||
|
||||
function parseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeId(prefix: string, value: string): string {
|
||||
return `${prefix}:${value.replace(/[^a-zA-Z0-9:_-]/g, "_")}`;
|
||||
}
|
||||
|
||||
function edgeId(sourceId: string, targetId: string, kind: string): string {
|
||||
return `${sourceId}->${targetId}:${kind}`;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
|
@ -150,6 +150,20 @@ code {
|
|||
overflow: auto;
|
||||
}
|
||||
|
||||
.inspector-stack {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.graph-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: rgba(5, 9, 13, 0.94);
|
||||
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
|
|
|
|||
115
package-lock.json
generated
115
package-lock.json
generated
|
|
@ -13,16 +13,44 @@
|
|||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@epiphanygraph/epiphany-graph-viewer": "file:../EpiphanyGraph/web/epiphany-graph-viewer",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^42.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.0.13",
|
||||
"wait-on": "^9.0.10"
|
||||
}
|
||||
},
|
||||
"../EpiphanyGraph/web/epiphany-graph-viewer": {
|
||||
"name": "@epiphanygraph/epiphany-graph-viewer",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"elkjs": "^0.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
|
||||
|
|
@ -376,6 +404,10 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@epiphanygraph/epiphany-graph-viewer": {
|
||||
"resolved": "../EpiphanyGraph/web/epiphany-graph-viewer",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@hapi/address": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
|
||||
|
|
@ -941,6 +973,26 @@
|
|||
"xmlbuilder": ">=11.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/responselike": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz",
|
||||
|
|
@ -970,6 +1022,32 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
|
||||
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rolldown/plugin-babel": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
|
|
@ -1784,6 +1862,13 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -3937,6 +4022,29 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.6"
|
||||
}
|
||||
},
|
||||
"node_modules/read-binary-file-arch": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||
|
|
@ -4113,6 +4221,13 @@
|
|||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
|
||||
|
|
|
|||
|
|
@ -31,11 +31,17 @@
|
|||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@epiphanygraph/epiphany-graph-viewer": "file:../EpiphanyGraph/web/epiphany-graph-viewer",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^42.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"typescript": "^5.9.0",
|
||||
"vite": "^8.0.13",
|
||||
"wait-on": "^9.0.10"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"rootDir": "."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
root: "inspector",
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../dist-inspector",
|
||||
emptyOutDir: true
|
||||
|
|
|
|||
Loading…
Reference in a new issue