From 601383993cebcb9fb85e72300892e061c9f7db05 Mon Sep 17 00:00:00 2001 From: Mikers Date: Wed, 1 Jul 2026 22:02:04 -1000 Subject: [PATCH] Improve Calibration dashboard fixture demo --- .../calibration/fixture-seed-summary.json | 328 ++++++++ docs/calibration-worker-demo.md | 31 +- package.json | 1 + .../seed-calibration-registry-fixtures.mjs | 796 ++++++++++++++++++ src/worker/calibration-demo.mjs | 182 +++- test/calibration-worker.test.mjs | 7 + 6 files changed, 1330 insertions(+), 15 deletions(-) create mode 100644 artifacts/calibration/fixture-seed-summary.json create mode 100644 scripts/seed-calibration-registry-fixtures.mjs diff --git a/artifacts/calibration/fixture-seed-summary.json b/artifacts/calibration/fixture-seed-summary.json new file mode 100644 index 0000000..d97200c --- /dev/null +++ b/artifacts/calibration/fixture-seed-summary.json @@ -0,0 +1,328 @@ +{ + "generatedAt": "2026-07-02T09:05:15.917Z", + "dryRun": false, + "network": "filecoin_calibration", + "chainId": 314159, + "registry": "0x8F6563Bb9E53aeDfE9d87d4C1E162f0371649c18", + "signer": "0xF00DCE36817586672B47480FB48C94177A97278B", + "seed": "2026-07-demo-grid-durable", + "gasLimit": "125000000", + "receiptTimeoutMs": 300000, + "requestedFixtures": { + "objects": 48, + "accounts": 24, + "committed": 8, + "uploading": 8, + "requested": 32, + "requestExpiresAt": "4102444800" + }, + "counts": { + "before": { + "objectCount": "83", + "accountCount": "49", + "datasetRecordCount": "9", + "coordinatorCount": "1", + "relayerCount": "1" + }, + "after": { + "objectCount": "97", + "accountCount": "49", + "datasetRecordCount": "17", + "coordinatorCount": "1", + "relayerCount": "1" + } + }, + "observedFixtures": { + "total": 48, + "requested": 32, + "uploading": 8, + "committed": 8, + "other": 0, + "missing": [], + "requestExpiresAt": "4102444800", + "requestExpiresAtMismatches": [], + "objectIds": { + "first": "50", + "last": "97" + } + }, + "changes": { + "created": [ + "fixture-35", + "fixture-36", + "fixture-37", + "fixture-38", + "fixture-39", + "fixture-40", + "fixture-41", + "fixture-42", + "fixture-43", + "fixture-44", + "fixture-45", + "fixture-46", + "fixture-47", + "fixture-48" + ], + "advanced": [ + "fixture-1:uploading", + "fixture-2:uploading", + "fixture-3:uploading", + "fixture-4:uploading", + "fixture-5:uploading", + "fixture-6:uploading", + "fixture-7:uploading", + "fixture-8:uploading", + "fixture-9:uploading", + "fixture-10:uploading", + "fixture-11:uploading", + "fixture-12:uploading", + "fixture-13:uploading", + "fixture-14:uploading", + "fixture-15:uploading", + "fixture-16:uploading", + "fixture-1:committed", + "fixture-2:committed", + "fixture-3:committed", + "fixture-4:committed", + "fixture-5:committed", + "fixture-6:committed", + "fixture-7:committed", + "fixture-8:committed" + ], + "skipped": [], + "txCount": 46, + "txHashes": [ + { + "label": "request fixture-35", + "functionName": "requestUpload", + "hash": "0x7be9a730b5df042698a6fde478b445dea06b0db9330e9c519df09f2446ecdd9f" + }, + { + "label": "request fixture-36", + "functionName": "requestUpload", + "hash": "0x8e16d08a3d0aae91e38585da0e8bd98decc2a3ae36cce1e4210952caaf50c72d" + }, + { + "label": "request fixture-37", + "functionName": "requestUpload", + "hash": "0xd3c3f00c330a3bc7e4dc5521c4ab13e869a7107f1805894f522fbc7788ba605e" + }, + { + "label": "request fixture-38", + "functionName": "requestUpload", + "hash": "0xa3c7c2c8d726e9ae41786a7fc81e43b51a96660d73d70794a44a64c66bd29afa" + }, + { + "label": "request fixture-39", + "functionName": "requestUpload", + "hash": "0xa34175c80a9d88fc42d82ca0d4def982b0f45cf2fcc7e3c1097a48b65a1b78da" + }, + { + "label": "request fixture-40", + "functionName": "requestUpload", + "hash": "0x1dcf6ad1dad0e5f918fae79dabeea9b9f5ddecca9dead01009340a54b87133f9" + }, + { + "label": "request fixture-41", + "functionName": "requestUpload", + "hash": "0x6bb2d58fc1d3e22c2ba51b477b930f53bbefa20ab598d04b5769eb9d20bfbc54" + }, + { + "label": "request fixture-42", + "functionName": "requestUpload", + "hash": "0xfaee677fd8a294a4fe2134a0c5e5e8019c7bfa95c1a71481af2040b012bead7a" + }, + { + "label": "request fixture-43", + "functionName": "requestUpload", + "hash": "0x0ef2e4346befdf1677a1ba29bb4439e0f77936af4058b76acfcf1066dc8d4d87" + }, + { + "label": "request fixture-44", + "functionName": "requestUpload", + "hash": "0x4109b49df83201558738ba43d28011a72c3007e4cee054586851bd5bbab0f204" + }, + { + "label": "request fixture-45", + "functionName": "requestUpload", + "hash": "0x53a211e66603dd26b21eae87918b2fa7e2227d16087824b9ac6a43e267f7ab8e" + }, + { + "label": "request fixture-46", + "functionName": "requestUpload", + "hash": "0x0da0bfe3c99689baa7b205c6129dc6aa28f1b759aa2fda9ea6ce4943bf8c9ae0" + }, + { + "label": "request fixture-47", + "functionName": "requestUpload", + "hash": "0x274c9ec197a3f1dc2e071bf7a715f475c1f7eab92d7b5c6cd9948563b1b8204c" + }, + { + "label": "request fixture-48", + "functionName": "requestUpload", + "hash": "0xad204cec95f83cfacab2effc30b6a0e81c2427f2b82b3fa2510d07508221a8da" + }, + { + "label": "start fixture-1", + "functionName": "startUpload", + "hash": "0x97b7740075c8926472b82a46aeda347477a184c7af6bc6d97d61d939698a0a4e" + }, + { + "label": "start fixture-2", + "functionName": "startUpload", + "hash": "0xcd50d0f0ae3096c8069a1421ee57b272baf812c639dc87672d7e8b319dd5ebfd" + }, + { + "label": "start fixture-3", + "functionName": "startUpload", + "hash": "0x8c9c83e89544fdd7ce1fc7c239287a4eba139198fce4a071213175f7221afdea" + }, + { + "label": "start fixture-4", + "functionName": "startUpload", + "hash": "0x32ba4107a1ad9019c288cbe1d59fc2e222e07818efec4d4f063518252dbf7063" + }, + { + "label": "start fixture-5", + "functionName": "startUpload", + "hash": "0x02d193a44f9b0b10d617f45d34e47a4cc55986373f35546db5f45509e2d32354" + }, + { + "label": "start fixture-6", + "functionName": "startUpload", + "hash": "0x2c1fe54859839481f6f56fdb0446ef26c85c8162417d70790c43f6aa6bf1fc19" + }, + { + "label": "start fixture-7", + "functionName": "startUpload", + "hash": "0xc78864856ba9ab385a4223f9c69008016bee5df705347c4d500b0517bac0ac0a" + }, + { + "label": "start fixture-8", + "functionName": "startUpload", + "hash": "0xf99b032c4302ae8febe3909f2a25886f0090c878762606d018242b6874dca2af" + }, + { + "label": "start fixture-9", + "functionName": "startUpload", + "hash": "0xd743c3e93e1bd1c1401dd938e27e49ad2f27f92a406728d4f6a59ee9a9045257" + }, + { + "label": "start fixture-10", + "functionName": "startUpload", + "hash": "0x5733d0bfbd02fab2315552582159032dbd43d9c9b540dff62b46ef021e2a44da" + }, + { + "label": "start fixture-11", + "functionName": "startUpload", + "hash": "0x31be64a72fc871bb98ba94b40d89be97be20d66ce9b83e6bcc7d5e779a7905bd" + }, + { + "label": "start fixture-12", + "functionName": "startUpload", + "hash": "0xa4a0b5e35a7cfcaefce9f06ef1838ba56233ce0239118ed0e83c060bd71922cc" + }, + { + "label": "start fixture-13", + "functionName": "startUpload", + "hash": "0x61dc51db02f6b05201b15b4800b9c932be78281134e15ba4cbbdec21a5804591" + }, + { + "label": "start fixture-14", + "functionName": "startUpload", + "hash": "0x6e6b78d54b67f6aea250fe6d26438090959d71652e6c6f258a5ea04eddb985c4" + }, + { + "label": "start fixture-15", + "functionName": "startUpload", + "hash": "0x3bfea9ab66a6791bcc1332ed46366576f35df07251831ced6019a74e30a76def" + }, + { + "label": "start fixture-16", + "functionName": "startUpload", + "hash": "0x5f358af902f8e9c41b35e4cd469d225eb963ea4f0914486d4e98fb289f9b9d8e" + }, + { + "label": "record dataset fixture-1", + "functionName": "recordDataset", + "hash": "0x3aff3243b0655eaf4e3742ae3d4b741e179a1574a6baa78e529f59a79f70b4f5" + }, + { + "label": "finalize fixture-1", + "functionName": "finalizeUpload", + "hash": "0x1c6e5bc01498c93e8ea663ab57d4ebf54b049511bfb1e2a16bfb8d3406b2350a" + }, + { + "label": "record dataset fixture-2", + "functionName": "recordDataset", + "hash": "0x95956b6f0159cda613a63e119932ea779eac0d9717a4e9b52160176fe256cf57" + }, + { + "label": "finalize fixture-2", + "functionName": "finalizeUpload", + "hash": "0x32da45ae9eb6298b06c48283a94b1645731ebae78a468bbdb60ab4906330ef62" + }, + { + "label": "record dataset fixture-3", + "functionName": "recordDataset", + "hash": "0x65a916c83079ed89ba8ce130029ac683988ddfe762fd2d2b033f772faa9a223a" + }, + { + "label": "finalize fixture-3", + "functionName": "finalizeUpload", + "hash": "0x44f296a17bc3ddb2ccdef2c22fe2359b5585f62fa3a94f6245481a0b45865fb6" + }, + { + "label": "record dataset fixture-4", + "functionName": "recordDataset", + "hash": "0xaa9d3fc334169ce09ea4d9d22c4ad4c5d1a19d5bb3ace389e52baeeb85939230" + }, + { + "label": "finalize fixture-4", + "functionName": "finalizeUpload", + "hash": "0xb677765df1d433d7d4b4ead8e3156e69712d445b1b76b270d9c6ee2fae3151b9" + }, + { + "label": "record dataset fixture-5", + "functionName": "recordDataset", + "hash": "0xc01aaaa6e023270e761a2174ecdeaaef6c9d2dbbde560cf1670670cb01919be4" + }, + { + "label": "finalize fixture-5", + "functionName": "finalizeUpload", + "hash": "0xbfcc20c2984385f58a2ffa0289b2b2924795af34f091e7a5aea74b676b5c1310" + }, + { + "label": "record dataset fixture-6", + "functionName": "recordDataset", + "hash": "0xcfcae35f98d0a642fa606a91c0a0c4347500f412bb2f08155edb16eb459ff6da" + }, + { + "label": "finalize fixture-6", + "functionName": "finalizeUpload", + "hash": "0x035d22fb065fd75bc33d9707bd0cbd98ae5eab602d8b0cc8e95b2191dc7083e5" + }, + { + "label": "record dataset fixture-7", + "functionName": "recordDataset", + "hash": "0x6e78f52825936dc4ca97707a6f8ffe60f7b998d741674b9d46d5c915c6996dd8" + }, + { + "label": "finalize fixture-7", + "functionName": "finalizeUpload", + "hash": "0x0584516ddc005c1fe0145a5e24339a22a4337d0d09cea6c9cf232914dbc199e8" + }, + { + "label": "record dataset fixture-8", + "functionName": "recordDataset", + "hash": "0xb3d58a56446fb6a6c5053950cf4642f362bdf5f786f8dbecfcb0341a4095bf80" + }, + { + "label": "finalize fixture-8", + "functionName": "finalizeUpload", + "hash": "0x5e46f407dd2fd6354caa2ac3fb2eeb6ab2a31055af3a0489381eaef181b2f5c3" + } + ] + }, + "boundary": "Synthetic registry-only fixtures for the public read-only Worker dashboard; these rows do not prove real FOC provider storage." +} diff --git a/docs/calibration-worker-demo.md b/docs/calibration-worker-demo.md index a8f38cd..2dd70b1 100644 --- a/docs/calibration-worker-demo.md +++ b/docs/calibration-worker-demo.md @@ -177,6 +177,25 @@ request, starts it, records dataset attribution, finalizes the receipt, and writes public evidence to `artifacts/calibration/demo-evidence.json`. It does not print or write the private key. +## Seeding Admin Fixture Rows + +The deployed Worker admin dashboard reads directly from the public Calibration +registry, so richer demo tables require local registry transactions. Use the +fixture seeder when the dashboard needs many rows without claiming additional +FOC provider evidence: + +```sh +# Export PRIVATE_KEY or PLATFORM_ROOT_PRIVATE_KEY in the current shell first. +pnpm demo:seed-calibration-fixtures -- --objects 48 --accounts 24 --committed 8 --uploading 8 +``` + +The seeder creates deterministic synthetic registry-only rows: committed rows +include synthetic receipt and dataset records, uploading rows are advanced to +`Uploading`, and the rest remain `Requested`. It uses durable request expiries +by default (`4102444800`, 2100-01-01 UTC), is idempotent for a given `--seed`, +never writes the private key, and writes a public summary to +`artifacts/calibration/fixture-seed-summary.json`. + ## Current Evidence Status The committed demo evidence now points at a live Calibration object: @@ -195,8 +214,8 @@ The committed demo evidence now points at a live Calibration object: | Piece CID | `bafkzcibeqcad6egqwuynxmfu6jof2lzkfdp65aelknasuautd4mmjgpvujkaq2ytey` | | Retrieval URL | `https://caliberation-pdp.infrafolio.com/piece/bafkzcibeqcad6egqwuynxmfu6jof2lzkfdp65aelknasuautd4mmjgpvujkaq2ytey` | | Worker URL | `https://foc-platform-calibration-demo.snissn.workers.dev` | -| Worker version | `c83bd91f-164e-4a48-9d0a-06518cd51212` | -| Dashboard direct-read proof | `objectCount=1`, `accountCount=1`, `datasetRecordCount=1`, `coordinatorCount=1`, `relayerCount=1`, `readBatch.resultCount=5` | +| Worker version | `63d11d7e-6cc1-41af-8c1b-7056a3b1d8e8` | +| Dashboard direct-read proof | Initial evidence object remains object `1`; fixture seed summary `artifacts/calibration/fixture-seed-summary.json` shows live counts after durable seeding: `objectCount=97`, `accountCount=49`, `datasetRecordCount=17`, `coordinatorCount=1`, `relayerCount=1`; verified durable fixture objects span `50`-`97` with 8 committed, 8 uploading, 32 requested, and no expiry mismatches. | Public registry transaction hashes: @@ -217,3 +236,11 @@ This run proves the direct pagination/list/detail/readBatch ABI used by the Worker dashboard. It still does not prove a real session-key coordinator or expiry/revoke path. The local dev root address was allowlisted as coordinator and relayer to complete the public end-to-end Worker demo. + +On July 2, 2026, the admin dashboard registry was also seeded with synthetic +registry-only fixture rows for demo table density: 48 fixture files across 24 +fixture accounts, with 8 committed, 8 uploading, and 32 requested fixture +targets. These rows are intentionally separate from the provider-backed object +`1` evidence above and should not be described as FOC provider storage proof. +The Worker dashboard defaults to 10 rows per page to stay within Worker +subrequest limits while still showing pagination over the larger registry. diff --git a/package.json b/package.json index 38cdb58..9943561 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:artifacts": "forge build && node scripts/generate-registry-artifacts.mjs && node scripts/generate-tokenhost-wrapper-artifacts.mjs", "build:contracts": "forge build", "build:tokenhost": "node scripts/generate-tokenhost-wrapper-artifacts.mjs", + "demo:seed-calibration-fixtures": "node scripts/seed-calibration-registry-fixtures.mjs", "lint": "node scripts/check-workspace.mjs", "ops:smoke": "node scripts/run-ops-smoke.mjs", "ops:validate": "node scripts/validate-ops-config.mjs", diff --git a/scripts/seed-calibration-registry-fixtures.mjs b/scripts/seed-calibration-registry-fixtures.mjs new file mode 100644 index 0000000..752ba3f --- /dev/null +++ b/scripts/seed-calibration-registry-fixtures.mjs @@ -0,0 +1,796 @@ +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { + createPublicClient, + createWalletClient, + getAddress, + http, + isAddress, + keccak256, + stringToHex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { filecoinCalibration } from "viem/chains"; + +import { registryAbi } from "../src/registry/read-model.mjs"; + +const DEFAULT_RPC_URL = "https://api.calibration.node.glif.io/rpc/v1"; +const DEFAULT_REGISTRY_ADDRESS = "0x8F6563Bb9E53aeDfE9d87d4C1E162f0371649c18"; +const DEFAULT_OUTPUT_PATH = "artifacts/calibration/fixture-seed-summary.json"; +const DEFAULT_FIXTURE_SEED = "2026-07-demo-grid-durable"; +const DEFAULT_FIXTURE_REQUEST_EXPIRES_AT = 4_102_444_800n; // 2100-01-01T00:00:00Z +const DEFAULT_GAS_LIMIT = 125_000_000n; +const DEFAULT_RPC_TIMEOUT_MS = 120_000; +const DEFAULT_RECEIPT_TIMEOUT_MS = 300_000; +const ZERO_BYTES32 = `0x${"0".repeat(64)}`; +const FIXTURE_COORDINATOR_MAX_FINALIZE_DELAY = 86_400n; +const STATUS = Object.freeze({ + requested: 1, + uploading: 2, + committed: 3, + partial: 4, + failed: 5, +}); + +if (isMainModule(import.meta.url, process.argv[1])) { + try { + await main(); + } catch (error) { + console.error(error?.stack ?? error); + process.exitCode = 1; + } +} + +export async function main({ argv = process.argv, stdout = console.log } = {}) { + const args = parseArgs(argv.slice(2)); + if (args.help) { + stdout(`Usage: node scripts/seed-calibration-registry-fixtures.mjs [options] + +Creates deterministic synthetic registry-only demo rows on Filecoin Calibration. +The script never prints or writes the private key. + +Required environment: + PRIVATE_KEY or PLATFORM_ROOT_PRIVATE_KEY local Calibration signer + +Common optional environment: + FILECOIN_CALIBRATION_RPC_URL defaults to public GLIF Calibration RPC + FOC_PLATFORM_REGISTRY_ADDRESS defaults to the committed demo registry + FOC_PLATFORM_FIXTURE_GAS_LIMIT defaults to 125000000 + FOC_PLATFORM_FIXTURE_RPC_TIMEOUT_MS defaults to 120000 + FOC_PLATFORM_FIXTURE_RECEIPT_TIMEOUT_MS defaults to 300000 + +Options: + --objects total fixture files to ensure, default 48 + --accounts total fixture users/accounts to rotate across, default 24 + --committed number of files to finalize as committed, default 8 + --uploading number of files to advance to uploading, default 8 + --seed deterministic fixture namespace, default ${DEFAULT_FIXTURE_SEED} + --request-expires-at + durable request expiry timestamp, default ${DEFAULT_FIXTURE_REQUEST_EXPIRES_AT} + --provider synthetic provider id base, default 40 + --dataset synthetic dataset id base, default 13000 + --piece synthetic piece id base, default 7000 + --batch-size transactions to send before waiting, default 12 + --output write public summary JSON, default ${DEFAULT_OUTPUT_PATH} + --dry-run read and plan only; do not submit transactions +`); + return null; + } + + const result = await seedCalibrationRegistryFixtures({ + env: process.env, + options: args, + stdout, + }); + stdout(JSON.stringify(result.summary, null, 2)); + return result; +} + +export async function seedCalibrationRegistryFixtures({ + env = process.env, + options = {}, + stdout = console.log, +} = {}) { + const config = loadConfig(env, options); + const publicClient = createPublicClient({ + chain: filecoinCalibration, + transport: http(config.rpcUrl, { + retryCount: 2, + retryDelay: 1_000, + timeout: config.rpcTimeoutMs, + }), + }); + const walletClient = createWalletClient({ + account: config.account, + chain: filecoinCalibration, + transport: http(config.rpcUrl, { + retryCount: 2, + retryDelay: 1_000, + timeout: config.rpcTimeoutMs, + }), + }); + + const txHashes = []; + const skipped = []; + const created = []; + const advanced = []; + + await assertOwner({ publicClient, config }); + await ensureCoordinator({ publicClient, walletClient, config, stdout, txHashes }); + await ensureRelayer({ publicClient, walletClient, config, stdout, txHashes }); + + const beforeCounts = await readCounts({ publicClient, config }); + const rowStates = buildFixtureRows(config).map((row) => ({ + row, + objectId: 0n, + status: 0, + existed: false, + })); + + for (const state of rowStates) { + const { row } = state; + const existingObjectId = await readObjectByIdempotencyKey({ publicClient, config, row }); + state.objectId = existingObjectId; + state.existed = existingObjectId !== 0n; + + if (existingObjectId !== 0n) { + const object = await readStorageObject({ publicClient, config, objectId: existingObjectId }); + assertFixtureRequestMatches({ row, objectId: existingObjectId, object }); + state.status = objectStatus(object); + } + } + + const requestPlans = rowStates + .filter((state) => state.objectId === 0n) + .map((state) => ({ + label: `request ${state.row.label}`, + functionName: "requestUpload", + args: [state.row.requestParams, "0x"], + })); + await sendBatch({ + publicClient, + walletClient, + config, + stdout, + dryRun: config.dryRun, + txHashes, + plans: requestPlans, + }); + + for (const state of rowStates) { + const { row } = state; + if (state.objectId === 0n && !config.dryRun) { + state.objectId = await readObjectByIdempotencyKey({ publicClient, config, row }); + if (state.objectId !== 0n) created.push(row.label); + } + if (state.objectId === 0n) { + skipped.push({ label: row.label, reason: "dry_run_not_created" }); + continue; + } + const object = await readStorageObject({ publicClient, config, objectId: state.objectId }); + assertFixtureRequestMatches({ row, objectId: state.objectId, object }); + state.status = objectStatus(object); + } + + const startPlans = rowStates + .filter( + (state) => + state.objectId !== 0n && + state.row.targetStatus >= STATUS.uploading && + state.status === STATUS.requested, + ) + .map((state) => ({ + state, + label: `start ${state.row.label}`, + functionName: "startUpload", + args: [state.objectId], + })); + await sendBatch({ + publicClient, + walletClient, + config, + stdout, + dryRun: config.dryRun, + txHashes, + plans: startPlans, + }); + if (!config.dryRun) { + for (const plan of startPlans) { + plan.state.status = STATUS.uploading; + advanced.push(`${plan.state.row.label}:uploading`); + } + } + + for (const state of rowStates) { + if (state.objectId === 0n || state.row.targetStatus < STATUS.committed) continue; + const object = await readStorageObject({ publicClient, config, objectId: state.objectId }); + state.status = objectStatus(object); + if (state.status >= STATUS.committed) { + skipped.push({ + label: state.row.label, + objectId: state.objectId.toString(), + reason: "already_terminal", + }); + } + } + + const finalizationPlans = rowStates + .filter( + (state) => + state.objectId !== 0n && + state.row.targetStatus >= STATUS.committed && + state.status === STATUS.uploading, + ) + .flatMap((state) => [ + { + state, + label: `record dataset ${state.row.label}`, + functionName: "recordDataset", + args: [state.row.dataset], + }, + { + state, + label: `finalize ${state.row.label}`, + functionName: "finalizeUpload", + args: [state.objectId, state.row.receipt], + }, + ]); + await sendBatch({ + publicClient, + walletClient, + config, + stdout, + dryRun: config.dryRun, + txHashes, + plans: finalizationPlans, + }); + if (!config.dryRun) { + for (const plan of finalizationPlans.filter((item) => item.functionName === "finalizeUpload")) { + plan.state.status = STATUS.committed; + advanced.push(`${plan.state.row.label}:committed`); + } + } + + const afterCounts = config.dryRun ? beforeCounts : await readCounts({ publicClient, config }); + const summary = { + generatedAt: new Date().toISOString(), + dryRun: config.dryRun, + network: "filecoin_calibration", + chainId: 314159, + registry: config.registryAddress, + signer: config.account.address, + seed: config.seed, + gasLimit: config.gas.toString(), + receiptTimeoutMs: config.receiptTimeoutMs, + requestedFixtures: { + objects: config.objectCount, + accounts: config.accountCount, + committed: config.committedCount, + uploading: config.uploadingCount, + requested: Math.max(0, config.objectCount - config.committedCount - config.uploadingCount), + requestExpiresAt: config.requestExpiresAt.toString(), + }, + counts: { + before: stringifyCounts(beforeCounts), + after: stringifyCounts(afterCounts), + }, + observedFixtures: summarizeObservedFixtures(rowStates, config), + changes: { + created, + advanced, + skipped, + txCount: txHashes.length, + txHashes, + }, + boundary: + "Synthetic registry-only fixtures for the public read-only Worker dashboard; these rows do not prove real FOC provider storage.", + }; + + if (!config.dryRun && config.outputPath) { + await writeFile(config.outputPath, `${JSON.stringify(summary, null, 2)}\n`); + } + + return { summary }; +} + +function loadConfig(env, options) { + const privateKey = normalizePrivateKey(env.PRIVATE_KEY ?? env.PLATFORM_ROOT_PRIVATE_KEY); + const objectCount = positiveInt(options.objects ?? "48", "objects"); + const accountCount = positiveInt(options.accounts ?? "24", "accounts"); + const committedCount = boundedCount(options.committed ?? "8", objectCount, "committed"); + const uploadingCount = boundedCount(options.uploading ?? "8", objectCount - committedCount, "uploading"); + + return { + account: privateKeyToAccount(privateKey), + rpcUrl: optionalEnvDefault(env, "FILECOIN_CALIBRATION_RPC_URL", DEFAULT_RPC_URL), + registryAddress: normalizeAddress( + optionalEnvDefault(env, "FOC_PLATFORM_REGISTRY_ADDRESS", DEFAULT_REGISTRY_ADDRESS), + "FOC_PLATFORM_REGISTRY_ADDRESS", + ), + rpcTimeoutMs: positiveInt( + optionalEnvDefault(env, "FOC_PLATFORM_FIXTURE_RPC_TIMEOUT_MS", DEFAULT_RPC_TIMEOUT_MS), + "FOC_PLATFORM_FIXTURE_RPC_TIMEOUT_MS", + ), + receiptTimeoutMs: positiveInt( + optionalEnvDefault(env, "FOC_PLATFORM_FIXTURE_RECEIPT_TIMEOUT_MS", DEFAULT_RECEIPT_TIMEOUT_MS), + "FOC_PLATFORM_FIXTURE_RECEIPT_TIMEOUT_MS", + ), + gas: BigInt(optionalEnvDefault(env, "FOC_PLATFORM_FIXTURE_GAS_LIMIT", DEFAULT_GAS_LIMIT.toString())), + batchSize: positiveInt(options.batchSize ?? "12", "batch-size"), + dryRun: Boolean(options.dryRun), + outputPath: options.output ?? DEFAULT_OUTPUT_PATH, + seed: String(options.seed ?? DEFAULT_FIXTURE_SEED), + requestExpiresAt: uint64( + options.requestExpiresAt ?? DEFAULT_FIXTURE_REQUEST_EXPIRES_AT.toString(), + "request-expires-at", + ), + objectCount, + accountCount, + committedCount, + uploadingCount, + providerBase: positiveInt(options.provider ?? "40", "provider"), + datasetBase: positiveInt(options.dataset ?? "13000", "dataset"), + pieceBase: positiveInt(options.piece ?? "7000", "piece"), + }; +} + +function buildFixtureRows(config) { + return Array.from({ length: config.objectCount }, (_, index) => { + const fixtureNumber = index + 1; + const accountNumber = (index % config.accountCount) + 1; + const label = `fixture-${fixtureNumber}`; + const accountId = bytes32(`${config.seed}:account:${accountNumber}`); + const user = fixtureUserAddress(config.seed, accountNumber); + const idempotencyKey = bytes32(`${config.seed}:object:${fixtureNumber}`); + const providerId = BigInt(config.providerBase + (accountNumber % 4)); + const datasetId = BigInt(config.datasetBase + accountNumber); + const pieceId = BigInt(config.pieceBase + fixtureNumber); + const size = BigInt(131_072 + fixtureNumber * 4096); + const maxCost = 1_000_000_000_000_000n + BigInt(fixtureNumber); + const actualCost = BigInt(fixtureNumber % 5); + const targetStatus = + index < config.committedCount + ? STATUS.committed + : index < config.committedCount + config.uploadingCount + ? STATUS.uploading + : STATUS.requested; + const metadata = { + project: "foc-platform", + purpose: "worker-admin-fixture", + seed: config.seed, + fixtureNumber, + accountNumber, + }; + const retrievalUrl = `https://example.invalid/foc-platform/fixtures/${config.seed}/${fixtureNumber}`; + const contentHash = bytes32(`${config.seed}:content:${fixtureNumber}`); + const metadataHash = keccak256(stringToHex(stableJson(metadata))); + const syntheticPieceCid = `fixture-piece-${config.seed}-${fixtureNumber}`; + const receiptHash = keccak256( + stringToHex( + stableJson({ + ...metadata, + contentHash, + metadataHash, + pieceId: pieceId.toString(), + retrievalUrl, + actualCost: actualCost.toString(), + }), + ), + ); + + return { + label, + targetStatus, + accountId, + idempotencyKey, + requestParams: { + accountId, + user, + idempotencyKey, + contentHash, + metadataHash, + size, + requestedCopies: 1, + withCDN: false, + maxCost, + requestExpiresAt: config.requestExpiresAt, + }, + dataset: { + accountId, + payer: config.account.address, + providerId, + datasetId, + storageClass: keccak256(stringToHex("foc-calibration-fixture")), + withCDN: false, + createdAt: 0n, + updatedAt: 0n, + }, + receipt: { + finalizationStatus: 0, + payer: config.account.address, + pieceCidHash: keccak256(stringToHex(syntheticPieceCid)), + size, + requestedCopies: 1, + completedCopies: 1, + actualCost, + receiptHash, + copies: [ + { + providerId, + datasetId, + pieceId, + addPieceTxHash: ZERO_BYTES32, + retrievalUrlHash: keccak256(stringToHex(retrievalUrl)), + isNewDataSet: false, + }, + ], + }, + }; + }); +} + +async function ensureCoordinator({ publicClient, walletClient, config, stdout, txHashes }) { + const policy = await publicClient.readContract({ + address: config.registryAddress, + abi: registryAbi, + functionName: "coordinatorPolicies", + args: [config.account.address], + }); + const allowed = Boolean(policy.allowed ?? policy[0]); + const maxFinalizeDelay = BigInt(policy.maxFinalizeDelay ?? policy[1]); + const sessionKeyExpiresAt = BigInt(policy.sessionKeyExpiresAt ?? policy[2]); + const permissionsHash = String(policy.permissionsHash ?? policy[3]).toLowerCase(); + if ( + allowed && + maxFinalizeDelay === FIXTURE_COORDINATOR_MAX_FINALIZE_DELAY && + sessionKeyExpiresAt === 0n && + permissionsHash === ZERO_BYTES32 + ) { + return; + } + + await maybeSend({ + publicClient, + walletClient, + config, + stdout, + dryRun: config.dryRun, + txHashes, + label: "set fixture coordinator", + functionName: "setCoordinator", + args: [ + config.account.address, + { + allowed: true, + maxFinalizeDelay: FIXTURE_COORDINATOR_MAX_FINALIZE_DELAY, + sessionKeyExpiresAt: 0n, + permissionsHash: ZERO_BYTES32, + }, + ], + }); +} + +async function ensureRelayer({ publicClient, walletClient, config, stdout, txHashes }) { + const relayerAllowed = await publicClient.readContract({ + address: config.registryAddress, + abi: registryAbi, + functionName: "isRelayer", + args: [config.account.address], + }); + if (relayerAllowed) return; + + await maybeSend({ + publicClient, + walletClient, + config, + stdout, + dryRun: config.dryRun, + txHashes, + label: "set fixture relayer", + functionName: "setRelayer", + args: [config.account.address, true], + }); +} + +async function assertOwner({ publicClient, config }) { + const owner = await publicClient.readContract({ + address: config.registryAddress, + abi: registryAbi, + functionName: "owner", + }); + if (owner.toLowerCase() !== config.account.address.toLowerCase()) { + throw new Error(`Signer ${config.account.address} is not registry owner ${owner}`); + } +} + +async function readObjectByIdempotencyKey({ publicClient, config, row }) { + return await publicClient.readContract({ + address: config.registryAddress, + abi: registryAbi, + functionName: "objectByIdempotencyKey", + args: [row.accountId, row.idempotencyKey], + }); +} + +async function readStorageObject({ publicClient, config, objectId }) { + return await publicClient.readContract({ + address: config.registryAddress, + abi: registryAbi, + functionName: "getStorageObject", + args: [objectId], + }); +} + +async function readCounts({ publicClient, config }) { + const [objectCount, accountCount, datasetRecordCount, coordinatorCount, relayerCount] = + await Promise.all( + ["objectCount", "accountCount", "datasetRecordCount", "coordinatorCount", "relayerCount"].map( + (functionName) => + publicClient.readContract({ + address: config.registryAddress, + abi: registryAbi, + functionName, + }), + ), + ); + return { objectCount, accountCount, datasetRecordCount, coordinatorCount, relayerCount }; +} + +async function maybeSend({ + publicClient, + walletClient, + config, + stdout, + dryRun, + txHashes, + label, + functionName, + args, +}) { + if (dryRun) { + stdout(`[dry-run] ${label}`); + return null; + } + stdout(`[tx] ${label}`); + const hash = await walletClient.writeContract({ + address: config.registryAddress, + abi: registryAbi, + functionName, + args, + gas: config.gas, + }); + const receipt = await publicClient.waitForTransactionReceipt({ hash }); + if (receipt.status !== "success") { + throw new Error(`${functionName} reverted: ${hash}`); + } + txHashes.push({ label, functionName, hash }); + stdout(`[ok] ${label} ${hash}`); + return hash; +} + +async function sendBatch({ + publicClient, + walletClient, + config, + stdout, + dryRun, + txHashes, + plans, +}) { + if (plans.length === 0) return; + if (dryRun) { + for (const plan of plans) stdout(`[dry-run] ${plan.label}`); + return; + } + + for (let offset = 0; offset < plans.length; offset += config.batchSize) { + const batch = plans.slice(offset, offset + config.batchSize); + let nonce = await publicClient.getTransactionCount({ + address: config.account.address, + blockTag: "pending", + }); + const pending = []; + + for (const plan of batch) { + stdout(`[tx] ${plan.label}`); + const hash = await walletClient.writeContract({ + address: config.registryAddress, + abi: registryAbi, + functionName: plan.functionName, + args: plan.args, + gas: config.gas, + nonce, + }); + nonce += 1; + txHashes.push({ label: plan.label, functionName: plan.functionName, hash }); + stdout(`[sent] ${plan.label} ${hash}`); + pending.push({ ...plan, hash }); + } + + const receipts = await Promise.all( + pending.map((plan) => + publicClient.waitForTransactionReceipt({ + hash: plan.hash, + timeout: config.receiptTimeoutMs, + }), + ), + ); + for (let index = 0; index < receipts.length; index += 1) { + const receipt = receipts[index]; + const plan = pending[index]; + if (receipt.status !== "success") { + throw new Error(`${plan.functionName} reverted: ${plan.hash}`); + } + stdout(`[ok] ${plan.label} ${plan.hash}`); + } + } +} + +function assertFixtureRequestMatches({ row, objectId, object }) { + const checks = [ + ["objectId", object.objectId ?? object[0], objectId], + ["accountId", object.accountId ?? object[1], row.requestParams.accountId], + ["user", object.user ?? object[2], row.requestParams.user], + ["idempotencyKey", object.idempotencyKey ?? object[3], row.requestParams.idempotencyKey], + ["contentHash", object.contentHash ?? object[4], row.requestParams.contentHash], + ["metadataHash", object.metadataHash ?? object[5], row.requestParams.metadataHash], + ["size", object.size ?? object[7], row.requestParams.size], + ["requestedCopies", object.requestedCopies ?? object[8], row.requestParams.requestedCopies], + ["withCDN", object.withCDN ?? object[10], row.requestParams.withCDN], + ["maxCost", object.maxCost ?? object[11], row.requestParams.maxCost], + ["requestExpiresAt", object.requestExpiresAt ?? object[16], row.requestParams.requestExpiresAt], + ]; + const mismatches = checks + .filter(([, actual, expected]) => identityPart(actual) !== identityPart(expected)) + .map(([field, actual, expected]) => `${field} expected ${expected} got ${actual}`); + if (mismatches.length > 0) { + throw new Error(`Existing fixture object ${objectId} does not match deterministic row: ${mismatches.join("; ")}`); + } +} + +function objectStatus(object) { + return Number(object.status ?? object[14]); +} + +function summarizeObservedFixtures(rowStates, config) { + const summary = { + total: 0, + requested: 0, + uploading: 0, + committed: 0, + other: 0, + missing: [], + requestExpiresAt: config.requestExpiresAt.toString(), + requestExpiresAtMismatches: [], + }; + for (const state of rowStates) { + if (state.objectId === 0n) { + summary.missing.push(state.row.label); + continue; + } + summary.total += 1; + if (state.status === STATUS.requested) summary.requested += 1; + else if (state.status === STATUS.uploading) summary.uploading += 1; + else if (state.status === STATUS.committed) summary.committed += 1; + else summary.other += 1; + if (state.row.requestParams.requestExpiresAt !== config.requestExpiresAt) { + summary.requestExpiresAtMismatches.push(state.row.label); + } + } + return summary; +} + +function parseArgs(argv) { + const parsed = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--") { + continue; + } else if (arg === "--help" || arg === "-h") { + parsed.help = true; + } else if (arg === "--dry-run") { + parsed.dryRun = true; + } else if (arg.startsWith("--")) { + const key = arg.slice(2).replace(/-([a-z])/g, (_, char) => char.toUpperCase()); + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + throw new Error(`${arg} requires a value`); + } + parsed[key] = value; + i += 1; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return parsed; +} + +function normalizePrivateKey(value) { + const raw = required(value, "PRIVATE_KEY or PLATFORM_ROOT_PRIVATE_KEY"); + const prefixed = raw.startsWith("0x") ? raw : `0x${raw}`; + if (!/^0x[0-9a-fA-F]{64}$/.test(prefixed)) { + throw new Error("private key must be 32-byte hex"); + } + return prefixed; +} + +function normalizeAddress(value, label) { + const raw = required(value, label); + if (!isAddress(raw)) throw new Error(`${label} must be an EVM address`); + return getAddress(raw); +} + +function optionalEnvDefault(env, key, fallback) { + const value = env[key]; + if (value === undefined || value === null || String(value).trim() === "") return fallback; + return String(value).trim(); +} + +function required(value, label) { + if (value === undefined || value === null || String(value).trim() === "") { + throw new Error(`${label} is required`); + } + return String(value).trim(); +} + +function positiveInt(value, label) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer`); + } + return parsed; +} + +function boundedCount(value, max, label) { + const parsed = Number.parseInt(String(value), 10); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > max) { + throw new Error(`${label} must be an integer between 0 and ${max}`); + } + return parsed; +} + +function uint64(value, label) { + const parsed = BigInt(String(value)); + if (parsed < 0n || parsed > 18_446_744_073_709_551_615n) { + throw new Error(`${label} must be an unsigned 64-bit integer`); + } + return parsed; +} + +function fixtureUserAddress(seed, accountNumber) { + return getAddress(`0x${bytes32(`${seed}:user:${accountNumber}`).slice(-40)}`); +} + +function bytes32(value) { + return keccak256(stringToHex(String(value))); +} + +function stringifyCounts(counts) { + return Object.fromEntries(Object.entries(counts).map(([key, value]) => [key, value.toString()])); +} + +function identityPart(value) { + return String(value ?? "").trim().toLowerCase(); +} + +function stableJson(value) { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value) { + if (Array.isArray(value)) return value.map(sortJson); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => [key, sortJson(item)]), + ); + } + return value; +} + +function isMainModule(metaUrl, entrypoint) { + return Boolean(entrypoint) && metaUrl === pathToFileURL(resolve(entrypoint)).href; +} diff --git a/src/worker/calibration-demo.mjs b/src/worker/calibration-demo.mjs index 9f70fb1..2b503c4 100644 --- a/src/worker/calibration-demo.mjs +++ b/src/worker/calibration-demo.mjs @@ -20,7 +20,7 @@ const DEFAULT_REGISTRY_DEPLOY_BLOCK = "3854411"; const DEFAULT_REGISTRY_RUNTIME_SHA256 = "0x2c49443e7a9ebf3337453240e706df249d29f4f217ec948d6c10e9502a199d1f"; const DEFAULT_RPC_URL = "https://api.calibration.node.glif.io/rpc/v1"; -const DEFAULT_DASHBOARD_PAGE_LIMIT = 20; +const DEFAULT_DASHBOARD_PAGE_LIMIT = 10; const PAGE_SCOPED_RECONCILIATION_OMITTED_FAMILIES = Object.freeze([ "account_usage", "dataset_records", @@ -793,6 +793,7 @@ function fileRowsFromObjectPage(page) { activeBytes: row.object.activeBytes, reservedCost: row.object.reservedCost, actualCost: row.object.actualCost, + pieceCidHash: row.object.pieceCidHash, receiptHash: row.object.receiptHash, receiptPayer: row.receiptPayer, coordinator: row.object.coordinator, @@ -1179,6 +1180,73 @@ function renderAdminDashboardHtml(evidence, { live = true } = {}) { font: inherit; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + .object-link { + max-width: 100%; + border: 0; + background: transparent; + color: var(--accent-2); + padding: 0; + text-align: left; + cursor: pointer; + font: inherit; + font-weight: 800; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + text-decoration: underline; + text-underline-offset: 3px; + } + .muted-state { + color: var(--muted); + font-weight: 700; + } + .detail-row[hidden] { + display: none; + } + .detail-row td { + padding: 0; + background: #fbfcfd; + } + .object-detail { + display: grid; + gap: 12px; + padding: 14px; + border-bottom: 1px solid var(--line); + } + .detail-grid { + display: grid; + grid-template-columns: repeat(4, minmax(140px, 1fr)); + gap: 10px; + } + .detail-item { + min-width: 0; + display: grid; + gap: 3px; + } + .detail-item span, + .receipt-list span { + color: var(--muted); + font-size: 12px; + font-weight: 700; + } + .detail-item strong, + .receipt-item strong { + min-width: 0; + color: var(--code); + font-size: 13px; + overflow-wrap: anywhere; + } + .receipt-list { + display: grid; + gap: 8px; + } + .receipt-item { + display: grid; + grid-template-columns: repeat(4, minmax(130px, 1fr)); + gap: 8px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--surface); + } .empty, .error { padding: 18px 14px; color: var(--muted); @@ -1249,12 +1317,20 @@ function renderAdminDashboardHtml(evidence, { live = true } = {}) { flex: 0 0 auto; } .metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .detail-grid, + .receipt-item { + grid-template-columns: repeat(2, minmax(140px, 1fr)); + } } @media (max-width: 620px) { .topbar { align-items: flex-start; } .statusline { justify-content: flex-start; } .toolbar { grid-template-columns: 1fr; } .metrics { grid-template-columns: 1fr; } + .detail-grid, + .receipt-item { + grid-template-columns: 1fr; + } table { min-width: 760px; } } @@ -1280,8 +1356,8 @@ function renderAdminDashboardHtml(evidence, { live = true } = {}) { @@ -1343,6 +1419,60 @@ function renderAdminDashboardHtml(evidence, { live = true } = {}) { const tone = ["Committed", "active", "matched"].includes(normalized) ? "good" : ["Failed", "Expired", "mismatch", "expired", "disabled"].includes(normalized) ? "bad" : "warn"; return '' + esc(normalized) + ''; }; + const zeroHex = (value) => /^0x0+$/i.test(text(value)); + const pending = (value) => '' + esc(value) + ''; + const objectButton = (row) => { + const id = esc(row.objectId); + return ''; + }; + const receiptCell = (row) => { + if (!zeroHex(row.receiptHash)) return copy(row.receiptHash); + return row.status === "Committed" ? pill("Missing receipt") : pending("Pending receipt"); + }; + const coordinatorCell = (row) => { + if (!zeroHex(row.coordinator)) return copy(row.coordinator); + return row.status === "Requested" ? pending("Not assigned") : pill("Missing coordinator"); + }; + const providerCell = (row) => { + const providers = row.providerIds || []; + if (providers.length > 0) return esc(providers.join(", ")); + return row.status === "Committed" ? pill("Missing providers") : pending("n/a"); + }; + const detailItem = (label, value) => + '
' + esc(label) + '' + value + '
'; + const fileDetailMarkup = (row) => { + const receipts = row.copyReceipts || []; + const receiptRows = receipts.length > 0 + ? receipts.map((receipt) => + '
' + + detailItem("Provider", esc(receipt.providerId)) + + detailItem("Dataset", esc(receipt.datasetId)) + + detailItem("Piece", esc(receipt.pieceId)) + + detailItem("Add piece tx", copy(receipt.addPieceTxHash)) + + detailItem("Retrieval URL", copy(receipt.retrievalUrlHash)) + + detailItem("New dataset", esc(receipt.isNewDataSet)) + + '
', + ).join("") + : pending(row.status === "Committed" ? "No copy receipts returned" : "Copy receipts pending"); + return '
' + + '
' + + detailItem("Object", copy(row.objectId)) + + detailItem("Account", copy(row.accountId)) + + detailItem("User", copy(row.user)) + + detailItem("Status", pill(row.status)) + + detailItem("Size", esc(row.size)) + + detailItem("Active bytes", esc(row.activeBytes)) + + detailItem("Copies", esc(row.completedCopies) + "/" + esc(row.requestedCopies)) + + detailItem("Coordinator", coordinatorCell(row)) + + detailItem("Reserved cost", esc(row.reservedCost)) + + detailItem("Actual cost", esc(row.actualCost)) + + detailItem("Piece CID hash", zeroHex(row.pieceCidHash) ? pending("Pending piece") : copy(row.pieceCidHash)) + + detailItem("Receipt hash", receiptCell(row)) + + detailItem("Receipt payer", zeroHex(row.receiptPayer) ? pending("Pending payer") : copy(row.receiptPayer)) + + '
' + + '
Copy receipts' + receiptRows + '
' + + '
'; + }; async function fetchJson(path, view = state.view) { const url = new URL(path, location.origin); url.searchParams.set("limit", $("limit").value); @@ -1411,16 +1541,7 @@ function renderAdminDashboardHtml(evidence, { live = true } = {}) { function renderView(body, view = state.view) { state.pagination = primaryPagination(body.pagination); if (body.source === "skipped") return renderSkippedView(body); - if (view === "files") return table(["Object", "Status", "Account", "Size", "Copies", "Providers", "Receipt", "Coordinator"], body.files, (row) => [ - copy(row.objectId), - pill(row.status), - copy(row.accountId), - esc(row.size), - esc(row.completedCopies) + "/" + esc(row.requestedCopies), - esc((row.providerIds || []).join(", ") || "n/a"), - copy(row.receiptHash), - copy(row.coordinator), - ]); + if (view === "files") return renderFileRows(body.files); if (view === "accounts") return table(["Account", "Objects", "Active bytes", "Pending bytes", "Reserved", "Finalized", "Failed"], body.accounts, (row) => [ copy(row.accountId), esc((row.objectIds || []).join(", ") || row.activeObjects), @@ -1489,6 +1610,31 @@ function renderAdminDashboardHtml(evidence, { live = true } = {}) { } $("table-wrap").innerHTML = sections.join(""); } + function renderFileRows(rows) { + if (!rows || rows.length === 0) { + $("table-wrap").innerHTML = '
No rows on this page
'; + return; + } + const headers = ["Object", "Status", "Account", "Size", "Copies", "Providers", "Receipt", "Coordinator"]; + $("table-wrap").innerHTML = + '' + headers.map((header) => '').join("") + '' + + rows.map((row) => { + const id = esc(row.objectId); + const cells = [ + objectButton(row), + pill(row.status), + copy(row.accountId), + esc(row.size), + esc(row.completedCopies) + "/" + esc(row.requestedCopies), + providerCell(row), + receiptCell(row), + coordinatorCell(row), + ]; + return '' + cells.map((cell) => '').join("") + '' + + ''; + }).join("") + + '
' + esc(header) + '
' + cell + '
'; + } function tableMarkup(headers, rows, mapRow) { return '' + headers.map((header) => '').join("") + '' + rows.map((row, index) => '' + mapRow(row, index).map((cell) => '').join("") + '').join("") + '
' + esc(header) + '
' + cell + '
'; } @@ -1584,6 +1730,16 @@ function renderAdminDashboardHtml(evidence, { live = true } = {}) { applyPageAction(action.dataset.pageAction); return; } + const objectToggle = event.target.closest("[data-object-id]"); + if (objectToggle) { + const detail = document.getElementById("object-detail-" + objectToggle.dataset.objectId); + if (detail) { + const expanded = objectToggle.getAttribute("aria-expanded") === "true"; + objectToggle.setAttribute("aria-expanded", String(!expanded)); + detail.hidden = expanded; + } + return; + } const target = event.target.closest("[data-copy]"); if (target && navigator.clipboard) navigator.clipboard.writeText(target.dataset.copy); }); diff --git a/test/calibration-worker.test.mjs b/test/calibration-worker.test.mjs index a5d688f..6433716 100644 --- a/test/calibration-worker.test.mjs +++ b/test/calibration-worker.test.mjs @@ -112,6 +112,13 @@ test("Worker serves HTML and public evidence endpoints", async () => { assert.match(htmlBody, /\/api\/admin\/files/); assert.match(htmlBody, /data-page-action="next"/); assert.match(htmlBody, /function renderCoordinatorView/); + assert.match(htmlBody, /function renderFileRows/); + assert.match(htmlBody, /data-object-id/); + assert.match(htmlBody, /aria-expanded/); + assert.match(htmlBody, /Pending receipt/); + assert.match(htmlBody, /Not assigned/); + assert.match(htmlBody, /Piece CID hash/); + assert.match(htmlBody, /Retrieval URL/); assert.match(htmlBody, /function combinedOffsetPagination/); assert.match(htmlBody, /function renderSkippedView/); assert.match(htmlBody, /body\.source === "skipped"/);