Skip to content

Commit 26fe613

Browse files
smypmsaclaude
andcommitted
viewer: copy, explorer links, column labels, SQL, CSV and filter
A dashboard over wallets, contracts and transactions could not be read or checked: every hex value was shortened with the full one only in a tooltip, nothing linked anywhere, headers were SQL aliases, and an unexplained dot marked amount columns. - Every hex cell has a copy button for the full value; a panel's columns.<name>.full shows it unshortened. - chain_sources[].explorer_url links address, transaction and block cells. Addresses are detected from their values, tx_hash and block_number by name; a 32-byte value is never guessed to be a transaction, since it may be any id. - columns.<name>.label, .description and .kind on a panel. A header with a description is underlined and explains itself on hover; an amount column explains its decimals. The dot is gone. - Each panel links to its SQL in the source bundle, tables and charts download their rows as CSV with exact values, and a table over ten rows gets a filter. - Bar charts with up to 30 bars label every bar, tilted when crowded; past that the axis is a scale and labels thin out as before. - A KPI beside a table or chart keeps its own height instead of stretching into an empty card; two KPIs side by side still level. explorer_url and the SQL paths are written into the dashboard doc, not release.json, so they count toward the content digest. Releases built before this render as they did, without the new links. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ac70302 commit 26fe613

12 files changed

Lines changed: 781 additions & 102 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ chain_sources:
7878
- id: mainnet
7979
chain_id: 1
8080
rpc_secret: RPC_URL # the env var name, never the value
81+
explorer_url: https://etherscan.io # optional; links addresses, txs, blocks
8182
finality: { policy: finalized }
8283

8384
event_sources:
@@ -159,6 +160,30 @@ Display metadata never alters stored values. `decimals: 6` renders
159160
`983644533552` as `983,644.533552 USDC`; the exact integer stays in the result
160161
JSON and in the hover title.
161162

163+
`columns` on a panel names, explains and types what a query returns:
164+
165+
```yaml
166+
panels:
167+
- query: largest_sells
168+
chart: table
169+
columns:
170+
wallet: { label: Seller, description: "The wallet that sold." }
171+
tx_hash: { label: Transaction, full: true }
172+
order_id: { kind: text } # a bytes32 id, not a transaction
173+
```
174+
175+
`label` replaces the header text and `description` appears when hovering it; a
176+
header with a description is underlined. `kind` is `address`, `tx`, `block` or
177+
`text`. Left out, a 20-byte hex value is read as an address, a column named
178+
`tx_hash` as a transaction and one named `block_number` as a block. A 32-byte
179+
value is never guessed to be a transaction, since it may as well be any id.
180+
With an `explorer_url` on the chain source, those cells link to the explorer.
181+
Every hex cell has a copy button that copies the full value; `full: true` also
182+
shows it unshortened.
183+
184+
Every panel links to the SQL behind it, tables and charts download their rows
185+
as CSV with exact values, and a table longer than ten rows gets a filter box.
186+
162187
## What a release weighs
163188

164189
A release is a static page plus the answers, and that part is about 800 KB

‎docs/capabilities.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ Machine-readable source of truth: `chainplot capabilities --json`.
3434
- Publish root: beside `latest.json`, `publish` writes an `index.html` that forwards to the release the pointer names. A release directory is content-addressed, so its URL moves whenever the release does — a change to the viewer bundle is enough, since the bundle is part of the digest. The root is the URL that stays put, and `publish` returns it as `entry_url`. The page is written both at `<prefix>/index.html` and at `<prefix>/`: a host that resolves directories finds the former, an object store that serves keys needs the latter, and writing both keeps the bare URL working on either without a rewrite rule at the CDN. That second key is best effort: a prefix-less target has no directory key, a `directory` target cannot name a file that way, a store may refuse a key ending in a separator, and a foreign object already there is left alone. `entry_url` then names `index.html` explicitly, so the URL returned resolves either way. An `index.html` already at that key without chainplot's marker is left alone and `entry_point_written` comes back false, so publishing into a bucket that serves a site of its own does not replace that site's front page. On `s3` that refusal is enforced by a conditional write and holds against a concurrent writer; on `directory` it is a check followed by a rename, so a foreign file written into the same key mid-publish is overwritten. The page reads `latest.json` in the browser rather than naming a release, so its bytes depend only on the prefix: every publish writes the same page, and the entry point cannot fall behind the pointer whatever order concurrent publishers finish in. It needs script, as does the viewer it forwards to. Both the page and `latest.json` are written `no-cache, must-revalidate`, since a cache that serves either without asking would show an older release.
3535
- Charts: `line`, `bar`, `area`, `kpi`, `table` (allowlisted encodings only)
3636
- Dataset modes: `results_only` (default), `dataset_referenced`, `dataset_included`. The default publishes the page and its results only. `dataset_referenced` uploads the parquet beside the release and records a release-relative path and checksum, so `fork` fetches and verifies it on demand. `dataset_included` copies it into the release. Set per build (`--mode`) or per project (`policy.release_mode`).
37-
- Panel presentation: `title`, `description`, `span` (`half`/`full`), `hide_columns`, `unit`
37+
- Panel presentation: `title`, `description`, `span` (`half`/`full`), `hide_columns`, `unit`, `columns`
3838
- Column display: `raw_amount_columns[].decimals` / `.symbol` / `.label` (display only; stored values are never rewritten)
39+
- Panel columns: `columns.<name>.label` / `.description` (header tooltip) / `.kind` (`address`, `tx`, `block`, `text`) / `.full` (hex unshortened). Addresses are detected from their values, `tx_hash` and `block_number` by name; 32-byte values are never guessed to be transactions.
40+
- Explorer links: `chain_sources[].explorer_url` links address, transaction and block cells to `<url>/address/…`, `/tx/…`, `/block/…`. The first chain source that declares one is used.
41+
- Viewer affordances: copy button on every hex cell, a link to each panel's SQL in the source bundle, CSV download of a table's or chart's rows (exact values, query column names), a filter on tables over ten rows
3942

4043
## SQL admission control
4144

‎schemas/project.schema.json‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,24 @@
107107
"type": "array",
108108
"items": { "type": "string" }
109109
},
110-
"unit": { "type": "string", "maxLength": 16 }
110+
"unit": { "type": "string", "maxLength": 16 },
111+
"columns": {
112+
"description": "Per-column presentation, keyed by the column name the query returns.",
113+
"type": "object",
114+
"additionalProperties": {
115+
"type": "object",
116+
"additionalProperties": false,
117+
"properties": {
118+
"label": { "description": "Header text, in place of the column name.", "type": "string", "maxLength": 60 },
119+
"description": { "description": "Shown when hovering the header.", "type": "string", "maxLength": 240 },
120+
"kind": {
121+
"description": "What the values are. address, tx and block link to the chain's explorer_url. Left out, a 20-byte hex value is read as an address, a column named tx_hash as a transaction and one named block_number as a block; text turns that off.",
122+
"enum": ["address", "tx", "block", "text"]
123+
},
124+
"full": { "description": "Show hex values in full instead of shortened. Long hashes widen the table.", "type": "boolean" }
125+
}
126+
}
127+
}
111128
}
112129
}
113130
}
@@ -132,6 +149,12 @@
132149
"id": { "type": "string" },
133150
"chain_id": { "type": "integer" },
134151
"rpc_secret": { "type": "string" },
152+
"explorer_url": {
153+
"description": "Block explorer base URL, without a trailing slash. Table cells holding addresses, transactions and blocks link to <url>/address/<a>, <url>/tx/<hash> and <url>/block/<n>, the Etherscan and Blockscout layout.",
154+
"type": "string",
155+
"pattern": "^https?://[^\\s]+[^/\\s]$",
156+
"maxLength": 200
157+
},
135158
"finality": {
136159
"oneOf": [
137160
{ "type": "object", "additionalProperties": false, "required": ["policy"],

‎src/project/types.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ export interface Query {
2929

3030
export type ChartKind = "line" | "bar" | "area" | "kpi" | "table";
3131

32+
export type ColumnKind = "address" | "tx" | "block" | "text";
33+
34+
export interface PanelColumn {
35+
label?: string;
36+
description?: string;
37+
kind?: ColumnKind;
38+
full?: boolean;
39+
}
40+
3241
export interface DashboardPanel {
3342
query: string;
3443
chart: ChartKind;
@@ -37,6 +46,7 @@ export interface DashboardPanel {
3746
span?: "half" | "full";
3847
hide_columns?: string[];
3948
unit?: string;
49+
columns?: Record<string, PanelColumn>;
4050
}
4151

4252
export interface Dashboard {
@@ -61,6 +71,7 @@ export interface ChainSource {
6171
id: string;
6272
chain_id: number;
6373
rpc_secret: string;
74+
explorer_url?: string;
6475
finality: Finality;
6576
}
6677

‎src/publish/writeRelease.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,18 @@ export async function buildRelease(
322322
// Dashboards data. Panel headings are resolved here — panel title, then
323323
// the query's title, then its id — so the viewer needs no query registry.
324324
const queryTitles = new Map(queries.map((q) => [q.id, q.title ?? q.id]));
325+
// Each panel links to the SQL behind it, which the source bundle ships
326+
// under source/. Only files inside the bundle's allowlist get a link.
327+
const querySql = new Map(
328+
queries
329+
.map((q) => [q.id, path.posix.normalize(q.file.replace(/\\/g, "/"))] as const)
330+
.filter(([, file]) => file.startsWith("queries/"))
331+
.map(([id, file]) => [id, `source/${file}`]),
332+
);
333+
// Lives here rather than in release.json so that it counts toward the
334+
// content digest: it changes what the page renders.
335+
const explorerUrl =
336+
project.chain_sources?.find((c) => c.explorer_url)?.explorer_url ?? null;
325337
const dashboardIds: string[] = [];
326338
for (const dashboard of project.dashboards ?? []) {
327339
const rel = path.join("dashboards", `${dashboard.id}.json`);
@@ -330,10 +342,12 @@ export async function buildRelease(
330342
dashboard_id: dashboard.id,
331343
title: dashboard.title,
332344
description: dashboard.description ?? null,
345+
explorer_url: explorerUrl,
333346
panels: dashboard.panels.map((panel) => ({
334347
...panel,
335348
title: panel.title ?? queryTitles.get(panel.query) ?? panel.query,
336349
span: panel.span ?? "half",
350+
sql: querySql.get(panel.query) ?? null,
337351
})),
338352
});
339353
files.push(rel);

‎tests/cli/build.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ describe("build", () => {
5151
fs.readFileSync(path.join(dist, "dashboards/overview.json"), "utf8"),
5252
);
5353
expect(dash.title).toBe("Amounts");
54+
// Each panel links to its SQL in the source bundle; no chain, no explorer.
55+
expect(dash.panels[0].sql).toBe("source/queries/raw_amounts.sql");
56+
expect(dash.explorer_url).toBeNull();
5457

5558
const raw = JSON.parse(
5659
fs.readFileSync(path.join(dist, "results/raw_amounts.json"), "utf8"),

‎tests/cli/validate.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,33 @@ describe("validate", () => {
5656
expect(result.error?.pointer).toBe("/dashboards/0/panels/0/by size");
5757
});
5858

59+
it("accepts per-column presentation and an explorer, and refuses a malformed explorer", async () => {
60+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-columns-"));
61+
fs.cpSync(template, dir, { recursive: true });
62+
const yaml = path.join(dir, "chainplot.yaml");
63+
const withChain = (explorer: string) =>
64+
fs
65+
.readFileSync(path.join(template, "chainplot.yaml"), "utf8")
66+
.replace(
67+
"datasets:\n",
68+
`chain_sources:\n - { id: mainnet, chain_id: 1, rpc_secret: RPC_URL, explorer_url: "${explorer}", finality: { policy: finalized } }\ndatasets:\n`,
69+
)
70+
.replace(
71+
" span: full\n",
72+
' span: full\n columns:\n amount: { label: Amount, description: "Signed, raw.", kind: text, full: true }\n',
73+
);
74+
75+
fs.writeFileSync(yaml, withChain("https://etherscan.io"));
76+
const ok = await runCliJson(["validate", "--json"], dir);
77+
expect(ok.ok).toBe(true);
78+
79+
// A trailing slash would double up in every link the viewer builds.
80+
fs.writeFileSync(yaml, withChain("https://etherscan.io/"));
81+
const bad = await runCliJson(["validate", "--json"], dir);
82+
expect(bad.ok).toBe(false);
83+
expect(bad.error?.pointer).toBe("/chain_sources/0/explorer_url");
84+
});
85+
5986
it("rejects follow_finalized plus confirmation_depth", async () => {
6087
const result = await runCliJson(["validate", "--json"], path.join(fixtures, "follow-plus-depth"));
6188
expect(result.ok).toBe(false);

‎tests/viewer/format.test.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { describe, expect, it } from "vitest";
22
import {
33
asBigInt,
4+
columnKind,
45
columnLabel,
56
compareValues,
67
displayAmount,
8+
explorerHref,
9+
headerHelp,
10+
headerLabel,
11+
kpiStandsAlone,
12+
rowMatches,
13+
toCsv,
714
isNumericColumn,
815
formatCell,
916
groupDigits,
@@ -359,3 +366,106 @@ describe("isNumericColumn", () => {
359366
expect(isNumericColumn(column, [null, null])).toBe(false);
360367
});
361368
});
369+
370+
const ADDR = "0x9cf687eaca65e5da625d3fde534a73513b0a959f";
371+
const TX = "0xdda2c8a4868abbd3613be2bd5621746ba1441e69f93618bf3573095fd9ec1166";
372+
const text = (name: string) => ({ name, logical_type: "VARCHAR" });
373+
374+
describe("columnKind", () => {
375+
it("reads a column of 20-byte hex values as addresses", () => {
376+
expect(columnKind(text("wallet"), undefined, [ADDR, null, ADDR])).toBe("address");
377+
});
378+
379+
it("never guesses a 32-byte value is a transaction", () => {
380+
// It may as well be a bytes32 order id; only a name or a declaration says tx.
381+
expect(columnKind(text("id"), undefined, [TX])).toBe("text");
382+
expect(columnKind(text("tx_hash"), undefined, [TX])).toBe("tx");
383+
expect(columnKind(text("hash"), { kind: "tx" }, [TX])).toBe("tx");
384+
});
385+
386+
it("knows block_number by name, and lets a declaration turn detection off", () => {
387+
expect(columnKind({ name: "block_number", logical_type: "BIGINT" }, undefined, ["1"])).toBe("block");
388+
expect(columnKind(text("wallet"), { kind: "text" }, [ADDR])).toBe("text");
389+
});
390+
391+
it("reads a mixed or empty column as text", () => {
392+
expect(columnKind(text("who"), undefined, [ADDR, "FOMO"])).toBe("text");
393+
expect(columnKind(text("who"), undefined, [null])).toBe("text");
394+
});
395+
});
396+
397+
describe("explorerHref", () => {
398+
const base = "https://explorer.example";
399+
400+
it("builds the Etherscan-style path for each kind", () => {
401+
expect(explorerHref(base, "address", ADDR)).toBe(`${base}/address/${ADDR}`);
402+
expect(explorerHref(base, "tx", TX)).toBe(`${base}/tx/${TX}`);
403+
expect(explorerHref(base, "block", "76491309")).toBe(`${base}/block/76491309`);
404+
});
405+
406+
it("refuses a value that is not the kind it claims, and works without a base", () => {
407+
expect(explorerHref(base, "tx", ADDR)).toBeNull();
408+
expect(explorerHref(base, "block", "0x10")).toBeNull();
409+
expect(explorerHref(base, "text", ADDR)).toBeNull();
410+
expect(explorerHref(null, "address", ADDR)).toBeNull();
411+
});
412+
});
413+
414+
describe("header label and help", () => {
415+
const amount = { name: "usdg", logical_type: "VARCHAR", raw_amount: true, decimals: 6, label: "USDG" };
416+
417+
it("prefers the panel's label, then the amount label, then the name", () => {
418+
expect(headerLabel(amount, { label: "USDG sold" })).toBe("USDG sold");
419+
expect(headerLabel(amount)).toBe("USDG");
420+
expect(headerLabel(text("tx_hash"))).toBe("Tx hash");
421+
});
422+
423+
it("explains a raw amount unless the panel says something itself", () => {
424+
expect(headerHelp(amount)).toContain("6 decimals");
425+
expect(headerHelp(amount, { description: "What the sells fetched." })).toBe("What the sells fetched.");
426+
expect(headerHelp(text("wallet"))).toBeNull();
427+
});
428+
});
429+
430+
describe("rowMatches", () => {
431+
const row = ["VRAX 9464", ADDR, "4806"];
432+
433+
it("matches any visible cell, ignoring case and surrounding space", () => {
434+
expect(rowMatches(row, [0, 1, 2], " vrax ")).toBe(true);
435+
expect(rowMatches(row, [0, 1, 2], "0x9CF6")).toBe(true);
436+
expect(rowMatches(row, [0, 2], "0x9cf6")).toBe(false);
437+
expect(rowMatches(row, [0], "")).toBe(true);
438+
});
439+
});
440+
441+
describe("toCsv", () => {
442+
it("writes exact values under the query's own column names", () => {
443+
const columns = [text("token"), { name: "usdg", logical_type: "VARCHAR", raw_amount: true, decimals: 6 }];
444+
const csv = toCsv(columns, [["VRAX, 9464", "813817279396"], ['say "hi"', null]], [0, 1]);
445+
expect(csv).toBe('token,usdg\r\n"VRAX, 9464",813817279396\r\n"say ""hi""",\r\n');
446+
});
447+
448+
it("leaves hidden columns out", () => {
449+
expect(toCsv([text("a"), text("b")], [["1", "2"]], [1])).toBe("b\r\n2\r\n");
450+
});
451+
});
452+
453+
describe("kpiStandsAlone", () => {
454+
it("frees a KPI from a non-KPI row-mate, and only then", () => {
455+
expect(
456+
kpiStandsAlone([
457+
{ chart: "kpi" }, { chart: "kpi" }, // level pair
458+
{ chart: "kpi" }, { chart: "table" }, // KPI beside a table
459+
{ chart: "bar", span: "full" },
460+
{ chart: "line" }, { chart: "kpi" }, // table-first order too
461+
{ chart: "kpi" }, // last, with no row-mate
462+
]),
463+
).toEqual([false, false, true, false, false, false, true, false]);
464+
});
465+
466+
it("starts a fresh row after a full-width panel", () => {
467+
expect(
468+
kpiStandsAlone([{ chart: "kpi" }, { chart: "table", span: "full" }, { chart: "table" }, { chart: "kpi" }]),
469+
).toEqual([false, false, false, true]);
470+
});
471+
});

0 commit comments

Comments
 (0)