Make Huginn graph first

This commit is contained in:
Meta Vens 2026-05-18 12:45:41 +01:00
commit 6098b3ffd1
2 changed files with 550 additions and 79 deletions

View file

@ -89,7 +89,11 @@ function HuginnApp() {
const bytes = new Uint8Array(await file.arrayBuffer());
setInspection(inspectCultCacheBytes(file.name, bytes, file.size));
setSelection({ record: 0, catalog: 0 });
setGraphSelection(null);
setGraphSelection({
kind: "node",
graphKey: "dataflow",
nodeId: "store",
});
setErrorMessage("");
} catch (error) {
setInspection(undefined);
@ -120,49 +124,81 @@ function HuginnApp() {
}}
onDrop={handleDrop}
>
<section className="sidebar">
<div className="brand">
<img src={assetPath("hugin-64.png")} alt="" width="64" height="64" />
<div>
<h1>Huginn</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);
}
}}
{inspection
? (
<InspectionView
errorMessage={errorMessage}
inspection={inspection}
graphProjection={graphProjection}
graphSelection={graphSelection}
setGraphSelection={setGraphSelection}
selection={selection}
setSelection={setSelection}
onInspectFile={inspectFile}
/>
<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}
graphProjection={graphProjection}
graphSelection={graphSelection}
setGraphSelection={setGraphSelection}
selection={selection}
setSelection={setSelection}
/>
)
: <NoFile />}
</section>
)
: (
<EmptyWorkspace
errorMessage={errorMessage}
onInspectFile={inspectFile}
/>
)}
</main>
);
}
function EmptyWorkspace({
errorMessage,
onInspectFile,
}: {
errorMessage: string;
onInspectFile: (file: File) => Promise<void>;
}) {
return (
<section className="empty-workspace">
<HuginnFieldCanvas imageUrl={assetPath("hugin.png")} />
<section className="floating-panel brand-panel is-primary">
<BrandBlock />
<FilePicker onInspectFile={onInspectFile} />
{errorMessage ? <div className="error">{errorMessage}</div> : null}
<EmptySummary />
</section>
<NoFile />
</section>
);
}
function BrandBlock() {
return (
<div className="brand">
<img src={assetPath("hugin-64.png")} alt="" width="64" height="64" />
<div>
<h1>Huginn</h1>
<p>CultCache state inspection for <code>.cc</code> files.</p>
</div>
</div>
);
}
function FilePicker({ onInspectFile }: { onInspectFile: (file: File) => Promise<void> }) {
return (
<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 onInspectFile(file);
}
}}
/>
<span className="drop-title">Drop CultCache file</span>
<span className="drop-copy">or choose one from disk</span>
</label>
);
}
function Summary({ inspection }: { inspection: CultCacheInspection }) {
return (
<div className="summary">
@ -205,22 +241,42 @@ function NoFile() {
}
function InspectionView({
errorMessage,
inspection,
graphProjection,
graphSelection,
setGraphSelection,
selection,
setSelection,
onInspectFile,
}: {
errorMessage: string;
inspection: CultCacheInspection;
graphProjection: GraphProjection;
graphSelection: ViewerSelection | null;
setGraphSelection: (selection: ViewerSelection | null) => void;
selection: Selection;
setSelection: (selection: Selection) => void;
onInspectFile: (file: File) => Promise<void>;
}) {
const record = inspection.records[selection.record];
const catalogEntry = inspection.catalog[selection.catalog];
const expandedNode = graphSelection?.kind === "node"
? {
graphKey: graphSelection.graphKey,
nodeId: graphSelection.nodeId,
className: "huginn-expanded-node",
ariaLabel: "Selected CultCache node",
content: (
<ExpandedNodePanel
catalogEntry={catalogEntry}
inspection={inspection}
record={record}
selection={selection}
/>
),
}
: undefined;
const selectRaw = (next: RawSelection) => {
if (next.kind === "record") {
setSelection({ ...selection, record: next.index });
@ -258,9 +314,10 @@ function InspectionView({
};
return (
<div className="inspector-stack">
<div className="inspector-stage">
<section className="graph-panel">
<EpiphanyGraphViewer
className="huginn-graph-shell"
state={graphProjection.state}
initialGraph="dataflow"
selection={graphSelection}
@ -270,8 +327,11 @@ function InspectionView({
viewportBackground="#03070a"
overlayPanels
showSidebar={false}
focusSelection
selectionFocusMode="preview"
expandedNode={expandedNode}
graphLabels={{ architecture: "File", dataflow: "Payload" }}
style={{ minHeight: 620 }}
style={{ minHeight: "100vh" }}
/>
{graphProjection.truncatedValueNodes > 0 ? (
<div className="graph-warning">
@ -279,14 +339,20 @@ function InspectionView({
</div>
) : null}
</section>
<div className="columns">
<section className="panel">
<header><h2>Records</h2><span>{inspection.records.length}</span></header>
<div className="overlay-layer">
<section className="floating-panel brand-panel">
<BrandBlock />
<FilePicker onInspectFile={onInspectFile} />
{errorMessage ? <div className="error">{errorMessage}</div> : null}
<Summary inspection={inspection} />
</section>
<section className="floating-panel data-panel records-panel">
<PanelHeader title="Records" count={inspection.records.length.toString()} />
<div className="list">
{inspection.records.length
? inspection.records.map((entry, index) => (
<RecordButton
key={`${entry.schemaId}:${entry.key}`}
key={`${entry.schemaId}:${entry.key}:${index}`}
record={entry}
index={index}
selected={index === selection.record}
@ -296,14 +362,8 @@ function InspectionView({
: <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>
<section className="floating-panel data-panel catalog-panel">
<PanelHeader title="Schema Catalog" count={inspection.catalog.length.toString()} />
<div className="list">
{inspection.catalog.length
? inspection.catalog.map((entry, index) => (
@ -318,15 +378,82 @@ function InspectionView({
: <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 className="floating-panel detail-panel">
<PanelHeader title="Selected Raw View" count={record?.key ?? catalogEntry?.schemaVersion ?? ""} />
<div className="detail-tabs">
<section>
<h3>Record</h3>
{record ? <RecordDetail record={record} /> : <div className="empty">No record selected</div>}
</section>
<section>
<h3>Schema</h3>
{catalogEntry ? <CatalogDetail entry={catalogEntry} /> : <div className="empty">No catalog entry selected</div>}
</section>
</div>
</section>
</div>
</div>
);
}
function PanelHeader({ title, count }: { title: string; count: string }) {
return (
<header>
<h2>{title}</h2>
<span>{count}</span>
</header>
);
}
function ExpandedNodePanel({
catalogEntry,
inspection,
record,
selection,
}: {
catalogEntry: InspectedCatalogEntry | undefined;
inspection: CultCacheInspection;
record: InspectedRecord | undefined;
selection: Selection;
}) {
return (
<article className="expanded-content">
<div className="expanded-kicker">CultCache Node</div>
<header className="expanded-header">
<h1>{record?.key ?? catalogEntry?.schemaName ?? inspection.filePath}</h1>
<div className="expanded-chips">
<span>{inspection.format}</span>
<span>{inspection.records.length} records</span>
<span>{inspection.catalog.length} schemas</span>
</div>
</header>
<section className="expanded-summary">
{record ? (
<>
<p>{record.schemaName} record stored at {record.storedAt} with {record.payloadBytes} payload bytes.</p>
{record.payloadDecodeError ? <div className="error">{record.payloadDecodeError}</div> : null}
</>
) : (
<p>{inspection.filePath} contains a schema catalog and persisted MessagePack record set.</p>
)}
</section>
<section className="expanded-article">
<div>
<h2>Record Payload</h2>
{record ? <RecordDetail record={record} /> : <div className="empty">Select a record node to inspect payload data.</div>}
</div>
<div>
<h2>Schema Entry</h2>
{catalogEntry ? <CatalogDetail entry={catalogEntry} /> : <div className="empty">Select a schema node to inspect catalog data.</div>}
</div>
</section>
<footer className="expanded-footer">
Raw selection: record {selection.record + 1}, schema {selection.catalog + 1}
</footer>
</article>
);
}
function RecordButton({
record,
index,
@ -341,7 +468,7 @@ function RecordButton({
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>
<span>{record.schemaName} - {record.payloadBytes} bytes</span>
</button>
);
}

View file

@ -27,6 +27,7 @@ body {
margin: 0;
min-width: 760px;
min-height: 100vh;
overflow: hidden;
}
button,
@ -40,8 +41,8 @@ code {
}
.workspace {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
position: relative;
display: block;
min-height: 100vh;
background:
radial-gradient(circle at 78% 18%, rgba(39, 202, 239, 0.12), transparent 30%),
@ -150,22 +151,45 @@ code {
overflow: auto;
}
.inspector-stack {
.empty-workspace,
.inspector-stage {
position: fixed;
inset: 0;
overflow: hidden;
background: #03070a;
}
.empty-workspace {
display: grid;
gap: 14px;
place-items: center;
padding: clamp(18px, 4vw, 56px);
}
.empty-workspace .huginn-field-canvas {
opacity: 0.46;
}
.overlay-layer {
position: fixed;
inset: 0;
z-index: 12;
pointer-events: none;
}
.graph-panel {
position: fixed;
inset: 0;
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
border: 0;
border-radius: 0;
overflow: hidden;
background: #03070a;
box-shadow: 0 22px 46px rgba(0, 0, 0, 0.28);
}
.graph-panel > section {
min-height: 620px !important;
width: 100vw !important;
height: 100vh !important;
min-height: 100vh !important;
}
.huginn-field-canvas {
@ -185,8 +209,98 @@ code {
box-shadow: 0 0 28px rgba(51, 230, 243, 0.08), 0 18px 48px rgba(0, 0, 0, 0.28) !important;
}
.huginn-graph-shell {
width: 100vw;
height: 100vh;
}
.huginn-graph-shell .epiphany-graph-overlay-panel,
.floating-panel {
opacity: 0.28;
transition:
opacity 160ms ease,
transform 160ms ease,
box-shadow 160ms ease,
background-color 160ms ease;
}
.huginn-graph-shell .epiphany-graph-overlay-panel:hover,
.huginn-graph-shell .epiphany-graph-overlay-panel:focus-within,
.floating-panel:hover,
.floating-panel:focus-within,
.floating-panel.is-primary {
opacity: 1;
transform: translateY(-2px);
}
.floating-panel {
position: absolute;
pointer-events: auto;
container-type: inline-size;
border: 1px solid rgba(51, 230, 243, 0.22);
border-radius: 24px;
background: rgba(4, 11, 16, 0.72);
padding: clamp(10px, 1.4vw, 18px);
box-shadow: 0 20px 54px rgba(0, 0, 0, 0.34), 0 0 28px rgba(51, 230, 243, 0.08);
backdrop-filter: blur(16px);
}
.brand-panel {
top: 16px;
left: 16px;
width: min(330px, calc(100vw - 32px));
transform: translateX(-242px);
}
.brand-panel:hover,
.brand-panel:focus-within,
.brand-panel.is-primary {
transform: translateX(0);
}
.records-panel {
left: 16px;
bottom: 16px;
width: min(360px, calc(50vw - 24px));
max-height: min(44vh, 420px);
transform: translateY(calc(100% - 48px));
}
.catalog-panel {
right: 16px;
bottom: 16px;
width: min(360px, calc(50vw - 24px));
max-height: min(44vh, 420px);
transform: translateY(calc(100% - 48px));
}
.detail-panel {
top: 112px;
right: 16px;
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 152px);
transform: translateX(calc(100% - 56px));
}
.records-panel:hover,
.records-panel:focus-within,
.catalog-panel:hover,
.catalog-panel:focus-within,
.detail-panel:hover,
.detail-panel:focus-within {
transform: translate(0, 0);
}
.graph-warning {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 14;
max-width: min(720px, calc(100vw - 36px));
transform: translateX(-50%);
border-top: 1px solid rgba(212, 154, 61, 0.3);
border: 1px solid rgba(212, 154, 61, 0.3);
border-radius: 18px;
background: rgba(72, 45, 12, 0.34);
color: #ffd38a;
padding: 10px 12px;
@ -195,10 +309,14 @@ code {
}
.hero {
position: relative;
z-index: 2;
display: grid;
align-content: center;
justify-items: center;
min-height: calc(100vh - 36px);
max-width: 680px;
text-align: center;
}
.hero img {
@ -214,22 +332,14 @@ code {
color: #f0fbff;
font-size: 42px;
line-height: 1;
text-shadow: 0 0 18px rgba(3, 7, 10, 0.92), 0 0 34px rgba(51, 230, 243, 0.28);
}
.hero p {
margin: 14px 0 0;
color: var(--muted);
line-height: 1.55;
}
.columns {
display: grid;
grid-template-columns: minmax(260px, 0.82fr) minmax(420px, 1.18fr);
gap: 14px;
}
.bottom {
margin-top: 14px;
text-shadow: 0 0 14px rgba(3, 7, 10, 0.96);
}
.panel {
@ -241,7 +351,8 @@ code {
box-shadow: 0 18px 38px rgba(0, 0, 0, 0.24);
}
.panel header {
.panel header,
.floating-panel header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
@ -251,13 +362,15 @@ code {
background: rgba(16, 28, 39, 0.92);
}
.panel h2 {
.panel h2,
.floating-panel h2 {
margin: 0;
color: #f0fbff;
font-size: 15px;
}
.panel header span {
.panel header span,
.floating-panel header span {
overflow: hidden;
color: var(--muted);
font-size: 12px;
@ -268,7 +381,7 @@ code {
.list {
display: grid;
gap: 8px;
max-height: 430px;
max-height: inherit;
overflow: auto;
padding: 10px;
}
@ -305,6 +418,28 @@ code {
min-height: 360px;
}
.detail-panel {
overflow: hidden;
}
.detail-tabs {
display: grid;
gap: 10px;
max-height: calc(100vh - 238px);
overflow: auto;
padding: 10px;
}
.detail-tabs section {
min-width: 0;
}
.detail-tabs h3 {
margin: 4px 2px 8px;
color: #dffbff;
font-size: 13px;
}
.detail > dl,
.detail > pre,
.detail > .error {
@ -328,7 +463,7 @@ dd {
}
pre {
max-height: 440px;
max-height: min(440px, 42vh);
overflow: auto;
border-radius: 6px;
border: 1px solid #142b39;
@ -361,3 +496,212 @@ pre {
padding: 18px;
color: var(--muted);
}
.huginn-expanded-node {
scrollbar-color: rgba(51, 230, 243, 0.58) rgba(3, 7, 10, 0.32);
transition:
border-radius 180ms ease,
box-shadow 180ms ease,
padding 180ms ease;
}
.expanded-content {
display: grid;
gap: clamp(10px, 1.4vw, 18px);
min-height: 100%;
padding: clamp(14px, 2.2vw, 30px);
color: #e7fbff;
}
.expanded-kicker {
color: var(--cyan);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.expanded-header {
display: grid;
gap: 10px;
}
.expanded-header h1 {
margin: 0;
overflow-wrap: anywhere;
color: #f8fdff;
font-size: clamp(1.45rem, 3.4vw, 3rem);
line-height: 1;
}
.expanded-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.expanded-chips span,
.expanded-footer {
border: 1px solid rgba(51, 230, 243, 0.22);
border-radius: 999px;
background: rgba(5, 18, 26, 0.68);
color: #aeeef5;
padding: 6px 9px;
font-size: 12px;
font-weight: 700;
}
.expanded-summary {
color: rgba(231, 251, 255, 0.78);
line-height: 1.55;
}
.expanded-article {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
min-height: 0;
}
.expanded-article h2 {
margin: 0 0 8px;
color: #f8fdff;
font-size: 18px;
}
.expanded-footer {
width: fit-content;
}
.huginn-expanded-node[data-node-stage="summary"] {
overflow: hidden;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-content {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-kicker,
.huginn-expanded-node[data-node-stage="summary"] .expanded-summary,
.huginn-expanded-node[data-node-stage="summary"] .expanded-article,
.huginn-expanded-node[data-node-stage="summary"] .expanded-footer,
.huginn-expanded-node[data-node-stage="summary"] .expanded-chips {
display: none;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-header {
min-width: 0;
}
.huginn-expanded-node[data-node-stage="summary"] .expanded-header h1 {
overflow: hidden;
font-size: 1.02rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.huginn-expanded-node[data-node-stage="preview"] {
overflow: hidden;
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-article {
grid-template-columns: 1fr;
}
.huginn-expanded-node[data-node-stage="preview"] .expanded-article > div:nth-child(2),
.huginn-expanded-node[data-node-stage="preview"] .expanded-footer {
display: none;
}
.huginn-expanded-node[data-node-stage="preview"] pre {
max-height: 170px;
}
.huginn-expanded-node[data-node-stage="article"] {
overflow: auto;
}
@container (max-width: 260px) {
.brand p,
.drop-copy,
.metric span,
.row span,
.floating-panel header span {
display: none;
}
.brand {
grid-template-columns: 42px minmax(0, 1fr);
}
.brand img {
width: 42px;
height: 42px;
}
.brand h1 {
font-size: 22px;
}
.dropzone {
min-height: 54px;
}
}
@container (max-width: 180px) {
.summary,
.dropzone,
.list,
.detail-tabs {
display: none;
}
.floating-panel header {
border-bottom: 0;
padding: 6px 8px;
}
}
@media (max-width: 900px) {
body {
min-width: 320px;
}
.brand-panel {
width: min(300px, calc(100vw - 24px));
transform: translateX(calc(-100% + 58px));
}
.records-panel,
.catalog-panel {
left: 12px;
right: 12px;
width: auto;
max-height: 32vh;
}
.records-panel {
bottom: 76px;
}
.catalog-panel {
bottom: 12px;
}
.detail-panel {
top: auto;
right: 12px;
bottom: 12px;
left: 12px;
width: auto;
max-height: 46vh;
transform: translateY(calc(100% - 54px));
}
.expanded-article {
grid-template-columns: 1fr;
}
}