From ac70302d3c607fbc0b82759ef400e0ee96f786b0 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Thu, 1 Oct 2026 11:47:44 +0200 Subject: [PATCH 1/5] cli: name the missing snapshot and the stray key in errors A dataset in an ingest project whose snapshot apply does not write (a lookup table filled in by a script afterwards) passed validation, since ingest snapshots appear at apply time, and then failed the build with an internal error carrying the stat call's ENOENT. It is now a validation error naming the dataset, its path and what to do. Schema errors for an additional property now name the key. "must NOT have additional properties" alone sent a reader hunting; the key usually gives the cause away, as when an unquoted comma in a flow-mapping title splits it into a second key. The missing-snapshot pointer for dataset-only projects also narrows from /datasets to the entry. Co-Authored-By: Claude Opus 5.5 --- src/project/validate.ts | 37 ++++++++++++++----- src/publish/writeRelease.ts | 15 ++++++++ tests/cli/build.test.ts | 74 +++++++++++++++++++++++++++++++++++++ tests/cli/validate.test.ts | 20 ++++++++++ 4 files changed, 136 insertions(+), 10 deletions(-) diff --git a/src/project/validate.ts b/src/project/validate.ts index 7696436..d356196 100644 --- a/src/project/validate.ts +++ b/src/project/validate.ts @@ -31,16 +31,33 @@ function error( }; } +function strayProperty(err: ErrorObject): string | null { + if (err.keyword !== "additionalProperties") return null; + const prop = (err.params as { additionalProperty?: string }).additionalProperty; + return typeof prop === "string" ? prop : null; +} + function pointerFromAjv(err: ErrorObject): string | null { - if (err.keyword === "additionalProperties") { - const prop = (err.params as { additionalProperty?: string }).additionalProperty; - if (typeof prop === "string") { - return `${err.instancePath}/${prop}`; - } - } + const prop = strayProperty(err); + if (prop !== null) return `${err.instancePath}/${prop}`; return err.instancePath || null; } +/** + * Ajv's text, with the stray key named. "must NOT have additional properties" + * alone sends a reader hunting; the key usually gives the cause away, as when + * an unquoted comma in a flow-mapping title splits it into a second key. + */ +function messageFromAjv(errors: ErrorObject[]): string { + return errors + .map((err) => { + const text = `project${err.instancePath} ${err.message ?? "is invalid"}`; + const prop = strayProperty(err); + return prop === null ? text : `${text}: ${JSON.stringify(prop)}`; + }) + .join(", "); +} + export function validateProject( doc: unknown, projectDir: string, @@ -66,8 +83,8 @@ export function validateProject( if (!validateSchema(doc)) { const first = validateSchema.errors?.[0]; const pointer = first ? pointerFromAjv(first) : null; - const message = first - ? ajv.errorsText(validateSchema.errors, { dataVar: "project" }) + const message = validateSchema.errors?.length + ? messageFromAjv(validateSchema.errors) : "invalid project document"; return { ok: false, @@ -154,7 +171,7 @@ export function validateProject( // Snapshot files are authored inputs for dataset-only projects. Ingest // projects materialize them at apply time, so their absence is not an error. const isIngest = (project.event_sources ?? []).length > 0; - for (const dataset of project.datasets ?? []) { + for (const [index, dataset] of (project.datasets ?? []).entries()) { if (isIngest) break; const filePath = path.resolve(projectDir, dataset.snapshot); if (!fs.existsSync(filePath)) { @@ -162,7 +179,7 @@ export function validateProject( ok: false, error: error("validation", `missing snapshot file: ${dataset.snapshot}`, { resource_id: dataset.id, - pointer: "/datasets", + pointer: `/datasets/${index}/snapshot`, }), }; } diff --git a/src/publish/writeRelease.ts b/src/publish/writeRelease.ts index de98b98..d257348 100644 --- a/src/publish/writeRelease.ts +++ b/src/publish/writeRelease.ts @@ -150,6 +150,21 @@ export async function buildRelease( // lets someone fork the release and recompute, and it is also what turns an // 800 KB page into hundreds of megabytes. const mode = opts.mode ?? project.policy?.release_mode ?? "results_only"; + // A snapshot chainplot did not write itself — a lookup table a script fills + // in after `apply`, say — may simply not exist yet. Say which one, rather + // than surfacing the stat call's ENOENT as an internal error. + for (const [index, dataset] of datasets.entries()) { + if (!fs.existsSync(path.resolve(projectDir, dataset.snapshot))) { + throw { + ...error( + "validation", + `dataset ${dataset.id} has no snapshot at ${dataset.snapshot}`, + { resource_id: dataset.id, pointer: `/datasets/${index}/snapshot` }, + ), + suggested_next: "produce the snapshot (`apply` writes an event source's own), then build again", + } satisfies CommandError; + } + } let copiedBytes = 0; for (const dataset of datasets) { const parquetPath = path.resolve(projectDir, dataset.snapshot); diff --git a/tests/cli/build.test.ts b/tests/cli/build.test.ts index 371f6d3..f9bc63e 100644 --- a/tests/cli/build.test.ts +++ b/tests/cli/build.test.ts @@ -63,6 +63,80 @@ describe("build", () => { expect(fs.existsSync(path.join(dist, "source/.env"))).toBe(false); expect(fs.existsSync(path.join(dist, "source/.chainplot"))).toBe(false); }); + + // An ingest project may declare a snapshot that `apply` does not write — a + // lookup table a script fills in afterwards. Validation lets it through, + // since ingest snapshots appear at apply time, and the build used to fail on + // it with an internal error carrying the stat call's ENOENT. + it("names a dataset whose snapshot is missing", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-build-missing-")); + fs.cpSync(template, dir, { recursive: true }); + fs.mkdirSync(path.join(dir, "abis")); + fs.writeFileSync( + path.join(dir, "abis/ERC20.json"), + JSON.stringify([ + { + type: "event", + name: "Transfer", + anonymous: false, + inputs: [ + { name: "from", type: "address", indexed: true }, + { name: "to", type: "address", indexed: true }, + { name: "value", type: "uint256", indexed: false }, + ], + }, + ]), + ); + const yaml = path.join(dir, "chainplot.yaml"); + fs.writeFileSync( + yaml, + fs + .readFileSync(yaml, "utf8") + .replace( + "datasets:\n", + [ + "chain_sources:", + " - { id: mainnet, chain_id: 1, rpc_secret: RPC_URL, finality: { policy: finalized } }", + "event_sources:", + " - id: usdc", + " chain: mainnet", + ' addresses: ["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"]', + " abi: abis/ERC20.json", + " events: [Transfer]", + " start_block: 100", + " end: { mode: pinned, block: 110 }", + "datasets:", + " - id: lookups", + " snapshot: .chainplot/lookups/blocks.parquet", + "", + ].join("\n"), + ), + ); + fs.mkdirSync(path.join(dir, ".chainplot")); + fs.writeFileSync( + path.join(dir, ".chainplot/coverage.json"), + JSON.stringify({ + schema_version: 1, + chain_id: 1, + sources: [ + { + source_id: "usdc", + segments: [{ start_block: 100, end_block: 110, status: "complete_empty", row_count: 0 }], + }, + ], + }), + ); + + const result = await runCliJson(["build", "--json"], dir); + expect(result.ok).toBe(false); + expect(result.error).toMatchObject({ + code: "validation", + resource_id: "lookups", + pointer: "/datasets/0/snapshot", + }); + expect(result.error?.message).toContain(".chainplot/lookups/blocks.parquet"); + expect(result.error?.suggested_next).toBeTruthy(); + }); }); // Models are materialized into every query's session. While each session diff --git a/tests/cli/validate.test.ts b/tests/cli/validate.test.ts index 06e568c..c0dc4b3 100644 --- a/tests/cli/validate.test.ts +++ b/tests/cli/validate.test.ts @@ -36,6 +36,26 @@ describe("validate", () => { expect(result.error?.code).toBe("validation"); }); + it("names the stray key, so a comma that split a flow-mapping title shows its cause", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-stray-key-")); + fs.cpSync(template, dir, { recursive: true }); + const yaml = path.join(dir, "chainplot.yaml"); + const doc = fs.readFileSync(yaml, "utf8"); + // Unquoted, the comma ends the title and starts a second, nameless key. + fs.writeFileSync( + yaml, + doc.replace( + / - query: raw_amounts\n[\s\S]*$/, + " - { query: raw_amounts, chart: table, title: Raw amounts, by size }\n", + ), + ); + const result = await runCliJson(["validate", "--json"], dir); + expect(result.ok).toBe(false); + expect(result.error?.code).toBe("validation"); + expect(result.error?.message).toContain('must NOT have additional properties: "by size"'); + expect(result.error?.pointer).toBe("/dashboards/0/panels/0/by size"); + }); + it("rejects follow_finalized plus confirmation_depth", async () => { const result = await runCliJson(["validate", "--json"], path.join(fixtures, "follow-plus-depth")); expect(result.ok).toBe(false); From 26fe6139bb17e52cfe05feb137e5fac65c030821 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Thu, 1 Oct 2026 11:47:54 +0200 Subject: [PATCH 2/5] 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..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..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 --- README.md | 25 +++ docs/capabilities.md | 5 +- schemas/project.schema.json | 25 ++- src/project/types.ts | 11 + src/publish/writeRelease.ts | 14 ++ tests/cli/build.test.ts | 3 + tests/cli/validate.test.ts | 27 +++ tests/viewer/format.test.ts | 110 ++++++++++ viewer/src/App.tsx | 399 +++++++++++++++++++++++++++--------- viewer/src/data.ts | 9 +- viewer/src/format.ts | 143 +++++++++++++ viewer/src/styles.css | 112 +++++++++- 12 files changed, 781 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index 1a3bd48..c3e4647 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,7 @@ chain_sources: - id: mainnet chain_id: 1 rpc_secret: RPC_URL # the env var name, never the value + explorer_url: https://etherscan.io # optional; links addresses, txs, blocks finality: { policy: finalized } event_sources: @@ -159,6 +160,30 @@ Display metadata never alters stored values. `decimals: 6` renders `983644533552` as `983,644.533552 USDC`; the exact integer stays in the result JSON and in the hover title. +`columns` on a panel names, explains and types what a query returns: + +```yaml +panels: + - query: largest_sells + chart: table + columns: + wallet: { label: Seller, description: "The wallet that sold." } + tx_hash: { label: Transaction, full: true } + order_id: { kind: text } # a bytes32 id, not a transaction +``` + +`label` replaces the header text and `description` appears when hovering it; a +header with a description is underlined. `kind` is `address`, `tx`, `block` or +`text`. 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. A 32-byte +value is never guessed to be a transaction, since it may as well be any id. +With an `explorer_url` on the chain source, those cells link to the explorer. +Every hex cell has a copy button that copies the full value; `full: true` also +shows it unshortened. + +Every panel links to the SQL behind it, tables and charts download their rows +as CSV with exact values, and a table longer than ten rows gets a filter box. + ## What a release weighs A release is a static page plus the answers, and that part is about 800 KB diff --git a/docs/capabilities.md b/docs/capabilities.md index d6accbb..85d44db 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -34,8 +34,11 @@ Machine-readable source of truth: `chainplot capabilities --json`. - 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 `/index.html` and at `/`: 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. - Charts: `line`, `bar`, `area`, `kpi`, `table` (allowlisted encodings only) - 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`). -- Panel presentation: `title`, `description`, `span` (`half`/`full`), `hide_columns`, `unit` +- Panel presentation: `title`, `description`, `span` (`half`/`full`), `hide_columns`, `unit`, `columns` - Column display: `raw_amount_columns[].decimals` / `.symbol` / `.label` (display only; stored values are never rewritten) +- Panel columns: `columns..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. +- Explorer links: `chain_sources[].explorer_url` links address, transaction and block cells to `/address/…`, `/tx/…`, `/block/…`. The first chain source that declares one is used. +- 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 ## SQL admission control diff --git a/schemas/project.schema.json b/schemas/project.schema.json index c4c8d10..d8874b8 100644 --- a/schemas/project.schema.json +++ b/schemas/project.schema.json @@ -107,7 +107,24 @@ "type": "array", "items": { "type": "string" } }, - "unit": { "type": "string", "maxLength": 16 } + "unit": { "type": "string", "maxLength": 16 }, + "columns": { + "description": "Per-column presentation, keyed by the column name the query returns.", + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "label": { "description": "Header text, in place of the column name.", "type": "string", "maxLength": 60 }, + "description": { "description": "Shown when hovering the header.", "type": "string", "maxLength": 240 }, + "kind": { + "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.", + "enum": ["address", "tx", "block", "text"] + }, + "full": { "description": "Show hex values in full instead of shortened. Long hashes widen the table.", "type": "boolean" } + } + } + } } } } @@ -132,6 +149,12 @@ "id": { "type": "string" }, "chain_id": { "type": "integer" }, "rpc_secret": { "type": "string" }, + "explorer_url": { + "description": "Block explorer base URL, without a trailing slash. Table cells holding addresses, transactions and blocks link to /address/, /tx/ and /block/, the Etherscan and Blockscout layout.", + "type": "string", + "pattern": "^https?://[^\\s]+[^/\\s]$", + "maxLength": 200 + }, "finality": { "oneOf": [ { "type": "object", "additionalProperties": false, "required": ["policy"], diff --git a/src/project/types.ts b/src/project/types.ts index 1472277..1e86ca3 100644 --- a/src/project/types.ts +++ b/src/project/types.ts @@ -29,6 +29,15 @@ export interface Query { export type ChartKind = "line" | "bar" | "area" | "kpi" | "table"; +export type ColumnKind = "address" | "tx" | "block" | "text"; + +export interface PanelColumn { + label?: string; + description?: string; + kind?: ColumnKind; + full?: boolean; +} + export interface DashboardPanel { query: string; chart: ChartKind; @@ -37,6 +46,7 @@ export interface DashboardPanel { span?: "half" | "full"; hide_columns?: string[]; unit?: string; + columns?: Record; } export interface Dashboard { @@ -61,6 +71,7 @@ export interface ChainSource { id: string; chain_id: number; rpc_secret: string; + explorer_url?: string; finality: Finality; } diff --git a/src/publish/writeRelease.ts b/src/publish/writeRelease.ts index d257348..295fe0c 100644 --- a/src/publish/writeRelease.ts +++ b/src/publish/writeRelease.ts @@ -322,6 +322,18 @@ export async function buildRelease( // Dashboards data. Panel headings are resolved here — panel title, then // the query's title, then its id — so the viewer needs no query registry. const queryTitles = new Map(queries.map((q) => [q.id, q.title ?? q.id])); + // Each panel links to the SQL behind it, which the source bundle ships + // under source/. Only files inside the bundle's allowlist get a link. + const querySql = new Map( + queries + .map((q) => [q.id, path.posix.normalize(q.file.replace(/\\/g, "/"))] as const) + .filter(([, file]) => file.startsWith("queries/")) + .map(([id, file]) => [id, `source/${file}`]), + ); + // Lives here rather than in release.json so that it counts toward the + // content digest: it changes what the page renders. + const explorerUrl = + project.chain_sources?.find((c) => c.explorer_url)?.explorer_url ?? null; const dashboardIds: string[] = []; for (const dashboard of project.dashboards ?? []) { const rel = path.join("dashboards", `${dashboard.id}.json`); @@ -330,10 +342,12 @@ export async function buildRelease( dashboard_id: dashboard.id, title: dashboard.title, description: dashboard.description ?? null, + explorer_url: explorerUrl, panels: dashboard.panels.map((panel) => ({ ...panel, title: panel.title ?? queryTitles.get(panel.query) ?? panel.query, span: panel.span ?? "half", + sql: querySql.get(panel.query) ?? null, })), }); files.push(rel); diff --git a/tests/cli/build.test.ts b/tests/cli/build.test.ts index f9bc63e..e459c98 100644 --- a/tests/cli/build.test.ts +++ b/tests/cli/build.test.ts @@ -51,6 +51,9 @@ describe("build", () => { fs.readFileSync(path.join(dist, "dashboards/overview.json"), "utf8"), ); expect(dash.title).toBe("Amounts"); + // Each panel links to its SQL in the source bundle; no chain, no explorer. + expect(dash.panels[0].sql).toBe("source/queries/raw_amounts.sql"); + expect(dash.explorer_url).toBeNull(); const raw = JSON.parse( fs.readFileSync(path.join(dist, "results/raw_amounts.json"), "utf8"), diff --git a/tests/cli/validate.test.ts b/tests/cli/validate.test.ts index c0dc4b3..f37d956 100644 --- a/tests/cli/validate.test.ts +++ b/tests/cli/validate.test.ts @@ -56,6 +56,33 @@ describe("validate", () => { expect(result.error?.pointer).toBe("/dashboards/0/panels/0/by size"); }); + it("accepts per-column presentation and an explorer, and refuses a malformed explorer", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "chainplot-columns-")); + fs.cpSync(template, dir, { recursive: true }); + const yaml = path.join(dir, "chainplot.yaml"); + const withChain = (explorer: string) => + fs + .readFileSync(path.join(template, "chainplot.yaml"), "utf8") + .replace( + "datasets:\n", + `chain_sources:\n - { id: mainnet, chain_id: 1, rpc_secret: RPC_URL, explorer_url: "${explorer}", finality: { policy: finalized } }\ndatasets:\n`, + ) + .replace( + " span: full\n", + ' span: full\n columns:\n amount: { label: Amount, description: "Signed, raw.", kind: text, full: true }\n', + ); + + fs.writeFileSync(yaml, withChain("https://etherscan.io")); + const ok = await runCliJson(["validate", "--json"], dir); + expect(ok.ok).toBe(true); + + // A trailing slash would double up in every link the viewer builds. + fs.writeFileSync(yaml, withChain("https://etherscan.io/")); + const bad = await runCliJson(["validate", "--json"], dir); + expect(bad.ok).toBe(false); + expect(bad.error?.pointer).toBe("/chain_sources/0/explorer_url"); + }); + it("rejects follow_finalized plus confirmation_depth", async () => { const result = await runCliJson(["validate", "--json"], path.join(fixtures, "follow-plus-depth")); expect(result.ok).toBe(false); diff --git a/tests/viewer/format.test.ts b/tests/viewer/format.test.ts index a9b3933..5235b43 100644 --- a/tests/viewer/format.test.ts +++ b/tests/viewer/format.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "vitest"; import { asBigInt, + columnKind, columnLabel, compareValues, displayAmount, + explorerHref, + headerHelp, + headerLabel, + kpiStandsAlone, + rowMatches, + toCsv, isNumericColumn, formatCell, groupDigits, @@ -359,3 +366,106 @@ describe("isNumericColumn", () => { expect(isNumericColumn(column, [null, null])).toBe(false); }); }); + +const ADDR = "0x9cf687eaca65e5da625d3fde534a73513b0a959f"; +const TX = "0xdda2c8a4868abbd3613be2bd5621746ba1441e69f93618bf3573095fd9ec1166"; +const text = (name: string) => ({ name, logical_type: "VARCHAR" }); + +describe("columnKind", () => { + it("reads a column of 20-byte hex values as addresses", () => { + expect(columnKind(text("wallet"), undefined, [ADDR, null, ADDR])).toBe("address"); + }); + + it("never guesses a 32-byte value is a transaction", () => { + // It may as well be a bytes32 order id; only a name or a declaration says tx. + expect(columnKind(text("id"), undefined, [TX])).toBe("text"); + expect(columnKind(text("tx_hash"), undefined, [TX])).toBe("tx"); + expect(columnKind(text("hash"), { kind: "tx" }, [TX])).toBe("tx"); + }); + + it("knows block_number by name, and lets a declaration turn detection off", () => { + expect(columnKind({ name: "block_number", logical_type: "BIGINT" }, undefined, ["1"])).toBe("block"); + expect(columnKind(text("wallet"), { kind: "text" }, [ADDR])).toBe("text"); + }); + + it("reads a mixed or empty column as text", () => { + expect(columnKind(text("who"), undefined, [ADDR, "FOMO"])).toBe("text"); + expect(columnKind(text("who"), undefined, [null])).toBe("text"); + }); +}); + +describe("explorerHref", () => { + const base = "https://explorer.example"; + + it("builds the Etherscan-style path for each kind", () => { + expect(explorerHref(base, "address", ADDR)).toBe(`${base}/address/${ADDR}`); + expect(explorerHref(base, "tx", TX)).toBe(`${base}/tx/${TX}`); + expect(explorerHref(base, "block", "76491309")).toBe(`${base}/block/76491309`); + }); + + it("refuses a value that is not the kind it claims, and works without a base", () => { + expect(explorerHref(base, "tx", ADDR)).toBeNull(); + expect(explorerHref(base, "block", "0x10")).toBeNull(); + expect(explorerHref(base, "text", ADDR)).toBeNull(); + expect(explorerHref(null, "address", ADDR)).toBeNull(); + }); +}); + +describe("header label and help", () => { + const amount = { name: "usdg", logical_type: "VARCHAR", raw_amount: true, decimals: 6, label: "USDG" }; + + it("prefers the panel's label, then the amount label, then the name", () => { + expect(headerLabel(amount, { label: "USDG sold" })).toBe("USDG sold"); + expect(headerLabel(amount)).toBe("USDG"); + expect(headerLabel(text("tx_hash"))).toBe("Tx hash"); + }); + + it("explains a raw amount unless the panel says something itself", () => { + expect(headerHelp(amount)).toContain("6 decimals"); + expect(headerHelp(amount, { description: "What the sells fetched." })).toBe("What the sells fetched."); + expect(headerHelp(text("wallet"))).toBeNull(); + }); +}); + +describe("rowMatches", () => { + const row = ["VRAX 9464", ADDR, "4806"]; + + it("matches any visible cell, ignoring case and surrounding space", () => { + expect(rowMatches(row, [0, 1, 2], " vrax ")).toBe(true); + expect(rowMatches(row, [0, 1, 2], "0x9CF6")).toBe(true); + expect(rowMatches(row, [0, 2], "0x9cf6")).toBe(false); + expect(rowMatches(row, [0], "")).toBe(true); + }); +}); + +describe("toCsv", () => { + it("writes exact values under the query's own column names", () => { + const columns = [text("token"), { name: "usdg", logical_type: "VARCHAR", raw_amount: true, decimals: 6 }]; + const csv = toCsv(columns, [["VRAX, 9464", "813817279396"], ['say "hi"', null]], [0, 1]); + expect(csv).toBe('token,usdg\r\n"VRAX, 9464",813817279396\r\n"say ""hi""",\r\n'); + }); + + it("leaves hidden columns out", () => { + expect(toCsv([text("a"), text("b")], [["1", "2"]], [1])).toBe("b\r\n2\r\n"); + }); +}); + +describe("kpiStandsAlone", () => { + it("frees a KPI from a non-KPI row-mate, and only then", () => { + expect( + kpiStandsAlone([ + { chart: "kpi" }, { chart: "kpi" }, // level pair + { chart: "kpi" }, { chart: "table" }, // KPI beside a table + { chart: "bar", span: "full" }, + { chart: "line" }, { chart: "kpi" }, // table-first order too + { chart: "kpi" }, // last, with no row-mate + ]), + ).toEqual([false, false, true, false, false, false, true, false]); + }); + + it("starts a fresh row after a full-width panel", () => { + expect( + kpiStandsAlone([{ chart: "kpi" }, { chart: "table", span: "full" }, { chart: "table" }, { chart: "kpi" }]), + ).toEqual([false, false, false, true]); + }); +}); diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index 8df9ffa..9782a6f 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -9,13 +9,22 @@ import { type ReleaseDoc, } from "./data.js"; import { - columnLabel, + columnKind, compareValues, + explorerHref, formatCell, + headerHelp, + headerLabel, + isHex, isNumericColumn, + kpiStandsAlone, relativeTime, + rowMatches, rowWindow, toChartNumber, + toCsv, + type ColumnKind, + type PanelColumn, } from "./format.js"; /** Where a reader learns how to rebuild a release from its dataset. */ @@ -52,14 +61,19 @@ function Chip({ ); } +// Up to this many bars, every bar gets its label. +const MAX_NAMED_BARS = 30; + function Chart({ result, kind, columns, + presentation, }: { result: QueryResultDoc; kind: ChartKind; columns: number[]; + presentation: Record | undefined; }) { const [el, setEl] = useState(null); const instanceRef = useRef<{ dispose(): void; resize(): void } | null>(null); @@ -79,6 +93,13 @@ function Chart({ const [categoryIndex, ...seriesIndexes] = columns; if (categoryIndex === undefined) return; const axis = result.rows.map((row) => String(row[categoryIndex] ?? "")); + // A few bars are named things — tokens, wallets — and a dropped label + // leaves a bar nobody can identify, so all of them are shown, tilted once + // they would collide. Past that, the axis is a scale (a bar per day, say) + // and thinning its labels out is the right call, as on a line. + const named = kind === "bar" && axis.length <= MAX_NAMED_BARS; + const crowded = + named && axis.reduce((sum, label) => sum + label.length, 0) * 7 > el.clientWidth * 0.8; // cp-ui-kit border-static / text-secondary, per theme. const grid = dark ? "#2e3338" : "#e4ebf1"; const text = dark ? "#8d95a5" : "#606772"; @@ -103,7 +124,16 @@ function Chart({ data: axis, boundaryGap: kind === "bar", axisLine: { lineStyle: { color: grid } }, - axisLabel: { color: text, hideOverlap: true }, + axisLabel: + named + ? { + color: text, + interval: 0, + rotate: crowded ? 35 : 0, + width: crowded ? 110 : undefined, + overflow: crowded ? "truncate" : undefined, + } + : { color: text, hideOverlap: true }, }, yAxis: { type: "value", @@ -113,7 +143,7 @@ function Chart({ series: seriesIndexes.map((index) => { const column = result.columns[index]!; return { - name: columnLabel(column), + name: headerLabel(column, presentation?.[column.name]), type: kind === "bar" ? "bar" : "line", smooth: kind !== "bar", showSymbol: result.rows.length <= 60, @@ -134,11 +164,68 @@ function Chart({ instanceRef.current?.dispose(); instanceRef.current = null; }; - }, [result, kind, columns, el]); + }, [result, kind, columns, presentation, el]); return
; } +/** Copies the exact value; says so for a moment, so the click is not silent. */ +function CopyButton({ value }: { value: string }) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1200); + return () => clearTimeout(timer); + }, [copied]); + return ( + + ); +} + +/** + * A hex value or a block: shortened unless the panel asks for it in full, + * always copyable whole, and linked when the project names an explorer. + */ +function ValueCell({ + value, + text, + kind, + full, + explorerUrl, +}: { + value: unknown; + text: string; + kind: ColumnKind; + full: boolean; + explorerUrl: string | null; +}) { + const exact = String(value); + const shown = full && isHex(value) ? exact : text; + const href = explorerHref(explorerUrl, kind, value); + return ( + + {href ? ( + + {shown} + + ) : ( + {shown} + )} + {isHex(value) ? : null} + + ); +} + // Below this, rendering every row costs nothing and avoids the measurement // dance entirely. const VIRTUALIZE_ABOVE = 200; @@ -148,14 +235,22 @@ const OVERSCAN = 12; // so this constant and the stylesheet cannot drift apart. const ASSUMED_ROW_HEIGHT = 33; +// A table this long gets a filter box; a shorter one is read at a glance. +const FILTER_ABOVE = 10; + function DataTable({ result, columns, + presentation, + explorerUrl, }: { result: QueryResultDoc; columns: number[]; + presentation: Record | undefined; + explorerUrl: string | null; }) { const [sortCol, setSortCol] = useState(null); + const [needle, setNeedle] = useState(""); const [asc, setAsc] = useState(true); const [scrollTop, setScrollTop] = useState(0); const [viewport, setViewport] = useState(0); @@ -176,15 +271,33 @@ function DataTable({ return numeric; }, [columns, result]); + const kinds = useMemo(() => { + const out = new Map(); + for (const index of columns) { + const column = result.columns[index]; + if (!column) continue; + out.set( + index, + columnKind(column, presentation?.[column.name], result.rows.map((row) => row[index])), + ); + } + return out; + }, [columns, result, presentation]); + + const filtered = useMemo( + () => (needle ? result.rows.filter((row) => rowMatches(row, columns, needle)) : result.rows), + [result, columns, needle], + ); + const sorted = useMemo(() => { - if (sortCol === null) return result.rows; - const rows = [...result.rows]; + if (sortCol === null) return filtered; + const rows = [...filtered]; rows.sort((a, b) => { const cmp = compareValues(a[sortCol], b[sortCol]); return asc ? cmp : -cmp; }); return rows; - }, [result, sortCol, asc]); + }, [filtered, sortCol, asc]); const virtual = sorted.length > VIRTUALIZE_ABOVE; @@ -207,95 +320,121 @@ function DataTable({ } return ( -
{ - const el = event.currentTarget; - setScrollTop(el.scrollTop); - setViewport(el.clientHeight); - } - : undefined - } - ref={ - virtual - ? (el) => { - if (el && viewport === 0) setViewport(el.clientHeight); - } - : undefined - } - > - - - - {columns.map((index) => { - const column = result.columns[index]!; - const active = sortCol === index; - return ( - - ); - })} - - - - {/* Spacers stand in for the rows above and below the window, so the - scrollbar reflects the whole result while the DOM holds a screenful. */} - {virtual && first > 0 ? ( - - ) : null} - {visible.map((row, i) => ( - + <> + {result.rows.length > FILTER_ABOVE ? ( + setNeedle(event.target.value)} + /> + ) : null} +
{ + const el = event.currentTarget; + setScrollTop(el.scrollTop); + setViewport(el.clientHeight); + } + : undefined + } + ref={ + virtual + ? (el) => { + if (el && viewport === 0) setViewport(el.clientHeight); + } + : undefined + } + > +
- -
+ + {columns.map((index) => { const column = result.columns[index]!; - const cell = formatCell(row[index], column); + const active = sortCol === index; + const help = headerHelp(column, presentation?.[column.name]); return ( - + + ); })} - ))} - {virtual && last < sorted.length ? ( - - ) : null} - -
- {cell.text} -
-
+ + + {/* Spacers stand in for the rows above and below the window, so the + scrollbar reflects the whole result while the DOM holds a screenful. */} + {virtual && first > 0 ? ( + + ) : null} + {visible.map((row, i) => ( + + {columns.map((index) => { + const column = result.columns[index]!; + const cell = formatCell(row[index], column); + const kind = kinds.get(index) ?? "text"; + const linked = kind !== "text" || isHex(row[index]); + return ( + + {linked && row[index] !== null && row[index] !== undefined ? ( + + ) : ( + cell.text + )} + + ); + })} + + ))} + {virtual && last < sorted.length ? ( + + ) : null} + + +
+ {needle && sorted.length === 0 ?

No rows match “{needle}”.

: null} + ); } @@ -327,12 +466,59 @@ function Kpi({ ); } +/** Hands the browser a file built in memory, without a round trip. */ +function download(name: string, body: string, type: string): void { + const url = URL.createObjectURL(new Blob([body], { type })); + const link = document.createElement("a"); + link.href = url; + link.download = name; + link.click(); + URL.revokeObjectURL(url); +} + +function PanelActions({ + panel, + result, + columns, +}: { + panel: DashboardPanelDoc; + result: QueryResultDoc | null | undefined; + columns: number[]; +}) { + const csv = result && !result.error && panel.chart !== "kpi" && result.rows.length > 0; + if (!panel.sql && !csv) return null; + return ( +
+ {panel.sql ? ( + + SQL + + ) : null} + {csv ? ( + + ) : null} +
+ ); +} + function Panel({ panel, result, + explorerUrl, + standsAlone, }: { panel: DashboardPanelDoc; result: QueryResultDoc | null | undefined; + explorerUrl: string | null; + standsAlone: boolean; }) { const columns = useMemo( () => (result ? visibleColumns(result.columns, panel.hide_columns) : []), @@ -358,15 +544,29 @@ function Panel({ return ; } if (panel.chart === "table") { - return ; + return ( + + ); } - return ; + return ( + + ); }; return ( -
+
-

{panel.title ?? panel.query}

+
+

{panel.title ?? panel.query}

+ +
{panel.description ?

{panel.description}

: null}
{body()} @@ -542,9 +742,18 @@ export function App() { {dash.description ?

{dash.description}

: null}
- {dash.panels.map((panel, i) => ( - - ))} + {(() => { + const alone = kpiStandsAlone(dash.panels); + return dash.panels.map((panel, i) => ( + + )); + })()}
))} diff --git a/viewer/src/data.ts b/viewer/src/data.ts index 9727280..5b5917d 100644 --- a/viewer/src/data.ts +++ b/viewer/src/data.ts @@ -1,6 +1,6 @@ -import type { ColumnMeta } from "./format.js"; +import type { ColumnMeta, PanelColumn } from "./format.js"; -export type { ColumnMeta }; +export type { ColumnMeta, PanelColumn }; export interface QueryResultDoc { schema_version: number; @@ -49,6 +49,9 @@ export interface DashboardPanelDoc { span?: "half" | "full"; hide_columns?: string[]; unit?: string; + columns?: Record; + /** Release-relative path of the query's SQL, when the source bundle has it. */ + sql?: string | null; } export interface DashboardDoc { @@ -56,6 +59,8 @@ export interface DashboardDoc { dashboard_id: string; title: string; description?: string | null; + /** Block explorer base URL; null or absent when the project declares none. */ + explorer_url?: string | null; panels: DashboardPanelDoc[]; } diff --git a/viewer/src/format.ts b/viewer/src/format.ts index 333538a..cdfb8c9 100644 --- a/viewer/src/format.ts +++ b/viewer/src/format.ts @@ -291,3 +291,146 @@ export function rowWindow( // been measured; render the overscan rather than nothing. return { virtual: true, first, last: Math.max(last, first + opts.overscan) }; } + +// ---- column presentation ------------------------------------------------- + +/** Per-column presentation a panel declares, keyed by column name. */ +export interface PanelColumn { + label?: string; + description?: string; + kind?: ColumnKind; + full?: boolean; +} + +/** What a column's values are: something with an explorer page, or text. */ +export type ColumnKind = "address" | "tx" | "block" | "text"; + +const ADDRESS = /^0x[0-9a-fA-F]{40}$/; +const HASH = /^0x[0-9a-fA-F]{64}$/; +const BLOCK = /^\d+$/; + +/** Column names that mean the same thing in every project rindexer writes. */ +const NAMED_KINDS: Record = { + tx_hash: "tx", + transaction_hash: "tx", + block_number: "block", +}; + +/** + * What a column holds. A declared kind wins; then the conventional names; then + * the values, but only for addresses: a 32-byte hex value may be a transaction + * or any bytes32 id, so it is never linked as one by guesswork. + */ +export function columnKind( + column: ColumnMeta, + presentation: PanelColumn | undefined, + values: readonly unknown[] = [], +): ColumnKind { + if (presentation?.kind) return presentation.kind; + const named = NAMED_KINDS[column.name.toLowerCase()]; + if (named) return named; + let seen = 0; + for (const value of values) { + if (value === null || value === undefined) continue; + if (typeof value !== "string" || !ADDRESS.test(value)) return "text"; + seen += 1; + } + return seen > 0 ? "address" : "text"; +} + +/** The explorer page for one value, or null when the value is not that kind. */ +export function explorerHref( + base: string | null | undefined, + kind: ColumnKind, + value: unknown, +): string | null { + if (!base || value === null || value === undefined) return null; + const text = String(value); + if (kind === "address" && ADDRESS.test(text)) return `${base}/address/${text}`; + if (kind === "tx" && HASH.test(text)) return `${base}/tx/${text}`; + if (kind === "block" && BLOCK.test(text)) return `${base}/block/${text}`; + return null; +} + +/** Header text: the panel's label, then the amount label, then the name. */ +export function headerLabel(column: ColumnMeta, presentation?: PanelColumn): string { + return presentation?.label ?? columnLabel(column); +} + +/** + * What hovering a header explains. A raw amount says what its figures are, + * since the rounding is otherwise invisible. + */ +export function headerHelp(column: ColumnMeta, presentation?: PanelColumn): string | null { + if (presentation?.description) return presentation.description; + if (column.raw_amount) { + const scale = column.decimals ? `, scaled by ${column.decimals} decimals` : ""; + return `Exact onchain amount${scale}. Hover a value for every digit.`; + } + return null; +} + +// ---- table filter and export --------------------------------------------- + +/** Whether any cell of a row contains the needle, ignoring case. */ +export function rowMatches(row: readonly unknown[], columns: readonly number[], needle: string): boolean { + const lowered = needle.trim().toLowerCase(); + if (!lowered) return true; + return columns.some((index) => { + const value = row[index]; + return value !== null && value !== undefined && String(value).toLowerCase().includes(lowered); + }); +} + +function csvField(value: unknown): string { + if (value === null || value === undefined) return ""; + const text = String(value); + return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +/** + * RFC 4180 CSV of exactly what the result holds: column names as the query + * returned them and raw values, never the display rounding. + */ +export function toCsv( + columns: readonly ColumnMeta[], + rows: readonly (readonly unknown[])[], + visible: readonly number[], +): string { + const lines = [visible.map((index) => csvField(columns[index]?.name)).join(",")]; + for (const row of rows) { + lines.push(visible.map((index) => csvField(row[index])).join(",")); + } + return `${lines.join("\r\n")}\r\n`; +} + +// ---- layout ---------------------------------------------------------------- + +/** + * Which KPI panels should keep their natural height. The two-column grid + * stretches cards in a row to one height, which levels two KPIs side by side + * but leaves a KPI beside a table as a tall card with its number at the + * bottom. So a KPI stands alone exactly when its row-mate is something else. + */ +export function kpiStandsAlone( + panels: readonly { chart: string; span?: "half" | "full" }[], +): boolean[] { + const alone = panels.map(() => false); + let open: number | null = null; // index of a half panel waiting for a row-mate + panels.forEach((panel, index) => { + if (panel.span === "full") { + open = null; + return; + } + if (open === null) { + open = index; + return; + } + const mate = panels[open]!; + if ((mate.chart === "kpi") !== (panel.chart === "kpi")) { + alone[mate.chart === "kpi" ? open : index] = true; + } + open = null; + }); + return alone; +} diff --git a/viewer/src/styles.css b/viewer/src/styles.css index b0664d8..5e72e92 100644 --- a/viewer/src/styles.css +++ b/viewer/src/styles.css @@ -208,10 +208,48 @@ body { grid-column: 1 / -1; } +/* A KPI beside a table or chart keeps its own height instead of growing to + its row-mate's, which left the number at the foot of an empty card. */ +.panel.natural-height { + align-self: start; +} + .panel-head { margin-bottom: 0.85rem; } +.panel-title { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.panel-actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.panel-actions a, +.panel-actions button { + padding: 0; + border: 0; + background: none; + color: var(--text-tertiary); + font: inherit; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.04em; + text-decoration: none; + cursor: pointer; +} + +.panel-actions a:hover, +.panel-actions button:hover { + color: var(--blue-brand); +} + .panel-head h3 { margin: 0; font-size: 0.8125rem; @@ -334,10 +372,78 @@ table.data-table td.text { color: var(--text-secondary); } -table.data-table th.raw button::after { - content: "·"; - margin-left: 0.3rem; +/* A header with something to say on hover is underlined, the way an + is, so the tooltip can be found without a legend. */ +table.data-table th button.has-help .header-text { + text-decoration: underline dotted var(--text-tertiary); + text-underline-offset: 3px; + cursor: help; +} + +/* ---- values with an explorer page or a hex body ---- */ + +.value { + display: inline-flex; + align-items: center; + gap: 0.35rem; +} + +.value a { color: var(--blue-brand); + text-decoration: none; +} + +.value a:hover { + text-decoration: underline; +} + +.copy { + padding: 0 0.2rem; + border: 0; + border-radius: var(--radius-xs); + background: none; + color: var(--text-tertiary); + font: inherit; + line-height: 1.2; + cursor: pointer; + /* Out of the way until the row is pointed at, but still reachable by + keyboard: focus shows it as well. */ + opacity: 0; +} + +tr:hover .copy, +.copy:focus-visible, +.copy.copied { + opacity: 1; +} + +.copy:hover { + color: var(--blue-brand); + background: var(--info-surface); +} + +.copy.copied { + color: var(--success-contrast); +} + +/* ---- table filter ---- */ + +.table-filter { + align-self: flex-start; + width: min(18rem, 100%); + margin-bottom: 0.6rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--border-default); + border-radius: var(--radius-s); + background: var(--bg-elevated); + color: var(--text-primary); + font: inherit; + font-size: 0.8125rem; +} + +.table-filter:focus { + outline: none; + border-color: var(--blue-brand); } /* ---- charts, states, footer ---- */ From 1c6a208223d746e54c697a68fd000dac3c675cb1 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Thu, 1 Oct 2026 12:04:34 +0200 Subject: [PATCH 3/5] docs: list panel columns and the layout rules The Presentation paragraph listed the panel options without columns, and capabilities did not say how bar labels and KPI height now behave. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- docs/capabilities.md | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c3e4647..ad6d555 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,9 @@ from a closed set — `validation`, `policy_refused`, `missing_credentials`, ## Presentation -Panels are declarative. `title`, `description`, `span`, `hide_columns` and -`unit` shape the page; `decimals`, `symbol` and `label` on a raw amount column -shape the numbers. Charts are `line`, `bar`, `area`, `kpi`, `table` — an +Panels are declarative. `title`, `description`, `span`, `hide_columns`, +`unit` and `columns` shape the page; `decimals`, `symbol` and `label` on a raw +amount column shape the numbers. Charts are `line`, `bar`, `area`, `kpi`, `table` — an allowlist, not an embedded plotting language. Display metadata never alters stored values. `decimals: 6` renders diff --git a/docs/capabilities.md b/docs/capabilities.md index 85d44db..30a31b5 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -39,6 +39,7 @@ Machine-readable source of truth: `chainplot capabilities --json`. - Panel columns: `columns..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. - Explorer links: `chain_sources[].explorer_url` links address, transaction and block cells to `/address/…`, `/tx/…`, `/block/…`. The first chain source that declares one is used. - 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 +- Layout: a bar chart with up to 30 bars labels every bar, tilted when crowded; past that its axis thins labels like a line's. A KPI beside a table or chart keeps its own height; two KPIs side by side are levelled ## SQL admission control From 5778a2a054f07fa464b7d18dc273d963a394d7d8 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Thu, 1 Oct 2026 12:04:34 +0200 Subject: [PATCH 4/5] viewer: link only web explorers and the release's own SQL Validation holds explorer_url to http(s), but the viewer renders whatever dashboard JSON it is served, and a release's JSON can be edited after the build: a javascript: base would have become a clickable script. explorerHref now refuses a base that is not http(s), and a panel's SQL link renders only for a path inside the release's source/ directory. security.md says what the viewer turns into links and why a cell value cannot carry a scheme into one. Co-Authored-By: Claude Opus 5.5 --- docs/security.md | 11 +++++++++++ tests/viewer/format.test.ts | 17 +++++++++++++++++ viewer/src/App.tsx | 8 +++++--- viewer/src/format.ts | 19 ++++++++++++++++++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/security.md b/docs/security.md index 0456ecf..dd7b08b 100644 --- a/docs/security.md +++ b/docs/security.md @@ -50,6 +50,17 @@ the statement count, so `SELECT 1; DROP TABLE t` is refused as two statements rather than passing a check aimed at the first. The statement is parsed, not run, by this call. +### Links the viewer renders + +A dashboard turns project-supplied strings into links: `explorer_url` on a +chain source, and each panel's SQL path. Validation holds `explorer_url` to +`http(s)://`, and the viewer checks again on what it is actually served, since +a release's JSON can be edited after the build: an explorer base that is not +http(s) produces no link, and a SQL path renders only when it stays inside the +release's `source/` directory. Cell values reach a link only after matching +the shape their kind requires (20-byte address, 32-byte hash, decimal block), +so a value cannot carry markup or a scheme into an `href`. + ## What is *not* defended - **Authenticity of a published release.** `release.json` lists a SHA-256 for diff --git a/tests/viewer/format.test.ts b/tests/viewer/format.test.ts index 5235b43..23fc862 100644 --- a/tests/viewer/format.test.ts +++ b/tests/viewer/format.test.ts @@ -10,6 +10,7 @@ import { headerLabel, kpiStandsAlone, rowMatches, + sqlHref, toCsv, isNumericColumn, formatCell, @@ -409,6 +410,22 @@ describe("explorerHref", () => { expect(explorerHref(base, "text", ADDR)).toBeNull(); expect(explorerHref(null, "address", ADDR)).toBeNull(); }); + + it("links only from a web base, whatever the dashboard JSON says", () => { + expect(explorerHref("javascript:alert(1)//", "address", ADDR)).toBeNull(); + expect(explorerHref("data:text/html,x", "block", "1")).toBeNull(); + }); +}); + +describe("sqlHref", () => { + it("keeps a path into the source bundle and drops anything else", () => { + expect(sqlHref("source/queries/top.sql")).toBe("source/queries/top.sql"); + expect(sqlHref("source/../release.json")).toBeNull(); + expect(sqlHref("source/queries/../../x.sql")).toBeNull(); + expect(sqlHref("https://elsewhere.example/q.sql")).toBeNull(); + expect(sqlHref("javascript:alert(1)//.sql")).toBeNull(); + expect(sqlHref(null)).toBeNull(); + }); }); describe("header label and help", () => { diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index 9782a6f..875a1d7 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -21,6 +21,7 @@ import { relativeTime, rowMatches, rowWindow, + sqlHref, toChartNumber, toCsv, type ColumnKind, @@ -486,11 +487,12 @@ function PanelActions({ columns: number[]; }) { const csv = result && !result.error && panel.chart !== "kpi" && result.rows.length > 0; - if (!panel.sql && !csv) return null; + const sql = sqlHref(panel.sql); + if (!sql && !csv) return null; return (
- {panel.sql ? ( - + {sql ? ( + SQL ) : null} diff --git a/viewer/src/format.ts b/viewer/src/format.ts index cdfb8c9..edba1e3 100644 --- a/viewer/src/format.ts +++ b/viewer/src/format.ts @@ -338,13 +338,18 @@ export function columnKind( return seen > 0 ? "address" : "text"; } +// Validation already holds a project's explorer_url to http(s), but the viewer +// reads whatever dashboard JSON it is served, so it checks again: a +// `javascript:` base would otherwise become a clickable script. +const WEB_BASE = /^https?:\/\/[^\s"'<>]+$/; + /** The explorer page for one value, or null when the value is not that kind. */ export function explorerHref( base: string | null | undefined, kind: ColumnKind, value: unknown, ): string | null { - if (!base || value === null || value === undefined) return null; + if (!base || !WEB_BASE.test(base) || value === null || value === undefined) return null; const text = String(value); if (kind === "address" && ADDRESS.test(text)) return `${base}/address/${text}`; if (kind === "tx" && HASH.test(text)) return `${base}/tx/${text}`; @@ -434,3 +439,15 @@ export function kpiStandsAlone( }); return alone; } + +/** + * A panel's SQL link, only when it points into the release's own source + * bundle. Anything else — another origin, a scheme, a climb out with `..` — + * is dropped rather than linked. + */ +export function sqlHref(path: string | null | undefined): string | null { + if (!path) return null; + return /^source\/[A-Za-z0-9._\/-]+\.sql$/.test(path) && !path.split("/").includes("..") + ? path + : null; +} From 890ca1334dd89f8354330291740c3cb858ba1c73 Mon Sep 17 00:00:00 2001 From: Anton Sauchyk Date: Thu, 1 Oct 2026 12:45:52 +0200 Subject: [PATCH 5/5] docs: viewer/dist is built, not committed compatibility.md still described the bundle as committed under viewer/dist/, which .gitignore has excluded since the bundle and its sources could drift. Match the README: pnpm build produces it, and build refuses to write a release without it. Co-Authored-By: Claude Opus 5.5 --- docs/compatibility.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/compatibility.md b/docs/compatibility.md index 94cc2bc..d7da900 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -50,14 +50,14 @@ Full static build on `main`: SELECT model graph (topo order, cycles refused at `validate`, SELECT-only enforced in the isolated worker), extended `build` (full §16.1 layout minus `latest.json`: `index.html`, `assets/`, `dashboards/`, `results/`, `datasets//{manifest.json,tables/*.parquet}`, -`source/` allowlist), viewer bundle (React + Vite + ECharts, committed under -`viewer/dist/`, no CDN), `serve` (127.0.0.1 by default, `--host` to change), `plan --intent build` +`source/` allowlist), viewer bundle (React + Vite + ECharts, built into +`viewer/dist/` by `pnpm build` and not committed, no CDN), `serve` (127.0.0.1 by default, `--host` to change), `plan --intent build` (no RPC). A2 + A9 groundwork pass offline. | Piece | Pin | | --- | --- | | viewer deps | react 19, echarts 6, vite 7 (`viewer/package.json`, own lockfile) | -| viewer bundle | committed `viewer/dist/`; rebuild with `pnpm --dir viewer build` | +| viewer bundle | built into `viewer/dist/` by `pnpm build`, not committed; `build` refuses to run without it | | release id | `local-` staged then renamed to `dist/releases/local` (M4 adds real ids + `latest.json`) | Notes: