diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index 0aa4d6ee..00000000 --- a/.claude/launch.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "devkit-ui", - "runtimeExecutable": "./bin/canton-devkit", - "runtimeArgs": ["localnet", "ui", "--port", "7777"], - "port": 7777, - "env": { - "CANTON_DEVKIT_REGISTRY": "/tmp/devkit-preview-registry" - } - } - ] -} diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..2db1839e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Report a problem with canton-devkit or a LocalNet instance +title: "[Bug]: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Before filing, run `canton-devkit localnet doctor` and check [troubleshooting](https://github.com/bitdynamics-ab/canton-devkit/blob/main/docs/troubleshooting.md). + Redact JWTs, party IDs, and other secrets from pasted output. + + - type: textarea + id: what_happened + attributes: + label: What happened? + description: Describe the problem and what you expected instead. + placeholder: | + I ran `canton-devkit localnet up demo`. The command exited with an error instead of starting containers. + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Run `canton-devkit localnet up demo` + 2. Run `canton-devkit localnet status --name demo` + 3. See error + validations: + required: true + + - type: input + id: version + attributes: + label: canton-devkit version + description: Output of `canton-devkit version` or `dpm version`. + placeholder: e.g. v0.1.0 + validations: + required: true + + - type: textarea + id: context + attributes: + label: Environment (optional) + description: OS, CLI vs Web UI, instance name, Splice version. + placeholder: | + macOS, CLI, instance `demo`, Splice 0.6.4 + + - type: textarea + id: output + attributes: + label: Command output / logs (optional) + description: stderr, `canton-devkit localnet doctor`, or docker compose output. + render: shell diff --git a/.github/actions/e2e-dpm-test/action.yml b/.github/actions/e2e-dpm-test/action.yml new file mode 100644 index 00000000..26209a65 --- /dev/null +++ b/.github/actions/e2e-dpm-test/action.yml @@ -0,0 +1,105 @@ +name: Run dpm localnet bats E2E test +description: Build the binary, install the DPM CLI, and run one dpm localnet bats e2e file. + +# Keep dpm-version / dpm-linux-sha256 defaults in sync with release.yml +# and e2e-test-dpm-installation.yml. +inputs: + bats-file: + description: Bats file under e2e-tests/ (for example dpm-dar-001.bats) + required: true + dpm-version: + description: DPM CLI version to install + required: false + default: "1.0.16" + dpm-linux-sha256: + description: SHA-256 of the linux/amd64 DPM tarball + required: false + default: "387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874" + +runs: + using: composite + steps: + - name: Build binary + # All jobs run on the same single self-hosted runner, so the binary + # is built here rather than passed between jobs via a GitHub + # artifact. CDK_BIN then lets bats skip its own rebuild. + shell: bash + run: make build + + - name: Install DPM CLI (sha256-verified) + shell: bash + run: | + set -euo pipefail + tar="${RUNNER_TEMP}/dpm-${{ inputs.dpm-version }}-linux-amd64.tar.gz" + curl -sSfL \ + "https://github.com/digital-asset/dpm/releases/download/${{ inputs.dpm-version }}/dpm-${{ inputs.dpm-version }}-linux-amd64.tar.gz" \ + -o "$tar" + echo "${{ inputs.dpm-linux-sha256 }} ${tar}" | sha256sum --check --strict - + bindir="${RUNNER_TEMP}/dpm-bin" + mkdir -p "$bindir" + tar -xzf "$tar" -C "$bindir" dpm + chmod 0755 "$bindir/dpm" + echo "$bindir" >> "$GITHUB_PATH" + "$bindir/dpm" --version + + - name: Run bats test (TAP) + shell: bash + env: + # Use the binary built above; skips the in-bats `make build`. + CDK_BIN: ${{ github.workspace }}/bin/canton-devkit + BATS_LIB_PATH: ${{ github.workspace }}/e2e-tests/test_helper + run: | + set -o pipefail + e2e-tests/bats/bin/bats --formatter tap "e2e-tests/${{ inputs.bats-file }}" \ + | tee "${RUNNER_TEMP}/bats.tap" + + - name: Write job summary + if: always() + shell: bash + run: | + tap="${RUNNER_TEMP}/bats.tap" + [ -f "$tap" ] || exit 0 + + { + echo "### E2E: ${{ inputs.bats-file }}" + echo + echo "| Result | # | Test |" + echo "| --- | --- | --- |" + } >> "$GITHUB_STEP_SUMMARY" + + passed=0 failed=0 skipped=0 + while IFS= read -r line; do + case "$line" in + "ok "*"# skip"*|"ok "*"SKIP"*) + num="${line#ok }"; num="${num%% *}" + name="${line#ok "$num" }"; name="${name%%#*}" + echo "| ⏭️ | $num | ${name% } |" >> "$GITHUB_STEP_SUMMARY" + skipped=$((skipped + 1)) + ;; + "ok "*) + num="${line#ok }"; num="${num%% *}" + name="${line#ok "$num" }" + echo "| ✅ | $num | $name |" >> "$GITHUB_STEP_SUMMARY" + passed=$((passed + 1)) + ;; + "not ok "*) + num="${line#not ok }"; num="${num%% *}" + name="${line#not ok "$num" }" + echo "| ❌ | $num | $name |" >> "$GITHUB_STEP_SUMMARY" + failed=$((failed + 1)) + ;; + esac + done < "$tap" + + { + echo + echo "**Total:** ${passed} passed, ${failed} failed, ${skipped} skipped" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Dump container state on failure + if: failure() + shell: bash + run: | + docker ps -a || true + docker compose ls || true + docker volume ls || true diff --git a/.github/actions/e2e-m1-test/action.yml b/.github/actions/e2e-m1-test/action.yml new file mode 100644 index 00000000..97731b68 --- /dev/null +++ b/.github/actions/e2e-m1-test/action.yml @@ -0,0 +1,33 @@ +name: Run Milestone 1 E2E test +description: Download the built binary and run one Milestone 1 E2E test script. + +inputs: + script: + description: Script filename under scripts/e2e/ (for example m1-up-001.sh) + required: true + +runs: + using: composite + steps: + - name: Download built binary + # actions/download-artifact@v4.1.7 + uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e + with: + name: canton-devkit-binary + path: bin + + - name: Make scripts executable + shell: bash + run: chmod +x bin/canton-devkit scripts/e2e/*.sh + + - name: Run test + shell: bash + run: scripts/e2e/${{ inputs.script }} + + - name: Dump container state on failure + if: failure() + shell: bash + run: | + docker ps -a || true + docker compose ls || true + docker volume ls || true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..2825cf1a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + + + +## Changes + + + +## Test plan + +- [ ] `make test` +- [ ] `make lint` +- [ ] `scripts/e2e/run-all.sh` +- [ ] + +## Checklist + +- [ ] Tests pass locally +- [ ] No coverage regression on touched code +- [ ] Docs updated (and `website/` synced if any mirrored `docs/*.md` changed) +- [ ] CLI ↔ Web UI parity maintained (or follow-up issue + `TODO(#issue)` comment) diff --git a/.github/workflows/analyzer-image.yml b/.github/workflows/analyzer-image.yml new file mode 100644 index 00000000..79f7239e --- /dev/null +++ b/.github/workflows/analyzer-image.yml @@ -0,0 +1,35 @@ +name: daml-analyzer image + +# Builds the pinned daml-analyzer image (build/daml-analyzer/Dockerfile) and +# pushes it to GHCR. Manual dispatch: the analyzer image changes rarely (only +# when the pinned upstream commit is bumped), so it is not built on every push. +on: + workflow_dispatch: + inputs: + tag: + description: "Image tag (keep in sync with analyzer.DefaultImage)" + required: true + default: "0.1.0-143a7e2" + +permissions: + contents: read + packages: write + +jobs: + build-push: + runs-on: [self-hosted, Linux] + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + run: | + set -euo pipefail + owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + image="ghcr.io/${owner}/daml-analyzer:${{ inputs.tag }}" + docker build -t "$image" build/daml-analyzer + docker push "$image" + echo "pushed $image" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 266ba94f..46006e09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - "go.mod" - "go.sum" - ".github/workflows/ci.yml" + - "install.sh" - "docs/design/mockups/**.jsx" - "frontend/**" - "Makefile" @@ -26,6 +27,7 @@ on: - "go.mod" - "go.sum" - ".github/workflows/ci.yml" + - "install.sh" - "docs/design/mockups/**.jsx" - "frontend/**" - "Makefile" @@ -56,6 +58,9 @@ jobs: go-version-file: go.mod cache: false + - name: Validate install.sh syntax + run: sh -n install.sh + - name: Build run: go build ./... diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..554e36bc --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,65 @@ +name: docs + +on: + push: + branches: [main] + paths: + - "website/**" + - "docs/**" + - ".github/workflows/docs.yml" + workflow_dispatch: + +permissions: + contents: read + +# One Pages deployment at a time; don't cancel an in-flight production deploy. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build site + runs-on: [self-hosted, Linux] + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: website/.nvmrc + cache: npm + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + working-directory: website + run: npm ci + + - name: Build + working-directory: website + run: npm run build + + - name: Configure Pages + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: website/dist + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: [self-hosted, Linux] + permissions: + contents: read + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy + id: deployment + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.github/workflows/e2e-test-devkit-functions.yml b/.github/workflows/e2e-test-devkit-functions.yml new file mode 100644 index 00000000..c4b15bba --- /dev/null +++ b/.github/workflows/e2e-test-devkit-functions.yml @@ -0,0 +1,340 @@ +name: "E2E: canton-devkit Functions" + +# Shell-based end-to-end tests. Milestone 1 runs one job per test so a +# failed case can be re-run without replaying the whole suite. +# +# Triggers: +# - schedule: nightly at 04:00 UTC (offset from integration.yml's 03:00). +# - workflow_dispatch: manual trigger for ad-hoc validation. +# - pull_request: only when the PR carries the `run-e2e` label, and +# only when the PR touches non-doc paths (docs/website/Markdown are +# ignored so label/synchronize on doc-only PRs does not queue jobs). +# - push to main: when Milestone 1 E2E scripts or workflow change. +# +# The run-e2e gate lives on the setup job only. Downstream jobs inherit +# skip/cancel behavior via needs; cleanup uses needs.setup.result so it +# does not run on label-less PR pushes. +# +# Platform: Linux only (self-hosted Proxmox e2e runner). macOS runners +# lack Docker; run scripts/e2e/run-all.sh locally for macOS validation. + +on: + schedule: + - cron: "0 4 * * *" + workflow_dispatch: + pull_request: + types: [labeled, synchronize] + paths-ignore: + - "docs/**" + - "website/**" + - "**.md" + push: + branches: + - main + paths: + - scripts/e2e/** + - scripts/e2e-observability.sh + - .github/workflows/e2e-test-devkit-functions.yml + - .github/actions/e2e-m1-test/** + +permissions: + contents: read + +jobs: + setup: + name: setup + if: | + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' || + (github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'run-e2e')) + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + steps: + - name: Check out repository + # actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + + - name: Set up Go + # actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + with: + go-version-file: go.mod + cache: false + + - name: Build binary + run: make build + + - name: Upload built binary + # actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: canton-devkit-binary + path: bin/canton-devkit + retention-days: 1 + + - name: Verify Docker daemon + run: | + docker version + docker compose version + + - name: Clean stale state from prior runs + run: | + for name in e2e-test-default e2e-named-test e2e-version-test e2e-bad-version; do + docker compose -p "canton-${name}" down --volumes 2>/dev/null || true + docker volume ls -q --filter "name=^canton-${name}_" \ + | xargs -r docker volume rm -f 2>/dev/null || true + rm -f "$HOME/.canton-devkit/localnet/${name}/.lock" + done + + m1-inst-003: + name: M1-INST-003 + needs: setup + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 10 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-inst-003.sh + + m1-doc-001: + name: M1-DOC-001 + needs: setup + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 10 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-doc-001.sh + + m1-doc-002: + name: M1-DOC-002 + needs: setup + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 10 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-doc-002.sh + + m1-up-001: + name: M1-UP-001 + needs: [setup, m1-inst-003, m1-doc-001, m1-doc-002] + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-up-001.sh + + m1-sts-001: + name: M1-STS-001 + needs: m1-up-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 10 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-sts-001.sh + + m1-log-001: + name: M1-LOG-001 + needs: m1-sts-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 10 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-log-001.sh + + m1-env-001: + name: M1-ENV-001 + needs: m1-log-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 10 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-env-001.sh + + m1-rst-001: + name: M1-RST-001 + needs: m1-env-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-rst-001.sh + + m1-stp-001: + name: M1-STP-001 + needs: m1-rst-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-stp-001.sh + + m1-snp-001: + name: M1-SNP-001 + needs: m1-stp-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 25 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-snp-001.sh + + m1-dwn-001: + name: M1-DWN-001 + needs: m1-snp-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-dwn-001.sh + + m1-rmv-001: + name: M1-RMV-001 + needs: m1-dwn-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-rmv-001.sh + + m1-up-002: + name: M1-UP-002 + needs: m1-rmv-001 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-up-002.sh + + m1-up-003: + name: M1-UP-003 + needs: m1-up-002 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-up-003.sh + + m1-lst-001: + name: M1-LST-001 + needs: m1-up-003 + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 10 + steps: + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - uses: ./.github/actions/e2e-m1-test + with: + script: m1-lst-001.sh + + cleanup: + name: cleanup + needs: + - setup + - m1-inst-003 + - m1-doc-001 + - m1-doc-002 + - m1-up-001 + - m1-sts-001 + - m1-log-001 + - m1-env-001 + - m1-rst-001 + - m1-stp-001 + - m1-snp-001 + - m1-dwn-001 + - m1-rmv-001 + - m1-up-002 + - m1-up-003 + - m1-lst-001 + if: always() && !cancelled() && needs.setup.result != 'skipped' + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 10 + steps: + - name: Force cleanup + run: | + for name in e2e-test-default e2e-named-test e2e-version-test e2e-bad-version; do + docker compose -p "canton-${name}" down --volumes 2>/dev/null || true + docker volume ls -q --filter "name=^canton-${name}_" \ + | xargs -r docker volume rm -f 2>/dev/null || true + done + rm -f /tmp/e2e-m1-snapshot.tgz + + e2e-observability: + name: e2e-observability + if: | + github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || + github.event_name == 'push' || + (github.event_name == 'pull_request' && + contains(github.event.pull_request.labels.*.name, 'run-e2e')) + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 45 + + steps: + - name: Check out repository + # actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + + - name: Set up Go + # actions/setup-go@v6 + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c + with: + go-version-file: go.mod + cache: false + + - name: Build binary + run: make build + + - name: Verify Docker daemon + run: | + docker version + docker compose version + + - name: Clean stale state from prior runs + run: | + docker compose -p canton-e2e-obs-shared down --volumes 2>/dev/null || true + docker volume ls -q --filter "name=^canton-e2e-obs-shared_" \ + | xargs -r docker volume rm -f 2>/dev/null || true + rm -rf "$HOME/.canton-devkit/localnet/e2e-obs-shared" + + - name: Run E2E observability tests + run: scripts/e2e-observability.sh + + - name: Dump container state on failure + if: failure() + run: | + docker ps -a || true + docker compose ls || true + + - name: Force cleanup + if: always() + run: | + docker compose -p canton-e2e-obs-shared down --volumes 2>/dev/null || true + docker volume ls -q --filter "name=^canton-e2e-obs-shared_" \ + | xargs -r docker volume rm -f 2>/dev/null || true diff --git a/.github/workflows/e2e-test-dpm-installation.yml b/.github/workflows/e2e-test-dpm-installation.yml new file mode 100644 index 00000000..285fe0d2 --- /dev/null +++ b/.github/workflows/e2e-test-dpm-installation.yml @@ -0,0 +1,147 @@ +name: "E2E: DPM Installation" + +# Weekly read-only detective check that the published DPM component at +# ghcr.io/bitdynamics-ab/canton-devkit is genuinely PUBLIC and usable. +# +# It proves three things: +# 1. Anonymously pullable from GHCR (the package is really Public). +# 2. Multi-arch in metadata — the OCI index advertises all three release +# platforms (linux/amd64, darwin/arm64, windows/amd64). +# 3. Installable + runnable on linux/amd64 — `dpm install package` +# succeeds and `dpm localnet --help` runs the installed binary. +# +# A failed run means a regression: the package was flipped private, a +# release broke the artifact, or a platform is missing from the index. +# +# Triggers: +# - schedule: Mondays 05:00 UTC. Offset from e2e (04:00), +# integration (03:00), and refresh-versions (Mon 06:00). +# - workflow_dispatch: manual trigger; optional `version` input. +# +# Platform: Linux/amd64 only (self-hosted Proxmox e2e runner). Functional +# execution tests linux/amd64 only; the other two platforms are verified +# at index-metadata level (reading the OCI index JSON), not by running +# their binaries. +# +# Anonymity: the curl steps use only the self-fetched public pull token; +# dpm uses its own auth config (not Docker credentials) and has no login +# config for GHCR on this runner by default. +# +# Audit/compliance: read-only detective control monitoring intentional +# public exposure of the package (ISO 27001 / Vanta evidence). Introduces +# no credentials and no write scopes. +# +# Maintenance: +# - DPM_VERSION / DPM_LINUX_SHA256 are duplicated from release.yml. +# Bump them together. Keep in sync with release.yml. +# - The platform list in "Multi-arch metadata assertion" mirrors +# RELEASE_TARGETS in release.yml — keep in sync. +# - NS is hard-coded to bitdynamics-ab/canton-devkit. +# Update if the org/repo moves. + +on: + schedule: + - cron: "0 5 * * 1" # Mon 05:00 UTC + workflow_dispatch: + inputs: + version: + description: "OCI tag to verify (default: latest)" + required: false + default: "latest" + +permissions: + contents: read # read-only detective check — no packages:write + +jobs: + verify: + name: e2e-test-dpm-installation + runs-on: [self-hosted, Linux, X64, proxmox, e2e] + timeout-minutes: 20 + env: + NS: bitdynamics-ab/canton-devkit + # Keep DPM_VERSION / DPM_LINUX_SHA256 in sync with release.yml. + DPM_VERSION: "1.0.16" + DPM_LINUX_SHA256: "387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874" + + steps: + - name: Resolve version + run: | + set -euo pipefail + VERSION="${{ github.event.inputs.version || 'latest' }}" + echo "VERSION=${VERSION}" >> "$GITHUB_ENV" + + - name: Anonymous registry fetch (raw v2 API) + run: | + set -euo pipefail + token=$(curl -fsS "https://ghcr.io/token?scope=repository:${NS}:pull" | jq -r .token) + code=$(curl -sS -o manifest.json -w '%{http_code}' \ + -H "Authorization: Bearer ${token}" \ + -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" \ + "https://ghcr.io/v2/${NS}/manifests/${VERSION}") + test "$code" = "200" || { + echo "::error::GHCR returned HTTP ${code} anonymously for ${NS}:${VERSION} — package may be private" + exit 1 + } + # Extract the strict semver from the manifest annotations. + # dpm install package requires a strict semver OCI tag; symbolic + # tags like "latest" are rejected. org.opencontainers.image.version + # is always present in our published manifests (set by dpm publish). + INSTALL_VERSION=$(jq -r '.annotations["org.opencontainers.image.version"]' manifest.json) + echo "INSTALL_VERSION=${INSTALL_VERSION}" >> "$GITHUB_ENV" + + - name: Multi-arch metadata assertion + run: | + set -euo pipefail + # Platform list mirrors RELEASE_TARGETS in release.yml. + for plat in linux/amd64 darwin/arm64 windows/amd64; do + os="${plat%/*}"; arch="${plat#*/}" + jq -e --arg os "$os" --arg arch "$arch" \ + '.manifests[]?.platform | select(.os==$os and .architecture==$arch)' manifest.json > /dev/null \ + || { + echo "::error::OCI index for ${NS}:${VERSION} is missing platform ${plat}" + exit 1 + } + done + + - name: Install DPM CLI (sha256-verified) + run: | + set -euo pipefail + tar="${RUNNER_TEMP}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" + curl -sSfL \ + "https://github.com/digital-asset/dpm/releases/download/${DPM_VERSION}/dpm-${DPM_VERSION}-linux-amd64.tar.gz" \ + -o "$tar" + echo "${DPM_LINUX_SHA256} ${tar}" | sha256sum --check --strict - + bindir="${RUNNER_TEMP}/dpm-bin" + mkdir -p "$bindir" + tar -xzf "$tar" -C "$bindir" dpm + chmod 0755 "$bindir/dpm" + echo "$bindir" >> "$GITHUB_PATH" + # Use absolute path — $GITHUB_PATH additions only take effect in + # subsequent steps, not in the same step where the echo is done. + "$bindir/dpm" --version + + - name: Anonymous install + smoke test (linux/amd64) + run: | + set -euo pipefail + # dpm install package reads daml.yaml from the current directory. + # Create a minimal project file — no sdk-version (which would pull + # in the SDK bundle and conflict with opt-in components), just the + # component reference with the resolved strict semver tag. + workdir="${RUNNER_TEMP}/dpm-verify" + mkdir -p "$workdir" + cat > "$workdir/daml.yaml" </dev/null || true diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 06c17794..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,78 +0,0 @@ -name: E2E - -# Shell-based end-to-end tests. Each milestone adds a job to this -# workflow. Currently: Milestone 1 (LocalNet CLI lifecycle). -# -# Triggers: -# - schedule: nightly at 04:00 UTC (offset from integration.yml's 03:00). -# - workflow_dispatch: manual trigger for ad-hoc validation. -# - pull_request: only when the PR carries the `run-e2e` label. -# -# Platform: Linux only (self-hosted Proxmox e2e runner). macOS runners -# lack Docker; run scripts/e2e-milestone1.sh locally for macOS validation. - -on: - schedule: - - cron: "0 4 * * *" - workflow_dispatch: - pull_request: - types: [labeled, synchronize] - -permissions: - contents: read - -jobs: - e2e: - name: e2e-milestone1 - if: | - github.event_name == 'schedule' || - github.event_name == 'workflow_dispatch' || - (github.event_name == 'pull_request' && - contains(github.event.pull_request.labels.*.name, 'run-e2e')) - runs-on: [self-hosted, Linux, X64, proxmox, e2e] - timeout-minutes: 60 - - steps: - - name: Check out repository - # actions/checkout@v5 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd - - - name: Set up Go - # actions/setup-go@v6 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c - with: - go-version-file: go.mod - cache: false - - - name: Build binary - run: make build - - - name: Verify Docker daemon - run: | - docker version - docker compose version - - - name: Clean stale state from prior runs - run: | - for name in e2e-test-default e2e-named-test e2e-version-test e2e-bad-version; do - docker compose -p "canton-${name}" down --volumes 2>/dev/null || true - rm -f "$HOME/.canton-devkit/localnet/${name}/.lock" - done - - - name: Run E2E Milestone 1 tests - run: scripts/e2e-milestone1.sh - - - name: Dump container + compose state on failure - if: failure() - run: | - docker ps -a || true - docker compose ls || true - docker volume ls || true - - - name: Force cleanup on failure - if: always() - run: | - for name in e2e-test-default e2e-named-test e2e-version-test e2e-bad-version; do - docker compose -p "canton-${name}" down --volumes 2>/dev/null || true - done - rm -f /tmp/e2e-m1-snapshot.tgz diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 26a44d77..fd3e2368 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -7,7 +7,9 @@ name: Integration # - schedule: nightly at 03:00 UTC against main. # - workflow_dispatch: manual trigger for ad-hoc validation. # - pull_request: only when the PR carries the `run-integration` -# label (apply via PR review UI). +# label (apply via PR review UI), and only when the PR touches +# non-doc paths (docs/website/Markdown are ignored so +# label/synchronize on doc-only PRs does not queue jobs). # # Tagged build tag: integration. Unit tests are unaffected (`go test # ./...` continues to skip this file). @@ -23,6 +25,10 @@ on: workflow_dispatch: pull_request: types: [labeled, synchronize] + paths-ignore: + - "docs/**" + - "website/**" + - "**.md" permissions: contents: read diff --git a/.github/workflows/release-stats.yml b/.github/workflows/release-stats.yml new file mode 100644 index 00000000..8db05178 --- /dev/null +++ b/.github/workflows/release-stats.yml @@ -0,0 +1,87 @@ +name: Release stats + +# Regenerate the release download-statistics charts embedded in the README. +# Data sources are both release repos (bitdynamics-ab/canton-devkit and +# bitdynamics-ab/homebrew-canton-devkit); download counts are merged by tag. +# Uses the built-in GITHUB_TOKEN only — no additional secrets required. +# +# Generated files are published to the `release-stats-data` branch (not +# `main`) and the README embeds them via raw.githubusercontent.com URLs, +# because the `main` branch ruleset requires all changes to go through a +# pull request and this bot commit can't satisfy that. + +on: + workflow_dispatch: + schedule: + # Daily at 06:17 UTC (off-peak; avoids the top-of-hour cron surge). + - cron: "17 6 * * *" + workflow_run: + # Refresh promptly after a release publishes new assets. + workflows: ["Release"] + types: + - completed + +permissions: + contents: write + +concurrency: + group: release-stats + cancel-in-progress: false + +jobs: + regenerate: + name: Regenerate download-stats charts + runs-on: self-hosted + timeout-minutes: 10 + steps: + - name: Check out repository + # actions/checkout@v7.0.0 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + + - name: Generate charts + append daily snapshot + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + STATS_REPOS: bitdynamics-ab/canton-devkit bitdynamics-ab/homebrew-canton-devkit + run: bash scripts/release-stats.sh + + - name: Publish charts + snapshot to release-stats-data branch + run: | + set -euo pipefail + files=( + docs/assets/release-downloads-by-platform.svg + docs/assets/release-downloads-by-version.svg + docs/assets/release-downloads.md + docs/assets/release-downloads-history.jsonl + ) + + # Stash the freshly generated files before switching branches — + # release-stats-data's tree is unrelated to main's, and main's + # working copy of these files (modified in place by the generator) + # must be reset or the branch switch below will refuse to proceed. + tmp="$(mktemp -d)" + for f in "${files[@]}"; do + mkdir -p "${tmp}/$(dirname "${f}")" + cp "${f}" "${tmp}/${f}" + done + git checkout -- "${files[@]}" + + if git fetch origin release-stats-data:refs/remotes/origin/release-stats-data; then + git checkout -B release-stats-data refs/remotes/origin/release-stats-data + else + git checkout --orphan release-stats-data + git reset --hard + fi + + for f in "${files[@]}"; do + cp "${tmp}/${f}" "${f}" + done + + git add "${files[@]}" + if git diff --cached --quiet; then + echo "Release stats already up to date." + exit 0 + fi + git -c user.name="github-actions[bot]" \ + -c user.email="github-actions[bot]@users.noreply.github.com" \ + commit -m "chore: refresh release download stats" + git push origin release-stats-data diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f91100d..704d24f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,6 @@ name: Release on: workflow_dispatch: push: - branches: - - main tags: - "v*" @@ -21,8 +19,16 @@ env: # DPM_LINUX_SHA256 below pins the linux-amd64 tarball used to install # the CLI in CI; recompute via: # curl -sL | sha256sum + # Keep in sync with e2e-test-dpm-installation.yml. DPM_VERSION: 1.0.16 DPM_LINUX_SHA256: 387421d4b3d0e799f05cde1f5c2adc704acd2824796d436861602eb2be759874 + # GitHub CLI, pinned for the same reason as DPM above: the self-hosted + # runner has no `gh` preinstalled, but the "public builds repo" steps + # below (release mirror, Homebrew formula, APT repo) all shell out to + # it. Recompute the sha256 via: + # curl -sL | sha256sum + GH_CLI_VERSION: 2.96.0 + GH_CLI_LINUX_SHA256: 83d5c2ccad5498f58bf6368acb1ab32588cf43ab3a4b1c301bf36328b1c8bd60 # Build matrix: shared by both the standalone-binary archives and the # DPM-component OCI artifact (both produced in the single release job). RELEASE_TARGETS: linux/amd64 darwin/arm64 windows/amd64 @@ -234,6 +240,26 @@ jobs: generate_release_notes: true files: dist/* + - name: Install GitHub CLI (sha256-verified) + if: startsWith(github.ref, 'refs/tags/') + # The self-hosted runner has no `gh` preinstalled — install it + # here rather than relying on runner-image contents, so the + # subsequent public-builds-repo steps below (which all shell out + # to `gh`) don't fail with "gh: command not found". + run: | + set -euo pipefail + tar="/tmp/gh-${GH_CLI_VERSION}-linux-amd64.tar.gz" + curl -sSfL \ + "https://github.com/cli/cli/releases/download/v${GH_CLI_VERSION}/gh_${GH_CLI_VERSION}_linux_amd64.tar.gz" \ + -o "$tar" + echo "${GH_CLI_LINUX_SHA256} ${tar}" | sha256sum --check --strict - + bindir="${RUNNER_TEMP:-/tmp}/canton-devkit-gh" + mkdir -p "$bindir" + tar -xzf "$tar" -C "$bindir" --strip-components=2 "gh_${GH_CLI_VERSION}_linux_amd64/bin/gh" + chmod 0755 "$bindir/gh" + echo "$bindir" >> "$GITHUB_PATH" + "$bindir/gh" --version + - name: Publish to public builds repo if: startsWith(github.ref, 'refs/tags/') env: diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 00000000..04368aa5 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,32 @@ +name: Static Analysis + +on: + pull_request: + paths: + - "scripts/e2e/**" + - ".github/workflows/static-analysis.yml" + push: + branches: + - main + paths: + - "scripts/e2e/**" + - ".github/workflows/static-analysis.yml" + +permissions: + contents: read + +jobs: + e2e-shell: + name: e2e scripts syntax + runs-on: [self-hosted, Linux] + + steps: + - name: Check out repository + # actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + + - name: Validate Milestone 1 E2E script syntax + run: | + for script in scripts/e2e/*.sh; do + bash -n "$script" + done diff --git a/.gitignore b/.gitignore index ada4c161..02ab6d31 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,10 @@ bin/ dist/ +# Scratch dir for local validation / temporary files +.tmp/ +tmp/ + # Local environment .env .env.* @@ -12,13 +16,27 @@ dist/ .idea/ .vscode/ -# Agent runtime state -.claude/scheduled_tasks.lock +# AI files +.cursor/ +.claude/ +AGENTS.md +CLAUDE.md # Vite/React build output for the embedded Web UI. The placeholder # index.html is tracked so go:embed has at least one match on a # fresh clone; `make frontend` overwrites it with the real bundle. # See internal/ui/assets.go. +# +# DO NOT commit a real Vite build's index.html over the placeholder: the +# hashed assets/*.js and *.css it references are git-ignored, so a checkout +# would point at files that don't exist. Keep the DEVKIT_FRONTEND_PLACEHOLDER +# version tracked; let `make frontend` produce the real bundle at build time. internal/ui/dist/* !internal/ui/dist/index.html .worktrees/ + +# worktrunk configs +.config/wt.toml + +# workspace files +*.code-workspace diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..678555f9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,9 @@ +[submodule "e2e-tests/bats"] + path = e2e-tests/bats + url = https://github.com/bats-core/bats-core.git +[submodule "e2e-tests/test_helper/bats-support"] + path = e2e-tests/test_helper/bats-support + url = https://github.com/bats-core/bats-support.git +[submodule "e2e-tests/test_helper/bats-assert"] + path = e2e-tests/test_helper/bats-assert + url = https://github.com/bats-core/bats-assert.git diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c2..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/AGENTS.md b/CONTRIBUTING.md similarity index 55% rename from AGENTS.md rename to CONTRIBUTING.md index b88415a3..0607d521 100644 --- a/AGENTS.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ -# Agent Guidelines for canton-devkit +# Contributing to canton-devkit -This file provides guidelines for AI agents contributing to canton-devkit. +Thanks for your interest in contributing! This document describes the conventions the project follows and what we look for in a pull request. ## Project Overview @@ -10,11 +10,79 @@ canton-devkit is a CLI tool for managing Canton LocalNet developer environments. - **CLI Framework:** Cobra - **Module path:** `github.com/bitdynamics-ab/canton-devkit` +## Getting Started + +```sh +git clone https://github.com/bitdynamics-ab/canton-devkit.git +cd canton-devkit +make build # → ./bin/canton-devkit +make test # run Go tests +make lint # golangci-lint +make frontend # build the Web UI bundle (optional) +``` + +### Setup Web UI dev environment + +To iterate on the `frontend/` UI, use the Vite dev server on port 5173. +Open **http://localhost:5173** (not `:7777`). Choose one of the two +setups below depending on whether you need live LocalNet data. + +#### a. Real backend and real data + +Run the Go API server and Vite side by side. Vite proxies `/api` and +`/events` to the backend on `:7777` — see `frontend/vite.config.ts`. + +**Terminal 1** — backend API + SSE (from repo root): + +```sh +go run ./cmd/canton-devkit localnet ui --port 7777 +``` + +**Terminal 2** — Vite dev server with hot reload: + +```sh +cd frontend +npm install # first run only; run `nvm use` to match .nvmrc +npm run dev +``` + +Live API data requires a running LocalNet (`dpm localnet up`). + +#### b. Mock data + +For UI-only work without Go or LocalNet: + +```sh +cd frontend +npm install # first run only; run `nvm use` to match .nvmrc +npm run dev:mock +``` + +The Vite dev server serves mock API responses from +`frontend/mock/fixtures/` via middleware — no backend on `:7777` +required. Mutations are in-memory only (reset on server restart). + +To refresh fixtures from a live instance: + +```sh +# With real backend + LocalNet running (setup a): +npm run mock:seed -- --instance [--as demo] +``` + +#### Notes + +- You do **not** need `make frontend` for either setup; that target only + builds the production bundle embedded into the Go binary. The + placeholder-bundle warning printed at `localnet ui` startup is + expected in setup **a** since the browser loads the Vite dev server. + +For anything non-trivial, please [open an issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first to discuss the change. For bugs, use the [bug report template](https://github.com/bitdynamics-ab/canton-devkit/issues/new?template=bug_report.yml). + ## Code Change Rules -### CLI ↔ Web UI parity (load-bearing) +### CLI ↔ Web UI parity -**Any user-facing feature must land on BOTH the CLI and the Web UI surface when it applies to both.** Single-surface features are a long-term debt: an operator who learns the feature in one place can't find it in the other, and the surfaces drift in subtle ways (different validation, different error shapes, different timeouts). +**Any user-facing feature must land on BOTH the CLI and the Web UI surface when it applies to both.** This is a core project convention. Single-surface features are a long-term debt: an operator who learns the feature in one place can't find it in the other, and the surfaces drift in subtle ways (different validation, different error shapes, different timeouts). When adding or changing a feature, ask: @@ -31,13 +99,13 @@ When the work spans both: - **Mirror the verbs.** If the UI gets `POST /api/instances/{name}/containers/{c}/restart`, the CLI should get `dpm localnet container restart `. The CLI name is a wrapper around the same handler logic; both pass through the same shared function. - **Mirror the guards.** If the Web UI's pre-flight gate refuses to start a Splice 0.6.4 instance on a 4 GiB host, `dpm localnet up --version 0.6.4` must refuse it for the same reason. Don't let one surface be lenient where the other is strict. -**When you can't reach parity in the same PR**, file a follow-up ticket and add a `// TODO(#issue): CLI parity — ` comment at the divergence point so reviewers can see it. Never close out a feature as "done" while one surface is silently missing it. +**When you can't reach parity in the same PR**, file a follow-up issue and add a `// TODO(#issue): CLI parity — ` comment at the divergence point so reviewers can see it. Never close out a feature as "done" while one surface is silently missing it. -### Docker Compose teardown must be `-p`-only (load-bearing) +### Docker Compose teardown must be `-p`-only **Teardown verbs (`docker compose down` / `stop` without an explicit service argument) MUST tear down by Docker project label — `-p ` — and MUST NOT pass `-f` compose files, `--env-file`, or `--profile`.** -Every Splice LocalNet service is profile-gated (`profiles: [sv, app-provider, app-user, multi-sync, ...]`). When `-f` compose files are present, `docker compose down`/`stop` apply **profile filtering** and act only on non-profiled + explicitly-enabled-profile services — for Splice that is the **empty set**. The result is a silent no-op (exit 0) that leaves every container running and strands ledger state. This is documented compose behavior (https://docs.docker.com/compose/how-tos/profiles/#stop-application-and-services-with-specific-profiles), not a version bug, and it reproduces on every supported Compose v2.x/v5.x. Tearing down by `-p` label only is profile-agnostic and removes the whole project. +Every Splice LocalNet service is profile-gated — each declares a `profiles:` list such as `sv`, `app-provider`, `app-user`, or `multi-sync`. When `-f` compose files are present, `docker compose down`/`stop` apply **profile filtering** and act only on non-profiled + explicitly-enabled-profile services — for Splice that is the **empty set**. The result is a silent no-op (exit 0) that leaves every container running and strands ledger state. This is documented compose behavior (https://docs.docker.com/compose/how-tos/profiles/#stop-application-and-services-with-specific-profiles), not a version bug, and it reproduces on every supported Compose v2.x/v5.x. Tearing down by `-p` label only is profile-agnostic and removes the whole project. Rules: @@ -69,7 +137,7 @@ Rules: ### Build - Build the binary: `make build` -- Version is injected at build time via `-ldflags -X main.version=...` +- Version is injected at build time via `-ldflags "-X main.version=$(VERSION)"` - Output goes to `bin/` ### Code Style @@ -83,12 +151,12 @@ Rules: - **Entry point:** `cmd/canton-devkit/main.go` - **CLI wiring:** `internal/cli/` — root command, version subcommand, localnet subcommands -- **Localnet subcommands** are partially implemented — check `internal/cli/localnet/` for the current set; commands not yet wired return a "not implemented yet" stub. +- **Localnet subcommands** live in `internal/cli/localnet/` — run `canton-devkit localnet --help` (or check that package) for the current set. - **DPM contract:** The CLI must dispatch correctly from an argv slice with no reliance on `argv[0]` or environment variables. The `TestRunIsArgvOnly` test guards this contract and must not be broken. ## CI Pipeline -- Both jobs (`test` and `lint`) run on `[self-hosted, Linux]` runners on every PR and push to `main`. +- All CI jobs (`test`, `mockup-syntax`, `lint`, `frontend`) run on `[self-hosted, Linux]` runners on every PR and push to `main`. - macOS / Windows CI was removed in commit `9a0dae1` — cross-platform validation now lives in `.github/workflows/release.yml`. - All GitHub Actions must be SHA-pinned (no floating tags like `@v4`) @@ -106,4 +174,4 @@ Before submitting: 3. No test coverage regression (check with `go tool cover`) 4. Relevant documentation added/updated 5. PR title is clear and understandable -6. **CLI ↔ Web UI parity:** if the change touches a user-facing feature, both surfaces are updated (or a follow-up ticket is filed with a `TODO(#issue): CLI parity` / `TODO(#issue): UI parity` comment at the divergence point). See "CLI ↔ Web UI parity" rule above. +6. **CLI ↔ Web UI parity:** if the change touches a user-facing feature, both surfaces are updated (or a follow-up issue is filed with a `TODO(#issue): CLI parity` / `TODO(#issue): UI parity` comment at the divergence point). See "CLI ↔ Web UI parity" rule above. diff --git a/Makefile b/Makefile index 15e7e34e..7cbb2af8 100644 --- a/Makefile +++ b/Makefile @@ -3,31 +3,27 @@ VERSION ?= dev COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null) LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -.PHONY: build clean docker-build lint test frontend frontend-install frontend-test ui +.PHONY: build clean docker-build lint test frontend frontend-install frontend-test ui e2e-dpm analyzer-image analyzer-push -# frontend-install: ensure the Vite project has its node_modules. -# Idempotent: a no-op when the lockfile + node_modules are in sync. -# Pulled out so `make frontend` can be the build-only target for -# CI runners that pre-cache deps. +# daml-analyzer image (see build/daml-analyzer/). Keep DAML_ANALYZER_IMAGE in +# sync with analyzer.DefaultImage. +DAML_ANALYZER_IMAGE ?= ghcr.io/bitdynamics-ab/daml-analyzer:0.1.0-143a7e2 + +# frontend-install: sync frontend/node_modules with the lockfile. +# Separate target so CI runners with pre-cached deps can build only. frontend-install: cd frontend && npm ci --silent -# frontend: produce the production Vite bundle into -# internal/ui/dist/. The Go binary's //go:embed picks it up at -# `go build` time, so the canonical release flow is: -# -# make frontend && make build -# -# Without `make frontend`, `go build` embeds the dev placeholder -# (internal/ui/dist/index.html with DEVKIT_FRONTEND_PLACEHOLDER) -# and `dpm localnet ui` prints a stderr warning at startup. See -# internal/ui/assets.go IsPlaceholderBundle. +# frontend: build the production Vite bundle into internal/ui/dist/, +# which `go build` embeds via //go:embed. Canonical release flow: +# `make frontend && make build`. Without it the binary embeds the dev +# placeholder and `dpm localnet ui` warns at startup (see +# internal/ui/assets.go IsPlaceholderBundle). frontend: frontend-install cd frontend && npm run build -# frontend-test: run the Vitest suite (jsdom + RTL). Fast — no -# Vite bundle, no Go embed step. Wire into CI alongside `make test` -# once the frontend lands on main. +# frontend-test: run the Vitest suite (jsdom + RTL); no Vite bundle +# or Go embed step needed. frontend-test: frontend-install cd frontend && npm test @@ -35,15 +31,10 @@ build: mkdir -p bin go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY_NAME) ./cmd/canton-devkit -# ui: convenience target for `dpm localnet ui` development. Builds -# the Vite bundle THEN the Go binary, so the embedded //go:embed -# always reflects current frontend source. Use this instead of -# plain `make build` when you've touched frontend/ and want to -# poke the running UI in a browser. -# -# Plain `make build` is preserved for Go-only contributors who -# don't have node installed — the placeholder warning at startup -# is the intentional signal that they need `make frontend` first. +# ui: build the Vite bundle then the Go binary so the embedded assets +# reflect current frontend source. Plain `make build` stays node-free +# for Go-only contributors — the placeholder warning at startup is the +# signal to run `make frontend` first. ui: frontend build test: @@ -52,8 +43,25 @@ test: lint: golangci-lint run +# e2e-dpm: run the `dpm localnet` bats e2e suite. bats-core and its +# helper libs are vendored as git submodules under e2e-tests/; this +# target initializes them on demand so a fresh checkout just works. +# Requires `dpm` on PATH (the suite skips gracefully otherwise). +e2e-dpm: + @if [ ! -x e2e-tests/bats/bin/bats ]; then \ + git submodule update --init --recursive e2e-tests; \ + fi + BATS_LIB_PATH="$(CURDIR)/e2e-tests/test_helper" \ + e2e-tests/bats/bin/bats e2e-tests/ + docker-build: docker build --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) -t $(BINARY_NAME):$(VERSION) . +analyzer-image: + docker build -t $(DAML_ANALYZER_IMAGE) build/daml-analyzer + +analyzer-push: analyzer-image + docker push $(DAML_ANALYZER_IMAGE) + clean: rm -rf bin dist diff --git a/README.md b/README.md index 2c17c5fc..5ea962db 100644 --- a/README.md +++ b/README.md @@ -1,493 +1,147 @@ -
- - - - - canton-devkit - - - # canton-devkit -### The fastest way to run a [Canton](https://canton.network/) network on your laptop. - -A single-binary toolkit for spinning up, inspecting, and tearing down a complete Canton developer stack — Canton synchronizer + participant, Splice super-validator apps, three party wallets (app-user, app-provider, SV), Scan explorer, signed JWTs — in **one command**. - -

- CI - Go Reference - Release - License: Apache 2.0 -

- -

- Quickstart · - Web UI · - Commands · - Architecture · - FAQ · - Roadmap -

- -
- -```sh -❯ canton-devkit localnet up demo - ✓ Splice 0.6.4 cache hit - ✓ Compose started · 12 containers - ✓ Health checks · canton · splice · postgres - ✓ JWTs signed · app-user · app-provider · super-validator - ✦ "demo" is ready · Splice 0.6.4 · ready in 1m 24s -``` - -
- -
- ---- - -## ✨ Why canton-devkit? - -[Canton](https://canton.network/) is the public blockchain with built-in privacy, designed for regulated finance — but its local-dev story has historically been a multi-hour expedition: clone [Splice](https://github.com/canton-network/splice), decode docker-compose layers, hunt JWT secrets, copy-paste party IDs. `canton-devkit` collapses that into a single binary built around three convictions: - - - - - - - - - - - - -
- -### ⚡ One command -No YAML editing, no env-file shuffling. `up` downloads Splice, signs JWTs, brings up a dozen containers, prints endpoints. Cold start **~90 s**. - - - -### 🌐 Two surfaces -Same code, two skins: a polished CLI for terminals & CI, a real Vite/React Web UI for browser-driven inspection. **Always at parity.** - - - -### 🔐 Zero lock-in -No forks, no patches. Thin wrapper over upstream [Splice LocalNet](https://github.com/canton-network/splice), pinned to **immutable commit SHAs** and verified by content hash. - -
- -### 🧪 Snapshot & restore -Save a working state to a `.tgz`, hand it to a teammate, replay it on CI. **Disaster recovery in 4 seconds.** - - - -### 📦 Single binary -`go install` or `dpm install package` — same artefact. macOS arm64, Linux amd64, Windows amd64. **No JVM, no Python, no Node** at runtime. - - - -### 🔭 Observability built-in -Optional `--profile observability` adds **Prometheus + Grafana** with a curated Canton dashboard. CLI scrapes the same metrics. - -
- ---- - -## 🎯 Who is this for? - -| You are… | We've got you because… | -|---|---| -| **A Daml/Canton app developer** | Reproducible local stack, signed JWTs, party IDs auto-recorded, hot DAR upload | -| **A CI engineer** | Pinned versions, `--json` everywhere, exit codes documented, snapshot/restore for fixtures | -| **An evaluator** | One command to a healthy network. Tear it down with `clean` when you're done | -| **A workshop facilitator** | Same demo on every laptop, regardless of OS or Apple Silicon | - -> [!NOTE] -> **Not for production.** This is a developer tool. For production Canton deployments, see the official [Canton documentation](https://docs.daml.com/canton/). - ---- +[![CI](https://github.com/bitdynamics-ab/canton-devkit/actions/workflows/ci.yml/badge.svg)](https://github.com/bitdynamics-ab/canton-devkit/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/bitdynamics-ab/canton-devkit?display_name=tag&sort=semver)](https://github.com/bitdynamics-ab/canton-devkit/releases/latest) +[![Docs](https://img.shields.io/badge/docs-current-brightgreen.svg)](https://bitdynamics-ab.github.io/canton-devkit/) +[![License](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE) -## 🚀 Quickstart +[![Homebrew Downloads](https://img.shields.io/github/downloads/bitdynamics-ab/homebrew-canton-devkit/total.svg?label=homebrew%20downloads)](https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases) +[![Other Downloads](https://img.shields.io/github/downloads/bitdynamics-ab/canton-devkit/total.svg?label=other%20downloads)](https://github.com/bitdynamics-ab/canton-devkit/releases) -> 📖 Full walkthrough — DPM + standalone install on macOS/Linux/Windows, -> Docker prerequisites, compatibility matrix, troubleshooting, and a -> zero-to-running LocalNet guide — lives in -> [docs/getting-started.md](docs/getting-started.md). -> -> **Docs index:** [Getting started](docs/getting-started.md) · -> [Tokens (CIP-0112 / V2)](docs/tokens.md) · -> [Explorer](docs/explorer.md) · -> [Dashboard customization](docs/dashboard-customization.md) · -> [FAQ](docs/faq.md) · -> [Troubleshooting](docs/troubleshooting.md) · -> [Versions](docs/versions.md) · -> [Limitations](docs/limitations.md) · -> [Validation checklist](docs/validation-checklist.md) · -> [Telemetry](docs/telemetry.md) -> -> Demo: [`scripts/demo.sh`](scripts/demo.sh) (guided tour) · -> [`scripts/validate-zero-to-localnet.sh`](scripts/validate-zero-to-localnet.sh) (timed M1 check) +canton-devkit runs a complete local [Canton](https://canton.network/) +network on your machine. You get two participant/validator nodes and a +super-validator node, each with its own party (app-user, app-provider, +super-validator) and JWT. Manage the stack from a single CLI or a local +Web UI. -### 1 · Install +**Website:** [https://bitdynamics-ab.github.io/canton-devkit/](https://bitdynamics-ab.github.io/canton-devkit/) -
-Pre-built binary — pick your OS (recommended) +Requires Docker and Compose v2, about 8 GB of free RAM for Docker, and +about 20 GB of free disk. See the +[installation guide](https://bitdynamics-ab.github.io/canton-devkit/getting-started/) +for details. -Releases live at [github.com/bitdynamics-ab/canton-devkit/releases](https://github.com/bitdynamics-ab/canton-devkit/releases). Three platforms ship today; for anything else use the *from source* path below. The snippets below pin `v0.7` — substitute the [latest tag](https://github.com/bitdynamics-ab/canton-devkit/releases) as it advances. +## Quick start -**macOS (Apple Silicon)** - -```sh -V=v0.7 -curl -L -o canton-devkit.tar.gz \ - "https://github.com/bitdynamics-ab/canton-devkit/releases/download/${V}/canton-devkit_${V}_darwin_arm64.tar.gz" -tar -xzf canton-devkit.tar.gz -chmod +x canton-devkit -sudo mv canton-devkit /usr/local/bin/ -canton-devkit version -``` - -**Linux (x86_64)** - -```sh -V=v0.7 -curl -L -o canton-devkit.tar.gz \ - "https://github.com/bitdynamics-ab/canton-devkit/releases/download/${V}/canton-devkit_${V}_linux_amd64.tar.gz" -tar -xzf canton-devkit.tar.gz -chmod +x canton-devkit -sudo mv canton-devkit /usr/local/bin/ -canton-devkit version -``` - -**Windows (x86_64)** — PowerShell - -```powershell -$V = "v0.7" -$dest = "$env:USERPROFILE\bin" -New-Item -ItemType Directory -Force $dest | Out-Null -Invoke-WebRequest ` - -Uri "https://github.com/bitdynamics-ab/canton-devkit/releases/download/$V/canton-devkit_${V}_windows_amd64.zip" ` - -OutFile canton-devkit.zip -Expand-Archive -Force canton-devkit.zip -DestinationPath $dest -# Add %USERPROFILE%\bin to your PATH (one time), then: -canton-devkit version -``` - -> **Note** — `v0.7` is a pre-release; check the [releases page](https://github.com/bitdynamics-ab/canton-devkit/releases) for the latest tag and substitute `V` in the URLs above. Each release publishes a `SHA256SUMS` file at the same base URL — pair the archive with it to verify the download (the CI examples in [`examples/ci/`](examples/ci/) show the verify pattern). Intel Mac (`darwin_amd64`) and Linux ARM (`linux_arm64`) artefacts are on the roadmap; for now, build from source on those platforms. - -
- -
-From source (Go 1.22+, Node 20+) - -```sh -git clone https://github.com/bitdynamics-ab/canton-devkit.git -cd canton-devkit -make build # → ./bin/canton-devkit -make frontend # → bakes the Web UI into the binary (optional) -``` - -
- -
-As a DPM component - -```sh -dpm install package canton-devkit -dpm localnet up demo -``` - -`dpm localnet …` and `canton-devkit localnet …` are the same binary; pick whichever your team uses. - -
- -### 2 · Run - -```sh -# Verify your host is ready (Docker, RAM, disk, ports) +```bash canton-devkit localnet doctor - -# Bring up a Canton network called "demo" canton-devkit localnet up demo - -# Inspect from the browser -canton-devkit localnet ui - -# …or stay in the terminal -canton-devkit localnet status # ports, health, uptime -canton-devkit localnet logs canton # tail Canton's logs -eval "$(canton-devkit localnet env)" # export endpoints to your shell - -# Snapshot, tear down, restore -canton-devkit localnet snapshot --to demo.tgz -canton-devkit localnet clean -canton-devkit localnet restore --from demo.tgz +canton-devkit localnet status demo +eval "$(canton-devkit localnet env demo)" +canton-devkit localnet down demo ``` -> [!TIP] -> Stuck? Run `canton-devkit localnet doctor` — it tells you exactly what's missing and how to fix it. - ---- - -## 🖥️ Web UI - -`canton-devkit localnet ui` launches a polished Vite/React dashboard, embedded in the binary, **loopback-only by default**. - - - - - - -
- -**What you get** - -- 📊 **Live overview** — instance status, container health (SSE) -- 🔑 **Developer setup** — copy JWTs, export `.env` / `.json` / `.yaml` -- 💾 **Backup & restore** — download a snapshot, drag-drop to restore -- 🪵 **Per-container logs** — `docker logs --tail` in the browser -- ⚡ **⌘ K palette** — fuzzy-jump between instances and routes -- 🩺 **Live preflight** — Docker, memory, disk, before every `up` - - - -**Security model** - -- Bound to `127.0.0.1` — refuses non-loopback hosts unless `--allow-non-loopback` -- CSRF: same-Origin gate on all state-changing requests -- JWTs redacted by default in responses; opt-in via explicit query flag -- Embedded SPA — no external CDN, no analytics, no phone-home - -For remote access: +`dpm localnet ` and `canton-devkit localnet ` are +interchangeable. + +## Commands + +The command surface covers the full development loop: + +| Area | Commands | +| ----------------- | ------------------------------------------------------------------------------------ | +| Lifecycle | `up` `down` `stop` `start` `restart` `pause` `resume` `clean` `list` `status` `logs` | +| Host checks | `doctor` — the same preflight `up` runs, with remediation hints | +| App wiring | `env` `creds` — endpoints, party IDs, and JWTs for tests and CI | +| DAR management | `dar upload / list / info / download / diff / remove / build-upload / watch` | +| Ledger inspection | `contracts ls / watch` · `tx ls / replay` | +| Tokens | `token create / mint / transfer / burn / balance` | +| State | `snapshot` / `restore` — a portable `.tgz` of a network's full state | +| Versions | `versions` — pinned Splice releases, keyed by commit SHA | + +## Features + +- Instance lifecycle management +- Host preflight checks with remediation hints +- App wiring for endpoints, party IDs, and JWTs +- DAR upload, inspect, diff, and hot redeploy +- Live ledger inspection (contracts and transactions) +- Token flows for CIP-0056 and Token Standard V2 — see the + [tokens guide](https://bitdynamics-ab.github.io/canton-devkit/guides/tokens/) +- Snapshot and restore of a network's full state +- Both CLI and Web UI are available +- Prometheus and Grafana +- Stable exit codes and `--format json` for CI; example workflow in + [`examples/ci/`](examples/ci/github-actions.yml) +- Multiple named instances with auto-allocated or pinned ports + (`--port-base`) + +## Install + +**DPM (primary):** add the DevKit OCI component to your project's +`daml.yaml` under `components`, remove the `sdk-version` field, then run +`dpm install package`. Full steps: +[Installation & Getting Started](https://bitdynamics-ab.github.io/canton-devkit/getting-started/). + +**Standalone (macOS / Linux):** ```sh -ssh -L 7777:127.0.0.1:7777 dev-host -``` - -
- ---- - -## 📚 Commands - -The CLI is organised under `localnet`: - -| Lifecycle | Inspect | Data | Diagnostics | -|---|---|---|---| -| `up` (or `start`) — start instance | `status` — health + ports | `snapshot` — tar volumes + state | `doctor` — host preflight | -| `down` (or `stop`) — stop containers | `list` — registered instances | `restore` — recreate from tar | `logs` — tail any container | -| `restart` — down + up | `env` — shell exports | `refresh` — re-sync from docker | `metrics` — Prometheus scrape | -| `clean` — wipe everything | `versions` — supported Splice tags | `dar upload`/`dar list` — Daml archives | `container ` — per-container ops | -| `ui` — launch Web UI | `contracts watch` — live ACS | `tx ls` / `tx replay` — ledger queries | | - -Every command supports `--help`. The output-oriented commands (`up`, `status`, `list`, `env`) also support `--json` for machine-readable output. Run `canton-devkit localnet --help`. - ---- - -## ⚙️ Configuration - -Defaults are tuned for "just works"; configuration is opt-in. - -| Flag / env | Default | Purpose | -|---|---|---| -| `` or `--name ` | required | Instance label + Docker compose project prefix | -| `--version ` | `latest` | Splice version (see [`versions`](docs/versions.md)) | -| `--profile observability` | off | Add Prometheus + Grafana to the compose stack | -| `--port ` (ui) | `7777` | Web UI port | -| `--host ` (ui) | `127.0.0.1` | Bind interface (loopback-enforced) | -| `CANTON_DEVKIT_REGISTRY` | `~/.canton-devkit/localnet` | Instance state directory | -| `NO_COLOR=1` | unset | Disable ANSI colour in CLI | - ---- - -## 🏗️ Architecture - -```mermaid -flowchart LR - subgraph User Surfaces - CLI[CLI
localnet up / status / …] - Web[Web UI
localhost:7777] - end - Core[internal/localnet
orchestrator] - Reg[Registry
~/.canton-devkit/] - Splice[Splice fetch
pinned by commit SHA] - Docker[Docker Compose
~12 containers] - Upstream[(github.com/
canton-network/
splice)] - - CLI --> Core - Web --> Core - Core --> Reg - Core --> Splice - Core --> Docker - Splice -.->|"archive/<sha>.tar.gz"| Upstream +curl -fsSL https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/main/install.sh | sh ``` -**What `localnet up` actually starts** - -A default instance comes up with 12 services on a single Docker Compose network: - -| Service | What it is | -|---|---| -| `canton` | A Canton node bundling **participant + synchronizer** (the Canton blockchain itself) | -| `splice` | A single JVM running the [Splice](https://github.com/canton-network/splice) reference apps — **super-validator, validator, ANS (name service), and Scan backends** — for the three party roles | -| `postgres` | Shared database backing the participant and the Splice apps | -| `nginx` | Reverse proxy that fronts the Web UIs and the participant HTTP/JSON Ledger API | -| `wallet-web-ui-{app-user, app-provider, sv}` | Wallet UI per [party role](https://docs.daml.com/concepts/glossary.html#party) | -| `ans-web-ui-{app-user, app-provider}` | Canton Name Service UI per party | -| `scan-web-ui` | Block explorer for the local Canton network | -| `sv-web-ui` | Super-validator operator console | -| `swagger-ui` | OpenAPI explorer for the Splice and Ledger APIs | - -The `splice` container runs **one Java process** (`SpliceApp daemon`) that hosts the super-validator + validator + ANS + Scan apps internally; the three party UIs are separate static-served bundles. Confirmed against an actual `localnet up` (`docker compose ps`). - -**Design notes** - -| | | -|---|---| -| **Splice integration** | We download `cluster/compose/localnet/` from upstream [`canton-network/splice`](https://github.com/canton-network/splice) — described by the project as *"reference applications for operating Validators and Super-Validators on the Canton Network"* — pinned by commit SHA (immutable) and verified by SHA-256 post-extract. No forks, no patches. Maintainer flow: [`docs/versions.md`](docs/versions.md) | -| **Registry** | Every instance has a `state.json` (ports, JWTs, party IDs, compose project name). Single source of truth for CLI + Web UI. Atomic writes + index lock for concurrent ups | -| **JWT signing** | Splice LocalNet authenticates ledger and app traffic with a **fixed dev secret** — the literal string `unsafe` — applied to HS-256 JWTs (Splice config labels: `unsafe-jwt-hmac-256` / `hs-256-unsafe`). The DevKit signs JWTs locally with that same secret so client code can `Bearer ` against the local participant. **Never reuse against MainNet or any non-LocalNet deployment** — warning reprinted on every signing path | -| **CLI ↔ Web UI parity** | Every user-facing operation lands on both surfaces. Codified in [`AGENTS.md`](AGENTS.md). No UI-only or CLI-only features | - ---- - -## 🗺️ Roadmap - -| Milestone | Status | Highlights | -|---|---|---| -| **M1 — LocalNet CLI** | ✅ Shipped | `up` / `down` / `status` / `list` / `logs` / `env` / `doctor` / `snapshot` / `restore` + friendly errors | -| **M2 — Web UI + Observability + DAR + Agent skills** | 🚧 In progress | Dashboard, container health, JWT issuer, app-config exporter, snapshot/restore UI | -| **M3 — Canton Token Standard** | 📅 Planned | `token create` / `mint` / `transfer` / `balance` — CLI + Web UI. Tracks [CIP-0056](https://github.com/canton-foundation/cips/blob/main/cip-0056/cip-0056.md) (finalised) and incorporates [CIP-0112](https://github.com/canton-foundation/cips) (V2 draft — privacy, performance, accounting improvements) as it stabilises | - -Follow progress in [open PRs](https://github.com/bitdynamics-ab/canton-devkit/pulls), or [open an issue](https://github.com/bitdynamics-ab/canton-devkit/issues/new) to weigh in on direction. +Release archives (macOS arm64, Linux amd64, Windows amd64), +`SHA256SUMS`, Homebrew +(`brew install bitdynamics-ab/canton-devkit/canton-devkit`), APT for +Debian/Ubuntu, and `go install` are documented in the same guide. ---- +## Documentation -## ❓ FAQ +Canonical docs live on the website: +[https://bitdynamics-ab.github.io/canton-devkit/](https://bitdynamics-ab.github.io/canton-devkit/). -
-Is this an official Canton or Digital Asset project? +Source Markdown also lives under [`docs/`](docs/) for browsing in the +repository: -No. It's a community tool built by [Bit Dynamics AB](https://bitdynamics.me/) under a [Canton Foundation grant](https://github.com/canton-foundation/canton-dev-fund/pull/18). The upstream Splice repo it wraps is governed by the [Canton Network](https://canton.network/). -
- -
-How is this different from cn-quickstart? - -[cn-quickstart](https://github.com/digital-asset/cn-quickstart) is an app-provider scaffold: it layers a backend service, Daml workflows, and a sample frontend on top of Splice LocalNet. `canton-devkit` is the LocalNet layer underneath. They're complementary — point `cn-quickstart` at a LocalNet brought up by `canton-devkit`. -
- -
-Can I run multiple instances in parallel? - -Yes. Each instance has its own Docker compose project, network, port range, and registry entry. `localnet list` shows them all; `localnet ui` shows them in a switcher. -
- -
-Where are the JWTs? Are they secure? - -`canton-devkit localnet env --include-jwt` prints them. By default they're redacted in any UI/CLI output (opt-in via the flag). The dev-secret warning is reprinted on every signing path. **Never reuse these tokens against MainNet** — they're for local dev only. -
- -
-My localnet up fails with PORTS_IN_USE. - -Another instance is using the default port range. Two ways to recover: - -1. **Stop the conflicting instance** — `localnet list` shows everything registered; `localnet down ` frees the ports. -2. **Run a fresh instance under a different name** — each name gets its own port window allocated automatically. `localnet up demo-2` will pick the next free range. - -Use `localnet doctor` to see exactly which ports are in use before retrying. -
- -
-I'm on Apple Silicon (M1/M2/M3). Anything special? - -Yes, this works. The Splice container images published under `ghcr.io/digital-asset/decentralized-canton-sync/docker/*` are multi-arch (verified via `docker manifest inspect` against the `0.6.4` tag — Canton, Splice, and the wallet/scan UIs all carry `linux/arm64` manifests). The DevKit binary itself ships as native arm64. Expect ~3-5 min cold start vs ~1-2 min on x86_64 Linux. -
- -
-How do I integrate with CI? - -```yaml -- name: Bring up Canton LocalNet - run: | - canton-devkit localnet up ci --json > /tmp/instance.json - eval "$(canton-devkit localnet env ci --include-jwt)" - -- name: Run integration tests - run: npm test - -- name: Tear down - if: always() - run: canton-devkit localnet clean --name ci -``` - -For fixtures, snapshot once and check in the `.tgz` (or stash on object storage); subsequent runs restore in 4 seconds. -
- -
-Where does state live? Can I wipe it? - -`~/.canton-devkit/localnet//state.json` per instance, plus an `index.json`. Docker volumes are named `canton-_`. `localnet clean --name ` removes both. To nuke everything: `rm -rf ~/.canton-devkit && docker compose ls -aq | xargs -I {} docker compose -p {} down -v`. -
- ---- - -## 💬 Get help - -| Question | Where to ask | -|---|---| -| **"I think this is a bug"** or **"How do I…?"** | [Open an issue](https://github.com/bitdynamics-ab/canton-devkit/issues/new) — we triage usage questions and bugs together | -| **Canton / Daml questions** | [Canton forum](https://forum.canton.network/) (formerly `discuss.daml.com`) — better answers there than from us | - ---- - -## 🤝 Contributing - -Contributions welcome — see [`AGENTS.md`](AGENTS.md) for the full set of conventions. - -**Quick rules** - -- ✅ All tests pass — `make test` + `cd frontend && npm test` -- ✅ **CLI ↔ Web UI parity** — every user-facing change lands on both surfaces -- ✅ One logical change per PR -- ✅ Stable error codes — `INSTANCE_NOT_FOUND`, `PORTS_IN_USE`, etc.; never renamed once shipped - -```sh -make build # build the binary -make test # run Go tests -make lint # golangci-lint -make frontend # build the Web UI bundle -make frontend-test # run Web UI tests -uvx pre-commit install # install Git hooks (requires uv) -``` +- Guides: [getting started](docs/getting-started.md) · + [explorer](docs/explorer.md) · + [observability](docs/observability.md) · + [dashboard customization](docs/dashboard-customization.md) · + [tokens](docs/tokens.md) · + [homebrew](docs/homebrew.md) +- Reference: [versions](docs/versions.md) · + [packaging](docs/packaging.md) · + [telemetry](docs/telemetry.md) · + [FAQ](docs/faq.md) · + [troubleshooting](docs/troubleshooting.md) · + [limitations](docs/limitations.md) -Open an [issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first for anything non-trivial. PRs against `main` welcomed. +This is a developer tool, not a production deployment path. For +production Canton, see the official +[Canton documentation](https://docs.daml.com/canton/). ---- +For bugs and usage questions, +[open an issue](https://github.com/bitdynamics-ab/canton-devkit/issues/new). +For general Canton and Daml questions, use the +[Canton forum](https://forum.canton.network/). -## 📦 Releasing +## Download statistics -Tagged builds (`v*`) publish: +Release download counts aggregated from +[`bitdynamics-ab/canton-devkit`](https://github.com/bitdynamics-ab/canton-devkit/releases) +and +[`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases). +Charts refresh daily via +[`release-stats.yml`](.github/workflows/release-stats.yml); checksum +files are excluded from the counts. Exact numbers: +[release-downloads.md](https://github.com/bitdynamics-ab/canton-devkit/blob/release-stats-data/docs/assets/release-downloads.md). -- Linux + macOS + Windows binaries to [GitHub Releases](https://github.com/bitdynamics-ab/canton-devkit/releases) -- Docker images to `ghcr.io/bitdynamics-ab/canton-devkit:` +Total downloads per release over time -Manual cut: `git tag v0.1.0 && git push origin v0.1.0`. The [release workflow](.github/workflows/release.yml) handles the rest. +All-time downloads per platform ---- +## Contributing -## 💛 Acknowledgements +See [CONTRIBUTING.md](CONTRIBUTING.md) for the build, test, and lint +setup and project conventions: a regression test for every fix, +CLI/Web-UI feature parity, SHA-pinned CI actions. -`canton-devkit` wraps the [Splice LocalNet](https://github.com/canton-network/splice) compose project published by the Canton Network community. Splice is [Digital Asset](https://www.digitalasset.com/)'s open-source reference implementation of the Canton Network validator and super-validator apps. The Global Synchronizer that underpins Canton Network is governed by the [Canton Foundation](https://canton.foundation/), which also funds this project via a [developer grant](https://github.com/canton-foundation/canton-dev-fund/pull/18). Daml — the smart-contract language Canton uses — is developed by Digital Asset. +To work on the documentation site locally, see +[website/DEVELOPMENT.md](website/DEVELOPMENT.md). -
+canton-devkit builds on the work of the +[Splice](https://github.com/canton-network/splice) and +[Canton](https://github.com/digital-asset/canton) teams. - -Built with care by Bit Dynamics AB · Licensed Apache 2.0 · ⭐ Star us - +## License -
+[Apache 2.0](LICENSE) diff --git a/assets/assets.go b/assets/assets.go index d8b88832..f3c524c8 100644 --- a/assets/assets.go +++ b/assets/assets.go @@ -3,31 +3,24 @@ // observability overlay (compose file, prometheus.yml, dashboard JSON, // provisioning configs). // -// Why this package lives at the repo root next to `assets/compose/` -// and `assets/grafana/` rather than under `internal/`: +// It lives at the repo root next to `assets/compose/` and +// `assets/grafana/` rather than under `internal/` because Go's +// `//go:embed` directive rejects paths containing `..`: embedded trees +// must live inside (or below) the directory of the .go file declaring +// the directive, so this tiny package's only job is to hold the embed +// and expose the FS. // -// - Go's `//go:embed` directive rejects paths containing `..` — embedded -// trees must live inside (or below) the directory of the .go file -// declaring the directive. The previous home for this embed was -// `internal/localnet/observability_overlay.go` with -// `//go:embed all:../../assets/compose` — that does not compile -// ("invalid pattern syntax"). -// - Co-locating the .go file with the asset trees is the conventional -// workaround: one tiny package whose only job is to hold the embed -// and expose the FS to anyone who needs it. -// -// Consumers import this package and read from [Observability], using -// `fs.WalkDir` to materialize files to a per-instance destination on -// disk. See internal/localnet/observability_overlay.go for the writer. +// Consumers read from [FS] using `fs.WalkDir` to materialize files to +// a per-instance destination on disk. See +// internal/localnet/observability_overlay.go for the writer. package assets import "embed" // FS embeds the compose overlays + Grafana provisioning that the // `localnet up --profile ` overlays materialize into an instance's -// data directory at boot. It is NOT observability-specific — it was -// once named Observability, which misled the tokens-v2 overlay author — -// every profile's compose fragment lives under compose/ in this tree. +// data directory at boot. It is NOT observability-specific: every +// profile's compose fragment lives under compose/ in this tree. // // Tree shape (post-walk): // @@ -37,6 +30,9 @@ import "embed" // grafana/dashboards/canton-localnet.json // grafana/provisioning/dashboards/canton.yaml // grafana/provisioning/datasources/prometheus.yaml +// nginx/app-provider.conf +// nginx/app-user.conf +// nginx/sv.conf // -//go:embed all:compose all:grafana +//go:embed all:compose all:grafana all:nginx var FS embed.FS diff --git a/assets/compose/prometheus.yml b/assets/compose/prometheus.yml index 9141f3f4..1cf1202e 100644 --- a/assets/compose/prometheus.yml +++ b/assets/compose/prometheus.yml @@ -6,12 +6,8 @@ # That file enables the built-in Prometheus reporter on port 10013 # (0.0.0.0:10013/metrics) with JVM metrics, all qualifiers # (errors/latency/saturation/traffic/debug), and 40k cardinality. -# -# We discovered this by inspecting the upstream image — prior -# versions of this comment assumed metrics were OFF in stock Splice; -# they're not. The reporter is ON by default in both images and -# active for the canton and splice JVM services without any extra -# configuration overlay on our side. +# The reporter is therefore ON by default in both images — no extra +# configuration overlay is needed on our side. # # Both `canton` and `splice` containers join the project's default # docker network when the observability overlay is applied, so the diff --git a/assets/compose/shared-prometheus.yml b/assets/compose/shared-prometheus.yml index 7f73d038..2dda3790 100644 --- a/assets/compose/shared-prometheus.yml +++ b/assets/compose/shared-prometheus.yml @@ -1,7 +1,7 @@ # Prometheus scrape config for the SHARED, host-level observability # stack (canton-devkit-observability project). Unlike the per-instance -# observability.yaml — which ran a Prometheus INSIDE each instance's -# network and scraped canton:10013/splice:10013 by service-name DNS — +# observability.yaml — which runs a Prometheus INSIDE each instance's +# network and scrapes canton:10013/splice:10013 by service-name DNS — # this single Prometheus lives in its own project/network and cannot # resolve those service names (and they collide: every instance has a # `canton`). Instead each instance publishes its canton/splice :10013 diff --git a/assets/dashboard_test.go b/assets/dashboard_test.go index 672d3b37..069efa7b 100644 --- a/assets/dashboard_test.go +++ b/assets/dashboard_test.go @@ -30,10 +30,10 @@ func TestDashboardJSONIsValid(t *testing.T) { } // TestDashboardHasACSAndThroughputPanels pins the two live-audited -// panels added to satisfy the completeness review. Stock Splice -// 0.6.4 does not expose exact ACS cardinality or template-grain -// submission counters via Prometheus, so these panel titles and -// queries must stay honest about the signals they actually show. +// ACS and throughput panels. Stock Splice 0.6.4 does not expose +// exact ACS cardinality or template-grain submission counters via +// Prometheus, so these panel titles and queries must stay honest +// about the signals they actually show. func TestDashboardHasACSAndThroughputPanels(t *testing.T) { raw, err := FS.ReadFile("grafana/dashboards/canton-localnet.json") if err != nil { @@ -49,7 +49,7 @@ func TestDashboardHasACSAndThroughputPanels(t *testing.T) { t.Fatalf("parse dashboard: %v", err) } want := map[int]string{ - 14: "ACS Lookup Buffer Length", + 14: "ACS Lookup Buffer", 15: "Top 10 gRPC Methods by Throughput (ops/s, 5m)", } got := map[int]string{} diff --git a/assets/grafana/dashboards/canton-localnet.json b/assets/grafana/dashboards/canton-localnet.json index fe2ec170..cbee8f53 100644 --- a/assets/grafana/dashboards/canton-localnet.json +++ b/assets/grafana/dashboards/canton-localnet.json @@ -48,13 +48,15 @@ { "id": 3, "type": "stat", - "title": "Sequencer Submission Latency (p95)", + "title": "Sequencer Submission Latency (avg)", + "description": "Mean sequencing time (sum/count). Stock Splice 0.6.4 exports this histogram with only the +Inf bucket, so histogram_quantile percentiles are NaN — the mean is the reliable figure. Add p50/p95 panels on Splice versions whose histograms carry finite le buckets.", "datasource": "Prometheus", "gridPos": { "h": 6, "w": 6, "x": 12, "y": 0 }, + "fieldConfig": { "defaults": { "unit": "s" } }, "targets": [ { - "expr": "histogram_quantile(0.95, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket{instance=~\"$instance\"}[5m])) by (le))", - "legendFormat": "p95" + "expr": "sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum{instance=~\"$instance\"}[5m])) / sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count{instance=~\"$instance\"}[5m]))", + "legendFormat": "avg" } ] }, @@ -110,25 +112,23 @@ { "id": 13, "type": "timeseries", - "title": "Submission Sequencing Latency", + "title": "Submission Sequencing Latency (avg)", + "description": "Mean sequencing time per component (sum/count). histogram_quantile percentiles are NaN on stock Splice 0.6.4 — its histogram carries only the +Inf bucket.", "datasource": "Prometheus", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 14 }, + "fieldConfig": { "defaults": { "unit": "s" } }, "targets": [ { - "expr": "histogram_quantile(0.50, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket{instance=~\"$instance\"}[5m])) by (le, component))", - "legendFormat": "p50 {{component}}" - }, - { - "expr": "histogram_quantile(0.95, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket{instance=~\"$instance\"}[5m])) by (le, component))", - "legendFormat": "p95 {{component}}" + "expr": "sum by (component) (rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum{instance=~\"$instance\"}[5m])) / sum by (component) (rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count{instance=~\"$instance\"}[5m]))", + "legendFormat": "avg {{component}}" } ] }, { "id": 14, "type": "stat", - "title": "ACS Lookup Buffer Length", - "description": "ACS-related index lookup buffer length across participants. Stock Splice 0.6.4 does not expose total active-contract cardinality as a Prometheus metric; use the Web UI Explorer / JSON API ACS lookup for exact active contract counts.", + "title": "ACS Lookup Buffer", + "description": "Active-contracts index buffer size across participants. Stock Splice 0.6.4 does not expose total active-contract cardinality as a Prometheus metric; use the Web UI Explorer / JSON API ACS lookup for exact active contract counts.", "datasource": "Prometheus", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 22 }, "options": { @@ -139,7 +139,7 @@ }, "targets": [ { - "expr": "sum(daml_participant_api_index_db_active_contract_lookup_batch_buffer_length{instance=~\"$instance\"})", + "expr": "sum(daml_participant_api_index_active_contracts_buffer_size{instance=~\"$instance\"})", "legendFormat": "ACS lookup buffer" } ] diff --git a/assets/nginx/00-devkit-tuning.conf b/assets/nginx/00-devkit-tuning.conf new file mode 100644 index 00000000..c3ba506a --- /dev/null +++ b/assets/nginx/00-devkit-tuning.conf @@ -0,0 +1,12 @@ +# canton-devkit: http-context tuning, included ahead of the role server +# blocks via nginx's `include /etc/nginx/conf.d/*.conf` (the `00-` prefix +# sorts it first). This file carries NO server{} block — its directives +# land directly in the http context. +# +# DevKit's role-scoped vhosts (...localhost, +# e.g. grpc-ledger-api.app-provider.localnet-2.localhost) are longer than +# nginx's default 64-byte server-name hash bucket, so without this nginx +# fails to boot with: +# [emerg] could not build server_names_hash, you should increase +# server_names_hash_bucket_size: 64 +server_names_hash_bucket_size 128; diff --git a/assets/nginx/app-provider.conf b/assets/nginx/app-provider.conf new file mode 100644 index 00000000..2699ec8b --- /dev/null +++ b/assets/nginx/app-provider.conf @@ -0,0 +1,68 @@ +# canton-devkit: server_name values are role-scoped instance vhosts of the +# form ...localhost (role = app-provider here), +# injected via ${VHOST_*_APP_PROVIDER} env vars by WriteNginxVhostOverlay +# (e.g. wallet.app-provider.localnet-2.localhost). The flat Splice names +# (wallet.localhost, ans.localhost, ...) are intentionally NOT served — +# DevKit advertises only the instance-scoped names so URLs stay unambiguous +# across concurrently running localnets. +# +# NOTE: *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +# in the JVM/Node/Python resolvers (and some Rust HTTP clients). Programmatic +# clients on those runtimes must send an explicit `Host:` header (HTTP) or +# `:authority:` pseudo-header (gRPC), or add an /etc/hosts entry. +server { + listen ${APP_PROVIDER_UI_PORT}; + server_name ${VHOST_ANS_APP_PROVIDER}; + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:3${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; + } + location / { + proxy_pass http://ans-web-ui-app-provider:8080/; + } +} + +# Deprecated, use the json-ledger-api vhost instead. Left on the flat +# canton.localhost name (upstream-deprecated; not worth instance-scoping). +server { + listen ${APP_PROVIDER_UI_PORT}; + server_name canton.localhost; + location / { + proxy_pass http://canton:3${PARTICIPANT_JSON_API_PORT_SUFFIX}; + include /etc/nginx/includes/cors-headers.conf; + } +} + +server { + listen ${APP_PROVIDER_UI_PORT}; + server_name ${VHOST_JSON_LEDGER_APP_PROVIDER}; + location / { + proxy_pass http://canton:3${PARTICIPANT_JSON_API_PORT_SUFFIX}; + include /etc/nginx/includes/cors-headers.conf; + } +} + +server { + listen ${APP_PROVIDER_UI_PORT} http2; + server_name ${VHOST_GRPC_LEDGER_APP_PROVIDER}; + location / { + grpc_pass grpc://canton:3${PARTICIPANT_LEDGER_API_PORT_SUFFIX}; + } +} + + +server { + listen ${APP_PROVIDER_UI_PORT}; + server_name ${VHOST_WALLET_APP_PROVIDER}; + + # Reverse proxy for /api/validator + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:3${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; + } + + # Reverse proxy to wallet-web-ui + location / { + proxy_pass http://wallet-web-ui-app-provider:8080/; + } +} diff --git a/assets/nginx/app-user.conf b/assets/nginx/app-user.conf new file mode 100644 index 00000000..2eada518 --- /dev/null +++ b/assets/nginx/app-user.conf @@ -0,0 +1,67 @@ +# canton-devkit: server_name values are role-scoped instance vhosts of the +# form ...localhost (role = app-user here), +# injected via ${VHOST_*_APP_USER} env vars by WriteNginxVhostOverlay +# (e.g. wallet.app-user.localnet-2.localhost). The flat Splice names +# (wallet.localhost, ans.localhost, ...) are intentionally NOT served — +# DevKit advertises only the instance-scoped names so URLs stay unambiguous +# across concurrently running localnets. +# +# NOTE: *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +# in the JVM/Node/Python resolvers (and some Rust HTTP clients). Programmatic +# clients on those runtimes must send an explicit `Host:` header (HTTP) or +# `:authority:` pseudo-header (gRPC), or add an /etc/hosts entry. +server { + listen ${APP_USER_UI_PORT}; + server_name ${VHOST_ANS_APP_USER}; + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:2${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; + } + location / { + proxy_pass http://ans-web-ui-app-user:8080/; + } +} + +# Deprecated, use the json-ledger-api vhost instead. Left on the flat +# canton.localhost name (upstream-deprecated; not worth instance-scoping). +server { + listen ${APP_USER_UI_PORT}; + server_name canton.localhost; + location / { + proxy_pass http://canton:2${PARTICIPANT_JSON_API_PORT_SUFFIX}; + include /etc/nginx/includes/cors-headers.conf; + } +} + +server { + listen ${APP_USER_UI_PORT}; + server_name ${VHOST_JSON_LEDGER_APP_USER}; + location / { + proxy_pass http://canton:2${PARTICIPANT_JSON_API_PORT_SUFFIX}; + include /etc/nginx/includes/cors-headers.conf; + } +} + +server { + listen ${APP_USER_UI_PORT} http2; + server_name ${VHOST_GRPC_LEDGER_APP_USER}; + location / { + grpc_pass grpc://canton:2${PARTICIPANT_LEDGER_API_PORT_SUFFIX}; + } +} + +server { + listen ${APP_USER_UI_PORT}; + server_name ${VHOST_WALLET_APP_USER}; + + # Reverse proxy for /api/validator + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:2${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; + } + + # Reverse proxy to wallet-web-ui + location / { + proxy_pass http://wallet-web-ui-app-user:8080/; + } +} diff --git a/assets/nginx/sv.conf b/assets/nginx/sv.conf new file mode 100644 index 00000000..c67c377e --- /dev/null +++ b/assets/nginx/sv.conf @@ -0,0 +1,95 @@ +# canton-devkit: server_name values are instance-scoped vhosts. The SV +# node's own UIs are single-per-instance (scan..localhost via +# ${VHOST_SCAN}, sv..localhost via ${VHOST_SV}); the sv-role +# wallet is role-scoped (wallet.sv..localhost via +# ${VHOST_WALLET_SV}). All injected as env vars by WriteNginxVhostOverlay. +# The flat Splice names (wallet.localhost, scan.localhost, sv.localhost, ...) +# are intentionally NOT served — DevKit advertises only the instance-scoped +# names so URLs stay unambiguous across concurrently running localnets. +# +# NOTE: *.localhost resolves to 127.0.0.1 in browsers, curl, and Go, but NOT +# in the JVM/Node/Python resolvers (and some Rust HTTP clients). Programmatic +# clients on those runtimes must send an explicit `Host:` header (HTTP) or +# `:authority:` pseudo-header (gRPC), or add an /etc/hosts entry. +server { + listen ${SV_UI_PORT}; + # Catch-all default server: keeps the stub_status endpoint reachable and + # absorbs unmatched Host headers. `_` (not `localhost`) so the bare host + # URL does not fall through to the non-existent /usr/share/nginx/sv-html + # static dir (which 404'd before PR #279). + server_name _; + + location = /status { + stub_status; + } + + # Serve static files from /usr/share/nginx/html + location / { + root /usr/share/nginx/sv-html; + try_files $uri $uri/ =404; + } + + client_max_body_size 10M; + keepalive_timeout 65; +} + +server { + listen ${SV_UI_PORT}; + server_name ${VHOST_SV}; + + location /api/sv { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:5014/api/sv; + } + location / { + proxy_pass http://sv-web-ui:8080/; + } +} + +server { + listen ${SV_UI_PORT}; + server_name ${VHOST_SCAN}; + + location /api/scan { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:5012/api/scan; + } + location /registry { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:5012/registry; + } + location / { + proxy_pass http://scan-web-ui:8080/; + } +} + +server { + listen ${SV_UI_PORT}; + server_name ${VHOST_WALLET_SV}; + + # Reverse proxy for /api/validator + location /api/validator { + rewrite ^\/(.*) /$1 break; + proxy_pass http://splice:4${VALIDATOR_ADMIN_API_PORT_SUFFIX}/api/validator; + } + + # Reverse proxy to wallet-web-ui + location / { + proxy_pass http://wallet-web-ui-sv:8080/; + } +} + +server { + listen ${SV_UI_PORT}; + server_name canton.localhost; + location /docs/openapi { + proxy_pass http://canton:4${PARTICIPANT_JSON_API_PORT_SUFFIX}/docs/openapi; + include /etc/nginx/includes/cors-headers.conf; + } + + location /v2 { + include /etc/nginx/includes/cors-options-headers.conf; + proxy_pass http://canton:4${PARTICIPANT_JSON_API_PORT_SUFFIX}/v2; + include /etc/nginx/includes/cors-headers.conf; + } +} diff --git a/build/daml-analyzer/Dockerfile b/build/daml-analyzer/Dockerfile new file mode 100644 index 00000000..f2b264cc --- /dev/null +++ b/build/daml-analyzer/Dockerfile @@ -0,0 +1,22 @@ +# syntax=docker/dockerfile:1 +# daml-analyzer (Certora, Apache-2.0) packaged for reproducible use. +# Build stage compiles the fat jar from a pinned upstream commit; runtime +# is a slim JRE. Invoke: docker run --rm -v :/in/pkg.dar:ro IMAGE /in/pkg.dar -f json +FROM eclipse-temurin:17-jdk-jammy AS build +ARG SBT_VERSION=1.10.7 +ARG ANALYZER_COMMIT=143a7e2a9f24db5ea9bbd7680809d596d1151bcb +RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL "https://github.com/sbt/sbt/releases/download/v${SBT_VERSION}/sbt-${SBT_VERSION}.tgz" | tar -xz -C /opt \ + && ln -s /opt/sbt/bin/sbt /usr/local/bin/sbt +WORKDIR /src +RUN git clone https://github.com/Certora/daml-analyzer . && git checkout "${ANALYZER_COMMIT}" +RUN sbt -batch assembly + +FROM eclipse-temurin:17-jre +LABEL org.opencontainers.image.source="https://github.com/Certora/daml-analyzer" \ + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.revision="143a7e2a9f24db5ea9bbd7680809d596d1151bcb" +COPY --from=build /src/target/scala-2.13/daml-analyzer-*.jar /opt/daml-analyzer/daml-analyzer.jar +# No network needed at analysis time. +ENTRYPOINT ["java","-Xss4m","-jar","/opt/daml-analyzer/daml-analyzer.jar"] diff --git a/build/daml-analyzer/LICENSE b/build/daml-analyzer/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/build/daml-analyzer/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/build/daml-analyzer/README.md b/build/daml-analyzer/README.md new file mode 100644 index 00000000..7bdaadec --- /dev/null +++ b/build/daml-analyzer/README.md @@ -0,0 +1,39 @@ +# daml-analyzer image + +Packages Certora's [daml-analyzer](https://github.com/Certora/daml-analyzer) +(Apache-2.0) — a static analyzer for cross-package interactions in a compiled +Daml package — as a pinned, reproducible container image. The devkit runs it +via `docker run` (see `internal/analyzer`), so there is no host Java dependency +and nothing heavy in git; the image lives in a registry. + +- Upstream commit: `143a7e2a9f24db5ea9bbd7680809d596d1151bcb` +- License: Apache-2.0 (see `LICENSE`) +- Default image ref: `ghcr.io/bitdynamics-ab/daml-analyzer:0.1.0-143a7e2` + (matches `analyzer.DefaultImage`; override with `DAML_ANALYZER_IMAGE`). + +The `Dockerfile` is multi-stage: it builds the fat jar from the pinned commit +in a JDK+sbt stage and ships it on a slim JRE. The entrypoint is the analyzer, +so arguments are the in-container dar path plus flags. + +## Build locally + + make analyzer-image # builds DAML_ANALYZER_IMAGE (default tag) + +or directly: + + docker build -t ghcr.io/bitdynamics-ab/daml-analyzer:0.1.0-143a7e2 build/daml-analyzer + +## Run manually + + docker run --rm --network none -v "$PWD/foo.dar:/in/foo.dar:ro" \ + ghcr.io/bitdynamics-ab/daml-analyzer:0.1.0-143a7e2 /in/foo.dar -f json + +## Publish + +The `.github/workflows/analyzer-image.yml` workflow builds and pushes to GHCR +on manual dispatch. Bump the pinned commit here, in the `Dockerfile` +(`ANALYZER_COMMIT`), and in `analyzer.DefaultImage` together, then re-publish. +For arm64 as well as amd64, build multi-arch with buildx: + + docker buildx build --platform linux/amd64,linux/arm64 --push \ + -t ghcr.io/bitdynamics-ab/daml-analyzer:0.1.0-143a7e2 build/daml-analyzer diff --git a/component.yaml b/component.yaml index 1f017299..14579eb9 100644 --- a/component.yaml +++ b/component.yaml @@ -10,8 +10,15 @@ # # DPM strips the registered command name from argv and passes # `exec-args` + the user's args to the binary, so `dpm localnet up --name x` -# invokes `canton-devkit localnet up --name x`. The TestRunIsArgvOnly test -# (internal/cli/cli_test.go) locks this invariant. +# invokes `canton-devkit --via-dpm localnet up --name x`. The +# TestRunIsArgvOnly test (internal/cli/cli_test.go) locks this invariant. +# +# The leading `--via-dpm` marker is how the binary knows it was launched by +# DPM (argv is otherwise identical to a direct `canton-devkit localnet …` +# call). App.Run strips it before Cobra parses and uses it to switch help +# text / examples to `dpm localnet …` and to hide flags that don't apply +# under DPM (e.g. `--project`, since `dpm localnet` only runs inside a Daml +# project). See internal/cli/app.go. # # Published per-platform via `dpm publish component` from the release CI; # the `path` is rewritten to `canton-devkit.exe` on Windows by @@ -25,5 +32,5 @@ spec: - path: canton-devkit name: localnet desc: Manage Canton LocalNet developer environments — lifecycle, DAR, contracts, observability, and CIP-0112 token tooling. - exec-args: ["localnet"] + exec-args: ["--via-dpm", "localnet"] aliases: [] diff --git a/docs/adoption/reviewer-kit.md b/docs/adoption/reviewer-kit.md deleted file mode 100644 index 7b30d938..00000000 --- a/docs/adoption/reviewer-kit.md +++ /dev/null @@ -1,76 +0,0 @@ -# DevKit reviewer kit (M1 adoption) - -The M1 adoption metric is: **≥3 external companies/teams have reviewed -canton-devkit and tested LocalNet setup + lifecycle.** This kit is -everything you need to recruit and run those reviews. The recruiting -itself — identifying and contacting teams — is a human step; this page -makes it turnkey once you have a contact. - -> **Status of the metric is people, not code.** Securing 3 external teams -> is outreach work. Track the actual reviewers in the table at the bottom. - -## Who to approach - -Good first reviewers are teams who already touch Canton/Daml and feel the -LocalNet pain canton-devkit removes: - -- Daml app developers who currently hand-roll `docker compose` against - Splice. -- Teams on the CIP-0112 token path. -- Canton Foundation ecosystem contacts (co-marketing). -- Internal teams at partner orgs already piloting Canton. - -## The ask (copy-paste outreach template) - -> Subject: 15-minute LocalNet review — canton-devkit -> -> Hi — we built **canton-devkit**, a single-binary tool that brings -> up a full Canton LocalNet (sequencers, mediators, participants, Splice -> apps) in one command, with a CLI + Web UI for the whole lifecycle and -> CIP-0112 token tooling. -> -> Would you spend ~15 minutes taking it zero-to-running and telling us -> where it's rough? Everything you need is one page: -> `docs/getting-started.md`, and there's a self-timing harness -> (`scripts/validate-zero-to-localnet.sh`) if you want it. -> -> We're specifically validating "new user → running LocalNet in under 10 -> minutes." Your friction notes are the whole point — no prep needed. - -## What to send them - -1. **Install + first run** — [getting-started.md](../getting-started.md). -2. **The checklist** — [validation-checklist.md](../validation-checklist.md) - (manual boxes + the timed harness). -3. **A token walkthrough** (optional, for CIP-0112 teams) — - [tokens.md](../tokens.md) or `scripts/demo.sh --with-tokens`. -4. **Where to file feedback** — a GitHub issue with the `doctor` output + - their platform, or the structured form below. - -## Feedback form (what to collect per reviewer) - -``` -Company / team: -Reviewer: -Platform (OS + arch): -Docker memory: -Cache: cold | warm -zero-to-LocalNet wall-clock: -Result: pass | fail (which step) -Top 3 friction points: -Would they use it again? (y/n + why) -CIP-0112 token flow tried? (y/n) -OK to attribute publicly? (y/n) -``` - -## Tracking - -| # | Company / team | Contact | Date | Result | Friction notes | Public-OK | -|---|---|---|---|---|---|---| -| 1 | _TBD_ | | | | | | -| 2 | _TBD_ | | | | | | -| 3 | _TBD_ | | | | | | - -Three rows filled with a `pass` (or a fixed `fail`) closes the M1 -adoption metric. Feed the friction notes into the UX polish backlog -and the aggregate into the M4 adoption transparency update. diff --git a/docs/assets/release-downloads-by-platform.svg b/docs/assets/release-downloads-by-platform.svg new file mode 100644 index 00000000..b39db0af --- /dev/null +++ b/docs/assets/release-downloads-by-platform.svg @@ -0,0 +1,13 @@ + + +All-time downloads per platform +macOS (arm64) + +19 +Linux (amd64) + +7 +Windows (amd64) + +4 + diff --git a/docs/assets/release-downloads-by-version.svg b/docs/assets/release-downloads-by-version.svg new file mode 100644 index 00000000..30e7fe05 --- /dev/null +++ b/docs/assets/release-downloads-by-version.svg @@ -0,0 +1,48 @@ + + +Total downloads, by release + +0 + +2 + +4 + +6 + +8 + +11 + + +v0.3 +v0.4 +v0.5 +v0.6 +v0.7 +v0.8.1 +v0.9.0 +v0.10.1 +v0.12.1 +v0.12.2 +v0.13.0 +v0.14.0 +v0.15.0 +Release tag (oldest -> newest) + + + + + + + + + + + + + + + +Total downloads + diff --git a/docs/assets/release-downloads-history.jsonl b/docs/assets/release-downloads-history.jsonl new file mode 100644 index 00000000..ad3f78ab --- /dev/null +++ b/docs/assets/release-downloads-history.jsonl @@ -0,0 +1,2 @@ +{"date":"2026-07-04","total":26,"byPlatform":{"macOS (arm64)":15,"Linux (amd64)":7,"Windows (amd64)":4,"Debian (.deb)":0},"byVersion":{"v0.3":1,"v0.4":3,"v0.5":4,"v0.6":1,"v0.7":1,"v0.8.1":0,"v0.9.0":5,"v0.10.1":11}} +{"date":"2026-07-10","total":30,"byPlatform":{"macOS (arm64)":19,"Linux (amd64)":7,"Windows (amd64)":4,"Debian (.deb)":0},"byVersion":{"v0.3":1,"v0.4":3,"v0.5":4,"v0.6":1,"v0.7":1,"v0.8.1":0,"v0.9.0":5,"v0.10.1":11,"v0.12.1":1,"v0.12.2":0,"v0.13.0":1,"v0.14.0":1,"v0.15.0":1}} diff --git a/docs/assets/release-downloads.md b/docs/assets/release-downloads.md new file mode 100644 index 00000000..9055e462 --- /dev/null +++ b/docs/assets/release-downloads.md @@ -0,0 +1,31 @@ + + + +**Total downloads:** 30 across 13 releases. + +### Downloads per version + +| Version | Downloads | +|---|---| +| v0.15.0 | 1 | +| v0.14.0 | 1 | +| v0.13.0 | 1 | +| v0.12.2 | 0 | +| v0.12.1 | 1 | +| v0.10.1 | 11 | +| v0.9.0 | 5 | +| v0.8.1 | 0 | +| v0.7 | 1 | +| v0.6 | 1 | +| v0.5 | 4 | +| v0.4 | 3 | +| v0.3 | 1 | + +### Downloads per platform + +| Platform | Downloads | +|---|---| +| macOS (arm64) | 19 | +| Linux (amd64) | 7 | +| Windows (amd64) | 4 | + diff --git a/docs/changes-from-proposal.md b/docs/changes-from-proposal.md new file mode 100644 index 00000000..a6e0e792 --- /dev/null +++ b/docs/changes-from-proposal.md @@ -0,0 +1,395 @@ +# Changes from Original Proposal + +This document records every deliberate deviation — command syntax, flag names, behaviour, or scope — between the [original DevKit Development Fund proposal](./original-devkit-proposal.md) and the shipped implementation. + +Every deviation listed here is **intentional**, not an oversight or implementation mistake. Each one was made for a concrete reason: improving developer or user experience, system performance or resource efficiency, security, correctness, or CLI ↔ Web UI parity. The per-entry **"Why"** notes record that rationale. Where the proposal's wording was a high-level intent rather than a precise spec, the shipped form is the deliberate concretization of that intent. + +**Maintenance rule:** any PR that introduces or changes a command name, flag name, alias, default, or user-facing behaviour relative to the proposal **must** add or update an entry here in the same PR. See the "Proposal deviation tracking" rule in [AGENTS.md](../AGENTS.md). + +--- + +## Table of contents + +- [Cross-cutting conventions](#cross-cutting-conventions) + - [Instance name addressing](#instance-name-addressing) + - [Machine-readable output flag](#machine-readable-output-flag) + - [Command aliases](#command-aliases) +- [`localnet remove` (renamed from `clean`)](#localnet-remove-renamed-from-clean) +- [`localnet up`](#localnet-up) + - [`--allow-uncurated` flag (new)](#--allow-uncurated-flag-new) + - [`--profile` flag (new)](#--profile-flag-new) + - [`--port-base` flag (new)](#--port-base-flag-new) +- [`localnet pause` / `resume` (new)](#localnet-pause--resume-new) +- [`localnet stop` / `start` (new)](#localnet-stop--start-new) +- [`localnet creds` (new)](#localnet-creds-new) +- [`localnet versions` (new)](#localnet-versions-new) +- [`localnet ui` (new)](#localnet-ui-new) +- [`localnet refresh` (new)](#localnet-refresh-new) +- [`localnet container` (new)](#localnet-container-new) +- [`localnet observability` (new)](#localnet-observability-new) +- [`localnet skills` (new)](#localnet-skills-new) +- [`localnet contracts` / `tx`](#localnet-contracts--tx) + - [`contracts ls` (new)](#contracts-ls-new) + - [Endpoint not yet auto-discovered](#endpoint-not-yet-auto-discovered) +- [`localnet dar`](#localnet-dar) + - [Connection flags per-command](#connection-flags-per-command) + - [`--instance` flag name](#--instance-flag-name) +- [`localnet token`](#localnet-token) + - [Additional subcommands (new)](#additional-subcommands-new) + - [`transfer accept` subcommand](#transfer-accept-subcommand) + - [`transfer --atomic` flag (experimental, new)](#transfer---atomic-flag-experimental-new) + - [`allocations settle` deferred](#allocations-settle-deferred) + - [`burn` requires explicit confirmation](#burn-requires-explicit-confirmation) + - [`--instance` required flag](#--instance-required-flag) + - [`--name` collision in `token create`](#--name-collision-in-token-create) +- [`telemetry` (root-level, new)](#telemetry-root-level-new) + +--- + +## Cross-cutting conventions + +### Instance name addressing + +**Proposal said:** instance name is always passed as `--name ` across all commands. + +**Shipped:** +- Most lifecycle/inspection commands (`up`, `down`, `restart`, `pause`, `resume`, `status`, `logs`, `creds`, `snapshot`, `restore`) accept the name as **either** a positional argument **or** `--name` — both are equivalent. Example: `dpm localnet up dev` and `dpm localnet up --name dev` do the same thing. +- `remove`, `list`, `doctor`, `refresh`, `metrics` are `--name`-only (no positional arg). +- `dar` subcommands use `--instance` (alias `--name`). +- `token` subcommands use required `--instance`. + +**Why:** The positional form is faster to type for interactive use and matches conventions in similar tools (`kubectl`, `docker`). `--name`-only commands are those that are conceptually multi-instance by default (e.g. `list`) or where positional args would be ambiguous. + +--- + +### Machine-readable output flag + +**Proposal said:** machine-readable output is requested via `--json`. + +**Shipped:** commands use `--format ` with accepted values `json`, `text` (and sometimes `table`). Example: `dpm localnet status dev --format json`. + +**Why:** `--format` is more flexible (allows future formats such as `yaml` or `table` without adding new flags) and is consistent with the established pattern in tools like `docker` and `gh`. + +--- + +### Command aliases + +The following aliases are not in the proposal but are shipped: + +| Canonical command | Alias(es) | Notes | +|---|---|---| +| `localnet remove` | `clean` | Backward-compatible after the `clean` → `remove` rename (see [`localnet remove`](#localnet-remove-renamed-from-clean)) | +| `localnet resume` | `unpause` | Matches `docker compose unpause` terminology | +| `localnet observability` | `obs` | Shorter for interactive use | +| `localnet container list` | `ls`, `ps` | Matches Docker CLI conventions | +| `localnet token party ls` | `list` | Consistency within party subcommand | +| `localnet token party rm` | `remove` | Consistency within party subcommand | + +`localnet list` has **no** `ls` alias despite the pattern above — adding it would shadow `localnet logs` with a common prefix, increasing ambiguity in tab-completion. + +**Behaviour change (removed aliases):** earlier builds shipped `start` as an alias for `up` and `stop` as an alias for `down`. These aliases have been **removed** — `start` and `stop` are now standalone commands with distinct behaviour (see [`localnet stop` / `start`](#localnet-stop--start-new)). `localnet stop` no longer removes containers (use `down` for that), and `localnet start` no longer unconditionally recreates the stack (though it converges to a running instance, falling back to `up` when containers are gone). + +--- + +## `localnet remove` (renamed from `clean`) + +**Proposal said:** the destructive teardown verb — the command that removes an instance's data volumes and registry state — was named `clean`. + +**Shipped:** the canonical command is `dpm localnet remove`, with `clean` retained as an alias. Both forms are equivalent: `dpm localnet remove dev` and `dpm localnet clean dev` do the same thing. The instance name is now a **positional argument** (`dpm localnet remove `), matching `up`/`down`/`stop`/`start`; `--name ` is still accepted for backward compatibility, but passing both the positional and `--name` is an error. `--all` remains mutually exclusive with naming a single instance. Other flags are unchanged (`--force`, `--dry-run`). + +**Why:** `remove` names the action plainly — it removes the instance's containers, volumes, and registry state — and reads unambiguously next to the other lifecycle verbs (`down`, `stop`, `remove`), where "clean" could be mistaken for a non-destructive tidy-up. The `clean` alias is kept so existing scripts, CI pipelines, and muscle memory continue to work without a breaking change. Accepting the name positionally aligns `remove` with the rest of the lifecycle verbs, which already take `` positionally. + +--- + +## `localnet up` + +### `--allow-uncurated` flag (new) + +**Proposal said:** `--version ` pins a Splice LocalNet version from the supported set. Unsupported versions were not addressed. + +**Shipped:** `--allow-uncurated` lets users pass a Splice tag that is not in the DevKit curated catalogue. DevKit resolves the tag against the upstream Splice GitHub repo and proceeds, printing a warning that the resulting LocalNet is not tested by DevKit. + +**Why:** Gives power users and maintainers a path to test prereleases and alpha tags without waiting for a catalogue update, while keeping the default path (no flag) restricted to tested versions. + +--- + +### `--profile` flag (new) + +**Proposal said:** per-component toggles for Prometheus and Grafana as a LocalNet configuration model item; the exact mechanism was not specified. + +**Shipped:** `--profile ` (repeatable) is a flag on `localnet up`. Supported values include `prometheus`, `grafana`, and `observability` (legacy umbrella that activates both). Profiles are persisted in instance state so a subsequent `up` re-enables the same set. The `localnet observability enable/disable` command can toggle sidecars on a running instance without `--profile` at `up` time. + +**Why:** Docker Compose profiles are the natural mechanism for optional service groups in the Splice LocalNet stack. Exposing them directly as `--profile` keeps the model transparent and auditable. Persisting the profile set enables reproducible restarts. + +--- + +### `--port-base` flag (new) + +**Proposal said:** named instances use explicit port configuration so two LocalNets can run on one machine, but the mechanism for specifying ports was not defined. + +**Shipped:** `--port-base ` pins host ports deterministically starting from `n` (each service gets `base+N`). With `--port-base 0` (default), ports are auto-allocated with stable reuse across restarts. Every derived port must be free or `up` fails immediately with no silent fallback. + +**Why:** Auto-allocation works for single-developer use; `--port-base` is needed for CI layouts and reproducible multi-instance setups where port assignments must be predictable and documented. + +--- + +## `localnet pause` / `resume` (new) + +**Proposal said:** not mentioned. + +**Shipped:** `dpm localnet pause ` and `dpm localnet resume `. + +`pause` sends SIGSTOP to all containers in the instance (via `docker compose pause`) — they hold in-memory state and published ports but stop using CPU. `resume` sends SIGCONT (alias `unpause`, matching `docker compose unpause`). No readiness wait is performed on resume. + +**Why:** Useful when stepping away briefly without wanting to pay the full boot cost of `down`/`up`. Frees CPU and reduces resource consumption without discarding ledger state. Required for CLI ↔ Web UI parity (the UI exposes a pause/resume action on the instance card). + +--- + +## `localnet stop` / `start` (new) + +**Proposal said:** not mentioned as standalone commands. Earlier DevKit builds shipped `stop` and `start` only as aliases for `down` and `up`. + +**Shipped:** `dpm localnet stop ` and `dpm localnet start ` are now first-class lifecycle commands sitting between pause/resume and down/up: + +- `stop` gracefully stops the instance's containers (`docker compose stop`) but **keeps** them on disk. CPU and the container runtime are freed; ledger state and the containers themselves survive. +- `start` starts a stopped instance's containers (`docker compose start`), skipping image pulls and stack recreation. If the containers have already been removed (e.g. the instance was `down`ed, or containers were pruned externally), `start` transparently falls back to a full `up` — reusing the recorded Splice version and profiles — with no extra flag or confirmation. `start` accepts `--no-wait` to skip the readiness wait. + +The teardown/bring-up ladder is therefore: `pause`/`resume` (freeze, RAM held) → `stop`/`start` (stop containers, kept on disk) → `down`/`up` (remove and recreate containers) → `remove` (alias `clean`; removes data volumes and state). + +**Why:** `stop`/`start` fill the gap between the instant-but-RAM-heavy pause and the slow-but-clean down: they free container resources while avoiding the cost of recreating the stack on the next start. Making them standalone commands (rather than aliases) gives users the full Docker Compose lifecycle vocabulary. The intelligent `start` fallback means users never have to remember whether an instance was stopped or downed — `start` always converges to a running instance. Required for CLI ↔ Web UI parity (the UI exposes Stop and Start actions on the instance card). + +**Behaviour change:** because `stop`/`start` are no longer aliases, `localnet stop` no longer removes containers and `localnet start` no longer unconditionally recreates the stack. Users who relied on the old alias behaviour should use `down`/`up` explicitly. + +--- + +## `localnet creds` (new) + +**Proposal said:** not mentioned as a standalone command. `env` was the credential/config export surface. + +**Shipped:** `dpm localnet creds [name]` prints the HS256 JWTs captured at `up` time, in four formats: `table` (default — includes JWTs), `env` (shell-exportable `AUTH__TOKEN=...` lines), `json` (full credential objects including JWTs), `raw` (single JWT, requires `--role`). All successful LocalNet credential surfaces return raw JWT values because LocalNet is a loopback-only development environment. The `localnet env` and `localnet status` commands no longer expose a redaction opt-in flag. + +**Why:** `env` covers Ledger API endpoints and wallet URLs, while `creds` remains the dedicated surface for auth tokens. LocalNet credentials are intentionally usable by default; error messages, audit records, and access logs continue to exclude raw JWTs. + +--- + +## `localnet versions` (new) + +**Proposal said:** `--version ` in `localnet up` selects the Splice version. Supported versions and a compatibility matrix were mentioned as documentation items, not as a CLI command. + +**Shipped:** `dpm localnet versions` is a live command that lists every Splice version in the DevKit curated catalogue plus every tag the upstream Splice GitHub repository currently exposes. Each row has a status: `supported`, `drifted` (force-pushed — security signal), `available` (upstream only, not yet catalogued), or `catalogued-only` (removed upstream). Supports `--offline` and `--format json`. + +**Why:** The catalogue cross-reference against upstream helps maintainers catch force-pushed tags early (a security signal) and gives users live visibility into which versions are safe to pin, without consulting external documentation. + +--- + +## `localnet ui` (new) + +**Proposal said:** a Web UI exists, but the proposal described it as a dashboard accessible alongside the CLI, not as a separately invocable CLI command. + +**Shipped:** `dpm localnet ui` starts the embedded Vite/React HTTP server (default port 7777, loopback-only). Flags: `--port`, `--host`, `--allow-non-loopback`. Non-loopback binding is refused by default as a DNS-rebinding defence; SSH tunnelling is the recommended remote-access path. + +**Why:** Packaging the UI launch as a CLI subcommand keeps the single-binary model and lets users control when the UI server is running. The loopback-only default and the `--allow-non-loopback` guard are a deliberate security measure — the UI handles JWTs and party identifiers and is not designed for unauthenticated LAN-wide exposure. + +--- + +## `localnet refresh` (new) + +**Proposal said:** not mentioned. + +**Shipped:** `dpm localnet refresh [--name ]` triggers an on-demand reconciliation pass that syncs the registry's persisted status with the live `docker compose ps` state. This is the CLI mirror of the background reconciler that runs inside `localnet ui`. + +**Why:** Required for CLI ↔ Web UI parity. Useful when a user has stopped containers externally (e.g. via `docker compose down` directly) and wants the registry to reflect that without restarting the UI server. + +--- + +## `localnet container` (new) + +**Proposal said:** `dpm localnet restart [service] --name ` restarts the full LocalNet or one service. + +**Shipped:** Full-instance restart remains `dpm localnet restart`. Per-container operations are under a separate `container` parent: + +- `localnet container list ` (aliases `ls`, `ps`) — lists containers with state/health. +- `localnet container restart ` — restarts one container; verifies it belongs to the instance's compose project before acting. +- `localnet container logs ` — tails logs for one container (flags: `--tail`, `--since`). + +**Why:** Separating the `container` subtree from top-level lifecycle commands keeps the namespace clean and mirrors the Web UI's Container Health panel. Accepting both the service short name and the full container name (e.g. `splice` or `pr432-splice`) improves UX over the raw Docker form. The membership check before restart is a security measure that prevents a typo or hostile input from restarting an arbitrary host container. + +--- + +## `localnet observability` (new) + +**Proposal said:** `dpm localnet metrics` prints Grafana dashboard URLs and a concise text summary. No separate toggle command was proposed; observability components were to be controlled via `--profile` flags at `up` time. + +**Shipped:** In addition to `localnet metrics`, a `localnet observability` command (alias `obs`) manages the Prometheus/Grafana sidecars **on a running instance** without restarting Canton: + +- `observability enable [--prometheus] [--grafana]` — brings sidecars up. +- `observability disable [--prometheus] [--grafana]` — stops them; Canton is untouched. +- `observability status` — read-only report of which sidecars are active and their URLs. + +Both `--prometheus` and `--grafana` flags allow controlling each sidecar independently. With neither flag, both are selected (umbrella semantics). The enabled state is persisted so a subsequent `down`/`up` re-enables it automatically. + +**Why:** Enabling observability at `up` time via `--profile` requires a full restart to change. The `observability enable/disable` path lets developers toggle the monitoring stack without disrupting a running ledger — saving the boot cost and preserving in-flight ledger state. Matches the Web UI's "Enable observability now" toggle for CLI ↔ Web UI parity. + +--- + +## `localnet skills` (new) + +**Proposal said:** DevKit "may provide optional, editor-agnostic AI agent skill documents." The proposal described them as documentation artifacts, not as CLI commands. + +**Shipped:** `dpm localnet skills` is a full subcommand tree: + +- `skills list` — lists the embedded skill documents (name, description, filename). +- `skills install [--target claude|codex] [--dir ] [--force]` — writes the embedded skill documents into the appropriate agent skills directory (`~/.claude/skills/` for Claude, `~/.codex/skills/` for Codex). Clobber-safe by default: a destination that exists with different content is skipped unless `--force` is passed. + +The embedded skill docs are the same artifacts that back the Web UI's Agent Skills screen, ensuring CLI and UI show the same content. + +**Why:** Users need a one-step way to install skill documents without manually locating and copying files. The clobber-safe default protects hand-edited skill docs from being silently overwritten on re-install. Required for CLI ↔ Web UI parity (the Web UI's Agent Skills screen surfaces the same embedded docs). + +--- + +## `localnet contracts` / `tx` + +### `contracts ls` (new) + +**Proposal said:** `dpm localnet contracts watch` — live tail of create/archive events. + +**Shipped:** `contracts watch` is present and matches the proposal. In addition, `contracts ls` lists active contracts via a one-shot query rather than a live stream. + +**Why:** A non-streaming snapshot is more useful than a continuous watch in CI and scripted contexts where the caller wants to assert on current state without keeping a long-lived process open. + +--- + +### Endpoint not yet auto-discovered + +**Proposal said:** commands connect to the LocalNet participants automatically (implied by the named-instance model). + +**Shipped:** `contracts` and `tx` commands require callers to pass `--endpoint host:port` explicitly. Auto-discovery of the gRPC participant port from registry state is not yet implemented. A comment in `localnet.go` documents this as pending work. + +**Why:** Auto-discovery was deferred to avoid blocking the initial contract/tx CLI release. The explicit `--endpoint` flag is a deliberate interim design — it keeps the commands usable against any Ledger API endpoint (not just DevKit-managed instances) until the auto-discovery path lands. + +--- + +## `localnet dar` + +### Connection flags per-command + +**Proposal said:** DAR commands connect to participants via the named instance implicitly. + +**Shipped:** Each `dar` subcommand carries its own connection flags: `--admin-host`, `--token`, `--insecure` (defaults to `true`), `--ca-cert`, `--instance` (alias `--name`), `--role` (default `app-user`). There is no standalone `dar connect` command. + +**Why:** Per-command connection flags make the DAR subcommands usable against any Ledger API endpoint, not just DevKit-managed instances. This gives operators more flexibility in CI and multi-environment workflows without requiring a running LocalNet registry. + +--- + +### `--instance` flag name + +**Proposal said:** instance selection is `--name ` uniformly. + +**Shipped:** `dar` subcommands use `--instance` as the primary flag name (with `--name` as an alias). + +**Why:** In `dar` contexts, `--name` is ambiguous between the instance name and the DAR/package name. Using `--instance` as the primary name eliminates that ambiguity and makes commands self-documenting at a glance. + +--- + +## `localnet token` + +### Additional subcommands (new) + +**Proposal said:** `token create`, `token mint`, `token transfer`, `token burn`, `token balance`. + +**Shipped:** all five from the proposal, plus: + +| New command | Purpose | +|---|---| +| `token balances` | Portfolio-style matrix view across all instruments for one or more parties | +| `token summary` | Aggregate stats for one instrument (supply, holder count, recent activity) | +| `token activity` | Recent transaction history feed for an instrument (`--limit` defaults to 50) | +| `token party new ` | Register a named party alias for use in token commands | +| `token party ls` | List registered party aliases | +| `token party rm ` | Remove a party alias | +| `token faucet ` | Fund a party with an auto-accepted transfer (no recipient interaction needed) | +| `token demo` | One-step provision: creates a DEMO instrument and seeds a holder wallet | +| `token identity` | List the act-as identities (roles) available for the instance's token commands | +| `token allocations` | List V2 DvP allocations, optionally filtered to one authorizer party | +| `token allocations withdraw` | Withdraw a V2 allocation (only after its settlement deadline) | +| `token allocations cancel` | Cancel a V2 allocation | + +**Why:** The alias registry (`token party`) improves UX by eliminating repeated `--party ` flags across commands. `faucet` and `demo` target workshop and onboarding use cases where speed matters more than exercising the full CIP-0112 two-phase flow. `balances`, `summary`, and `activity` provide portfolio-level and historical views that are essential for verifying token operations during testing. + +--- + +### `transfer accept` subcommand + +**Proposal said:** `token transfer` as a single command. + +**Shipped:** `token transfer` initiates a transfer; `token transfer accept` accepts a pending incoming transfer. CIP-0112 transfers are two-phase (offer + accept), so both halves are exposed as CLI subcommands. + +**Why:** The two-phase model is required by the CIP-0112 protocol — it is not a simplification but a faithful implementation of the standard. Exposing both steps gives scripts and workshops full control over the accept timing, enabling realistic multi-party test scenarios. + +--- + +### `transfer --atomic` flag (experimental, new) + +**Proposal said:** not mentioned; `token transfer` was a single-shot command. + +**Shipped:** `token transfer --atomic` is an **experimental** flag that only takes effect together with `--auto-accept`. When both are set, the transfer and the receiver-side accept are batched into one all-or-nothing `BatchingUtilityV2` transaction (on-ledger test tokens only). On current Splice this is **not yet functional** — the accept leg cannot reference the transfer leg's instruction within a single batch, so the command errors and nothing commits; the default sequential path (`--auto-accept` alone) is the supported behaviour. In the Web UI the atomic checkbox is disabled unless auto-accept is on and carries an explicit experimental warning. + +**Why:** The flag is shipped ahead of ledger support so the atomic-settlement path is wired and testable end-to-end the moment Splice can reference a batched instruction. Gating it behind `--auto-accept`, defaulting it off, and surfacing an experimental warning keeps the unsupported path from being reached accidentally. + +--- + +### `allocations settle` deferred + +**Proposal said:** not mentioned (allocations/DvP settlement were not part of the proposal's token surface). + +**Shipped:** the DvP allocation surface ships `allocations` (list), `allocations withdraw`, and `allocations cancel`, but the **`allocations settle` verb is intentionally not exposed** on the CLI or in the Web UI. The settlement factory plumbing exists and is unit-tested, but the end-to-end settle path is not yet functional against current Splice, so the user-facing action is withheld until it works rather than shipping a command that always fails. + +**Why:** Exposing a settle action that cannot succeed would be a misleading dead end. Withholding it — while keeping withdraw/cancel, which do work — keeps the surface honest; the verb will be re-enabled in the same place once the settlement flow is functional. + +--- + +### `burn` requires explicit confirmation + +**Proposal said:** `token burn {token-name} {amount}` as a straightforward command. + +**Shipped:** `token burn` prompts for confirmation before executing because the operation is irreversible. The prompt is bypassed with `--yes` / `-y`. + +**Why:** Guarding an irreversible ledger operation with a confirmation prompt is standard CLI practice and prevents accidental burns in interactive sessions. The `--yes` flag preserves full scriptability for automation. + +--- + +### `--instance` required flag + +**Proposal said:** token commands connect to the active or `--name`-selected instance. + +**Shipped:** `--instance` is a **required** flag on all `token` subcommands (no default or auto-resolution from a single registered instance). + +**Why:** Making `--instance` explicit prevents token commands from silently targeting the wrong LocalNet when multiple instances are registered — a correctness and safety measure, not an inconvenience. + +--- + +### `--name` collision in `token create` + +**Proposal said:** instance selection via `--name `. + +**Shipped:** In `token create`, `--name` refers to the **instrument name** (e.g. `--name "My Token"`), not the instance. The instance is selected via `--instance`. This is an intentional exception to the general `--name` = instance name convention. + +**Why:** The instrument name is the primary user-facing input in the token creation wizard. Using `--name` for it matches natural language ("name this token") and makes the interactive wizard more intuitive, even though it breaks the global `--name` = instance convention elsewhere. + +--- + +## `telemetry` (root-level, new) + +**Proposal said:** not mentioned. Adoption measurement was described as a reporting/documentation exercise. + +**Shipped:** A root-level `telemetry` command (sibling to `localnet`, not nested under it) manages privacy-preserving usage telemetry: + +- `telemetry on` / `off` — opt in or out. +- `telemetry status` — show current state and the anonymous install ID. +- `telemetry preview [--format]` — show the payload that would be sent without sending it. +- `telemetry flush` — send any buffered events immediately. +- `telemetry reset-id` — generate a new anonymous ID. + +Telemetry is **on by default** with opt-out via `DPM_TELEMETRY=off` or `DO_NOT_TRACK=1`. An internal hidden subcommand `_record-install-surface ` is used by install scripts to record the distribution channel. + +**Why:** Provides the adoption signals described in Milestone 4 (install counts, usage trends) in a privacy-preserving, opt-out model without requiring manual tracking. The opt-out via standard `DO_NOT_TRACK` honours widely adopted ecosystem conventions. Placing it at the root level (not under `localnet`) reflects that it is a tool-wide concern, not a LocalNet-specific one. diff --git a/docs/dashboard-customization.md b/docs/dashboard-customization.md index a9c5709b..91282956 100644 --- a/docs/dashboard-customization.md +++ b/docs/dashboard-customization.md @@ -16,15 +16,16 @@ canton-devkit localnet up --name demo --profile observability ``` Grafana then runs at `http://localhost:` (see -`localnet status --name demo` for the port). The default credentials -are printed in the same output. +`localnet status --name demo` for the port). No login is required — +Grafana is provisioned with anonymous viewer access, bound to +127.0.0.1 only. --- ## 1. What ships out of the box The bundled dashboard lives at -[`assets/grafana/dashboards/canton-localnet.json`](../assets/grafana/dashboards/canton-localnet.json) +[`assets/grafana/dashboards/canton-localnet.json`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/assets/grafana/dashboards/canton-localnet.json) and is titled **Canton LocalNet — DApp Developer Overview**. It refreshes every 10s, defaults to a 15-minute window, and exposes a single `$instance` template variable backed by @@ -34,53 +35,60 @@ participant. The current bundle ships 10 panels (`id` 1-15 with gaps reserved for future inserts). The metric names match the live `daml_*`, `jvm_*`, and `db_client_*` families audited in -[docs/observability.md](observability.md) — not the older +[Observability](observability.md) — not the older non-existent `canton_*` names. | Panel | Type | PromQL | What it tells you | |---|---|---|---| | Ledger TPS (5m avg) | stat | `sum(rate(daml_participant_api_indexer_updates{instance=~"$instance"}[5m])) or vector(0)` | Steady-state ledger throughput. Drops here usually point at participant or sequencer back-pressure. | | Active Participants | stat | `count(up{component="canton", instance=~"$instance"} == 1)` | How many Canton nodes Prometheus can scrape right now. Anything less than expected means a node is unscrapeable. | -| Submission Sequencing Latency (p95) | stat | `histogram_quantile(0.95, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket{instance=~"$instance"}[5m])) by (le))` | Tail latency from client submit to sequenced commit. This is the closest audited “command completion” latency on stock Splice 0.6.4. | -| DB Connections In Use | stat | `sum(db_client_connections_usage{state="used", instance=~"$instance"})` | Active DB pool usage across the stack. A creeping value here is the early signal for connection-pool pressure. | +| Sequencer Submission Latency (avg) | stat | `sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum{instance=~"$instance"}[5m])) / sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count{instance=~"$instance"}[5m]))` | Mean time from client submit to sequenced commit. Stock Splice 0.6.4 exports this histogram with only the `+Inf` bucket, so `histogram_quantile` percentiles are NaN — the mean (sum/count) is the reliable figure. | +| DB Connections (in use) | stat | `sum(db_client_connections_usage{state="used", instance=~"$instance"})` | Active DB pool usage across the stack. A creeping value here is the early signal for connection-pool pressure. | | Transactions per Second | timeseries | `rate(daml_participant_api_indexer_updates{instance=~"$instance"}[1m]) or vector(0)` | Same signal as the TPS stat, broken out over time so you can see bursts and stalls. | | JVM Heap Used (per node) | timeseries | `jvm_memory_used_bytes{jvm_memory_type="heap", instance=~"$instance"}` | Heap pressure per component. A sawtooth rising baseline is the classic memory-leak shape. | | Sequencer Block Event Rate | timeseries | `rate(daml_sequencer_block_events_total{instance=~"$instance"}[1m])` | Sequencer-level event rate. Useful for separating ledger-layer slowness from transport-layer stalls. | -| Submission Latency by Component | timeseries | p50 + p95 of `daml_sequencer_client_submissions_sequencing_duration_seconds_bucket` grouped by `component` | Shows whether latency is isolated to one node or systemic. Diverging p50/p95 is the early sign of queueing or retries. | -| ACS Lookup Buffer Length | stat | `sum(daml_participant_api_index_db_active_contract_lookup_batch_buffer_length{instance=~"$instance"})` | ACS-related index lookup buffer length. Stock Splice 0.6.4 does not expose total active-contract cardinality as a Prometheus metric; use the Explorer / JSON API ACS lookup for exact counts. | -| Top 10 gRPC Methods by Throughput | bar gauge | `topk(10, sum by (grpc_method_name) (rate(daml_grpc_server_handled_total{instance=~"$instance"}[5m])))` | API throughput by live gRPC method. Stock Splice 0.6.4 does not expose template-grain submission counters. | +| Submission Sequencing Latency (avg) | timeseries | mean (sum/count) of `daml_sequencer_client_submissions_sequencing_duration_seconds` grouped by `component` | Mean sequencing time per component, so you can see whether latency is isolated to one node. Percentiles need finite histogram buckets, which stock Splice 0.6.4 does not provide. | +| ACS Lookup Buffer | stat | `sum(daml_participant_api_index_active_contracts_buffer_size{instance=~"$instance"})` | Active-contracts index buffer size. Stock Splice 0.6.4 does not expose total active-contract cardinality as a Prometheus metric; use the Explorer / JSON API ACS lookup for exact counts. | +| Top 10 gRPC Methods by Throughput (ops/s, 5m) | bar gauge | `topk(10, sum by (grpc_method_name) (rate(daml_grpc_server_handled_total{instance=~"$instance"}[5m])))` | API throughput by live gRPC method. Stock Splice 0.6.4 does not expose template-grain submission counters. | For the full metric-family audit and substitution table, see -[docs/observability.md](observability.md). +[Observability](observability.md). --- ## 2. Editing the JSON directly -The dashboard JSON is mounted into the Grafana container by the -provisioner configured in -[`assets/grafana/provisioning/dashboards/canton.yaml`](../assets/grafana/provisioning/dashboards/canton.yaml). -Grafana re-scans this directory every 30 seconds, so edits take -effect without a container restart: +The dashboard JSON mounted into the Grafana container is the +per-instance copy under +`~/.canton-devkit/localnet//observability/grafana/dashboards/`, +materialized from the assets embedded in the DevKit binary. The +provisioner (configured in +[`assets/grafana/provisioning/dashboards/canton.yaml`](https://github.com/bitdynamics-ab/canton-devkit/blob/main/assets/grafana/provisioning/dashboards/canton.yaml)) +re-scans that directory every 30 seconds, so edits take effect +without a container restart: ```bash -# 1. Edit the JSON -$EDITOR assets/grafana/dashboards/canton-localnet.json +# 1. Edit the per-instance JSON (the directory Grafana mounts) +$EDITOR ~/.canton-devkit/localnet//observability/grafana/dashboards/canton-localnet.json # 2. Wait up to 30 seconds, then refresh the Grafana tab. # No restart needed. ``` +Editing the repo's `assets/grafana/dashboards/canton-localnet.json` +has no effect on a running instance — it only changes the embedded +baseline for future builds. + If you want the change to apply instantly, restart only the Grafana container: ```bash -docker compose -p canton-devkit-demo restart grafana +canton-devkit localnet container restart demo grafana ``` -`canton-devkit` does not own the Grafana container lifecycle beyond -the overlay; `docker compose restart` against the project name is the -direct path. +This is the same action as the Web UI's Container Health panel; +`docker compose -p canton-demo restart grafana` is the raw-docker +equivalent. ### Adding a panel @@ -114,9 +122,10 @@ goes up to 15). Place the panel below the existing rows by setting ## 3. UI edits vs. JSON edits Grafana lets you edit panels from the browser (the pencil icon on -each panel). With the bundled provisioning config, those UI edits are -**ephemeral by default**: as soon as the provisioner re-syncs from -disk it will overwrite anything you did in the UI. +each panel). With the bundled provisioning config +(`allowUiUpdates: false`), Grafana **refuses to save UI edits** — you +get a "Cannot save provisioned dashboard" dialog, and unsaved tweaks +disappear on page reload. This is intentional. It keeps the on-disk JSON the source of truth and avoids the "what's actually deployed?" question that crops up @@ -127,7 +136,7 @@ prefer Grafana's panel editor over hand-editing JSON — flip `allowUiUpdates` to `true` in the provisioner config: ```yaml -# assets/grafana/provisioning/dashboards/canton.yaml +# ~/.canton-devkit/localnet//observability/grafana/provisioning/dashboards/canton.yaml providers: - name: canton-localnet type: file @@ -140,8 +149,9 @@ providers: With `allowUiUpdates: true`, Grafana writes UI edits back into its own database. The on-disk JSON is still loaded on startup as the -initial state, but subsequent UI changes survive across reloads -until you reset to defaults. +initial state, and subsequent UI changes survive across page reloads +— but only for the lifetime of the Grafana container (see the next +section). Pick one mode and stick with it. Mixing edits across both surfaces is how teams end up with two slightly different dashboards and no @@ -151,32 +161,38 @@ clear answer for which is canonical. ## 4. Persisting across `down` and `up` -The provisioning directory is bind-mounted into the Grafana -container from the repo, so the JSON survives every `localnet down` -and `localnet up` cycle automatically — the file is on your disk, -not in a container volume. +The dashboards Grafana actually loads live in the per-instance +directory +`~/.canton-devkit/localnet//observability/grafana/dashboards/`. +DevKit materializes it there from the assets embedded in the binary +and bind-mounts it into the Grafana container. Your edits to those +files survive every `localnet down`/`up` cycle: a re-up detects that +a file differs from the bundled default, keeps it, and prints a +"preserving local edits" notice instead of overwriting it. Two things to know: -1. **The JSON path on the host is the source of truth.** Edit - `assets/grafana/dashboards/canton-localnet.json` (or drop a new - `.json` file next to it — the provisioner picks up every JSON in - that directory). Changes persist with the repo. -2. **UI edits live in a Grafana volume.** When `allowUiUpdates` is - on, the Grafana SQLite database holds your edits. `localnet down` - keeps the volume around; `localnet clean --name ` removes it. - If you want UI edits to survive across instances, export them via - **Dashboard settings → JSON Model → Save** and check the JSON - into the repo. +1. **The per-instance JSON is the source of truth.** Edit + `~/.canton-devkit/localnet//observability/grafana/dashboards/canton-localnet.json` + (or drop a new `.json` file next to it — the provisioner picks up + every JSON in that directory). Your edits persist across re-ups; + delete the file to get the bundled default back on the next `up`. +2. **UI edits live in the Grafana container.** When `allowUiUpdates` + is on, Grafana stores your edits in its own database inside the + container filesystem — there is no Grafana volume — so they are + lost whenever the container is removed, which is what + `localnet down` does. If you want UI edits to survive, export + them via **Dashboard settings → JSON Model** and save the JSON + into the per-instance `dashboards/` directory above. ### Dropping in your own dashboard -The provisioner loads every `*.json` in -`assets/grafana/dashboards/`. To add a second dashboard alongside -the default, drop a new JSON file next to it: +The provisioner loads every `*.json` in the per-instance +`dashboards/` directory. To add a second dashboard alongside the +default, drop a new JSON file next to it: ```bash -cp my-team-dashboard.json assets/grafana/dashboards/ +cp my-team-dashboard.json ~/.canton-devkit/localnet//observability/grafana/dashboards/ # wait ~30s; refresh Grafana → Dashboards → Browse ``` @@ -187,25 +203,28 @@ Each dashboard needs a unique `uid`. The bundled one uses ## 5. Resetting to defaults -Because the on-disk JSON is the source of truth, restoring defaults -is a `git checkout`: +DevKit preserves your edits to the per-instance dashboard file, so +restoring the bundled default means deleting that file — the default +is re-materialized from the binary on the next `up`: ```bash -git checkout -- assets/grafana/dashboards/canton-localnet.json +rm ~/.canton-devkit/localnet//observability/grafana/dashboards/canton-localnet.json +canton-devkit localnet up --name +# or, on a running instance: +# canton-devkit localnet observability enable --name ``` -If you had `allowUiUpdates: true` and made UI edits, also wipe the -Grafana volume so its database doesn't override the file on startup: +If you had `allowUiUpdates: true` and made UI edits, those live in +the Grafana container's own filesystem — recreate the container to +drop them: ```bash -docker compose -p canton-devkit- stop grafana -docker volume rm canton-devkit-_grafana-data -canton-devkit localnet restart --name +canton-devkit localnet observability disable --name --grafana +canton-devkit localnet observability enable --name --grafana ``` -`docker volume ls` will show you the exact volume name for your -instance; the prefix is the compose project name printed by -`localnet status`. +(`docker compose -p canton- up -d --force-recreate grafana` run +against the instance's compose files is the raw-docker equivalent.) --- @@ -231,7 +250,7 @@ If a panel renders as "No data", the fastest debug is to open Prometheus directly, type the metric name, and see whether the instance is producing it at all. For the full audited substitution table from the earlier `canton_*` placeholders to the live names, see -[docs/observability.md](observability.md). +[Observability](observability.md). --- @@ -239,17 +258,22 @@ table from the earlier `canton_*` placeholders to the live names, see ### Alert when TPS drops to zero -Edit the **Ledger TPS (5m avg)** stat panel and add an alert rule -(Grafana 9+ alerting). The expression is the same one the panel -uses: +The bundled Grafana (11.4.0) uses unified alerting, so alert rules +are created under **Alerting → Alert rules**, not on the panel +itself. The expression is the same one the **Ledger TPS (5m avg)** +stat panel uses: ``` sum(rate(daml_participant_api_indexer_updates{instance=~"$instance"}[5m])) or vector(0) ``` -Fire when the value is below `0.01` for 5 minutes. The alert lives -inside the dashboard JSON under the panel's `alert` field, so it -persists like any other panel edit. +Fire when the value is below `0.01` for 5 minutes. Be aware that +unified-alerting rules are stored in Grafana's database, **not** in +the dashboard JSON — and since this stack keeps no Grafana volume, a +UI-created alert rule is lost when the container is removed. If the +signal needs to persist, express it as a panel threshold in the +dashboard JSON instead, or provision the rule via Grafana's alerting +provisioning files. ### Filter every panel to a single participant @@ -289,11 +313,12 @@ pool rather than the ledger itself. ## 8. See also -- [docs/getting-started.md](getting-started.md) — installing DevKit +- [Getting started](getting-started.md) — installing DevKit and starting LocalNet with the observability overlay. -- [docs/observability.md](observability.md) — audited metric families +- [Observability](observability.md) — audited metric families and the `canton_*` → `daml_*` substitution table. -- [docs/telemetry.md](telemetry.md) — what metrics DevKit itself - emits (separate from Canton's metrics). -- [docs/troubleshooting.md](troubleshooting.md) — common Grafana / +- [Telemetry](telemetry.md) — the anonymous usage counters + the DevKit CLI itself records (separate from Canton's Prometheus + metrics). +- [Troubleshooting](troubleshooting.md) — common Grafana / Prometheus startup issues. diff --git a/docs/design/localnet-token-workspace.md b/docs/design/localnet-token-workspace.md deleted file mode 100644 index 047c5961..00000000 --- a/docs/design/localnet-token-workspace.md +++ /dev/null @@ -1,160 +0,0 @@ -# LocalNet token workspace — "god-mode" token management - -## Problem - -Doing anything with tokens across parties on a LocalNet instance is -currently raw-protocol work. To verify one cross-party transfer in -testing we had to: - -1. Allocate a party via raw `grpcurl` against PartyManagementService. -2. Grant `CanActAs`/`CanReadAs` via raw `grpcurl` against - UserManagementService. -3. Look up the participant ledger port by hand. -4. Look up the DSO admin party by hand. -5. Capture a 130-char `TransferInstruction` contract id and paste it - into a second command. -6. Hand-inject the Amulet instrument into `state.json` so the UI list - would show it. - -None of that is something a DevKit user should ever touch. The current -token surface treats each party as if it were an independent -custodian — which is the right model for a *production* wallet, but the -wrong model for a LocalNet sandbox. - -## Core insight - -**On LocalNet there is no trust boundary between parties — you own all -of them.** The `unsafe` dev secret signs for every role; the operator -can allocate parties, grant rights, and read any ACS at will. A LocalNet -token tool should be built around that fact, not fight it. - -That reframes the whole surface from "a wallet per party" to "a single -god-mode workspace over the instance" where the developer can: - -- refer to parties by short alias, never by fingerprinted id, -- see every party's balance of every instrument at once, -- move tokens between any two parties in one step, -- fund a fresh party instantly, - -without ever thinking about JWTs, ports, rights, or contract ids. - -## Proposal - -Five pieces. (1) and (2) are the foundation; the rest build on the -alias registry. - -### 1. Party registry with aliases - -`registry.State` gains `Parties map[string]PartyRef` (alias → party id + -participant role + created_at). Populated two ways: - -- **Auto-seed on `up`**: the bootstrap local parties get aliases - `app-user`, `app-provider`, `sv` (mirroring the roles) — discovered - via `ListKnownParties` + the role-prefix match we already do in - `localPartiesForRole`. -- **`localnet party new `**: allocates a party - (PartyManagementService), auto-grants the role JWT `CanActAs` + - `CanReadAs` for it (the manual grpcurl step #2 we hit), and records - the alias. - -Everywhere a party id is accepted — `--from`, `--to`, `--party`, -`balance --party` — an alias resolves transparently via the registry. -A 90-char id still works; an alias is just sugar. - -New CLI: - -``` -localnet party ls # alias → id table -localnet party new bob # allocate + grant + record -localnet party rm bob # forget alias (party stays on ledger) -``` - -### 2. Multi-party balance matrix - -Because the operator can read as every registered party, the natural -view is one table — **instruments × parties → amount** — not a -single-party wallet. - -``` -localnet token balances # the whole matrix -INSTRUMENT app-user app-provider sv bob -Amulet 10985.16 4220.16 9301.5 75.0 -``` - -Implementation: iterate the registry's parties, ACS-query each with the -HoldingV2 filter (we already have `runBalanceLive` per party), pivot -into a matrix. The existing single-party `balance` stays for scripting. - -Web UI: replace the per-instrument "Holdings" sub-table with this -matrix as the default Tokens view; party columns come from the alias -registry, instruments from on-chain discovery (#4). - -### 3. One-shot auto-accept transfer - -Two-step offer→accept is correct V2 semantics, but on LocalNet the -operator controls the receiver, so the ceremony is pure friction for -iteration. Add `--auto-accept` (default true on LocalNet, override -with `--no-auto-accept` to exercise the real two-step flow): - -``` -localnet token transfer alice bob 75 --instrument Amulet -# offer → capture instruction id → accept as bob, in one command -``` - -Builds directly on the offer/accept orchestration already shipped; just -chains them when the receiver alias is locally hosted. Falls back to the -two-step flow (print the instruction id) when the receiver isn't a -locally-controlled party. - -### 4. On-chain instrument discovery - -Stop relying on `registry.State.Tokens` for the instrument list. Scan -the ACS for every contract implementing `HoldingV2`, collect distinct -`instrumentId` values, and present that as the instrument set (Amulet + -anything `token create` produced). `state.Tokens` stays as the source -of human metadata (name, symbol, decimals) but no longer gates -visibility. Removes the manual-seed hack and makes the UI reflect the -ledger. - -### 5. Faucet - -Funding a fresh test party is the single most common dev need and is -currently impossible (mint is unsupported on Amulet). A faucet taps an -already-funded party (the SV or validator operator wallet, which holds -Amulet from LocalNet bootstrap) and transfers to the target: - -``` -localnet token faucet bob 100 # sv → bob, 100 Amulet, auto-accepted -``` - -Implemented as a transfer (#3) from a well-known funded party — no new -ledger primitive, just a convenience wrapper. - -## What stays the same - -- The low-level live transfer/accept/balance orchestration is the - engine; this is all sugar + discovery on top. -- Production-shaped single-party commands remain for users who want to - exercise real wallet semantics. -- No change to the auth model — still the `unsafe` dev secret, - loopback-only, never reused against a real network. - -## Sequencing - -1. **Party registry + aliases (#1)** — foundation, unblocks everything. -2. **Balance matrix (#2)** — highest day-to-day value once aliases exist. -3. **Auto-accept (#3)** + **faucet (#5)** — small wrappers on the above. -4. **Instrument discovery (#4)** — independent; can land any time, fixes - the UI cosmetic gap. - -## Open questions - -- Alias collisions / reserved names (`sv`, `app-user`) — reject or - shadow? -- Should `party new` auto-grant on *all three* participants or just the - role that allocated it? (Cross-participant parties need explicit - hosting; LocalNet's default topology hosts each party on one - participant.) -- Faucet source selection: always SV, or pick the richest funded party - automatically? -- CLI ↔ UI parity (AGENTS.md): every piece here lands on both surfaces. diff --git a/docs/design/mockups/screens-lifecycle.jsx b/docs/design/mockups/screens-lifecycle.jsx index 1a52cd44..fa0d9eb7 100644 --- a/docs/design/mockups/screens-lifecycle.jsx +++ b/docs/design/mockups/screens-lifecycle.jsx @@ -151,7 +151,7 @@ function ScreenDown() { Stopped LocalNet "hubble" · state preserved. - Run localnet up --name hubble to resume, or localnet clean to remove volumes. + Run localnet up --name hubble to resume, or localnet remove to remove volumes. ); diff --git a/docs/design/mockups/screens-tokens-help.jsx b/docs/design/mockups/screens-tokens-help.jsx index 4d802842..280e3fca 100644 --- a/docs/design/mockups/screens-tokens-help.jsx +++ b/docs/design/mockups/screens-tokens-help.jsx @@ -89,8 +89,8 @@ function ScreenHelp() {
{`
    ┌──────────────────────────────────────────┐
    │   canton-devkit · localnet                │
-   │   manage Canton LocalNets like a normal   │
-   │   process, not a Docker compose project   │
+   │   spin up and manage a full Canton        │
+   │   LocalNet with one command               │
    └──────────────────────────────────────────┘`}
Usage dpm localnet <command> [flags] diff --git a/docs/design/mockups/webui-metrics-agent.jsx b/docs/design/mockups/webui-metrics-agent.jsx index b73f17ed..465ea2b4 100644 --- a/docs/design/mockups/webui-metrics-agent.jsx +++ b/docs/design/mockups/webui-metrics-agent.jsx @@ -358,8 +358,8 @@ function AgentScreen() {

Stop & clean

-{`dpm localnet down  --name `}{``}{`   # keep volumes`}
-{`dpm localnet clean --name `}{``}{` # remove volumes (asks confirmation)`} +{`dpm localnet down --name `}{``}{` # keep volumes`}
+{`dpm localnet remove --name `}{``}{` # remove volumes (asks confirmation)`}
diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md new file mode 100644 index 00000000..2265d0fa --- /dev/null +++ b/docs/e2e-testing.md @@ -0,0 +1,198 @@ +# End-to-End Testing + +Unit tests (`go test ./...`) cover DevKit's Go logic. End-to-end (E2E) +tests cover the parts that only a *real* invocation exercises: the DPM +component surface, shelling out to `dpm`/`daml`, Docker-backed LocalNet +lifecycle, and the artifacts DevKit produces on disk. + +The repository has two E2E layers: + +| Layer | Location | Framework | Scope | +|---|---|---|---| +| **Milestone 1 (LocalNet lifecycle)** | `scripts/e2e/` (`run-all.sh` + per-test `m1-*.sh`) | hand-rolled shell harness (`scripts/e2e/lib.sh`) | `up`/`down`/`status`/`logs`/`snapshot`/`restore`, multi-instance, Docker | +| **`dpm localnet` component** | `e2e-tests/` | [bats-core](https://github.com/bats-core/bats-core) | the DPM component path: `dpm localnet …`, nested `dpm build`, DAR build/upload | + +New E2E tests use **bats-core**. The Milestone 1 shell suite predates +that decision and will migrate to bats over time; until then the two +layers coexist. + +## Why bats-core + +The `dpm localnet` suite was originally a bespoke shell harness with its +own `pass`/`fail`/`skip` counters, result table, and aggregate-vs- +standalone dispatch. bats-core replaces all of that boilerplate with a +maintained TAP-compliant runner, so tests carry only their own logic: + +- `@test` blocks, one assertion group each, each in its own subprocess. +- `setup`/`teardown` (per test) and `setup_file`/`teardown_file` (once + per file) hooks. +- The `run` helper plus `$status`/`$output`/`$lines`, and the + [`bats-assert`](https://github.com/bats-core/bats-assert) matchers + (`assert_success`, `refute_output --partial`, …). +- Native `skip "reason"` for graceful skips (e.g. `dpm` not installed). + +## Layout + +```text +e2e-tests/ + bats/ bats-core runner (git submodule) + test_helper/ + bats-support/ assertion support library (git submodule) + bats-assert/ assertion matchers (git submodule) + dpm.bash DevKit-specific helpers + dpm-dar-001.bats one test file per test ID + daml-test-contracts/ shared Daml fixtures +``` + +bats-core and its helper libraries are vendored as **pinned git +submodules**, so a checkout resolves the exact tested versions with no +network install at run time (consistent with how the rest of the +toolchain is pinned): + +| Submodule | Path | Pin | +|---|---|---| +| bats-core | `e2e-tests/bats` | `v1.13.0` | +| bats-support | `e2e-tests/test_helper/bats-support` | `v0.3.0` | +| bats-assert | `e2e-tests/test_helper/bats-assert` | `v2.2.4` | + +The DevKit-specific glue lives in `e2e-tests/test_helper/dpm.bash`: + +- `dpm_available` — succeeds when the `dpm` CLI is on `PATH`; tests use + it to `skip` gracefully rather than hard-fail. +- `dpm_build_component` — assembles a local **file-based** DevKit + component (`bin/canton-devkit` + a rendered `component.yaml`) from the + binary built in this run. Resolving the component from a local + `{name, path}` reference keeps the suite hermetic — no OCI registry, + no TLS — and exercises *this* binary, not a released one. +- `dpm_make_project` — scaffolds a minimal, compilable Daml project that + installs the local component. + +## Running locally + +Prerequisites: + +- Go (see `go.mod`) — the suite builds `bin/canton-devkit`. +- The [`dpm`](getting-started.md) CLI on `PATH`. If it is missing the + suite **skips** rather than fails. +- macOS or Linux (the `dpm localnet` suite does not require Docker; the + Milestone 1 suite does). + +Run the whole `dpm localnet` suite: + +```sh +make e2e-dpm +``` + +`make e2e-dpm` initializes the bats submodules on demand (so a fresh +clone just works) and runs every `e2e-tests/*.bats` file. To run one +file, or pass bats flags, invoke the vendored runner directly: + +```sh +BATS_LIB_PATH="$PWD/e2e-tests/test_helper" \ + e2e-tests/bats/bin/bats e2e-tests/dpm-dar-001.bats +``` + +Useful overrides (environment variables): + +| Variable | Effect | +|---|---| +| `DPM` | Path to the `dpm` CLI (default `dpm`). | +| `CDK_BIN` | Use a prebuilt `canton-devkit` binary instead of building one. | +| `DPM_SKIP_BUILD` | Skip the in-suite `make build` (CI builds once, up front). | + +Scratch (built components, scaffolded projects) is written under the +repo's gitignored `.tmp/`, never `/tmp`. + +## Writing a test + +Each test file is `e2e-tests/.bats`, where the test ID follows +the milestone convention (`DPM-DAR-001`, `DPM-DAR-002`, …). A minimal +file: + +```bash +#!/usr/bin/env bats + +setup_file() { + bats_load_library bats-support + bats_load_library bats-assert + load 'test_helper/dpm' + + dpm_available || skip "dpm not found on PATH (DPM=${DPM:-dpm})" + + # Build + assemble the local component once for the file. + if [ -z "${DPM_SKIP_BUILD:-}" ] && [ -z "${CDK_BIN:-}" ]; then + make -C "$DPM_REPO_ROOT" build >&2 + fi + COMPONENT_DIR="$(dpm_build_component)" + export COMPONENT_DIR +} + +setup() { + bats_load_library bats-support + bats_load_library bats-assert + load 'test_helper/dpm' + + dpm_available || skip "dpm not found on PATH (DPM=${DPM:-dpm})" + PROJECT_DIR="$(dpm_make_project "$COMPONENT_DIR")" +} + +teardown() { + [ -n "${PROJECT_DIR:-}" ] && rm -rf "$PROJECT_DIR" +} + +@test "DPM-DAR-002: " { + cd "$PROJECT_DIR" + run "$DPM" localnet dar build-upload --build-only + assert_success + refute_output --partial "file exists" +} +``` + +Guidelines: + +- Put reusable DevKit logic in `dpm.bash`, not in individual tests. +- Prefer `run` + `assert_*`/`refute_*` over hand-rolled + `out=$(...); [ ... ]` checks — the failure output is far clearer. +- Skip (don't fail) when a required tool is absent, so contributors + without `dpm` and unrelated CI jobs stay green. +- Keep tests hermetic: build the component locally, scaffold into + `.tmp/`, and clean up in `teardown`. + +## Continuous integration + +The `dpm localnet` suite runs in +[`.github/workflows/e2e-test-dpm-localnet.yml`](../.github/workflows/e2e-test-dpm-localnet.yml) +on the self-hosted Linux runner, one job per test ID (a failed case can +be re-run without replaying the suite). Checkout uses +`submodules: recursive` so the pinned bats submodules are present. The +per-test composite action +[`.github/actions/e2e-dpm-test`](../.github/actions/e2e-dpm-test/action.yml) +builds the binary once, installs a SHA-256-verified `dpm`, and runs one +`.bats` file — passing the built binary via `CDK_BIN` rather than a +cross-job artifact (all jobs share one runner). + +Triggers: + +- **schedule** — nightly. +- **workflow_dispatch** — manual, ad-hoc validation. +- **pull_request** — only when the PR carries the `run-e2e` label. + +The Milestone 1 suite runs analogously from +[`.github/workflows/e2e-test-devkit-functions.yml`](../.github/workflows/e2e-test-devkit-functions.yml). + +## Updating pinned versions + +To move a vendored library to a new release, update the submodule and +commit the new gitlink: + +```sh +cd e2e-tests/bats +git fetch --tags +git checkout v1.13.1 # the desired tag +cd ../.. +git add e2e-tests/bats +git commit -m "test(e2e): bump bats-core to v1.13.1" +``` + +The recorded submodule commit is the pin; CI and `make e2e-dpm` resolve +exactly that revision. diff --git a/docs/explorer.md b/docs/explorer.md index d7d33d42..277686b8 100644 --- a/docs/explorer.md +++ b/docs/explorer.md @@ -1,4 +1,4 @@ -# Explorer Usage +# Explorer The Explorer is the Web UI's window into a running LocalNet's ledger. It reads the Active Contract Set (ACS) and recent @@ -6,16 +6,15 @@ transactions from the participant's gRPC ledger API, so you can see what's on the ledger without writing a script. This guide covers what the Explorer can do today, the equivalent -CLI commands for scripted workflows, and which features are -planned but not yet shipped — so you can tell at a glance whether -the Explorer fits your task. +CLI commands for scripted workflows, and current limitations — so you can +tell at a glance whether the Explorer fits your task. --- ## 1. What you see -Open the Web UI (`canton-devkit localnet ui --name ` or whatever -port the bundled UI is running on) and switch to the **Explorer** +Open the Web UI (`canton-devkit localnet ui`, default port 7777, or +whatever port the bundled UI is running on) and switch to the **Explorer** tab. The screen has three views, selectable from the toggle in the top bar: @@ -60,8 +59,8 @@ against the (live) snapshot already loaded: - **Templates sidebar.** Click a template chip to restrict the table to that template. Click again to clear. Multiple chips - combine as OR. Templates are rendered as `Module:Entity`; hover - to see the fully-qualified `package_id:Module:Entity` form. + combine as OR. Templates are rendered as `Module:Entity`; hover a + table row to see the fully-qualified `package_id:Module:Entity` form. - **Parties sidebar.** Same pattern, but filters to contracts where the chosen party appears as signatory or observer. - **Search box** (top-right of the table, focus with `/`). Free-text @@ -96,15 +95,17 @@ shows: - **Payload** as pretty-printed JSON. Records, lists, optionals, primitives, parties, and contract IDs all render natively; variants/enums/maps fall back to a textual proto form (a typed - decoder is planned). + decoder using Daml-LF metadata is not yet supported). - **Signatories** and **Observers** as separate lists. -- **Created** with the RFC 3339 timestamp the participant recorded - and a human-readable "Xs/m/h/d ago". +- **Created** with the RFC 3339 timestamp the participant recorded, + plus a link to the creating transaction (the table's Created column + shows the relative "Xs/m/h/d ago" form). The detail drawer is read-only — there is no "exercise choice" UI in -the Explorer. Exercising choices is a CLI / SDK action; see -`canton-devkit localnet token …` for the prebuilt CIP-0112 flows or -build a regular Daml/SDK client. +the Explorer. Exercising choices is a CLI / SDK action; see the +`canton-devkit localnet token ` family (e.g. `token transfer`, +`token mint`) for the prebuilt CIP-0112 flows, or build a regular +Daml/SDK client. --- @@ -131,7 +132,9 @@ applied **server-side** over the participant's offset window: - **party** — comma-separate to project through specific parties. Omit to project through the role JWT's own parties. -- **template** — `Module:Entity` or `pkg:Module:Entity`, +- **template** — `#pkg-name:Module:Entity` (a package-name reference + the participant resolves to the highest vetted version) or + `:Module:Entity` (an exact 64-hex package-id pin), comma-separated for multiple. - **from / to** — bound the scanned ledger-offset window (`from` is exclusive, `to` inclusive). Leave blank for a generous recent @@ -163,8 +166,9 @@ renders two strips: - A **density strip** with bar height proportional to the bucket's update count. -- A **glyph row** with one coloured cell per update (green = - transaction, blue = reassignment, purple = topology). +- A **glyph row** with one coloured cell per update (cobalt = + transaction, teal = reassignment, amber = topology — matching the + legend under the strip). Hover a glyph to preview the update in the side panel; click to pin the selection so you can read its event tree without keeping @@ -182,7 +186,7 @@ The **Contracts** view is live: 1. It first calls `StateService.GetActiveContracts` at the participant's current ledger end (the snapshot). 2. It then opens a server-sent-events stream - (`GET .../contracts/stream`) resuming from the snapshot's + (`GET /api/instances/{name}/contracts/stream`) resuming from the snapshot's `ledger_end`, applying create/archive deltas in place. The handoff is a single atomic offset boundary, so no event between the snapshot and the stream is missed. @@ -194,8 +198,8 @@ The **Contracts** view is live: The stream-status pill in the top bar and the table sub-header report the real connection state — `live`, `reconnecting`, `truncated` (the backend capped the stream; reconciliation takes -over), or `idle`. The wording is honest: it tracks the stream, not -a hard-coded label. +over), or `idle`. The label tracks the actual stream state, not a +hard-coded value. The **Transactions** and **Timeline** views are still snapshots — they call `UpdateService` for the most recent N updates. Re-apply @@ -209,13 +213,14 @@ Every view in the Explorer has a CLI equivalent that returns the same data as JSON, suitable for `jq` and scripting: ```bash -# Snapshot the ACS. --party is repeatable; --template accepts -# Module:Entity or pkg:Module:Entity. +# Snapshot the ACS. --party is repeatable; --template takes a +# package-name reference "#pkg-name:Module:Entity" (quote it — a bare +# leading # is a shell comment) or an exact ":Module:Entity". canton-devkit localnet contracts ls \ --name demo \ --endpoint localhost: \ --party alice \ - --template Token:Holding + --template '#my-token:Token:Holding' # Stream ACS changes from the current ledger end. canton-devkit localnet contracts watch \ @@ -228,7 +233,7 @@ canton-devkit localnet tx ls \ --name demo \ --endpoint localhost: \ --party alice \ - --template Token:Holding + --template '#my-token:Token:Holding' # Replay one transaction's per-party visibility projection — the # CLI mirror of the Transactions view's "replay" button. @@ -238,7 +243,7 @@ canton-devkit localnet tx replay \ --party alice ``` -The `contracts ls --format json` output now includes the decoded +The `contracts ls --format json` output includes the decoded contract `payload` (the same field the Web UI drawer shows), so a `jq` consumer can read field values, not just contract IDs. @@ -270,7 +275,7 @@ Things the Explorer does **not** do today: - **Variants, enums, maps fall back to a textual proto form** in the payload preview. Records, lists, primitives, parties, and contract IDs decode natively. The full typed decoder using - Daml-LF metadata is planned. + Daml-LF metadata is not yet supported in the payload preview. If the Explorer can't show what you need, the CLI usually can — or the underlying gRPC API directly via the SDK. @@ -290,10 +295,10 @@ or the underlying gRPC API directly via the SDK. ## 11. See also -- [docs/getting-started.md](getting-started.md) — starting a +- [Getting started](getting-started.md) — starting a LocalNet and finding its participant ports. -- [docs/tokens.md](tokens.md) — driving CIP-0112 token flows from +- [Tokens](tokens.md) — driving CIP-0112 token flows from the CLI; useful to populate the ACS with realistic contracts while you explore. -- [docs/troubleshooting.md](troubleshooting.md) — port-recapture +- [Troubleshooting](troubleshooting.md) — port-recapture and JWT-related fixes. diff --git a/docs/faq.md b/docs/faq.md index 4a342866..871e9027 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -9,13 +9,14 @@ Common questions about canton-devkit. See also A single-binary developer tool for running and operating a Canton **LocalNet** — a full local Canton Network (sequencers, mediators, participants, Splice apps) in Docker. It gives you a CLI -(`canton-devkit localnet …`, or `dpm localnet …` under DPM) and an +(`canton-devkit localnet `, or `dpm localnet ` under DPM) and an embedded Web UI for the same operations. **CLI or Web UI — which should I use?** -Both expose the same operations (CLI ↔ UI parity is a project rule). Use -the CLI for scripting/CI; `canton-devkit localnet ui` for a dashboard, -the contract explorer, DAR management, metrics, and the token workspace. +Both expose the same operations — the two surfaces are kept in parity +by design. Use the CLI for scripting/CI; `canton-devkit localnet ui` +for a dashboard, the contract explorer, DAR management, metrics, and +the token workspace. **Does it fork or patch Splice?** No. It downloads the upstream `cluster/compose/localnet/` tree pinned by @@ -23,9 +24,11 @@ immutable commit SHA and verified by SHA-256 after extraction. See [versions.md](versions.md). **Which platforms are supported?** -macOS (arm64) and Linux (amd64) are the primary, CI-tested targets. -Windows (amd64) binaries are published; cross-platform coverage is -tracked under the release matrix. +macOS (arm64), Linux (amd64), and Windows (amd64) are the released, +tested targets. Other OS/arch combinations may work (DevKit only +orchestrates Docker) but are untested — `localnet doctor` warns on +unsupported platforms. See the compatibility matrix in +[getting-started.md](getting-started.md#4-compatibility-matrix). ## Versions @@ -41,17 +44,21 @@ content SHA. Uncurated tags are resolved live against GitHub and cached locally — handy for trying a brand-new upstream release before it's curated. -## Tokens (CIP-0112 / V2) +## Tokens **V1 or V2?** -This tool targets **Token Standard V2 (CIP-0112)** only. V1 / CIP-0056 is -not supported. See [tokens.md](tokens.md). +Both, routed per instrument. Reads and transfers work against +**CIP-0056** (Final) instruments — what existing assets such as Canton +Coin implement on stable Splice releases. Creating a **new** instrument +uses **Token Standard V2 (CIP-0112**, approved but not yet final**)**, +which requires the alpha track below. See [tokens.md](tokens.md). **Why is V2 "alpha" and what does `--profile tokens-v2` do?** V2 runs on a special upstream Splice build (alpha protocol 35) on the `-dev` image repo. `--profile tokens-v2` injects the Canton config that enables alpha-version-support + protocol 35. Without it the stack can't -run the V2 protocol; `doctor` warns. +run the V2 protocol; `up` warns loudly if you select the alpha version +without the profile. **Why can't I mint or burn Amulet?** Amulet (Canton Coin) has no developer-facing mint/burn surface — those @@ -66,11 +73,18 @@ admin), so `token burn` archives the holder's `Holding` contracts directly and returns change. Supply = sum of holdings, so this removes the burned amount from circulation. -**Why are party aliases safe here but not in production?** -On LocalNet the `unsafe` dev secret signs for every party, so "you own -all parties" is true and the god-mode workspace is appropriate. That -assumption does **not** hold on a real network — the dev JWTs are -loopback-only and must never be reused off-box. +**How does the authorization work differently in production?** +On LocalNet, token commands authenticate with the **validator-backend +dev JWT** — a static token signed with the validator node's hardcoded +development secret. That credential can be granted act-as/read-as rights +for **any** party on the node, so your application can use a single token +for every party you allocate on the LocalNet validator (`bob`, `alice`, …) +and transfer, mint, or query on behalf of all of them. + +Production networks won't expose that model: each party uses its **own** +credentials, tokens are issued per session (not static JWTs), and you +should not use backend credentials to sign for other parties on the +network. ## Operations @@ -84,8 +98,9 @@ Yes. Each `--name` gets isolated Docker resources and a port block. is safe; it re-downloads on next `up`. **Snapshot / restore — is it crash-consistent?** -Snapshots capture Docker volumes + registry state. They are **not** -guaranteed application-consistent for a *running* instance — see the -warning in [troubleshooting.md](troubleshooting.md#snapshot-consistency) -and `localnet snapshot --help`. Stop the instance for a fully consistent -snapshot. +Snapshots capture a logical PostgreSQL dump (`pg_dumpall`) of the +instance's database plus its registry state. The instance must be +**running** — `pg_dumpall` reads from the live Postgres. DevKit pauses +the node containers for the duration of the dump, so the snapshot is +application-consistent, not merely crash-consistent. See +`localnet snapshot --help`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 5b1fd992..2b2601bc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -4,11 +4,12 @@ Canton DevKit is a single Go binary that orchestrates the Splice LocalNet Docker stack. It ships two ways: 1. **DPM component** (primary) — install through the Daml Package - Manager and invoke as `dpm localnet …`. + Manager and invoke as `dpm localnet `. 2. **Standalone binary** (`canton-devkit`) — a self-contained executable for users who don't run DPM (CI, DevOps, workshop facilitators), shipped as release archives plus APT convenience - packages for Debian/Ubuntu hosts. Invoke as `canton-devkit localnet …`. + packages for Debian/Ubuntu hosts. Invoke as + `canton-devkit localnet `. Both paths ship the **same binary** and expose the **same command tree**. Throughout the docs, `dpm localnet ` and @@ -19,8 +20,6 @@ tree**. Throughout the docs, `dpm localnet ` and > never changes host permissions. It orchestrates the existing Splice > LocalNet container stack. ---- - ## 1. Prerequisites | Requirement | Why | Check | @@ -41,22 +40,29 @@ when a check fails, printing copy-pasteable remediation. It's the same preflight `localnet up` runs, so a green `doctor` means `up` will pass preflight. ---- - ## 2. Install — DPM component (primary) -DevKit is published as a native DPM component to an OCI registry. Add -it to your project's `daml.yaml` (or `multi-package.yaml`) `components` -list and install: +DevKit is published as a native DPM component to an OCI registry. Remove +the `sdk-version` field from your project's `daml.yaml` (or +`multi-package.yaml`) and declare the SDK packages plus the DevKit +component under `components`, then install: ```yaml # daml.yaml -sdk-version: +#sdk-version: name: my-app version: 0.1.0 source: . dependencies: [] components: + - canton-open-source: + - codegen: + - damlc: + - daml-new: + - daml-script: + - upgrade-check: + - scribe: + - daml-shell: - oci://ghcr.io/bitdynamics-ab/canton-devkit: ``` @@ -65,58 +71,76 @@ dpm install package dpm localnet --help # confirms the component loaded ``` -DPM registers a single top-level `localnet` command; every DevKit -subcommand (`up`, `down`, `status`, `dar …`, `contracts …`, `token …`, -`metrics`, `doctor`, …) lives under it. This keeps the DPM surface -minimal and conflict-free. +Replace `` with the Canton/Daml release you are +targeting, and `` with a DevKit release tag (semver, no `v` +prefix) or `latest`. ---- +DPM registers a single top-level `localnet` command; every DevKit +subcommand (`up`, `down`, `status`, `dar`, `contracts`, `tx`, `token`, +`metrics`, `doctor`, and the rest) lives under it. This keeps the DPM +surface minimal and conflict-free. ## 3. Install — standalone binary -Download the binary for your platform from the -[Releases page](https://github.com/bitdynamics-ab/canton-devkit/releases), -verify its checksum, mark it executable, and put it on your `PATH`. - -Release assets are versioned archives named -`canton-devkit___.tar.gz` (`.zip` on Windows) — each +Standalone builds are published from the distribution repository +[`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit). +Release archives are named +`canton-devkit_v__.tar.gz` (`.zip` on Windows) — each contains the `canton-devkit` binary plus `LICENSE` and `README.md`. Every -release also publishes a single `SHA256SUMS` file covering all archives; -the examples below verify against it. +release also publishes a single `SHA256SUMS` file covering all archives. + +### Quick install (macOS arm64 / Linux amd64) +```bash +curl -fsSL https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/main/install.sh | sh +``` -### macOS (Apple Silicon) +Or with `wget`: ```bash -VERSION=v0.7 # replace with the latest release tag -ASSET="canton-devkit_${VERSION}_darwin_arm64.tar.gz" -base="https://github.com/bitdynamics-ab/canton-devkit/releases/download/${VERSION}" -curl -fLO "${base}/${ASSET}" -curl -fLO "${base}/SHA256SUMS" -# verify against the release checksums (recommended) -grep " ${ASSET}\$" SHA256SUMS | shasum -a 256 -c - || { echo "checksum mismatch"; exit 1; } -tar -xzf "${ASSET}" # → canton-devkit, LICENSE, README.md -chmod +x canton-devkit -sudo mv canton-devkit /usr/local/bin/ -# Gatekeeper: first run may need this once -xattr -d com.apple.quarantine /usr/local/bin/canton-devkit 2>/dev/null || true -canton-devkit version +wget -qO- https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/main/install.sh | sh ``` -### Linux (amd64) +Options (pass as environment variables): ```bash -VERSION=v0.7 -ASSET="canton-devkit_${VERSION}_linux_amd64.tar.gz" -base="https://github.com/bitdynamics-ab/canton-devkit/releases/download/${VERSION}" -curl -fLO "${base}/${ASSET}" -curl -fLO "${base}/SHA256SUMS" -grep " ${ASSET}\$" SHA256SUMS | sha256sum -c - || { echo "checksum mismatch"; exit 1; } -tar -xzf "${ASSET}" # → canton-devkit, LICENSE, README.md -chmod +x canton-devkit -sudo mv canton-devkit /usr/local/bin/ -canton-devkit version +# Pin a specific version +curl -fsSL https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/main/install.sh | VERSION=0.12.2 sh + +# Custom install directory +curl -fsSL https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/main/install.sh | INSTALL_DIR=/usr/local/bin sh +``` + +The installer detects your platform, downloads the matching archive from +the [releases page](https://github.com/bitdynamics-ab/canton-devkit/releases), +verifies the SHA-256 checksum, and installs to `~/.local/bin` by default. +It warns when that directory is not on your `PATH`. + +Supported platforms: + +- macOS Apple Silicon (`darwin/arm64`) +- Linux x86_64 (`linux/amd64`) + +### Homebrew (macOS arm64 / Linux amd64) + +```bash +brew tap bitdynamics-ab/canton-devkit +brew install bitdynamics-ab/canton-devkit/canton-devkit ``` +To upgrade after a new release is published: + +```bash +brew update +brew upgrade canton-devkit +``` + +The formula downloads platform-specific release tarballs from the tap +repository release page: +[`bitdynamics-ab/homebrew-canton-devkit/releases`](https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases). + +> See the [Homebrew guide](homebrew.md) for the tap layout and how the +> formula is kept in sync on each release. + ### APT — Debian / Ubuntu (amd64) Tagged releases update a static APT repository hosted from the public @@ -140,21 +164,22 @@ apt policy canton-devkit Install a specific version: ```bash -sudo apt install canton-devkit=0.7.0 +sudo apt install canton-devkit=0.12.2 # pick a version from `apt list -a canton-devkit` ``` The APT repo is currently unsigned and therefore uses `trusted=yes`; -the release still publishes SHA-256 metadata, and package installation -records a best-effort anonymous `apt` install-surface telemetry ping. -Adding a signed repository key is a follow-up hardening step. +the release still publishes SHA-256 metadata. Repository signing has +not been added yet. Package installation records a best-effort anonymous `apt` +install-surface telemetry ping — see [Telemetry](telemetry.md) +for what is sent and how to opt out before installing. Direct `.deb` install also works: ```bash -VERSION=v0.7 +VERSION=v0.12.2 # replace with the latest release tag DEB_VERSION="${VERSION#v}" ASSET="canton-devkit_${DEB_VERSION}_amd64.deb" -base="https://github.com/bitdynamics-ab/canton-devkit/releases/download/${VERSION}" +base="https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases/download/${VERSION}" curl -fLO "${base}/${ASSET}" curl -fLO "${base}/SHA256SUMS" grep " ${ASSET}\$" SHA256SUMS | sha256sum -c - || { echo "checksum mismatch"; exit 1; } @@ -166,12 +191,49 @@ The Debian package installs `/usr/bin/canton-devkit`. It does not install Docker; run `canton-devkit localnet doctor` after installation to verify Docker CLI, Compose v2, ports, disk, memory, and host prerequisites. +### Manual download — macOS (Apple Silicon) + +Download the binary for your platform from the +[releases page](https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases), +verify its checksum, mark it executable, and put it on your `PATH`: + +```bash +VERSION=v0.12.2 # replace with the latest release tag +ASSET="canton-devkit_${VERSION}_darwin_arm64.tar.gz" +base="https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases/download/${VERSION}" +curl -fLO "${base}/${ASSET}" +curl -fLO "${base}/SHA256SUMS" +# verify against the release checksums (recommended) +grep " ${ASSET}\$" SHA256SUMS | shasum -a 256 -c - || { echo "checksum mismatch"; exit 1; } +tar -xzf "${ASSET}" # → canton-devkit, LICENSE, README.md +chmod +x canton-devkit +sudo mv canton-devkit /usr/local/bin/ +# Gatekeeper: first run may need this once +xattr -d com.apple.quarantine /usr/local/bin/canton-devkit 2>/dev/null || true +canton-devkit version +``` + +### Manual download — Linux (amd64) + +```bash +VERSION=v0.12.2 # replace with the latest release tag +ASSET="canton-devkit_${VERSION}_linux_amd64.tar.gz" +base="https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases/download/${VERSION}" +curl -fLO "${base}/${ASSET}" +curl -fLO "${base}/SHA256SUMS" +grep " ${ASSET}\$" SHA256SUMS | sha256sum -c - || { echo "checksum mismatch"; exit 1; } +tar -xzf "${ASSET}" # → canton-devkit, LICENSE, README.md +chmod +x canton-devkit +sudo mv canton-devkit /usr/local/bin/ +canton-devkit version +``` + ### Windows (amd64, PowerShell) ```powershell -$Version = "v0.7" +$Version = "v0.12.2" # replace with the latest release tag $Asset = "canton-devkit_${Version}_windows_amd64.zip" -$base = "https://github.com/bitdynamics-ab/canton-devkit/releases/download/$Version" +$base = "https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases/download/$Version" Invoke-WebRequest -Uri "$base/$Asset" -OutFile $Asset Invoke-WebRequest -Uri "$base/SHA256SUMS" -OutFile SHA256SUMS # verify against the release checksums @@ -184,94 +246,13 @@ Move-Item canton-devkit-dist\canton-devkit.exe "$env:USERPROFILE\bin\canton-devk canton-devkit version ``` -### Homebrew (when published) - -```bash -brew install bitdynamics-ab/tap/canton-devkit -``` - -> Homebrew availability is tracked separately; until the tap is -> published, use the standalone download above. - ### From source (Go toolchain) ```bash go install github.com/bitdynamics-ab/canton-devkit/cmd/canton-devkit@latest ``` ---- - -## 4. Zero to running LocalNet - -```bash -# 1. Check the host (no changes made) -canton-devkit localnet doctor - -# 2. Start a named LocalNet (downloads Splice on first run; waits for readiness) -canton-devkit localnet up --name demo - -# 3. Inspect it — endpoints, health, credentials -canton-devkit localnet status --name demo - -# 4. Export endpoints for your app/tests -eval "$(canton-devkit localnet env --name demo)" - -# 5. Upload a DAR -canton-devkit localnet dar upload ./my-app.dar --instance demo - -# 6. Watch live contracts. The participant gRPC endpoint isn't -# host-published by default, so pass --endpoint host:port -# (auto-discovery from --name is a pending follow-up). Find the -# port under "participant_ledger_app-user" in `status` output. -canton-devkit localnet contracts watch --name demo --endpoint localhost: - -# 7. Tear it down -canton-devkit localnet down --name demo -``` - -Replace `canton-devkit` with `dpm` if you installed via the DPM -component. `up` waits for the stack to become healthy (Splice -onboarding can take several minutes on a cold start) and prints the -service endpoints and credential locations when ready. - -### Running two LocalNets at once - -```bash -canton-devkit localnet up --name alpha -canton-devkit localnet up --name beta -canton-devkit localnet list # both instances + their state -``` - -Each named instance gets its own deterministic compose project, -network, and host ports, so they don't collide. - -#### Explicit, deterministic ports (`--port-base`) - -By default DevKit **auto-allocates** host ports — the simplest path, and -it never conflicts because the kernel hands out free ports. When you need -a **fixed, predictable** port map instead — reproducible CI layouts, or -multiple instances at known offsets — pin a base: - -```bash -canton-devkit localnet up --name alpha --port-base 20000 # services at 20000+N -canton-devkit localnet up --name beta --port-base 30000 # services at 30000+N -``` - -Each service lands on `base + N`, identically across runs and machines. -Every derived port must be free or `up` fails fast (no silent fallback) — -so the layout you asked for is the layout you get. Pre-flight a base -before bringing anything up: - -```bash -canton-devkit localnet doctor --port-base 20000 # are 20000..20000+services free? -``` - -The same control is available in the Web UI's **New instance** dialog -under *Advanced → Fixed port base*. - ---- - -## 5. Compatibility matrix +## 4. Compatibility matrix ### Platforms (released, tested) @@ -287,28 +268,25 @@ platforms. ### Splice LocalNet versions -DevKit pins a **curated** set of Splice versions in -[`internal/splice/versions.json`](../internal/splice/versions.json); -`localnet up --version ` selects one. List them at runtime: +DevKit pins a catalogue of tested Splice versions; `localnet up +--version ` selects one. List them at runtime: ```bash canton-devkit localnet versions ``` -See [docs/versions.md](./versions.md) for how the catalogue is fetched -and verified. Uncurated upstream tags can be used at your own risk via -`up --version --allow-uncurated`. - ---- +See the [Splice version catalogue](versions.md) for how the +catalogue is fetched and verified. Uncurated upstream tags can be used +at your own risk via `up --version --allow-uncurated`. -## 6. Troubleshooting +## 5. Troubleshooting the install | Symptom | Cause | Fix | |---|---|---| | `doctor` says **Docker daemon** ✗ | Docker not running | Start Docker Desktop / `sudo systemctl start docker` | | `doctor` says **Compose v2** ✗ | Only Compose v1 present | Upgrade to Docker Compose v2 (`docker compose`, not `docker-compose`) | | `up` fails **PORTS_IN_USE** | Another process holds a port | Stop the conflicting process, or use a different `--name` | -| `up` hangs at "waiting for healthy" | Insufficient Docker memory | Raise Docker memory to ≥ 8 GB; see [docs/limitations.md](./limitations.md) | +| `up` hangs at "waiting for healthy" | Insufficient Docker memory | Raise Docker memory to ≥ 8 GB; see [Known limitations](limitations.md) | | Linux: `permission denied` on the Docker socket | User not in `docker` group | `sudo usermod -aG docker $USER` then re-login | | macOS: "cannot be opened because the developer cannot be verified" | Gatekeeper quarantine | `xattr -d com.apple.quarantine $(which canton-devkit)` | | Web UI / Explorer shows stale ports after a restart | Docker re-assigned ephemeral ports | DevKit re-captures them within ~15 s; or run `localnet restart --name ` | @@ -317,20 +295,10 @@ For anything else, attach the full `localnet doctor` output to a [GitHub issue](https://github.com/bitdynamics-ab/canton-devkit/issues) — it includes OS/arch, Docker/Compose versions, and the check results. ---- - -## 7. Uninstall / clean up - -```bash -# stop + remove a single instance's containers, volumes, and state -canton-devkit localnet clean --name demo - -# remove every DevKit-managed instance -canton-devkit localnet clean --all - -# remove the standalone binary -sudo rm /usr/local/bin/canton-devkit -``` +## 6. Next steps -`clean` refuses to touch a running instance unless you pass `--force` -(which tears it down first). Use `--dry-run` to preview. +- [LocalNet lifecycle](localnet-lifecycle.md) — zero to a running + LocalNet, multiple instances, deterministic ports, and clean-up. +- [Tokens](tokens.md) — CIP-0112 token flows on LocalNet. +- [Explorer](explorer.md) — browse the Active Contract Set and + recent transactions from the Web UI. diff --git a/docs/homebrew.md b/docs/homebrew.md index 6a18ab84..5a902119 100644 --- a/docs/homebrew.md +++ b/docs/homebrew.md @@ -1,36 +1,40 @@ # Homebrew install `canton-devkit` ships a Homebrew formula for macOS (Apple Silicon) and -Linux (x86_64). The formula and downloadable build artifacts live in the public -[`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit) -repository so users can download release artifacts without access to the -private source repository. +Linux (x86_64). The formula and downloadable build artifacts live in the +dedicated tap repository +[`bitdynamics-ab/homebrew-canton-devkit`](https://github.com/bitdynamics-ab/homebrew-canton-devkit), +following the standard Homebrew tap layout. -This private source repository does not keep a `Formula/` directory. Homebrew +This source repository does not keep a `Formula/` directory. Homebrew distribution files are maintained in `homebrew-canton-devkit`; this repository only -keeps the release helper script and docs that describe the process. +keeps the canonical `install.sh` script and docs that describe the process. +The tap repository keeps the Homebrew formula, APT repository metadata, +and a redirecting `install.sh` for backward compatibility. -## Install (direct, no tap) +## Install -After a public release is published and the formula is updated with real -checksums: +Homebrew requires formulae to live in a tap (installing a formula from +a URL or a bare file path is no longer supported), so install via the +tap: ```sh -brew install --formula \ - https://raw.githubusercontent.com/bitdynamics-ab/homebrew-canton-devkit/main/Formula/canton-devkit.rb +brew tap bitdynamics-ab/canton-devkit +brew install bitdynamics-ab/canton-devkit/canton-devkit ``` -> Note: the formula's stable `url` + `sha256` start as placeholders -> (`version "0.0.0"`, all-zero SHA) until the first release tag is cut; -> the release workflow then rewrites them automatically (see below). -> There is no public `--HEAD` install path because the source repository -> is private. +> Note: the formula's `url` + `sha256` are rewritten automatically by +> the release workflow on every release tag (see below), so the tap +> always installs the latest published release. There is no `--HEAD` +> install path — the formula installs prebuilt release artifacts only. + +## Upgrade -## Install (via tap) +After a new release is published and the formula is updated: ```sh -brew tap bitdynamics-ab/canton-devkit -brew install canton-devkit +brew update +brew upgrade canton-devkit ``` ## How the formula stays in sync @@ -61,15 +65,24 @@ wrong. ## Smoke test +Current Homebrew rejects bare-path formula installs ("Homebrew requires +formulae to be in a tap"), so test a not-yet-pushed formula bump by +tapping the local clone. `brew tap` clones the git repo, so commit the +bump locally in `../homebrew-canton-devkit` first, then: + ```sh -brew install --formula ../homebrew-canton-devkit/Formula/canton-devkit.rb -brew test canton-devkit # invokes `localnet --help` +brew tap bitdynamics-ab/canton-devkit ../homebrew-canton-devkit +brew install bitdynamics-ab/canton-devkit/canton-devkit +brew test canton-devkit canton-devkit localnet --help ``` -`brew test` is also exercised by the formula's `test do …` block, which -runs `canton-devkit localnet --help` and asserts the LocalNet command -tree is reachable. +After pushing, the plain `brew tap bitdynamics-ab/canton-devkit && +brew install canton-devkit` form verifies the published tap. + +`brew test canton-devkit` runs the formula's `test do` block, which +executes `canton-devkit localnet --help` and asserts the LocalNet +command tree is reachable. ## What's not supported (yet) diff --git a/docs/limitations.md b/docs/limitations.md index 449a4721..5fae67a0 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -1,49 +1,22 @@ # Known limitations -Living list of things DevKit does not (yet) do well, with the rationale -and links to follow-up tickets where applicable. Updated as we ship. +Things DevKit does not (yet) do well, with the rationale and +workarounds where applicable. This list is updated as limitations are +resolved. ## Instance naming - **`--name` must be a DNS label.** Names are validated against RFC 1123: 1-63 chars of lowercase `[a-z0-9-]`, must start and end with `[a-z0-9]`. Uppercase, underscores, and leading/trailing hyphens are rejected. - We chose DNS-label form so the same name is safe to embed as a - hostname in the future `{service}.{instance}.localhost` routing model - without a second translation step. Single source of truth lives in - `internal/registry/state.go` (`ValidateName`); the CLI layer delegates. - *Migration:* pre-PR-#20 instances created with uppercase or underscore - names (e.g. `MyStack`, `my_stack`) must be torn down with the old - binary and re-created under a DNS-label name. - -## Concurrency / locking - -- **(resolved)** *Earlier the Windows registry lock was a no-op and - `withIndexLock` was a process-local `sync.Mutex`, so two concurrent - `localnet up --name foo` invocations on Windows could race past the - lock.* Both now take real cross-process locks via - `windows.LockFileEx` (`internal/registry/lock_windows.go` uses - `LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY` for the - fail-fast per-instance lock; `internal/registry/index_lock_windows.go` - uses the blocking `LOCKFILE_EXCLUSIVE_LOCK` for the index - read-modify-write). The OS releases the lock when the handle closes - or the process exits, so there is no stale lock file to recover. This - uses `golang.org/x/sys/windows`, already a direct dependency (e.g. - `internal/localnet/snapshot/diskspace_windows.go`). - *Linux/macOS use `syscall.Flock`; behaviour is now equivalent across - platforms.* - -## Splice version pinning - -- **(resolved)** *Earlier the catalogue pinned the raw gzip SHA, which - could drift if GitHub regenerated the source-tarball.* The catalogue - now pins (a) the git commit SHA (immutable, content-addressable — - `internal/splice/versions.json`'s `commit` field) and (b) the - ContentSHA of the extracted `cluster/compose/localnet/` subtree - (`content_sha` field). The tarball-by-commit URL is byte-stable - enough; we hash the extracted tree, not the gzip envelope, so a - gzip-level rewrite (compression-level change, mtime drift) has no - effect. See `docs/versions.md`. + DNS-label form was chosen so the same name is safe to embed as a + hostname in a future `{service}.{instance}.localhost` routing model + without a second translation step. Name validation is centralized so + every surface enforces the same rule. + *Migration:* instances created with an older release that still + allowed uppercase or underscore names (e.g. `MyStack`, `my_stack`) + must be torn down with that older binary and re-created under a + DNS-label name. ## Container image pinning @@ -53,15 +26,16 @@ and links to follow-up tickets where applicable. Updated as we ship. through a single shared `IMAGE_TAG` variable (`image: "${IMAGE_REPO}canton:${IMAGE_TAG}"`, `${IMAGE_REPO}splice-app:${IMAGE_TAG}`, the web UIs, …). Because one - variable addresses ~6 distinct images, we can't inject per-image - `@sha256:` digests via the compose env — a single digest can't pin six - different images. + variable addresses ~6 distinct images, per-image `@sha256:` digests + cannot be injected via the compose env — a single digest can't pin + six different images. Instead DevKit VERIFIES post-up: after services are healthy it records each running image's content digest (image ID) in `state.json` - (`image_digests`) and, on a later `up`/`restart` of the SAME version, + (`image_digests`) and, on a later `up` of the SAME version, WARNs if a digest changed — i.e. a mutable ghcr tag was republished - under you. See `internal/localnet/image_digests.go`. This is a warning, + under you (`restart` reuses the existing containers, so no re-check + happens there). This is a warning, not a gate (a digest can legitimately change if you manually re-pull), and it's best-effort (a capture failure just skips the check). True digest-pinning at pull time would need upstream Splice to expose @@ -70,19 +44,22 @@ and links to follow-up tickets where applicable. Updated as we ship. ## Compose env reconstruction - **`composeContext` rebuilds env from registry state.** - `down` / `logs` / `creds` need the env that was passed to `up`. We - reconstruct it from `state.json` so a fresh shell can still operate - the instance. Any new env var Splice adds in a future release that - we don't capture in state will silently break operations from a - fresh shell. Mitigation: integration tests in CI (follow-up ticket). + `down` / `restart` / `pause` / `clean` need the env that was passed + to `up`; DevKit reconstructs it from `state.json` so a fresh shell + can still operate the instance. (`logs` and `creds` read the + registry/containers directly and need no env reconstruction.) + Any new env var a future Splice release adds + that is not captured in state will silently break operations from a + fresh shell. ## Integration testing -- **No CI integration test for `localnet up` against real Splice.** - Unit tests cover parsers and orchestration well, but the actual - bring-up flow is never exercised end-to-end in CI. The first - upstream-contract drift will be found by a user, not by us. Filed - separately as a follow-up. +- **Integration coverage for `localnet up` against real Splice runs + nightly, not on every PR.** Unit tests cover parsers and + orchestration on every PR, but the end-to-end bring-up flow runs + only nightly (and on PRs labeled `run-integration`) via + `.github/workflows/integration.yml`, so drift in the upstream + Splice compose contract can land up to a day before CI notices. ## Memory requirements @@ -90,27 +67,71 @@ and links to follow-up tickets where applicable. Updated as we ship. `cluster/compose/localnet/resource-constraints.yaml` (from [canton-network/splice](https://github.com/canton-network/splice)) sums to canton 4 GB + splice 3 GB + - postgres 2 GB + console 2 GB + 7 UI services @ 256-512 MB ≈ 12 GB. - In practice a single instance runs on 7-8 GB because most of those + postgres 2 GB + console 2 GB + 7 UI services @ 256-512 MB (plus + nginx/swagger-ui) ≈ 13 GB of limits — DevKit's coded recommendation + is 12 GB. In practice a single instance runs on 7-8 GB because most of those limits are headroom. But: - **Two concurrent instances exceed 8 GB Docker** → splice in one of them gets OOM-restarted by docker, never reaches healthy, and - `WaitForHealthy` times out at 15 min. - - **GitHub `ubuntu-latest` runners have 7 GB RAM** — enough for - `up` to start but Splice's onboarding may not complete. Use a - larger runner class or self-hosted for the integration job. - - **Docker Desktop default on macOS is 8 GB.** Bump via Settings → - Resources before running multi-instance scenarios. - - The preflight check enforces a 4 GB hard floor; the 12 GB - recommendation is documentation, not a gate — single-instance - setups on 7-8 GB work fine for most users. + `WaitForHealthy` times out at 25 min. + - **GitHub `ubuntu-latest` runners have 16 GB RAM on public repos + but only 8 GB on private repos** — on private-repo runners `up` + starts but Splice's onboarding may not complete; use a larger + runner class or a self-hosted runner there. + - **Docker Desktop defaults to 50% of host memory** (8 GB on a + 16 GB Mac). Bump via Settings → Resources before running + multi-instance scenarios. + + The preflight check enforces a per-version hard floor: 8 GB for the + 0.6 line (0.6.3, 0.6.4, 0.6.9/`latest`, 0.6.10, 0.6.11, the V2 alpha — + and any uncurated 0.6.x tag, which inherits the strictest catalogued + floor for its major), 4 GB for 0.5.18 and only for tags whose major + has no catalogued entry. The 12 GB figure is the coded recommendation + threshold (`recommended_memory_bytes`) — below it preflight WARNs + but does not refuse. On timeout, `WaitForHealthy` now dumps the last `docker compose ps` snapshot in its error so the stuck service + state are visible without re-running anything. +## Snapshot restore + +- **`restore` requires the target instance to already exist (was `up` + at least once).** A restore loads a `pg_dumpall` stream into the + instance's EXISTING Postgres volume via a throwaway loader container. + That volume is only a Docker-Compose-owned volume after an `up` + created it. Restoring into a never-`up` instance would force the + loader's `docker run -v :...` to CREATE the volume out of band — + producing a volume Compose does not own. `docker compose down + --volumes` (used by `localnet down` and `localnet remove --force`) + only removes volumes Compose itself created, so such an orphan volume + survives teardown and is silently adopted (with a "volume … already + exists but was not created by Docker Compose" warning) on the next + `up`. To keep the "never create a volume outside Compose" invariant, + restore now refuses when the instance is not registered and tells the + user to run `localnet up ` first. This also applies to + cross-name restore: the target name must have been `up` too. + *Workaround:* run `localnet up ` (or `up ` for a + cross-name restore) before `localnet restore`. + +- **The restore precondition is enforced via the registry, not a Docker + volume-ownership check (known gap).** The guard checks that a registry + record exists for the target instance, which is a PROXY for "the + Compose-owned volume exists". `up` writes `state.json` early (at + "creating" time, before the volume is guaranteed to exist), so a + crashed/half-finished `up` can leave a registry entry with no volume — + in which case restore would still proceed and the loader would + re-create the volume out of band, reintroducing the orphan. The + robust check is to verify the volume carries Compose's ownership + labels (`com.docker.compose.project=canton-` and + `com.docker.compose.volume=postgres`) via a label-filtered + `docker volume ls` — a bare `docker volume inspect ` is NOT + sufficient because a detached `docker run -v` volume exists under the + same name yet lacks those labels. That label-based volume-ownership + check is intentionally deferred for now; the registry gate covers the + common case. Tracked as a follow-up. + ## Platform parity - **Homebrew formula targets macOS arm64 and Linux x86_64 only.** @@ -120,30 +141,29 @@ and links to follow-up tickets where applicable. Updated as we ship. rather than DPM until the Windows `.exe` path through DPM is verified. -## Shared observability stack - -- **Observability is per-instance, not a single host-level stack - (yet).** Each LocalNet started (or runtime-toggled) with observability - gets its own Prometheus **and** Grafana container, joined to that - instance's docker network. Two observability-enabled instances - therefore run two of each — roughly **~250–350 MiB each** for - Prometheus and Grafana respectively (so ~600 MiB of duplicated - overhead per extra environment). -- **Trade-off.** The per-instance model keeps scraping trivial: - Prometheus resolves `canton:10013` / `splice:10013` over the - instance's own docker network DNS, so no metrics port is published to - the host and instances never contend on one scrape config. The cost - is the duplicated RAM above when several environments run at once. -- **Planned follow-up — host-level shared stack.** The original - proposal (docs/original-devkit-proposal.md line 188) envisioned ONE - host-level Prometheus + Grafana serving every instance via Prometheus - file-based service discovery (`file_sd_configs`) regenerated as - instances come and go. That requires the shared Prometheus to reach - each instance's metrics endpoint across docker networks (joining every - instance network, or publishing the metrics port to loopback) plus - refcounted teardown when the last instance referencing it goes away — - a larger, networking-sensitive change deferred to keep this pass - coherent. The runtime toggle already funnels through a single neutral - function (`internal/localnet.SetObservability`), so the migration is - additive rather than a rewrite of both surfaces. Tracked as a - `// TODO: shared observability stack` follow-up. +## Observability: transitional dual stack + +DevKit runs a host-level shared Prometheus + Grafana stack — one +stack serves every running LocalNet via file-based service discovery, +refcounted by target file. See +[Observability](observability.md#stack-topology--host-shared-with-a-transitional-per-instance-overlay) +for the topology. + +- **`up --observability-mode` selects the sidecar stack.** `auto` + (default) serves metrics from the shared stack and skips the + per-instance Prometheus + Grafana overlay when the shared stack is + reachable — avoiding roughly **~600 MiB** of duplicated overhead per + environment. `shared` forces shared-only; `per-instance` forces the + overlay. The choice is persisted, so a re-up preserves it. +- **Why the per-instance fallback is kept.** The per-instance scrape + uses in-network service DNS (`canton:10013`) rather than + `host.docker.internal`, so it works on any platform regardless of the + Linux `host-gateway` mapping; `auto` falls back to it when the shared + stack can't be started. +- **Native-Linux validation.** `scripts/e2e-observability.sh` (self-hosted + Linux CI) brings up a shared-mode instance and asserts the shared + Prometheus scrapes it via `host.docker.internal`. `auto` also + health-probes the shared Prometheus and falls back to the per-instance + overlay when it is up but not serving. Making `shared` the default (and + dropping the overlay entirely) follows that e2e going green; until then + `per-instance` is the platform-independent escape hatch. diff --git a/docs/localnet-lifecycle.md b/docs/localnet-lifecycle.md new file mode 100644 index 00000000..31812dbd --- /dev/null +++ b/docs/localnet-lifecycle.md @@ -0,0 +1,131 @@ +# LocalNet Lifecycle + +Canton DevKit is a single-binary developer tool for running and operating a +Canton **LocalNet** — a full local Canton Network (sequencers, mediators, +participants, Splice apps) in Docker. It gives you a CLI +(`canton-devkit localnet `, or `dpm localnet ` under DPM) and an +embedded Web UI for the same operations. + +This guide walks the full lifecycle: bring an instance up, inspect it, +run several at once, and clean up. See +[Installation & Getting Started](getting-started.md) first if you +haven't installed DevKit yet. + +## Zero to running LocalNet + +```bash +# 1. Check the host (no changes made) +canton-devkit localnet doctor + +# 2. Start a named LocalNet (downloads Splice on first run; waits for readiness) +canton-devkit localnet up --name demo + +# 3. Inspect it — endpoints, health, credentials +canton-devkit localnet status --name demo + +# 4. Export endpoints for your app/tests +eval "$(canton-devkit localnet env --name demo)" + +# 5. Upload a DAR +canton-devkit localnet dar upload ./my-app.dar --instance demo + +# 6. Watch live contracts. DevKit auto-discovers the participant +# endpoint and JWT from the registry (--endpoint host:port only +# overrides it; the captured port is shown under +# "participant_ledger_app-user" in `status` output). +canton-devkit localnet contracts watch --name demo + +# 7. Tear it down +canton-devkit localnet down --name demo +``` + +Replace `canton-devkit` with `dpm` if you installed via the DPM +component. `up` waits for the stack to become healthy (Splice +onboarding can take several minutes on a cold start) and prints the +service endpoints and credential locations when ready. + +## Running two LocalNets at once + +```bash +canton-devkit localnet up --name alpha +canton-devkit localnet up --name beta +canton-devkit localnet list # both instances + their state +``` + +Each named instance gets its own deterministic compose project, +network, and host ports, so they don't collide. + +### Explicit, deterministic ports (`--port-base`) + +By default DevKit **auto-allocates** host ports — the simplest path, and +it never conflicts because the kernel hands out free ports. When you need +a **fixed, predictable** port map instead — reproducible CI layouts, or +multiple instances at known offsets — pin a base: + +```bash +canton-devkit localnet up --name alpha --port-base 20000 # services at 20000+N +canton-devkit localnet up --name beta --port-base 30000 # services at 30000+N +``` + +Each service lands on `base + N`, identically across runs and machines. +Every derived port must be free or `up` fails fast (no silent fallback) — +so the layout you asked for is the layout you get. Pre-flight a base +before bringing anything up: + +```bash +canton-devkit localnet doctor --port-base 20000 # are 20000..20000+services free? +``` + +The same control is available in the Web UI's **New instance** dialog +under *Advanced → Fixed port base*. + +## Pause, stop, or tear down + +DevKit gives you three ways to make an instance stop doing work, each +trading resource savings against restart cost. All three have symmetric +"undo" commands and identical Web UI buttons on the instance detail card. + +| Command | What it does | Containers | Volumes/state | Resume with | Restart cost | +| --- | --- | --- | --- | --- | --- | +| `localnet pause` | Freezes containers in place (`docker compose pause`) | Kept, paused | Kept | `localnet resume` (alias `unpause`) | Instant — processes thaw | +| `localnet stop` | Gracefully stops containers (`docker compose stop`) | Kept, stopped | Kept | `localnet start` | Fast — containers restart | +| `localnet down` | Stops **and removes** containers (`docker compose down`) | Removed | Kept | `localnet up` | Slow — recreates the stack | + +Notes: + +- **Pause** holds RAM (containers still resident) but frees CPU — best + for a short break where you want to jump straight back in. +- **Stop** releases both CPU and the container runtime while keeping the + containers on disk, so `start` skips image pulls and stack recreation. +- **Down** frees everything except your data volumes; `up` rebuilds the + stack from the recorded version and profiles. `localnet start` on an + instance whose containers are already gone transparently falls back to + a full `up` for you. +- `localnet remove` (alias: `clean`, below) is the only command that + removes **data volumes and registry state** — it is not part of the + reversible set. + +**Most common choices:** + +- Stepping away for a few minutes → `pause` / `resume`. +- Done for the day, want a fast start tomorrow → `stop` / `start`. +- Freeing the machine or resetting the containers → `down` / `up`. +- Throwing the instance away entirely → `remove`. + +## Uninstall / clean up + +```bash +# stop + remove a single instance's containers, volumes, and state +canton-devkit localnet remove demo + +# remove every DevKit-managed instance +canton-devkit localnet remove --all + +# remove the standalone binary +sudo rm /usr/local/bin/canton-devkit +``` + +`remove` (alias: `clean`) refuses to touch a running instance unless you +pass `--force` (which tears it down first). Use `--dry-run` to preview. + +For common questions, see the [FAQ](faq.md). diff --git a/docs/observability.md b/docs/observability.md index b3cd7772..0f6d448c 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -14,11 +14,10 @@ persisted in the registry so re-up preserves bookmarked URLs. ## Metric naming convention -The live Splice 0.6.4 Prometheus surfaces **three** metric prefix -families. Earlier versions of the dashboard and `internal/metricsq` -used a `canton_*` prefix that does NOT exist upstream — those -queries silently returned no data. The audit notes below pin the -current convention so future panels stay aligned. +The live Splice 0.6.4 Prometheus surfaces **five** metric prefix +families. Earlier versions of the dashboard used a `canton_*` prefix that +does NOT exist upstream — those queries silently returned no data. The +audit notes below pin the current convention so future panels stay aligned. Probe used to ground-truth the names: @@ -58,21 +57,20 @@ metric is emitted by the Daml participant) or `daml_sequencer_*` / ## Smoke test (drift guard) -`internal/metricsq/smoke_test.go` (build tag `integration`) queries -every `Headline*` in `SummaryQueries` against a live Prometheus and -fails if any returns 0 results — the only way to catch silent -metric-name drift when Splice updates. +An integration test (build tag `integration`) queries every headline +metric in the CLI summary against a live Prometheus and fails if any +returns zero results — the way to catch silent metric-name drift when +Splice updates. -Run it locally: +Run it locally against a running observability-enabled instance: ``` canton-devkit localnet up --name metric-audit --profile observability PROM_PORT=$(canton-devkit localnet status --name metric-audit --format json \ | jq -r '.endpoints[] | select(.label=="prometheus_ui") | .port') METRICSQ_SMOKE_PROM=http://localhost:${PROM_PORT} \ - go test -tags=integration -run TestSummaryQueries_LiveProm \ - ./internal/metricsq/ -canton-devkit localnet clean --name metric-audit --force + go test -tags=integration -run TestSummaryQueries_LiveProm ./... +canton-devkit localnet remove metric-audit --force ``` The test is **skipped** when `METRICSQ_SMOKE_PROM` is unset, so @@ -103,12 +101,17 @@ on an already-running instance **without restarting Canton**: canton-devkit localnet observability status --name demo --format json ``` -Both surfaces call the **same** neutral orchestration -(`internal/localnet.SetObservability`) — there is no second +Both surfaces call the **same** orchestration path — there is no second docker-compose code path that could drift. With neither `--prometheus` nor `--grafana`, the verb acts on both sidecars (the legacy umbrella semantics); pass one flag to operate on a single component. +`status` also reports the metrics source: `Shared stack: registered` +(`"shared": true` in JSON) means the host-shared stack serves the +instance. A shared-only instance shows the per-instance sidecars off with +the shared stack registered — its Grafana URL points at the shared +Grafana, filtered to that instance. + ## Survives a down → up cycle The profile set an instance was brought up with — whether via @@ -117,14 +120,19 @@ persisted in the registry (`state.json`'s `profiles` field). A later `down` + `up` (or the Web UI **Restart**) **re-enables the same profiles automatically**; you do not have to re-pass `--profile`. An explicit `--profile` on the re-up still wins (replaces, doesn't merge), -so you can deliberately drop observability. This closes the prior gap -where Prometheus/Grafana silently vanished on every restart even though -the stable-port contract kept the bookmarked Grafana URL alive. +so you can deliberately drop observability. (Earlier releases did not +persist profiles, so Prometheus/Grafana silently vanished on every +restart even though the stable-port contract kept the bookmarked Grafana +URL alive.) + +The `--observability-mode` choice persists the same way +(`state.json`'s `observability_mode`), so a re-up keeps shared-only vs +per-instance without re-passing the flag. ## Stack topology — host-shared, with a transitional per-instance overlay -A single **host-level** Prometheus + Grafana (#39) serves every running -LocalNet, fulfilling the original proposal (line 188). It runs as its own +A single **host-level** Prometheus + Grafana serves every running +LocalNet. It runs as its own compose project (`canton-devkit-observability`), independent of any instance's lifecycle. Each observability-enabled instance publishes its canton/splice `:10013` metrics ports on `127.0.0.1:` and writes @@ -132,7 +140,7 @@ a Prometheus **file_sd** target file (`host.docker.internal:`, labelled `instance` + `component`); the shared Prometheus discovers instances from those files. The **number of target files is the refcount**: the stack starts on the first instance's `up` and is torn down when the -last instance's `down`/`clean` removes its target file. Register+ensure and +last instance's `down`/`remove` removes its target file. Register+ensure and deregister+teardown each run under a dedicated **shared-stack lock** so a concurrent `up` and `down` of different instances can't race the stack into a "registered but torn down" state, and orphaned target files (left by a @@ -141,17 +149,20 @@ Prometheus service carries `extra_hosts: ["host.docker.internal:host-gateway"]` so the loopback-published ports resolve; on Docker Desktop the name is auto-provided. -**Transitional dual stack (known trade-off).** Each observability-enabled -instance currently *also* still runs its own per-instance Prometheus + -Grafana overlay alongside the shared stack — so while running, an obs -instance has **two** Prometheus and **two** Grafana containers. This is a -deliberate, kept fallback: both the CLI and the Web UI read **shared-first** -and fall back to the per-instance Prometheus when the shared stack isn't -up, and the per-instance scrape uses in-network service DNS -(`canton:10013`) rather than `host.docker.internal`, so it works on any -platform regardless of the Linux `host-gateway` mapping. Gating the -per-instance overlay off (to drop the duplication) is deferred until the -shared-only path can be end-to-end validated on a native Linux Docker host -— see [docs/limitations.md](limitations.md#shared-observability-stack). The -extra resource cost (a second Prometheus+Grafana per instance) is the price -of that fallback on a dev machine; it carries no correctness impact. +**Per-instance overlay (opt-out).** `up --observability-mode` (and the +matching **Sidecar stack** picker in the Web UI create modal) selects the +sidecar stack: `auto` (default) serves metrics from the shared stack and +skips the per-instance Prometheus + Grafana overlay when the shared stack +is reachable — `auto` health-probes the shared Prometheus and falls back +to the overlay when it is up but not serving; `shared` forces shared-only; +`per-instance` forces the overlay. The mode is persisted, so a re-up +preserves it. When the overlay +runs (per-instance mode, or auto with the shared stack unreachable), an obs +instance has **two** Prometheus and **two** Grafana containers — a +deliberate fallback: the CLI and Web UI read **shared-first** and fall back +to the per-instance Prometheus, and the per-instance scrape uses in-network +service DNS (`canton:10013`) rather than `host.docker.internal`, so it works +on any platform regardless of the Linux `host-gateway` mapping. `shared` +becomes the default (dropping the overlay) once the shared-only path is +validated end-to-end on a native Linux Docker host — see +[Known limitations](limitations.md#observability-transitional-dual-stack). diff --git a/docs/original-devkit-proposal.md b/docs/original-devkit-proposal.md deleted file mode 100644 index a169fed8..00000000 --- a/docs/original-devkit-proposal.md +++ /dev/null @@ -1,409 +0,0 @@ -## Development Fund Proposal - -**Author:** Zhe Li (BitDynamics) - -**Status:** Submitted - -**Created:** 2026-02-22 - ---- - -## Abstract - -Canton DevKit is a native DPM component and standalone CLI for LocalNet operations, debugging, observability, and CIP-0112 (token standard V2) testing for the Canton network. Distributed primarily as a DPM component that registers a single "localnet" top-level command, DevKit integrates directly into the existing DPM toolchain — developers install it via dpm install package and access all features as "dpm localnet ". It is also available as a standalone CLI ("canton-devkit") for users who do not use DPM. -DevKit packages common LocalNet workflows into the dpm localnet command tree and an embedded Web UI: starting and managing named LocalNets, inspecting services and endpoints, uploading and inspecting DARs, exploring live contracts and transactions, viewing developer-focused observability dashboards, and testing CIP-0112 token flows locally. It builds on the existing Splice LocalNet and DPM toolchains rather than replacing them. - ---- - -## Specification - -### 1. Objective - -According to the [Canton Network Developer Experience and Tooling Survey](https://forum.canton.network/t/canton-network-developer-experience-and-tooling-survey-analysis-2026/8412), 41% of respondents cited Environment Setup & Node Operations as the task that took the longest to "get right." Developers are currently forced to be infrastructure engineers before they can be product builders. - -The current official LocalNet stack creates significant friction for onboarding, workshops, hackathons, and automated development workflows because it requires users to manually manage Docker containers, configuration files, environment variables, observability setup, and ad-hoc scripts for inspection and token operations. The survey also rated Local Development Frameworks as the most critical need, with specific mentions of tools like Hardhat, Foundry, and Anchor — a unified CLI toolchain that helps with orchestrating local node environments and automating testing and deployment pipelines without complex manual configuration. - -DevKit targets the following use cases: - -* Local app development, particularly with multiple participants -* Integration and end-to-end testing -* CI/CD flows -* Demos, workshops, and other repeatable/controlled environments - -The goal is to deliver a complementary DevKit for local Canton development. This maintained tooling will enable any developer or automation workflow to manage the complete lifecycle of one or more LocalNets using simple commands or a UI, monitor and explore activity, and experiment with CantonCoin and CIP-0112 flows locally. - -### 2. Implementation Mechanics -(Explain how the solution will be implemented. Include technologies, components, workflows, and operational approach.) - -The solution is delivered primarily as a **native DPM component** that registers a single top-level `localnet` command, and additionally as a **standalone CLI application** (`canton-devkit`). It will be implemented in **Go** and the same binary serves both distribution paths. DPM users install DevKit via `dpm install package` and invoke commands as `dpm localnet ...`; standalone users install a native binary and invoke commands as `canton-devkit localnet ...`. End users will not need Go, Node.js, Python, Rust, or a source checkout to run it. DevKit uses Docker containers to run LocalNet, and packages the developer experience into a single binary that requires no git clone, no Makefile knowledge, and no manual environment variable setup. It will also include other optional helper services that developers can enable or disable as needed. - -Throughout this document, commands are shown in their DPM form (`dpm localnet ...`). Standalone users invoke the same commands by replacing `dpm` with `canton-devkit` (e.g. `canton-devkit localnet up`). Both forms execute the same code path. - -DevKit will support all platforms: macOS (apple silicon), Linux, and Windows from the start. - -#### Distribution and Runtime Requirements - -DevKit's primary distribution path is a native DPM component published to an OCI registry. Users add a reference (e.g. `oci:///canton-devkit:`) to the `components` section of their `daml.yaml` or `multi-package.yaml` and run `dpm install package`. DPM then exposes DevKit as a single top-level `localnet` command (e.g. `dpm localnet up`, `dpm localnet dar upload`). Nesting all DevKit features under one top-level command keeps the DPM integration surface minimal and avoids naming conflicts with existing or future DPM builtins. - -Additionally, DevKit will be published as a standalone Go binary through GitHub Releases with checksums. The initial artifact set will target macOS (apple silicon), Linux, and Windows. Optional convenience install paths such as Homebrew where appropriate and/or an install script may be provided. The standalone path serves users who do not have or want DPM installed (for example DevOps engineers, CI pipelines, and workshop facilitators) and exposes the same command tree under the `canton-devkit` binary name. - -DevKit will not install or bundle Docker. A working Docker runtime is the only required local system dependency because DevKit orchestrates the existing Splice LocalNet container stack rather than replacing it. DevKit will not modify Docker daemon configuration, install system packages, or change user permissions on the host. - -#### Docker Handling - -`dpm localnet up` (or `canton-devkit localnet up`) will run Docker preflight checks before starting LocalNet, including Docker CLI availability, daemon connectivity, Docker Compose v2, required ports, disk space, memory suitable for the Splice LocalNet stack, and host-specific prerequisites such as Linux Docker permissions or Docker Desktop availability on macOS/Windows. If a check fails, DevKit will provide platform-specific remediation instructions instead of modifying the host system. - -DevKit will manage LocalNet resources through deterministic Docker Compose project names and labels, so named LocalNets can be started, inspected, logged, stopped, snapshotted, and cleaned without affecting unrelated Docker containers, networks, or volumes. It will also make port allocation explicit for named instances and print the actual endpoints selected for each LocalNet. - -#### Relationship to Existing Tooling - -Canton already ships several developer tools. The DevKit is designed to complement them, not to replace them: - -| Existing Tool | DevKit Relationship | -|---|---| -| **DPM** (`dpm`) | DevKit is distributed as a **native DPM component** that registers a single `localnet` top-level command, so DPM users access all DevKit features as `dpm localnet ` (e.g. `dpm localnet up`, `dpm localnet dar upload`). Command naming will be coordinated with the DPM maintainers to avoid conflicts with future builtins. | -| **Existing LocalNet setup in Splice codebase** | Splice LocalNet remains the underlying runtime. DevKit selects and version-pins known Splice LocalNet artifacts, generates local configuration, manages Docker lifecycle, exposes endpoints, health, logs, snapshots, and explorer workflows, while still allowing developers to use raw Splice LocalNet directly. | -| **`cn-quickstart` and official getting-started flows** | DevKit does not decide what official docs should recommend or replace quickstart content. It can provide a repeatable LocalNet lifecycle and inspection layer for quickstart-style development, workshops, and demos. | -| **Daml Shell** (`dpm daml-shell`) | DevKit does **not** replace Daml Shell, and intentionally does **not** duplicate its commands. One-shot single-contract lookup (`contract `), single-transaction inspection (`transaction `), per-template `active/creates/archives` listings, and CSV ACS export (`\| csv \| export`) remain the Daml Shell REPL's responsibility. DevKit adds capabilities Daml Shell does not provide: live `contracts watch` (streaming creates/archives), multi-filter `tx ls` (party + offset range + template in one query), per-party `tx replay` (visibility projection), and a visual Web UI explorer that spans **multiple participants of a named LocalNet**. | - -#### Canton DevKit Features - -##### LocalNet Management - -The existing LocalNet setup requires manually downloading Splice bundles, exporting environment variables, composing multi-flag Docker commands, and understanding Docker Compose profiles. DevKit collapses this into: - -###### LocalNet Scope and Boundaries - -DevKit's LocalNet scope starts with the core CLI lifecycle: starting, stopping, restarting, cleaning, checking status, viewing logs, selecting a Splice LocalNet version, running preflight checks, and using basic named-instance isolation. Richer automation conveniences such as machine-readable output, environment export, instance discovery, deeper diagnostics, and Web UI views are treated as incremental additions rather than requirements for the first usable LocalNet release. - -###### CI and Automation Support - -DevKit will support headless automation workflows without making CI the only design target. For the core CLI lifecycle, commands will return deterministic exit codes and `localnet up` will wait for LocalNet readiness or fail with a clear timeout/error. Additional automation conveniences will include machine-readable `--json` output, `.env`-style endpoint export for tests, and example CI workflows that start LocalNet, wait for readiness, run application tests, and tear the instance down safely. - -###### Multiple LocalNets on One Machine - -Basic named-instance support belongs in the core orchestration model because Docker resource naming and port isolation should be designed in from the beginning. DevKit will support `--name ` with deterministic Docker Compose project names, labels, and explicit port configuration so two LocalNets can run on one machine when sufficient resources and non-conflicting ports are available. Advanced instance discovery and dashboards, such as `localnet list`, `localnet env`, and Web UI views across named instances, are higher-level conveniences rather than requirements for the first usable LocalNet release. - -###### Splice Version Compatibility - -`--version ` in `localnet up` selects the Splice LocalNet version to run. DevKit documentation will include a compatibility matrix for supported Splice versions and platforms. The initial release will validate the initially supported version, while maintenance releases will cover smoke testing, compatibility updates, and patch releases for newer Splice releases. - -Compatibility with breaking Splice releases follows a best-effort model: the implementing team owns compatibility patches within a documented support window (or explicit cutoff) for each major Splice line, and will communicate timelines early when upstream breaking changes land so teams can plan upgrades. If ecosystem demand justifies it, stricter turnaround commitments (for example an SLA-style support tier) could be introduced by mutual agreement with the Committee without changing the default grant expectations. - -###### LocalNet Configuration Model - -DevKit will make the important LocalNet inputs explicit: instance name, Splice version, port settings, enabled optional services, observability settings, startup DAR uploads, and LocalNet-only token test setup. The initial scope does not require a full topology language; the priority is a predictable, documented configuration surface for common local development, workshop, and CI workflows. - -###### New dpm Commands - -The core lifecycle commands are part of the first usable CLI release. Automation and diagnostic conveniences such as environment export, instance discovery, richer status formats, and host diagnostics are added incrementally as the CLI matures. - -| Command | Purpose | Expected Output / Behavior | -|---|---|---| -| `dpm localnet up --name [--version ]` (or `canton-devkit localnet up ...` standalone) | Start a named LocalNet | Runs Docker preflight checks, selects the requested Splice LocalNet version, starts services, waits for readiness, and prints endpoints and credential locations. | -| `dpm localnet down --name ` | Stop a named LocalNet | Stops DevKit-managed services for that instance without touching unrelated Docker resources. | -| `dpm localnet restart [service] --name ` | Restart an instance or service | Restarts the full LocalNet or one service and re-runs readiness checks. | -| `dpm localnet clean --name ` | Remove LocalNet resources | Removes DevKit-managed containers, networks, and volumes for the named instance after confirmation. | -| `dpm localnet status --name ` | Inspect health | Shows service health, selected Splice version, ports, participant readiness, wallet/scan URLs, and next troubleshooting steps when unhealthy. | -| `dpm localnet logs [service] --name ` | Debug services | Streams or tails logs with optional service filtering. | -| `dpm localnet snapshot/restore --name ` | Save or replay state | Captures or restores LocalNet state for demos, workshops, and repeatable testing. | -| `dpm localnet env --name ` | Export app/test config | Prints `.env`-style values for Ledger API, JSON API, admin API, wallet UI, scan UI, parties, and users. | -| `dpm localnet list` | Discover instances | Lists DevKit-managed LocalNets and their state without touching unrelated Docker resources. | -| `dpm localnet doctor` | Diagnose host readiness | Checks Docker, Compose v2, permissions, ports, memory, disk, and supported platform assumptions. | - -The **standalone** binary exposes the same command tree; invoke it with `canton-devkit` instead of `dpm`, for example: - -``` -canton-devkit localnet up -``` - -###### Web UI Features - -The Web UI will provide a LocalNet dashboard showing named instances, service health, selected Splice version, endpoints, ports, credential locations, participant readiness, and recent logs. It will include service-level log views, participant/party/package views, links into Grafana dashboards, and quick actions for common LocalNet lifecycle operations such as start, stop, restart, status, and cleanup. - -##### DAR Management - -Today developers upload DARs to each LocalNet participant manually (via `daml ledger upload-dar`, the JSON API, or the Canton Console), and there is no built-in way to inspect, diff, or hot-redeploy packages across a multi-participant LocalNet. DevKit closes that gap without replicating `dpm build` / `daml build` — it offers a `build-upload` convenience shortcut that delegates compilation to `dpm` and then uploads the resulting DAR to LocalNet participants in a single step. - -Initially, DevKit consumes package metadata via DevKit's own DAR parser to extract module, template, choice, field, interface, and dependency information. Once the Canton core team's enriched package metadata endpoints become available, DevKit will prefer the upstream endpoints over local DAR parser. - -###### New dpm Commands -* `dpm localnet dar upload [--participant | --all-participants] [--vet] [--dry-run]` (or `canton-devkit localnet dar upload ...` standalone) — upload a DAR to one or all participants of the active (or `--name`-selected) LocalNet, optionally vetting for Smart Contract Upgrade (SCU). -* `dpm localnet dar list [--participant ]` — list uploaded packages with package ID, name, version, Daml-LF version, module count, upload time, and vetting status. -* `dpm localnet dar info ` — show modules, templates, interfaces, choices, fields, dependencies, and hash for a package. -* `dpm localnet dar download [--out ]` — fetch a DAR back from a participant. -* `dpm localnet dar diff ` — human-readable diff of templates/choices/fields between two package versions, with SCU-compatibility signals (name/version/LF-version/field deltas). -* `dpm localnet dar remove ` — unvet / remove where supported by the participant admin API. -* `dpm localnet dar build-upload [--project ]` — convenience shortcut that invokes `dpm build` (or `daml build`) and uploads the resulting DAR to LocalNet participants in a single step; skipped with a clear message if `dpm` is not available. -* `dpm localnet dar watch ` — watch mode: rebuild via `dpm build` and re-upload to selected participants on source change for a hot-deploy loop. - -###### Web UI Features -* Drag-and-drop DAR upload with per-participant vetting toggles. -* Package explorer tree: modules → templates → choices → fields (with types), interfaces, dependencies, and hashes. -* SCU-aware diff viewer between any two package versions. -* Hot-deploy indicator showing the last watch-mode upload and its status per participant. - -###### Scope Boundaries -* DevKit is **not** a Daml compiler. It delegates to `dpm build` / `daml build` and will not duplicate DPM functionality. -* SCU-compatibility output is best-effort based on package metadata and structural comparison — authoritative upgrade validation remains the responsibility of the Ledger API and `daml` tooling. - -##### Contract Tracking & Exploration - -The proposal already notes that developers "often build ad-hoc tools for exploring transactions, contract state, and token operations." DevKit ships a shared, privacy-aware explorer for the Active Contract Set (ACS) and transaction history across one or more named LocalNets, so teams stop rebuilding the same inspector. - -The first-pass scope is the **live** view: ACS table, transaction list, and detail views backed by Ledger API v2. Historical / archived-contract search via PQS is explicitly deferred. - -###### New dpm Commands -* `dpm localnet contracts watch [filters]` — live tail of create/archive events, similar to `kubectl get -w`. (Not provided by `daml-shell`, which reads PQS snapshots rather than streaming live updates.) -* `dpm localnet tx ls [--party

] [--from ] [--to ] [--template ]` (or `canton-devkit localnet tx ls ...` standalone) — list transactions with multi-dimensional filters (party + offset range + template). (`daml-shell` exposes per-template `creates`/`archives` listings bounded by session offsets but has no unified transactions-list with party filtering.) -* `dpm localnet tx replay ` — reconstruct the per-party visibility projection ("what this party sees") for debugging privacy and authorization issues. (Not provided by `daml-shell` or any other shipped DPM component.) - -One-shot contract and transaction lookups (e.g. fetching a single contract by ID, rendering a single transaction tree, or exporting the ACS as CSV) are already covered by `dpm daml-shell` (`contract `, `transaction `, `active | csv | export `). DevKit does not duplicate those commands at the CLI level; the Web UI surfaces the same data visually. - -###### Web UI Features -* **Explorer** section with a live ACS table filterable by template, party, and participant; payload previews, age, signatories/observers, and a detail drawer. -* **Transaction timeline** with expandable trees, party visibility badges, and links from exercise/create nodes to the affected contracts. -* **Contract detail view**: full payload (JSON and typed), lifecycle (created-at tx → exercises → archived-at tx), interface views, and related contracts by key or referenced contract ID. -* **Per-party projection** selector that always displays which participant + party the current view is projected through, to avoid a misleading "global ledger" impression. -* **Saved queries / bookmarks** shareable via URL, and an ad-hoc **event subscription panel** that updates in real time. - -(The mockup below makes the proposed Web UI scope more concrete by showing the LocalNet overview shell that would host the explorer, transaction views, service status, endpoints, and quick actions.) - -![DevKit LocalNet overview mockup showing the LocalNet dashboard, services, endpoints, parties, and recent activity.](./devkit-mock-overview.png) - -###### Implementation Notes -* Backend uses Ledger API v2: `StateService.GetActiveContracts`, `UpdateService.GetUpdates`, and `EventQueryService`, with `PackageService` + DAR metadata (from the DAR Management feature) to decode payloads into typed form. -* Multi-LocalNet aware via Milestone 1's named instances (`--name`); participant selector is present in every command and UI view. -* Privacy is not cosmetic: visibility is always projected through an explicit (participant, party) pair. - -###### Scope Boundaries -* No PQS dependency in the first pass; archived-contract history beyond what the live Ledger API exposes, and SQL-style historical queries, are out of scope. -* DevKit does not re-implement Daml Shell's REPL or duplicate its commands. The DevKit CLI focuses on capabilities `daml-shell` does not offer (live `contracts watch`, multi-filter `tx ls`, and per-party `tx replay`); the Web UI provides the visual counterpart for the same data. - -##### Observability and Monitoring - -DevKit does not rebuild the observability stack from scratch. Instead, it bundles and configures a Prometheus/Grafana stack tailored for LocalNet, with ongoing optimization of that stack where practical. - -* Per-component toggles for Prometheus, Grafana so developers enable only what they need. -* A single observability stack can serve multiple LocalNet instances on the host, reducing duplicated overhead when several environments are in use. -* Ships Canton-specific Grafana dashboard presets focused on DApp developers (as opposed to operator-level dashboards): transactions/sec, command completion latency, active contract counts, and per-template throughput. -* Adds a `dpm localnet metrics` subcommand (or `canton-devkit localnet metrics` standalone) that prints Grafana dashboard URLs and a concise text summary of key metrics (throughput, latency p50/p99, resource usage) for quick terminal-based checks. -* Documents how teams can extend or customize dashboards for their own services. - -##### Optional AI Agent Skill Documents - -DevKit may provide optional, editor-agnostic AI agent skill documents that describe safe workflows for invoking documented `dpm localnet` commands (or the equivalent `canton-devkit localnet` commands for standalone users). These documents are auxiliary documentation artifacts layered on top of the stable CLI; they are not part of the core runtime and do not prescribe how developers write code or which editor or agent they use. - -Example workflows include starting or stopping a named LocalNet, checking readiness with `dpm localnet status`, tailing logs with `dpm localnet logs [service]`, uploading a pre-built DAR, listing deployed packages, inspecting active contracts, and reporting LocalNet readiness. Where compilation is needed, the workflow delegates to existing Daml tooling such as `dpm build` and then uses DevKit only for LocalNet deployment and inspection. - -Initial examples may be provided for Claude and Codex-style agent formats, but the supported integration surface is the stable `dpm localnet` (and equivalent `canton-devkit localnet`) CLI rather than any specific editor or AI platform. - -##### Local Token Faucets & Token Standard Toolkit (CIP-0112) - -LocalNet already ships wallet UIs and a Registry API for token transfers, but developers still lack a CLI-driven faucet and a guided token-creation flow for everyday token operations. DevKit closes those gaps for LocalNet testing: it helps developers exercise token registration and common token flows before integrating with production-grade wallet, registry, custody, or compliance infrastructure. - -The token wizard and convenience commands (Milestone 3) target CIP-0112 first, so new projects align with the expected direction. CIP-56 (V1) compatibility and V1→V2 migration helpers remain optional and may be scoped to a later milestone or post-grant workstream depending on ecosystem demand and feedback during implementation. - -DevKit will use the LocalNet Ledger API, wallet UI/API, and registry APIs where available, but it will not act as a production issuer, custodian, wallet provider, or dApp connectivity layer. The committed token scope for this grant is Canton token-standard testing on LocalNet centered on CIP-0112 (V2) as primary; support for other token standards or a broad dual-V1/V2 product surface would require explicit scope renegotiation. - -* `dpm localnet token create` (or `canton-devkit localnet token create` standalone) — interactive "token wizard" to define new tokens (name, symbol, decimals, initial supply) and mint to test wallets, aligned with CIP-0112 semantics as the default path. -* `dpm localnet token [mint | transfer | burn | balance] {token-name} {amount} [--to wallet]` — convenience commands wrapping the Ledger API / Registry API for common token operations on that default path. - -(The mockup below shows the proposed token toolkit / faucet surface for CIP-0112-oriented LocalNet testing, including token cards, mint/transfer actions, and recent token activity) - -![DevKit token toolkit mockup showing CIP-0112 token cards, mint and transfer actions, and recent token activity.](./devkit-mock-token-faucet.png) - -### 3. Architectural Alignment - -The Canton DevKit removes the friction of managing local test environments so developers can focus on building their applications. It aligns with the Development Fund's remit to support developer tooling and critical infrastructure as common goods, and is consistent with the milestone‑based, CC‑denominated funding and governance model formalized under CIP‑100. Token tooling is designed to follow the CIP-0112 (Token Standard V2) direction (evolving CIP-56), making it easier for developers to test tokenized applications and integrations in a way that reflects Mainnet patterns. - -### 4. Backward Compatibility - -The Canton DevKit primarily targets LocalNet developer environments and does not change Canton protocol behavior, Daml semantics, or existing production deployments. Developers can continue using the Splice LocalNet Docker stack. - -No backward compatibility impact. - ---- - -## Milestones and Deliverables - -### Milestone 1: LocalNet Management — CLI - -- **Estimated Delivery:** Month 3 -- **Focus:** Single-command LocalNet lifecycle management via CLI. -- **Deliverables / Metrics:** - - `dpm localnet up/down/restart/clean/status/logs` CLI commands (and equivalent `canton-devkit localnet ...` standalone commands) with auto-generated configs, keys, identities, and printed endpoints and credentials. - - Version pinning (`--version`) and basic named-instance isolation (`--name`) using deterministic Docker Compose project names, labels, and explicit port configuration. - - Snapshot and restore (`dpm localnet snapshot/restore`) for saving and replaying LocalNet state. - - **Native DPM component packaging** (`component.yaml` plus OCI publishing in the release CI) so DevKit is installable via `dpm install package` from Milestone 1 onward. - - Standalone Go binary release artifacts for macOS arm64, Linux amd64, and Windows amd64, published with checksums (same binary as the DPM component). - - Installation and "Getting Started" guide for both DPM-component and standalone install paths on macOS, Linux, and Windows, including Docker prerequisite checks and troubleshooting. - - Docker preflight checks in `dpm localnet up` for Docker CLI availability, daemon connectivity, Docker Compose v2, required ports, disk space, memory, and host-specific prerequisites such as Linux Docker permissions or Docker Desktop availability on macOS/Windows. - - Basic `dpm localnet doctor` diagnostics covering Docker CLI availability, daemon connectivity, Docker Compose v2, platform support, required ports, disk space, memory, and host-specific prerequisites. - - Deterministic exit codes and readiness wait behavior suitable for basic headless automation. - - Compatibility matrix documenting the initially supported Splice LocalNet version and supported macOS/Linux/Windows platforms. - - Demo script showing startup, readiness, status, logs, teardown, and one two-instance run using explicit non-conflicting ports. - - Internal testing plus at least one external tester validating that a new developer can go from zero to running LocalNet in under 10 minutes. -- **Adoption Metrics:** at least 3 companies/teams have reviewed the tool and tested it for LocalNet setup and lifecycle usage. - -### Milestone 2: Web UI, Observability, Monitoring, DAR & Contract Tooling, Optional AI Agent Skill Documents - -- **Estimated Delivery:** Month 6 -- **Focus:** Web UI for LocalNet management, integrated observability, DAR package management, live contract and transaction exploration, and optional AI agent skill documents. -- **Deliverables / Value Metrics:** - - Web UI covering all CLI features from Milestone 1 with a user-friendly interface. - - Richer LocalNet automation conveniences, such as machine-readable status output, environment export for app/test configuration, named-instance discovery, enriched `doctor` diagnostics, and deeper troubleshooting guidance. - - Example CI workflow demonstrating LocalNet startup, readiness wait, optional DAR upload, test execution, and teardown. - - Bundled Prometheus/Grafana stack with per-component enable/disable, sensible lightweight defaults, and documentation of minimum practical resources when the full stack is enabled. - - Canton-specific Grafana dashboard presets focused on DApp developers: transactions/sec, command completion latency, active contract counts, and per-template throughput. - - `dpm localnet metrics` subcommand printing Grafana dashboard URLs and a concise text summary of key metrics (throughput, latency p50/p99, resource usage). - - DAR management CLI (`dpm localnet dar upload/list/info/download/diff/remove/build-upload/watch`) with multi-participant support, optional `dpm build` integration, and SCU-aware diff signals. - - DAR Web UI with drag-and-drop upload, per-participant vetting toggles, package explorer tree, diff viewer, and hot-deploy indicator. - - Contract tracking CLI (`dpm localnet contracts watch` and `dpm localnet tx ls/replay`) backed by Ledger API v2, scoped to capabilities not already provided by `dpm daml-shell` (live watch, multi-filter transaction listing, per-party visibility projection). - - Contract tracking Web UI "Explorer" with live ACS table, transaction timeline, contract detail drawer, and explicit per-party visibility projection. - - Optional AI agent skill documents demonstrating safe `dpm localnet` workflows for LocalNet lifecycle, DAR upload, package inspection, contract queries, and log/status checks. - - Documentation on recommended usage, dashboard customization, DAR workflows, contract explorer usage, and optional AI agent skill documents. -- **Adoption Metrics:** at least 5 companies/teams have started using it in their daily Canton development workflow. - -### Milestone 3: Token Faucets & Token Standard Tooling (CIP-0112) - -- **Estimated Delivery:** Month 9 -- **Focus:** CantonCoin / Token Standard tooling and UX polish, CIP-0112. -- **Deliverables / Value Metrics:** - - `dpm localnet token mint` CLI and Web UI minting for tokens on LocalNet on the CIP-0112 path. - - `dpm localnet token create` interactive token wizard defining new tokens (name, symbol, decimals, initial supply) aligned with CIP-0112 as the default. - - `dpm localnet token transfer / burn / balance` convenience commands wrapping the Ledger API / Registry API for that path. - - Expanded regression coverage across the supported macOS, Linux, and Windows targets, UX polish across CLI and Web UI, and consolidated documentation, FAQs, and troubleshooting guides (including explicit note of CIP-0112 scope and optional future CIP-56 support per ecosystem demand). -- **Adoption Metrics:** at least 7 external projects/teams demonstrate a LocalNet workflow on the CIP-0112 path. - -### Milestone 4: Adoption Validation and Ecosystem Outreach - -- **Estimated Delivery:** Month 12 -- **Focus:** Demonstrate meaningful external adoption of DevKit and publish ecosystem-facing validation artifacts. -- **Deliverables / Value Metrics:** - - Document at least 5 external apps/projects using DevKit in real development or testing workflows, evidenced by issue reports, demos, written feedback, case studies, or maintainer attestations. - - Publish a short adoption transparency update in release notes or changelog entries, including release/download/install trends, stars/forks/watchers (labeled as visibility), and telemetry aggregates if enabled. - - Report progress toward a composite floor of at least 250 cumulative installs/downloads across supported distribution channels (for example: GitHub Releases, Homebrew, install script). - - Track external feedback through issues, release notes, or documented changelog entries. - - Host 2 online/offline workshops about the Canton DevKit. - - Publish 1 case study or blog post. -- **Adoption Metrics:** at least 5 external apps/projects are actively using DevKit in real development or testing workflows by Milestone 4 acceptance. - -### Adoption Measurement - -Meaningful external adoption is evaluated using a composite view rather than any single KPI, no single public metric is treated as definitive proof on its own. - -DevKit adoption reporting will combine: - -* Installation-oriented signals (GitHub release downloads, package-manager installs such as Homebrew where available, and install-script usage counts where applicable). -* Visibility signals (GitHub stars, forks, watchers) used as discoverability indicators rather than direct usage proof. -* Privacy-preserving telemetry aggregates (if implemented), with clear documentation and user opt-out controls. -* Qualitative usage evidence such as issue reports, demos, case studies, feedback notes, or maintainer attestations from external teams/projects. - -Milestone 4 targets documented adoption across at least 5 external apps/projects, supported by a composite floor of at least 250 cumulative installs/downloads across supported channels and the qualitative evidence above. - ---- - -## Acceptance Criteria - -The Tech & Ops Committee will evaluate completion based on: - -* Delivery of the Canton DevKit capabilities specified for each milestone. -* **Milestone-specific adoption criteria:** - * **Milestone 1:** 3 external companies/teams have installed DevKit (via the DPM component, the standalone binary, or both) and successfully run `localnet up/status/down` across the supported macOS, Linux, and Windows environments, including at least one validated Windows installation/run, with at least one tester validating named-instance isolation using explicit non-conflicting ports. - * **Milestone 2:** 5 external companies/teams or representative Canton deployments have used the Web UI, DAR workflow, contract explorer, transaction explorer, or observability workflow against their own DAR/application and provided feedback artifacts. - * **Milestone 3:** At least 7 external projects/teams demonstrate a LocalNet workflow on the CIP-0112 path such as `create -> mint -> transfer` or `mint -> transfer -> burn` and provide feedback or demo artifacts. - * **Milestone 4:** Meaningful external adoption is demonstrated through at least 2 public workshops, 1 case study/blog post, documented usage by at least 5 external apps/projects in real development or testing workflows, and at least 250 cumulative installs/downloads across supported distribution channels; this is evaluated with composite evidence (downloads/installs + optional telemetry + visibility signals + direct feedback/case-study evidence), not any single metric in isolation. -* If the optional Maintenance & Compatibility Extension is approved, completion of that extension would be evaluated based on a maintained compatibility matrix for supported Splice releases/platforms, smoke tests against newer Splice releases, published compatibility notes, patch releases for compatibility fixes and high-priority bugs, and documented incorporation of user feedback during the extension term. -* Acceptable adoption and feedback evidence includes GitHub issues, pull requests, release notes, written feedback, demo recordings, workshop materials, case studies, Committee acceptance notes, release/download/install statistics, documented telemetry summaries (if enabled), and repository visibility metrics when reported as trends. -* Demonstrated functionality via scripts, demos, and documentation showing: - * Installation via the **native DPM component** (`dpm install package`) as the primary path, and via the standalone Go binary on macOS, Linux, and Windows as the additional path; neither requires users to install a programming language runtime. - * Single-command LocalNet startup and teardown, including named-instance isolation, explicit port configuration, and snapshot/restore workflows. - * Docker prerequisite handling with clear failures when Docker is missing, unreachable, lacks Compose v2, has insufficient resources, or has port conflicts. - * Web UI covering the same LocalNet management features as the CLI. - * Working Grafana dashboards for throughput, latency, and resource usage on a sample DApp. - * Upload, list, inspect, and diff DAR packages across multiple participants of a named LocalNet, including the `dpm`-backed `dar build-upload` convenience command and watch-mode hot redeploy. - * Live-watch ACS changes and list transactions with multi-dimensional filters via CLI (`contracts watch`, `tx ls`), reconstruct per-party visibility projection via `tx replay`, and browse the Active Contract Set and transaction history via the Web UI. - * Optional AI agent skill documents demonstrating use of documented `dpm localnet` commands (or equivalent `canton-devkit localnet` commands) to manage a named LocalNet, upload a DAR, and inspect resulting packages/contracts without requiring editor-specific integration. - * Token creation wizard and token flows (mint, transfer, burn, balance) on LocalNet targeting CIP-0112 as the default; optional CIP-56 support is out of scope for the committed acceptance bar unless later agreed. -* Documentation and knowledge transfer sufficient for developers to install, run, and extend DevKit. -* Evidence that feedback loops from external users are incorporated into releases (bug fixes, UX improvements, and docs updates). - ---- - -## Funding - -**Total Funding Request:** - -Base proposal total: **1,900,000 CC** over **12 months**. - -### Payment Breakdown by Milestone - -* Milestone 1 (LocalNet Management — CLI): 400,000 CC upon committee acceptance. -* Milestone 2 (Web UI, Observability, Monitoring, DAR & Contract Tooling, Optional AI Agent Skill Documents): 400,000 CC upon committee acceptance. -* Milestone 3 (Token Faucets & Token Standard Tooling, CIP-0112): 500,000 CC upon final release and acceptance. -* Milestone 4 (Adoption Validation and Ecosystem Outreach): 600,000 CC upon committee acceptance. - -### Optional Maintenance & Compatibility Extension - -An additional **600,000 CC** is proposed as a separate optional extension covering **12 months** of post-grant maintenance and Splice upgrade support after completion of the base proposal. - -If approved, this optional extension would cover: - -* Maintaining a documented compatibility matrix for supported Splice releases and platforms, consistent with the support-window / best-effort policy described under Splice Version Compatibility. -* Running smoke tests against newer Splice releases and publishing compatibility notes. -* Shipping patch releases for compatibility fixes and high-priority user-reported bugs. -* Ongoing incorporation of external feedback into maintenance releases. - -This optional extension is not included in the **1,900,000 CC** base proposal total above. If approved in addition to the base proposal, the combined total would be **2,500,000 CC**. - -Funding is requested in Canton Coin, consistent with the Development Fund's CC‑denominated, milestone‑based grants model under CIP‑100. - -### Volatility Stipulation - -The proposed base project duration is 12 months, with Months 1-9 focused on core delivery and Months 10-12 focused on adoption validation and ecosystem outreach. - -* The base grant is denominated in a fixed amount of Canton Coin (**1,900,000 CC**) with milestone allocations as above, and will be subject to re‑evaluation at the 6‑month mark to account for material CC/USD volatility, in line with the Fund's governance guidelines. -* If scope changes or delays requested by the Committee extend timelines beyond the original plan, remaining milestones and CC amounts can be renegotiated by mutual agreement. -* If the optional Maintenance & Compatibility Extension is approved, its support term, checkpoints, and CC allocation can be finalized by mutual agreement through the Committee process. -* The committed token tooling targets CIP-0112 as the default path; optional CIP-56 compatibility or other token standards would require renegotiation of milestone scope and funding if pursued within the grant period. - -### Post-grant sustainability - -The base twelve-month grant is structured to deliver the product and validate adoption using the milestone adoption metrics above. Beyond that window, sustainable operation may take the form of the optional Maintenance & Compatibility Extension described above, continued open-source/community-led maintenance, and/or handover or closer alignment with Digital Asset or the Canton Foundation—whichever the Committee judges best, informed by adoption evidence from the milestones. Nothing in this proposal binds the Foundation or Digital Asset to take ownership absent mutual agreement through the Fund’s governance process. - ---- - -## Team Background - -### BitDynamics - -BitDynamics brings deep experience in building and operating blockchain infrastructure. The team has worked across Ethereum client infrastructure, validator operations, and production-grade hosting systems supporting validator infrastructure securing more than 2 billion USD in assets. This background is directly relevant to building reliable, auditable, and security-conscious public infrastructure for a grants program. Team is also building actively on Canton. - ---- - -## Co-Marketing - -Upon release of major components (e.g., first public DevKit release, explorer, token tooling), the implementing entity will collaborate with the Canton Foundation on: - -* Coordinated announcements highlighting DevKit as shared developer tooling for the ecosystem. -* A case study or technical blog post explaining how DevKit simplifies LocalNet workflows and token experimentation. -* Participation in developer‑focused promotion such as workshops, hackathons, office hours, or webinars showcasing DevKit usage. - ---- - -## Motivation - -The Splice source code for Canton already provides a LocalNet environment, but developers must manually manage Docker, configs, and observability and often build ad‑hoc tools for exploring transactions, contract state, and token operations. This slows down onboarding for new teams, workshops, and hackathons, and leads to fragmented, privately maintained tooling rather than shared public goods. - -By consolidating LocalNet lifecycle management, observability, and token testing into a single CLI and Web UI tool suite, the proposal significantly lowers the barrier to entry for building on Canton. It directly supports the Fund's aim to back developer tooling and critical infrastructure that act as common goods and deliver long‑term value across the ecosystem. - ---- - -## Rationale - -Reducing the operational overhead of local development is a prerequisite for sustainable ecosystem growth; developer time reclaimed from infrastructure management translates directly into faster application delivery and broader adoption. Delivering functionality in three incremental, self‑contained milestones enables early value (single-command LocalNet lifecycle management) and iterative refinement (metrics, tokens) with clear checkpoints for the Committee. - -Success is measured through sustained, multi-signal adoption trends over time that combine team usage, installation-oriented indicators, and external feedback evidence. - -Separate, uncoordinated tools for observability, explorers, and token testing would increase maintenance burden and fragment the developer experience. A unified Canton DevKit CLI and Web UI tool suite offers a complementary local workflow layer over existing Canton tooling, while remaining extensible so the community can adapt it to evolving needs and future CIPs. diff --git a/docs/packaging.md b/docs/packaging.md index 5dd9c1ec..026a42a3 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -31,29 +31,55 @@ paths. Verify before unpacking/installing: ```sh sha256sum --check SHA256SUMS -tar -xzf canton-devkit_v0.1.0_linux_amd64.tar.gz +tar -xzf canton-devkit_v0.7.0_linux_amd64.tar.gz ./canton-devkit localnet --help ``` +For macOS arm64 and Linux amd64, a scripted installer is also available: + +```sh +curl -fsSL https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/main/install.sh | sh +``` + > **Version-string asymmetry:** the standalone archive filenames keep the -> `v` prefix (`canton-devkit_v0.1.0_…`), matching the git tag, while the -> DPM/OCI tag strips it (`…:0.1.0`) because DPM requires a bare-semver -> tag. Same release, two conventions — chosen to match each ecosystem's -> norm. +> `v` prefix (`canton-devkit_v0.7.0_linux_amd64.tar.gz`), matching the +> git tag, while the DPM/OCI tag strips it +> (`ghcr.io/bitdynamics-ab/canton-devkit:0.7.0`) because DPM requires a +> bare-semver tag. Same release, two conventions — chosen to match each +> ecosystem's norm. ## DPM component The DPM component is published to GitHub Container Registry on every tagged release at `ghcr.io/bitdynamics-ab/canton-devkit:`. -Install via: +`dpm install package` reads the component references from the project's +`daml.yaml`. Remove `sdk-version` and declare the SDK packages plus the +DevKit component, then install: + +```yaml +# daml.yaml +#sdk-version: +components: + - canton-open-source: + - codegen: + - damlc: + - daml-new: + - daml-script: + - upgrade-check: + - scribe: + - daml-shell: + - oci://ghcr.io/bitdynamics-ab/canton-devkit: +``` ```sh -dpm install package oci://ghcr.io/bitdynamics-ab/canton-devkit: +dpm install package dpm localnet --help ``` -`` follows semver (no `v` prefix); tag `latest` always points -at the newest published release. +Replace `` with the Canton/Daml release you are +targeting. `` for the DevKit OCI tag follows semver (no `v` +prefix); tag `latest` points at the most recently published final +(non-pre-release) release. ### Manifest @@ -62,30 +88,32 @@ delegates the rest of the DevKit CLI surface to the binary's own argv parser. See [`packaging/component.yaml.tmpl`](../packaging/component.yaml.tmpl). DPM does NOT pass the registered command name into the binary's argv — -only `exec-args` + user args reach it. `exec-args: ["localnet"]` is -therefore required so the binary always dispatches into its `localnet` -subtree regardless of how DPM invoked the component. A contract test -(`TestRunIsArgvOnly`) locks this invariant. +only `exec-args` + user args reach it. `exec-args: ["--via-dpm", +"localnet"]` is therefore required: the `localnet` arg makes the binary +dispatch into its `localnet` subtree regardless of how DPM invoked the +component, and the leading `--via-dpm` marker tells the binary it was +launched by DPM. A contract test (`TestRunIsArgvOnly`) locks this +invariant. The manifest lives as a template with a `@@BINARY_PATH@@` token: the release workflow substitutes `bin/canton-devkit` on Unix platforms and `bin/canton-devkit.exe` on Windows. DPM does NOT auto-append `.exe` on Windows — empirically verified against DPM 1.0.16, which fails -manifest validation with `stat ...: no such file or directory` when +manifest validation with `stat : no such file or directory` when the path doesn't include the extension. ### Why a single top-level command? DPM components register top-level commands into a flat namespace shared -with DPM builtins and every other component. We deliberately register -only `localnet` to: +with DPM builtins and every other component. DevKit deliberately +registers only `localnet` to: - Avoid collisions with DPM builtins (`install`, `publish`, `versions`, `bootstrap`, …) or with future first-party components. - Keep the DPM surface minimal — `dpm localnet up`, `dpm localnet dar upload`, `dpm localnet contracts ls`, etc. nest naturally. -All DevKit subcommands live inside our binary's own Cobra tree, not in +All DevKit subcommands live inside the binary's own Cobra tree, not in the DPM manifest. ## Local validation @@ -110,9 +138,10 @@ dpm publish component oci://localhost:5000/canton-devkit:0.0.1-dryrun \ --platform darwin/arm64=/tmp/cdk-component ``` -`✅ Component manifest is valid` confirms the manifest schema. CI runs -the same `--dry-run` on every push and the real publish only on `v*` -tags. +`✅ Component manifest is valid` confirms the manifest schema. The +release workflow runs the same `--dry-run` validation on every run +(tag pushes and manual dispatches); the real publish happens only on +`v*` tags. ## Debian / APT package @@ -138,7 +167,7 @@ apt policy canton-devkit Direct artifact install remains available: ```sh -sudo apt install ./canton-devkit_0.9.0_amd64.deb +sudo apt install ./canton-devkit_0.7.0_amd64.deb canton-devkit version ``` @@ -164,13 +193,14 @@ the collector is unreachable. The hosted repo is generated on every release by preserving all existing `apt/pool/main/c/canton-devkit/*.deb` files in `bitdynamics-ab/homebrew-canton-devkit`, adding the new version, and -rewriting `Packages`, `Packages.gz`, and `Release` metadata under -`apt/dists/stable/main/binary-amd64/`. +rewriting `Packages` and `Packages.gz` under +`apt/dists/stable/main/binary-amd64/` and the `Release` file at +`apt/dists/stable/`. -**Current hardening gap:** the APT repo is unsigned and documented with -`trusted=yes`. This is acceptable for an initial static repository backed -by HTTPS and release checksums, but a production-grade repo should add a -GPG-signed `InRelease` file and install instructions using `signed-by=`. +**Known limitation:** the APT repo is unsigned and documented with +`trusted=yes`. The repository is backed by HTTPS and release checksums, +but a GPG-signed `InRelease` file and install instructions using +`signed-by=` are planned hardening steps. ## Supply-chain integrity @@ -179,11 +209,10 @@ with `sha256sum --check`) plus the immutability of the GHCR OCI digest. The CI pipeline also pins every GitHub Action and the DPM CLI tarball by SHA. -**Known gap (follow-up):** the release artifacts are **not yet +**Known limitation:** the release artifacts are **not yet cryptographically signed**. There are no [cosign](https://github.com/sigstore/cosign)/Sigstore signatures on `SHA256SUMS` or on the OCI artifact, so consumers can verify *integrity* (the bytes match the checksum) but not *provenance* -(the bytes were produced by our pipeline). Adding keyless cosign signing -+ a published verification step is tracked as a post-v1 hardening item — -not blocking the initial release, but required before the artifacts are -promoted as a trusted distribution channel. +(the bytes were produced by the project's release pipeline). Keyless +cosign signing plus a published verification step is a planned +hardening item. diff --git a/docs/proposals/telemetry.md b/docs/proposals/telemetry.md deleted file mode 100644 index 26372cf1..00000000 --- a/docs/proposals/telemetry.md +++ /dev/null @@ -1,113 +0,0 @@ -# Telemetry — privacy-first usage counters - -**Status:** Implemented (v1.0, ship-dark) · **Scope:** v1 - -> v1.0 ships the CLI-side: counter package, allow-list, opt-out notice, -> precedence + `DPM_TELEMETRY` + `DPM_TELEMETRY_DEBUG`, `App.Run` wiring, -> the root `telemetry` command, and the golden tests. No production -> collector is deployed yet — with no endpoint baked in, counters stay -> local. **Consent model: opt-out** — telemetry is **on by default** and -> users disable it anytime (`telemetry off` / `DPM_TELEMETRY=off` / -> `DO_NOT_TRACK=1`). - -## Goal - -Lightweight, anonymous usage telemetry that shows **what's used** and -**what breaks** without compromising the privacy posture (loopback-only -UI, JWT redaction, no PII in commits). - -## Non-goals - -Identifying users or machines (no hardware-derived id, no IP) · capturing -what a command ran against (no instance/party/contract ids, paths, -hostnames, ports) · error content · sessionizing/sequencing invocations · -any flow enabling a behavioral profile. *One* exception, scoped tightly: -a single random, hardware-independent **install token** is sent solely to -de-duplicate install counts (Design #2) — it links to nothing else. - -## Design - -1. **Opt-out — telemetry is ON by default; users opt out anytime.** - On the first operational command a one-time TTY-gated notice states - it plainly: *"Telemetry is ON by default. Turn it off anytime: - `canton-devkit telemetry off` (or `DPM_TELEMETRY=off` / - `DO_NOT_TRACK=1`)."* All three switches disable it, and the choice - persists. Non-interactive runs never prompt and never block. -2. **One anonymous install token, nothing else.** No machine id, no - hashed hardware id, no IP retention. The single exception is a random - UUIDv4 (`install_id`) minted client-side and stored in the telemetry - config — *not* derived from any hardware attribute. It rides alongside - counter uploads so the collector can count DISTINCT installs (the one - adoption number additive counters can't yield), and is stored there - ALONE as `(token, active-date)`, never joined to a counter. It is - per-config-file (a fresh container/VM/reinstall mints a new one), - suppressed in CI, and rotatable via `telemetry reset-id`. Counters - themselves still merge into a daily aggregate with no per-invocation - row. -3. **Counter taxonomy (10 slots).** Closed, compile-time-enforced - allow-list (`internal/telemetry/allowlist.go`): `dpm/command`, - `dpm/command_exit`, `dpm/channel`, `dpm/os`, `dpm/arch`, `dpm/ci`, - `dpm/llm_agent`, `dpm/docker_engine`, `dpm/compose_version_bucket`, - `dpm/doctor_fail`. See [docs/telemetry.md](../telemetry.md) for buckets. -4. **Never collected.** instance/project/compose names · party/contract - ids · JWT fields · DAR/package/module names · file paths · hostnames · - IP/MAC · args beyond the verb · error messages · stack traces · ports · - env names/values · sub-week timestamps. -5. **Transport.** No event queue. Counters live in a local weekly file. - A completed past week uploads once (single POST, 2 s timeout, no - inner retries); on failure → mark deferred, retry next window; after 2 - misses → drop. Retrying an aggregate is privacy-safe; events are not. -6. **Collector.** Custom minimal endpoint `POST /v1/counters` with body - `{schema_version, period, granularity, counters, install_id?}` — not a - SaaS events API. The optional `install_id` is recorded only in a - separate `seen_install (token, active-date)` table for unique-install - counts; it is never stored beside a counter. -7. **Retention.** Local file: **current week + 3 prior weeks** (rolling - 4-week window — useful for offline debug, still no per-event row, no - sub-week timestamp, no id). Server raw intake: 24 h. Server aggregates: - 180 days. Dashboard: aggregated weeks only. (v1.1, server-side.) -8. **Small-cell suppression.** Start at **k = 3** for v1; ratchet upward - (5 → 10) as the install base grows. Encoded as a config knob, not a - structural change. (v1.1, server-side.) -9. **Disclosure UX.** TTY-gated one-time notice on the first *operational* - localnet verb; never on `version`/help/`telemetry …`/non-TTY. -10. **Precedence.** `DO_NOT_TRACK` → `DPM_TELEMETRY` → config file → - default on. `DPM_TELEMETRY_DEBUG=1` → print the would-send JSON to - stderr, skip the network. -11. **CLI surface.** Root-level `canton-devkit telemetry on|off|status|preview`. -12. **Web UI parity.** Settings toggle + `/api/telemetry` GET/POST + - `/api/telemetry/preview`, loopback-only. Optional — only build if a - real user surface motivates it; the CLI surface plus - `DPM_TELEMETRY_DEBUG=1` is the audit path operators actually need. -13. **Code shape.** `internal/telemetry/{allowlist,counter,store,config, - uploader,notice,context}.go`. -14. **Hook point.** `internal/cli/app.go` `App.Run`, after Cobra's - `root.ExecuteC()` returns. The verb derives from the executed - `*cobra.Command` via the `localnetVerb(cmd)` helper (NOT `args[0]`, - which would always be `"localnet"` for `canton-devkit localnet up`). - The sink is installed via `App.WithTelemetry()`; tests leave the - package's no-op sink so they never write or send. -15. **Channel detection.** `-ldflags -X main.channel=stable|nightly`; - defaults to `dev` for local `go build`. -16. **Domain.** `telemetry.canton-devkit.dev` subdomain. (v1.1.) -17. **Tests — defense in depth.** Two-layer enforcement of the allow-list: - (a) compile-time `go/ast` walk over every `telemetry.Inc(chart, bucket)` - literal in the tree; (b) **runtime allow-list check inside `Inc()`** - that silently drops unknown `(chart, bucket)` pairs — closes the gap - where buckets are concatenated at runtime (e.g. - `Inc("dpm/command_exit", verb+"/"+outcome)`) which the AST can't - enumerate. Plus `DO_NOT_TRACK`/precedence tests, weekly-merge no-id - /no-timestamp guard, 2-attempt-drop upload, `TestRunIsArgvOnly` - still green. -18. **Public artifacts.** This doc; allow-list + sender code in the public - repo; schema changes bump `schema_version` + update this doc. - -## Pending (not in v1.0) - -- **v1.1** — collector endpoint + nginx (no IP/UA/cookies) + weekly rollup - + k = 3-anonymized public dashboard at `telemetry.canton-devkit.dev`. -- **v1.2** — Web UI parity (`/api/telemetry` + Settings panel), per the - AGENTS.md CLI ↔ UI rule. Optional; only build if a real user surface - motivates it. -- Bake a `nightly` channel build (`[nightly]` commit trigger) when - nightly releases start. diff --git a/docs/telemetry.md b/docs/telemetry.md index 311bd3e0..1c49ba5a 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,12 +1,12 @@ # Telemetry canton-devkit records **anonymous, aggregate usage counters** — merged -into a daily total with **no per-invocation rows** — to help the team see -what's used and what breaks. The only identifier sent is a single +into a daily total with **no per-invocation rows** — to help maintainers +see what's used and what breaks. The only identifier sent is a single **anonymous random install token** (a UUID, not derived from any hardware -detail) used purely so we can count *distinct* installs; it never tags an -individual counter. See the full design at -[docs/proposals/telemetry.md](proposals/telemetry.md). +detail) used purely to count *distinct* installs; it never tags an +individual counter. This page is the complete reference for what is +collected, what is never collected, and how to inspect or disable it. Inspect exactly what's queued any time: @@ -20,8 +20,12 @@ Telemetry is **on by default**. The first time you run an operational command in an interactive terminal, a one-time notice explains this. The Debian package also records a one-time `apt` install-surface ping during package installation; because that path is non-interactive, opt out -**before** install with `DPM_TELEMETRY=off` or `DO_NOT_TRACK=1` if you do -not want it. Turn telemetry off any time — your choice persists: +**before** install if you do not want it. The package hook runs in the +root environment, and `sudo` strips exported shell variables by default, +so set the variable on the `sudo` command line itself: +`sudo DO_NOT_TRACK=1 apt install canton-devkit` (or +`sudo DPM_TELEMETRY=off apt install canton-devkit`). Turn telemetry off +any time — your choice persists: ```bash canton-devkit telemetry off # disable (persists) @@ -80,7 +84,7 @@ nothing else. One value is sent that *can* distinguish installs: a random **UUIDv4** minted on first upload and stored in your telemetry config. It exists for exactly one reason — so the collector can answer *"how many distinct -installs?"* (the one adoption number pure counters can't give). What it is +installs?"* (the one number pure counters can't give). What it is **not**: - **Not derived from your machine** — no hostname, MAC, serial, or @@ -104,7 +108,8 @@ construction — the model is counters, not events — no: - DAR names/hashes, package/module names - JWT audiences/issuers/fingerprints, ports, endpoints, file paths - command arguments beyond the verb, error messages, stack traces -- timestamps finer than the ISO week, environment variables, hostnames +- timestamps finer than the aggregation period (a calendar day), + environment variables, hostnames There is no per-invocation row to profile, and the one token we send correlates only to itself (an install count) — never to your usage. @@ -137,5 +142,4 @@ canton-devkit telemetry flush # send all queued counters now (skip DPM_TELEMETRY_DEBUG=1 canton-devkit localnet status # print the would-send JSON to stderr, send nothing ``` -See also: [proposals/telemetry.md](proposals/telemetry.md) (full design) · -[FAQ](faq.md) · [getting-started](getting-started.md). +See also: [FAQ](faq.md) · [Getting started](getting-started.md). diff --git a/docs/tests/e2e-test-milestone-1.html b/docs/tests/e2e-test-milestone-1.html index 97f834fa..bd2b1012 100644 --- a/docs/tests/e2e-test-milestone-1.html +++ b/docs/tests/e2e-test-milestone-1.html @@ -307,7 +307,7 @@

-

E2E Test Plan — Milestone 1: LocalNet Management CLI 18 Tests

+

E2E Test Plan — Milestone 1: LocalNet Management CLI 19 Tests

Proposal: original-devkit-proposal.md, Milestone 1 Delivery: Month 3 @@ -321,7 +321,7 @@

E2E Test Plan — Milestone 1: LocalNet Management CLI - Scope. 18 end-to-end test cases covering installation (DPM + standalone binary), preflight/doctor checks (Docker presence, resource constraints), full LocalNet lifecycle (up, down, restart, clean, status, logs), snapshot/restore, named instance isolation with port separation, environment variable export, and instance listing. Every test is designed for mechanical execution by an AI agent or CI pipeline. Both CLI modes (dpm localnet and canton-devkit localnet) must be exercised. + Scope. 19 end-to-end test cases covering installation (DPM + standalone binary), preflight/doctor checks (Docker presence, resource constraints), full LocalNet lifecycle (up, start, stop, down, restart, pause, resume, clean, status, logs), snapshot/restore, named instance isolation with port separation, environment variable export, and instance listing. Every test is designed for mechanical execution by an AI agent or CI pipeline. Both CLI modes (dpm localnet and canton-devkit localnet) must be exercised.

@@ -374,7 +374,7 @@

Test Cases

dpm install package canton-devkit
Expected: Exit code 0.

Verify dpm localnet --help exits 0 and output matches:

-
dpm localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)"
+
dpm localnet --help 2>&1 | grep -qE "(up|start|stop|down|restart|pause|resume|clean|status|logs|snapshot|restore)"
@@ -429,7 +429,7 @@

Test Cases

Step 2. Verify the binary runs:

./canton-devkit localnet --help
Expected: Exit code 0, output matches:
-
./canton-devkit localnet --help 2>&1 | grep -qE "(up|down|restart|clean|status|logs|snapshot|restore)"
+
./canton-devkit localnet --help 2>&1 | grep -qE "(up|start|stop|down|restart|pause|resume|clean|status|logs|snapshot|restore)"
@@ -476,15 +476,19 @@

Test Cases

Step 2. Check help output includes all Milestone 1 commands:

$CLI --help 2>&1 | grep -qE "up"
+$CLI --help 2>&1 | grep -qE "start"
+$CLI --help 2>&1 | grep -qE "stop"
 $CLI --help 2>&1 | grep -qE "down"
 $CLI --help 2>&1 | grep -qE "restart"
+$CLI --help 2>&1 | grep -qE "pause"
+$CLI --help 2>&1 | grep -qE "resume"
 $CLI --help 2>&1 | grep -qE "clean"
 $CLI --help 2>&1 | grep -qE "status"
 $CLI --help 2>&1 | grep -qE "logs"
 $CLI --help 2>&1 | grep -qE "snapshot"
 $CLI --help 2>&1 | grep -qE "restore"
 $CLI --help 2>&1 | grep -qE "doctor"
-
Expected: All grep commands exit 0.
+
Expected: All grep commands exit 0. start/stop are first-class lifecycle commands (no longer aliases of up/down), and pause/resume are listed too.
@@ -909,6 +913,67 @@

Test Cases

+ +
+ + + M1-STP-001 + Stop keeps containers; start restores them + Lifecycle + +
+
+ Preconditions: LocalNet e2e-test-default running + Platforms: All + Timeout: 300s +
+ +

stop/start are first-class lifecycle commands (they were previously aliases for down/up). stop runs docker compose stop — the containers are stopped but kept on disk — and start runs docker compose start, reusing the existing containers without recreating the stack. When the containers have already been removed (e.g. after down), start transparently falls back to a full up.

+ +
+ +
+

Step 1. Start LocalNet if not running:

+
$CLI up --name e2e-test-default
+
+
+ +
+ +
+

Step 2. Stop the instance:

+
$CLI stop --name e2e-test-default
+
Expected: Exit code 0.
+
+
+ +
+ +
+

Step 3. Verify containers are stopped but not removed:

+
# Containers still exist (stopped state)
+docker ps -a --filter "label=com.docker.compose.project=canton-e2e-test-default" --format '{{.Names}}' | grep -qE "e2e-test-default"
+# ...but none are running
+docker ps --filter "label=com.docker.compose.project=canton-e2e-test-default" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers still running" || echo "PASS"
+
Expected: Containers present in docker ps -a, absent from docker ps.
+
+
+ +
+ +
+

Step 4. Start the instance again:

+
$CLI start --name e2e-test-default
+
Expected: Exit code 0, no image pull / stack recreate (fast compose-start path).
+

Verify readiness after start:

+
$CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)"
+
+
+ +
Cleanup: $CLI clean --name e2e-test-default --force 2>/dev/null || true
+
+
+
@@ -1331,6 +1396,7 @@

Test Execution Summary

M1-STS-001Status shows healthy servicesStatusM1-UP-001 M1-LOG-001Logs — full and service-filteredLogsM1-UP-001 M1-RST-001Restart full + single serviceLifecycleM1-UP-001 + M1-STP-001Stop keeps containers; start restores themLifecycleM1-UP-001 M1-DWN-001Down stops instance cleanlyLifecycleM1-UP-001 M1-CLN-001Clean removes all resourcesLifecycleM1-DWN-001 M1-SNP-001Snapshot and restoreStateM1-UP-001 diff --git a/docs/tests/e2e-test-milestone-1.md b/docs/tests/e2e-test-milestone-1.md index 6834ece6..d02cb1b2 100644 --- a/docs/tests/e2e-test-milestone-1.md +++ b/docs/tests/e2e-test-milestone-1.md @@ -2,7 +2,7 @@ > **Proposal Reference:** `original-devkit-proposal.md`, Milestone 1 (Lines 230–247) > **Estimated Delivery:** Month 3 -> **Total Tests:** 18 +> **Total Tests:** 19 > **Platforms:** macOS (Apple Silicon), Linux (amd64), Windows (amd64) --- @@ -131,8 +131,12 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true 2. Check help output includes all Milestone 1 commands: ```bash $CLI --help 2>&1 | grep -qE "up" + $CLI --help 2>&1 | grep -qE "start" + $CLI --help 2>&1 | grep -qE "stop" $CLI --help 2>&1 | grep -qE "down" $CLI --help 2>&1 | grep -qE "restart" + $CLI --help 2>&1 | grep -qE "pause" + $CLI --help 2>&1 | grep -qE "resume" $CLI --help 2>&1 | grep -qE "clean" $CLI --help 2>&1 | grep -qE "status" $CLI --help 2>&1 | grep -qE "logs" @@ -140,7 +144,7 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true $CLI --help 2>&1 | grep -qE "restore" $CLI --help 2>&1 | grep -qE "doctor" ``` - - **Expected:** All grep commands exit `0`. + - **Expected:** All grep commands exit `0`. (`start`/`stop` are first-class lifecycle commands — no longer aliases of `up`/`down` — and `pause`/`resume` are listed too.) 3. Verify no runtime dependencies required (no Go, Node, Python, Rust): ```bash @@ -443,6 +447,55 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true --- +### M1-STP-001: Stop keeps containers; start restores them + +**Preconditions:** LocalNet `e2e-test-default` running. +**Platforms:** All +**Timeout:** 300 seconds + +`stop`/`start` are first-class lifecycle commands (they were previously +aliases for `down`/`up`). `stop` runs `docker compose stop` — the +containers are stopped but **kept on disk** — and `start` runs +`docker compose start`, reusing the existing containers without +recreating the stack. When the containers have already been removed +(e.g. after `down`), `start` transparently falls back to a full `up`. + +**Steps:** + +1. Start LocalNet if not running: + ```bash + $CLI up --name e2e-test-default + ``` + +2. Stop the instance: + ```bash + $CLI stop --name e2e-test-default + ``` + - **Expected:** Exit code `0`. + +3. Verify containers are stopped but **not** removed: + ```bash + # Containers still exist (stopped state) + docker ps -a --filter "label=com.docker.compose.project=canton-e2e-test-default" --format '{{.Names}}' | grep -qE "e2e-test-default" + # ...but none are running + docker ps --filter "label=com.docker.compose.project=canton-e2e-test-default" --format '{{.Names}}' | grep -qE "e2e-test-default" && echo "FAIL: containers still running" || echo "PASS" + ``` + - **Expected:** Containers present in `docker ps -a`, absent from `docker ps`. + +4. Start the instance again: + ```bash + $CLI start --name e2e-test-default + ``` + - **Expected:** Exit code `0`, no image pull / stack recreate (fast compose-start path). + - **Verify readiness after start:** + ```bash + $CLI status --name e2e-test-default 2>&1 | grep -qiE "(healthy|ready|running)" + ``` + +**Cleanup:** `$CLI clean --name e2e-test-default --force 2>/dev/null || true` + +--- + ### M1-DWN-001: Down stops instance cleanly **Preconditions:** LocalNet `e2e-test-default` running. @@ -751,6 +804,7 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true | M1-STS-001 | Status shows healthy services | Status | M1-UP-001 | | M1-LOG-001 | Logs — full and service-filtered | Logs | M1-UP-001 | | M1-RST-001 | Restart full + single service | Lifecycle | M1-UP-001 | +| M1-STP-001 | Stop keeps containers; start restores them | Lifecycle | M1-UP-001 | | M1-DWN-001 | Down stops instance cleanly | Lifecycle | M1-UP-001 | | M1-CLN-001 | Clean removes all resources | Lifecycle | M1-DWN-001 | | M1-SNP-001 | Snapshot and restore | State | M1-UP-001 | @@ -768,7 +822,7 @@ $CLI clean --name e2e-test-b --force 2>/dev/null || true **Binary version:** dev **Splice version (default/latest):** 0.6.4 **Splice version (explicit):** 0.6.3 -**Script:** `scripts/e2e-milestone1.sh` +**Script:** `scripts/e2e/run-all.sh` (full suite) or `scripts/e2e/m1-*.sh` (individual tests) ### CLI Syntax Adaptations @@ -781,7 +835,7 @@ The test plan assumes command syntax that differs from the actual CLI implementa | `$CLI snapshot --name X` | `$CLI snapshot --name X --to ` | `--to` is required — output path | | `$CLI restore --name X` | `$CLI restore --name X --from ` | `--from` is required — input path | | `$CLI --version` → semver | `$CDK --version` → `canton-devkit version dev` | Version is top-level, may be `dev` in local builds | -| `$CLI --help` shows `clean`, `restart` | Hidden commands; not in `--help` output | Exist and work via `--help` on each subcommand | +| `$CLI --help` shows lifecycle commands | `up`, `start`, `stop`, `down`, `restart`, `pause`, `resume`, `clean` all listed | `start`/`stop` are now standalone commands (no longer `up`/`down` aliases); `resume` has an `unpause` alias | | Docker label `canton-devkit` | `com.docker.compose.project=canton-` | Docker compose project label, not a custom label | | `$CLI down` then `$CLI clean` | `$CLI clean --force` on running instance | `down` deregisters the instance; `clean` can't find it after. Use `clean --force` directly | @@ -798,14 +852,15 @@ The test plan assumes command syntax that differs from the actual CLI implementa | ID | Result | Duration | Notes | |---|---|---|---| -| M1-INST-003 | **PASS** | <1s | Version (`dev`), help (10 visible + 2 hidden commands), Mach-O arm64 | +| M1-INST-003 | **PASS** | <1s | Version (`dev`), help lists all lifecycle commands (`up`/`start`/`stop`/`down`/`restart`/`pause`/`resume`/`clean`), Mach-O arm64 | | M1-DOC-001 | **PASS** | <2s | 0 issues, 1 warning (memory 8.84/12 GB). Exit 0. | | M1-DOC-002 | **PASS** | <2s | Exit 2 when Docker hidden from PATH. Remediation: "Install Docker Desktop for Mac" | | M1-UP-001 | **PASS** | ~2-4 min | Splice 0.6.4, cached images. Status: healthy. Docker compose project verified. | | M1-STS-001 | **PASS** | <2s | Status includes health, endpoints, participant info. Non-existent instance → exit 1. | | M1-LOG-001 | **PASS** | <10s | Full logs: 308 lines. Service-filtered (`canton`): 20 lines. | -| M1-ENV-001 | **PASS** | <1s | `export CANTON_*` format. Contains JWT (redacted), audience, port variables. | +| M1-ENV-001 | **PASS** | <1s | `export CANTON_*` format. Contains raw JWT, audience, and port variables. | | M1-RST-001 | **PASS** | ~5-8 min | Full restart + single-service (`--service canton`) restart. Readiness wait is slow post-restart. | +| M1-STP-001 | **NOT RUN** | — | Added after this run (PR #201 standalone `stop`/`start`). Covered by `scripts/e2e/run-all.sh`. | | M1-SNP-001 | **PASS*** | ~10 min | Snapshot: 78 MB .tgz. Restore + re-up works but splice re-sync can exceed 5 min (crash-consistent, not app-consistent). | | M1-DWN-001 | **PASS** | ~5s | Containers stopped, non-devkit containers unaffected. | | M1-CLN-001 | **PASS*** | ~10 min | See finding below. `clean --force` on running instance removes all resources (containers, volumes, networks). | @@ -819,7 +874,6 @@ The test plan assumes command syntax that differs from the actual CLI implementa **Severity:** Medium **Test:** M1-CLN-001 -**Issue:** [`docs/issues/down-clean-orphaned-volumes.md`](../issues/down-clean-orphaned-volumes.md) `localnet down` (default) deregisters the instance from the registry on success. A subsequent `localnet clean --name X --force` then reports "Nothing to clean" but Docker volumes remain on disk. This is a design gap — both commands work correctly individually but don't compose in the `down` → `clean` sequence. diff --git a/docs/tokens.md b/docs/tokens.md index da7b0e6e..83d29ea3 100644 --- a/docs/tokens.md +++ b/docs/tokens.md @@ -1,15 +1,21 @@ -# Tokens — Canton Token Standard V2 on LocalNet - -canton-devkit ships first-class tooling for the **Canton Token Standard -V2** (the CIP-0112 path) so you can create an instrument, mint/transfer/ -burn holdings, fund parties, and reconcile balances against a live -LocalNet — from the CLI **or** the Web UI, by readable party alias, with -no JWTs, ports, or 130-char contract ids in your face. - -> **Scope: V2 / CIP-0112 only.** This tooling targets the Token Standard -> V2 (CIP-0112) surface. V1 / CIP-0056 is **not** supported. V2 is -> currently an opt-in *alpha* track (see [the alpha caveat](#the-v2-alpha-caveat)); -> it is promoted to the default channel once V2 lands in mainline Splice. +# Tokens — Canton Token Standard on LocalNet + +canton-devkit ships first-class tooling for the Canton Token Standard so +you can create an instrument, mint/transfer/burn holdings, fund parties, +and reconcile balances against a live LocalNet — from the CLI **or** the +Web UI, by readable party alias, without surfacing raw JWTs, ports, or +full contract IDs in every command. + +> **Scope: both token-standard generations, routed per instrument.** +> Reads and transfers work against +> [CIP-0056](https://github.com/global-synchronizer-foundation/cips/blob/main/cip-0056/cip-0056.md) +> (Final) instruments — what existing assets such as Canton Coin +> implement on stable Splice releases. Creating a **new** instrument uses +> the Token Standard V2 +> ([CIP-0112](https://github.com/global-synchronizer-foundation/cips/blob/main/cip-0112/cip-0112.md), +> approved but not yet final) surface, which is an opt-in *alpha* track +> (see [the alpha caveat](#the-v2-alpha-caveat)); it will be promoted to +> the default channel once V2 lands in mainline Splice. --- @@ -21,17 +27,20 @@ V2 needs a special Splice build (alpha protocol 35) and a profile overlay: # list versions — the V2 entry is tagged channel: alpha canton-devkit localnet versions -# bring up a V2-capable instance +# bring up a V2-capable instance (up warns loudly if you select the +# alpha version without --profile tokens-v2) canton-devkit localnet up --name v2 --version token-standard-v2 --profile tokens-v2 -# confirm health (doctor warns if the alpha profile is missing) -canton-devkit localnet doctor --name v2 +# confirm the instance is healthy +canton-devkit localnet status --name v2 ``` All token subcommands take `--instance ` and, for on-ledger actions, `--endpoint ` (the participant ledger -gRPC port — `localnet status --name v2` prints it). Empty `--token` -auto-issues a per-role dev JWT; `--role` defaults to `app-user`. +gRPC port — `localnet status --name v2` prints it). Where a `--token` +flag exists it can stay empty — a per-role dev JWT is auto-issued +(`mint`/`create`/`demo` always auto-issue); `--role` defaults to +`app-user`. --- @@ -39,7 +48,7 @@ auto-issues a per-role dev JWT; `--role` defaults to `app-user`. On LocalNet there is **no trust boundary between parties — you own all of them** (the dev secret signs for every role). So the token tool is a -single *god-mode workspace* over the instance, not a wallet-per-party: +single operator workspace over the instance, not a wallet-per-party: - **Party aliases** — `token party new bob` allocates a party and lets you say `--to bob` everywhere instead of pasting its id. @@ -77,7 +86,7 @@ canton-devkit localnet token mint --instance $INST --endpoint $EP \ # 4. See everyone's balances at a glance canton-devkit localnet token balances --instance $INST --endpoint $EP -# 5. Transfer (─-auto-accept settles in one step on LocalNet) +# 5. Transfer (--auto-accept settles in one step on LocalNet) canton-devkit localnet token transfer --instance $INST --endpoint $EP \ --instrument RTK --from bob --to alice --amount 250 --auto-accept @@ -100,13 +109,14 @@ Add `--format json` to any read command (`balance`, `balances`, | Command | What it does | |---|---| | `token create` | Create an on-ledger V2 instrument (TokenRules) for an issuer. Auto-uploads the bundled `splice-test-token-v2` DARs if not vetted. `--non-interactive` for CI; otherwise a wizard. | +| `token demo` | One-command demo: allocate an issuer, create a V2 instrument on-ledger, mint the initial supply, and fund a holder so the token is transferable immediately (`--symbol DEMO`, `--supply 1000000` defaults). Same orchestration as the UI's Launch-demo-token button. | | `token mint` | Mint new supply to a party (`TokenRules_OfferMint`, controller = issuer). Native CIP-0112 v2 instruments only. | | `token transfer` | Sender-initiated transfer. `--auto-accept` chains the receiver-side accept (LocalNet default convenience); `--no-wait` returns the instruction id to hand off. | | `token transfer accept` | Receiver accepts a pending `TransferInstruction` by id. | | `token burn` | Burn supply. The example token has no protocol burn, so this archives the holder's `Holding` contracts directly (signatory = account parties + admin, all operator-controlled on LocalNet) and returns change. | | `token faucet ` | Fund a party from a well-known source, auto-accepted. `--source` overrides the default funded party. | | `token balance` | One party's balances. | -| `token balances` | Party × instrument balance matrix (god-mode reconciliation). | +| `token balances` | Party × instrument balance matrix (cross-party reconciliation). | | `token summary` | Supply / holder count / holding-contract count + holder distribution for one instrument. | | `token activity` | Mint/transfer/burn history for one instrument, reconstructed from the ledger. | | `token party new\|ls\|rm` | Manage the party alias registry. | @@ -119,9 +129,10 @@ V2 runs only on the upstream **alpha** Splice build (snapshot image on the `-dev` ghcr repo, `initial-protocol-version=35`). Consequences to know: - **The upstream V2 DevNet resets periodically.** The catalogue entry may - need refreshing each release cycle — see [docs/versions.md](versions.md). + need refreshing each release cycle — see [Splice version catalogue](versions.md). - **Use `--profile tokens-v2`.** Selecting the alpha version without it - brings up a stack that can't run the V2 protocol; `doctor` warns. + brings up a stack that can't run the V2 protocol; `up` warns loudly + at bring-up. - **Loopback-only dev auth.** Per-role JWTs are signed with a literal `unsafe` dev secret. They are valid only against your local stack — never reuse them against DevNet/TestNet/MainNet. @@ -138,6 +149,6 @@ V2 runs only on the upstream **alpha** Splice build (snapshot image on the create → mint → transfer → burn, all on-ledger, no scan registry dependency (its `TokenRules` *is* the registry). -See also: [getting-started.md](getting-started.md) · +See also: [Getting started](getting-started.md) · [FAQ](faq.md) · [troubleshooting](troubleshooting.md) · [versions](versions.md). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d9892580..771591f0 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1,16 +1,18 @@ # Troubleshooting -Failure modes and fixes. Start with `canton-devkit localnet doctor ---name ` — it checks Docker, memory, ports, version channel, -and the alpha-profile requirement, and prints targeted remediation. +Failure modes and fixes. Start with `canton-devkit localnet doctor` — +it runs the same host preflight as `localnet up` (Docker CLI, daemon, +Compose v2, disk + memory headroom, platform, port availability; pass +`--version ` to use that version's memory thresholds) and prints +targeted remediation. ## `localnet up` fails or containers OOM-loop **Symptom:** Canton container restarts repeatedly; `up` times out. **Cause:** Docker memory below the version's floor. Splice 0.6.x needs -≈8 GiB; the V2 alpha similar. The default Docker Desktop allocation -(4 GiB) is too low. +≈8 GiB; the V2 alpha similar. Docker Desktop defaults to allocating +50% of host memory, which on smaller machines lands below that floor. **Fix:** Raise Docker memory to the recommended value (`doctor` prints it), then `localnet up` again. The per-version preflight gate surfaces @@ -21,8 +23,8 @@ this before the stack starts. **Symptom:** `PORTS_IN_USE` error envelope on `up`. **Fix:** Another instance (or a stale container) holds the port block. -`localnet list` to find it, `localnet down --name ` to free it, or -pass a different `--name` (each name gets its own block). Note: Docker may +`localnet list` to find it, `localnet down ` to free it, or +pass a different instance name (each name gets its own block). Note: Docker may reassign ephemeral host ports across a restart — re-read them from `localnet status` rather than caching old values. @@ -37,25 +39,32 @@ when functional — and the off-ledger scan registry (behind nginx) isn't ready until the Splice app fully boots. **Fix:** -- Give the stack more time; `doctor` / `status` reflect real readiness - via the readyz fallback, not just the container healthcheck. +- Give the stack more time; the readiness wait in `up` / `start` / + `restart` treats the validator's `/api/validator/readyz` returning + 200 as ready even while Docker still reports `health: starting`. + `localnet status` renders Docker's reported health (a container in + `health: starting` shows as `syncing`), so `syncing` there does not + necessarily mean broken — and `doctor` checks the host only and + never probes instances. - The **native test-token** path (your own `splice-test-token-v2` instrument) needs **no scan registry** — its `TokenRules` is the registry — so create/mint/transfer/burn of your own token work even while the scan app is still coming up. Only **Amulet** transfers depend on the scan registry. - If the participant port is genuinely down, `localnet status` will show - it; restart with `localnet restart --name `. + it; restart with `localnet restart `. ## Token: "package not vetted" / manual DAR upload **Symptom:** `token create` errors that `splice-test-token-v2` isn't vetted. -**Fix:** `token create --endpoint …` auto-fetches and uploads the -test-token + burn-mint DARs (pinned to the instance's Splice commit). If -you're offline or the fetch fails, upload them manually with -`localnet dar upload ` and retry. +**Fix:** `token create --instance --endpoint ` +auto-fetches and uploads the test-token + burn-mint DARs (pinned to +the instance's Splice commit). If you're offline or the fetch fails, +upload them manually with +`localnet dar upload --instance --all-participants` and +retry. ## Token: mint/burn disabled in the Web UI @@ -67,24 +76,29 @@ no mint/burn surface — create your own token to exercise them. **Symptom:** token/ledger commands can't find a JWT for a role. -**Fix:** `localnet creds --name --role --format raw` -re-issues a dev token from the project's env files. The token commands -also auto-issue per-role tokens when `--token` is empty. +**Fix:** `localnet creds --role --format raw` +prints the JWT captured at `up` time from `state.json`. If no +credentials were captured (e.g. the `up` failed before JWT capture), +re-run `localnet up` to completion. The token commands also auto-issue +per-role tokens when `--token` is empty. -## Snapshot consistency +## Snapshot consistency -`localnet snapshot` captures Docker volumes + registry state. For a -**running** instance this is a crash-consistent (not -application-consistent) copy: in-flight transactions or unflushed -database writes may not be fully captured. For a guaranteed-consistent -snapshot, `localnet down --name ` first, then snapshot. `snapshot` -warns when run against a running instance. +`localnet snapshot --to ` captures a logical `pg_dumpall` of +the instance's Postgres plus registry state. The instance must be +**running** — the dump reads from live Postgres, so a stopped instance +cannot be snapshotted. Node containers are paused for the duration of +the dump, so the snapshot is application-consistent; there is no need +to `down` first. ## Still stuck? -- `localnet logs --name [service]` — tail container logs. -- `localnet doctor --name ` — host + instance diagnostics. -- File an issue with the `doctor` output and the failing command. +- `localnet logs [--service ]` — tail container logs + (repeat `--service` to filter to specific services). +- `localnet doctor` — host readiness diagnostics (docker, resources, + network); use `localnet status ` for per-instance state. +- File a [GitHub issue](https://github.com/bitdynamics-ab/canton-devkit/issues) + with the `doctor` output and the failing command. ## Log lookup implementation note diff --git a/docs/ux-improvement-followup.md b/docs/ux-improvement-followup.md deleted file mode 100644 index 34bc6f83..00000000 --- a/docs/ux-improvement-followup.md +++ /dev/null @@ -1,75 +0,0 @@ -# UX Improvement Followup: Positional Instance Name + Aliases - -This tracks the remaining docs and code surfaces that still use the -`--name ` flag form after the CLI was updated to accept the -instance name as a positional argument (e.g., `localnet up dev` -instead of `localnet up --name dev`). The `--name` flag still works -(backward compatible); these updates are cosmetic — switching -examples and suggestions to the shorter positional form. - -Also tracks surfaces that should mention the `start`/`stop` aliases -for `up`/`down`. - -## Context - -- **PR**: initial implementation of positional name + aliases -- **What changed**: 11 lifecycle commands (`up`, `down`, `restart`, - `pause`, `resume`, `env`, `logs`, `creds`, `snapshot`, `restore`, - `status`) accept the instance name as an optional positional arg -- **Aliases**: `start` → `up`, `stop` → `down` - -## Remaining work - -### 1. UI handler error strings - -User-facing error messages in the Web UI handlers still suggest -`--name` form. Update to positional form. - -- [ ] `internal/ui/handlers/instances.go` — `dpm localnet down --name …` - and `dpm localnet up --name …` suggestion strings -- [ ] `internal/ui/handlers/dar.go` — restart suggestion: - `dpm localnet down --name … followed by dpm localnet up --name …` -- [ ] `internal/ui/handlers/dar_inspect.go` — same pattern as dar.go -- [ ] `internal/ui/handlers/contracts.go` — same pattern as dar.go -- [ ] `internal/ui/handlers/metrics.go` — - `dpm localnet up --profile observability --name …` - -### 2. Internal doc-comment examples - -- [ ] `internal/ui/term/box.go` — doc-comment example: - `dpm localnet env --name hubble` -- [ ] `internal/ui/term/step.go` — doc-comment example: - `dpm localnet up --name hubble` - -### 3. User-facing docs guides - -Update lifecycle command examples from `--name` to positional form: - -- [ ] `docs/getting-started.md` — walkthrough commands (~15 instances) -- [ ] `docs/troubleshooting.md` — suggested commands (~10 instances) -- [ ] `docs/observability.md` — example commands (~4 instances) -- [ ] `docs/dashboard-customization.md` — example commands (~5 instances) -- [ ] `docs/tokens.md` — lifecycle examples (~3 instances; skip - `token create --name` which is a token name, not instance name) -- [ ] `docs/explorer.md` — lifecycle examples (~6 instances) -- [ ] `docs/limitations.md` — CLI usage notes (~2 instances) -- [ ] `docs/validation-checklist.md` — validation commands (~3 instances) -- [ ] `docs/faq.md` — prose reference to `--name` (~1 instance) - -### 4. E2E test transcript docs - -These are verbose test-case transcripts. The `--name` form still works, -so these are low priority but should eventually reflect the preferred -form. - -- [ ] `docs/tests/e2e-test-milestone-1.md` — ~100+ `--name` instances - across lifecycle commands; also update the conventions section - (lines ~779-782) to document positional form and aliases -- [ ] `docs/tests/e2e-test-milestone-2.md` — ~50+ `--name` instances -- [ ] `docs/tests/e2e-test-milestone-3.md` — ~20+ `--name` instances - -## Explicitly out of scope - -- `docs/original-devkit-proposal.md` — historical proposal, left as-is -- `docs/proposals/*` — design proposals, left as-is -- `AGENTS.md` — contributor conventions, not user-facing examples diff --git a/docs/validation-checklist.md b/docs/validation-checklist.md deleted file mode 100644 index 283a44b1..00000000 --- a/docs/validation-checklist.md +++ /dev/null @@ -1,59 +0,0 @@ -# Zero-to-LocalNet validation checklist - -The M1 adoption bar is: **a new developer reaches a running LocalNet in -under 10 minutes.** This page is the reviewer-facing checklist behind that -metric. Run it yourself before a release, or hand it to an external -reviewer (see [adoption/reviewer-kit.md](adoption/reviewer-kit.md)). - -## Automated harness - -```bash -# default 10-minute budget -scripts/validate-zero-to-localnet.sh - -# true cold start (clears the Splice cache first → includes the ~140 MB download) -COLD=1 scripts/validate-zero-to-localnet.sh - -# looser budget on a slow link -BUDGET_SECONDS=900 scripts/validate-zero-to-localnet.sh -``` - -Exit `0` = passed within budget · `1` = a step failed · `2` = over budget. -The harness times: binary present → `doctor` → `up` (the long pole) → -`status` healthy → teardown. - -## Manual reviewer checklist - -A first-time reviewer with Docker installed should be able to tick every -box without reading source: - -- [ ] **Install** — one command from [getting-started.md](getting-started.md) - (DPM component, `install.sh`, or a release binary) puts - `canton-devkit` / `dpm` on `PATH`. -- [ ] **Doctor** — `localnet doctor` runs and clearly reports any host gap - (Docker down, low memory, missing compose v2) with a fix. -- [ ] **Up** — `localnet up --name demo` downloads (on first run), boots, - waits for readiness, and prints endpoints. **Wall-clock < 10 min.** -- [ ] **Status** — `localnet status --name demo` shows healthy services + - participant/UI endpoints. -- [ ] **UI** — `localnet ui` opens a dashboard at the printed URL. -- [ ] **Down** — `localnet down --name demo` stops cleanly; `localnet list` - reflects it. -- [ ] **No surprises** — no manual Docker commands, no editing config - files, no hunting for ports. - -## What to record - -For each reviewer / run, capture: - -| Field | Example | -|---|---| -| Platform | macOS 14 arm64 / Ubuntu 22.04 amd64 | -| Docker memory | 8 GiB | -| Cold or warm cache | cold | -| `up` wall-clock | 6m12s | -| Result | pass / fail (step) | -| Friction notes | "doctor memory hint was clear"; "didn't know which port was the UI" | - -Aggregate these in the adoption transparency update (M4). Three external -reviewers passing the manual checklist satisfies the M1 adoption metric. diff --git a/docs/versions.md b/docs/versions.md index 2d3f7853..b2839666 100644 --- a/docs/versions.md +++ b/docs/versions.md @@ -1,10 +1,9 @@ # Splice version catalogue -DevKit pins to a **curated** list of Splice versions in -[`internal/splice/versions.json`](../internal/splice/versions.json) so -`localnet up` never composes-up an untested upstream tag. +DevKit pins to a **catalogue** of tested Splice versions embedded in the +binary so `localnet up` never composes-up an untested upstream tag. -## What we fetch, and from where +## What DevKit fetches, and from where > **Upstream repo:** [`canton-network/splice`](https://github.com/canton-network/splice) > **Subtree extracted:** `cluster/compose/localnet/` @@ -23,9 +22,9 @@ is a separate repo that *builds on top of* the same Splice LocalNet to provide an App-Provider quickstart with a backend service, frontend, Daml workflows, etc. — see its README for context. DevKit deliberately fetches the bare LocalNet base from `canton-network/splice` rather than -the App-Provider layer from cn-quickstart, because we want the minimal -infrastructure surface for our lifecycle (`up` / `down` / `status` / -`creds` / `logs`). App-Provider workflows are out of scope for DevKit; +the App-Provider layer from cn-quickstart, because the lifecycle +commands (`up` / `down` / `status` / `creds` / `logs`) only need the +minimal infrastructure surface. App-Provider workflows are out of scope for DevKit; users who want them can run cn-quickstart's `make start` on top of a DevKit-managed LocalNet. @@ -34,8 +33,8 @@ DevKit-managed LocalNet. GitHub may surface this repo as `hyperledger-labs/splice` in older documentation (e.g. cn-quickstart's README still uses that name). That URL redirects to `canton-network/splice` — GitHub's API resolves -both to the same canonical `full_name`, and tag SHAs match. We use the -canonical name in code and docs. +both to the same canonical `full_name`, and tag SHAs match. DevKit uses +the canonical name in code and docs. ## Anatomy of a catalogue entry @@ -45,19 +44,24 @@ canonical name in code and docs. "commit": "578b7822d62947763a48334d556aefebc7ffacec", "content_sha": "db1e1336dc4e33abe7011a0df29e5becd141d11c84cdf42849e48bf2106066af", "size": 137576613, - "major": "0.6" + "major": "0.6", + "min_memory_bytes": 8000000000, + "recommended_memory_bytes": 12000000000 } ``` | Field | Source of truth | Why it's pinned | |---|---|---| | `tag` | Upstream git tag (or branch label for pre-releases) | User-facing identifier; what `--version` accepts. | -| `commit` | `git ls-remote --tags` at catalogue time (or branch HEAD for pre-releases) | Immutable, content-addressable. We fetch via `archive/.tar.gz` so a force-pushed tag can't quietly change what `localnet up` installs. | +| `commit` | `git ls-remote --tags` at catalogue time (or branch HEAD for pre-releases) | Immutable, content-addressable. DevKit fetches via `archive/.tar.gz` so a force-pushed tag can't quietly change what `localnet up` installs. | | `content_sha` | `scripts/compute-tree-sha.sh` | SHA-256 over the extracted `cluster/compose/localnet/` subtree (sorted by path). Stable across upstream gzip-envelope rewrites; this is the authoritative integrity check at fetch time. | | `size` | byte count of the source-tarball | Informational; used to print a hint before download and to size the in-flight body cap. | -| `major` | first two segments of `tag` (or set manually for branch tags) | Routes to the per-major adapter in `internal/splice/v0X/`. | +| `major` | first two segments of `tag` (or set manually for branch tags) | Routes to the per-major Splice adapter for that release line. | +| `min_memory_bytes` | empirically derived per release line (see `versions.go`) | Minimum Docker daemon memory the version needs to start cleanly; the pre-flight memory gate refuses to `up` below it. `0` / absent inherits the strictest catalogued floor for the same major line; the global 4 GB floor applies only when the major has no catalogued entry at all. | +| `recommended_memory_bytes` | empirically derived per release line (see `versions.go`) | The value at which the version runs without resource warnings; surfaced as the "raise Docker memory to ≥ N" remediation hint when the minimum passes but this threshold is not met. | | `channel` *(optional)* | catalogue maintainer | `""` / `"stable"` → production-ready; `"alpha"` → opt-in pre-release (Token Standard V2 snapshot etc.). `up` prints a one-line warning when an alpha entry is selected. | | `image_repo` *(optional)* | catalogue maintainer | Overrides the default Docker image repository. Defaults to `ghcr.io/digital-asset/decentralized-canton-sync/docker`. Set to `ghcr.io/digital-asset/decentralized-canton-sync-dev/docker` for the V2 alpha track. The v06 adapter forwards this as the `IMAGE_REPO` compose env. | +| `image_tag` *(optional)* | catalogue maintainer | Overrides the Docker image tag. Empty falls back to `tag`, which is right for stable releases; the branch-backed `token-standard-v2` alpha entry sets it explicitly to a `0.6.5-snapshot` tag. The adapter forwards it as the `IMAGE_TAG` compose env. | ### The alpha channel @@ -79,8 +83,8 @@ Status flags per row: |---|---| | `supported` | Catalogued; upstream pin matches. Safe to use. | | `drifted` | Catalogued; upstream tag has been force-moved to a different commit. **Security signal** — re-review the catalogue entry before trusting. | -| `available` | Upstream has the tag; not yet in our catalogue. A maintainer can add it via the helper below. | -| `catalogued-only` | We catalogue it, but the online tag listing does not contain the same label. For stable entries this usually means the upstream tag was deleted and should be investigated before removal; branch-backed alpha entries such as `token-standard-v2` can also appear this way until branch/ref-aware status is added. | +| `available` | Upstream has the tag; not yet in the catalogue. A maintainer can add it via the helper below. | +| `catalogued-only` | In the catalogue, but the online tag listing does not contain the same label. For stable entries this usually means the upstream tag was deleted and should be investigated before removal; branch-backed alpha entries such as `token-standard-v2` can also appear this way until branch/ref-aware status is added. | ## Adding a new version (maintainer flow) @@ -96,8 +100,8 @@ The script: 5. Inserts a new entry into `versions.json` (sorted by tag). 6. Prints the diff. **Does not commit.** -A reviewer then: -- Verifies the diff. +A maintainer then: +- Reviews the diff. - Bumps `latest_alias` if the new tag should become the default `--version latest`. - Optionally runs the integration test against the new entry before @@ -106,7 +110,7 @@ A reviewer then: ## Two-layer resolution -DevKit now exposes the catalogue as the *default* tier of a two-layer +DevKit exposes the catalogue as the *default* tier of a two-layer version model — the curated path stays audited, and an explicit opt-in unlocks arbitrary upstream tags for prerelease testing. @@ -121,14 +125,13 @@ DevKit can't promise the bits were tested against this release. Orchestrators print a one-line "Using uncurated Splice tag" warning on the layer-2 path so the user is never surprised. -Because layer 2 exists, the previous weekly cron that auto-bumped -the catalogue was removed in this change — it added latency without -solving the prerelease use case, and the catalogue is now strictly -the curated-by-humans surface. +Because layer 2 covers the prerelease use case, the catalogue is +strictly a curated-by-humans surface — entries are only added by a +maintainer, never by automation. ## Why not just point at the latest tag? -Three reasons we curate: +Three reasons the catalogue is curated: 1. **Reproducibility.** A user running `localnet up --version 0.6.4` today must get exactly the bits that were tested when the entry @@ -137,19 +140,19 @@ Three reasons we curate: 2. **Surface area control.** Splice ships pre-release tags (`next-cilr`, etc.) and partial-release tags that aren't intended - for downstream consumption. We don't want to support every commit - that happens to land in the repo. + for downstream consumption. DevKit doesn't aim to support every + commit that happens to land in the repo. -3. **Adapter routing.** DevKit ships per-major adapters - (`internal/splice/v05/`, `v06/`). A new major version (e.g. `0.7.x`) +3. **Adapter routing.** DevKit ships per-major adapters for each Splice + major version. A new major version (e.g. `0.7.x`) needs a corresponding adapter before it can be added — the script leaves `major` blank for non-N.N.N tags so a maintainer notices. -## What changed in this refactor +## Why the content SHA, not the tarball hash -Pre-2026-05, the catalogue lived in `versions.go` as a Go map literal -and pinned both the gzip-tarball SHA and the ContentSHA. The gzip -hash was brittle: GitHub regenerates source-tarballs lazily and the -gzip metadata can drift. The current model drops the gzip hash; the -commit SHA in the URL + ContentSHA over the extracted tree is the full -integrity check, and it's stable across gzip envelope rewrites. +Pinning the gzip-tarball SHA would be brittle: GitHub regenerates +source-tarballs lazily and the gzip metadata can drift, so the same +source tree can yield different tarball hashes over time. The catalogue +therefore pins the commit SHA in the URL plus a ContentSHA over the +extracted tree — a complete integrity check that is stable across gzip +envelope rewrites. diff --git a/e2e-tests/bats b/e2e-tests/bats new file mode 160000 index 00000000..3bca150e --- /dev/null +++ b/e2e-tests/bats @@ -0,0 +1 @@ +Subproject commit 3bca150ec86275d6d9d5a4fd7d48ab8b6c6f3d87 diff --git a/e2e-tests/daml-test-contracts/daml/Token.daml b/e2e-tests/daml-test-contracts/daml/Token.daml index b0ec7380..38db82a6 100755 --- a/e2e-tests/daml-test-contracts/daml/Token.daml +++ b/e2e-tests/daml-test-contracts/daml/Token.daml @@ -1,30 +1,12 @@ -- Copyright (c) 2025 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -- SPDX-License-Identifier: Apache-2.0 --- A Daml file defines a module. module Token where --- Contract Templates define a type of contract that can exist on the ledger, --- together with its data, and involved parties. - -- | `Token` is a contract that has no state other than its existence and -- who holds the `Token` stored in field `owner`. template Token - -- Each `template` has a `with` block defining the data type of the data - -- stored on an instance of that contract. - -- Blocks are indicated through indentation. The `template` is a block so - -- `with` is indented. The contents of the `with` block are indented further. with - -- `owner` is the only field on an instance of `Token`. It has type - -- `Party`, which is an inbuilt type representing an entity present on - -- the ledger owner : Party - -- Following the `with` block is a `where` block, which gives the contract - -- meaning by defining the roles parties play and how contracts can - -- be transformed. where - -- Every `template` has a `signatory` expression in its `where` block. - -- The `signatory` expression defines one or more _parties_ to be - -- _signatories_. The signatories must authorize the creation of a - -- contract and verify the validity of any action performed on it. signatory owner diff --git a/e2e-tests/dpm-dar-001.bats b/e2e-tests/dpm-dar-001.bats new file mode 100644 index 00000000..5d72fdff --- /dev/null +++ b/e2e-tests/dpm-dar-001.bats @@ -0,0 +1,66 @@ +#!/usr/bin/env bats +# DPM-DAR-001: build step of `dpm localnet dar build-upload` (--build-only). +# Does not exercise the upload RPC and does not require a running +# LocalNet or a pre-existing Daml project (one is scaffolded below). +# +# Regression test for issue #230: under `dpm localnet …`, DPM injects +# DPM_RESOLUTION_FILE pointing at a temp resolution file it already +# wrote. build-upload shells out to a nested `dpm build`, which used to +# inherit that var and abort with: +# +# open /var/folders/.../T/.yaml: file exists +# dar build-upload: build failed: exit status 1 +# +# --build-only exercises the exact build step that regressed without +# needing a running LocalNet (the failure happened before any upload). + +setup_file() { + bats_load_library bats-support + bats_load_library bats-assert + load 'test_helper/dpm' + + dpm_available || skip "dpm not found on PATH (DPM=${DPM:-dpm})" + + # Build the binary (unless a prebuilt one is provided via CDK_BIN or + # DPM_SKIP_BUILD, e.g. CI builds once outside bats) and assemble the + # local component once for the file. + if [ -z "${DPM_SKIP_BUILD:-}" ] && [ -z "${CDK_BIN:-}" ]; then + make -C "$DPM_REPO_ROOT" build >&2 + fi + COMPONENT_DIR="$(dpm_build_component)" + export COMPONENT_DIR +} + +setup() { + bats_load_library bats-support + bats_load_library bats-assert + load 'test_helper/dpm' + + dpm_available || skip "dpm not found on PATH (DPM=${DPM:-dpm})" + + PROJECT_DIR="$(dpm_make_project "$COMPONENT_DIR")" +} + +teardown() { + [ -n "${PROJECT_DIR:-}" ] && rm -rf "$PROJECT_DIR" +} + +@test "DPM-DAR-001: build-upload build step, build-only (no upload/LocalNet) (issue #230)" { + cd "$PROJECT_DIR" + + run "$DPM" install package + assert_success + + # The failing command from issue #230. --build-only skips the upload + # RPC so no LocalNet is required. + run "$DPM" localnet dar build-upload --build-only + assert_success + + # The specific regression signature must be absent: DPM_RESOLUTION_FILE + # leaking into the nested build printed "open : file exists". + refute_output --partial "file exists" + + # A DAR must have been produced. + run ls "${PROJECT_DIR}/.daml/dist/" + assert_output --partial ".dar" +} diff --git a/e2e-tests/test_helper/bats-assert b/e2e-tests/test_helper/bats-assert new file mode 160000 index 00000000..f1e9280e --- /dev/null +++ b/e2e-tests/test_helper/bats-assert @@ -0,0 +1 @@ +Subproject commit f1e9280eaae8f86cbe278a687e6ba755bc802c1a diff --git a/e2e-tests/test_helper/bats-support b/e2e-tests/test_helper/bats-support new file mode 160000 index 00000000..24a72e14 --- /dev/null +++ b/e2e-tests/test_helper/bats-support @@ -0,0 +1 @@ +Subproject commit 24a72e14349690bcbf7c151b9d2d1cdd32d36eb1 diff --git a/e2e-tests/test_helper/dpm.bash b/e2e-tests/test_helper/dpm.bash new file mode 100644 index 00000000..1d3e312e --- /dev/null +++ b/e2e-tests/test_helper/dpm.bash @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Domain helpers for the `dpm localnet` bats e2e suite. +# +# These are the DevKit-specific bits the tests need; the generic +# pass/fail/skip/summary machinery is provided by bats-core itself +# (plus bats-support/bats-assert), so only the component + project +# scaffolding lives here. +# shellcheck shell=bash + +# Repo root, derived from this file's location (e2e-tests/test_helper/). +DPM_REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# DPM CLI under test. The component build-upload path shells out to a +# nested `dpm build`; that nested invocation is exactly what issue #230 +# regressed on. +DPM="${DPM:-dpm}" + +# Name the file-based component is registered under in daml.yaml. Must +# match the command the component publishes (`localnet`); DPM keys the +# component by name, the value is irrelevant to `dpm localnet` dispatch. +COMPONENT_NAME="${COMPONENT_NAME:-canton-devkit}" + +# Extra components the test project needs to actually compile Daml. +DAMLC_COMPONENT="${DAMLC_COMPONENT:-damlc:3.5.2}" +DAML_SCRIPT_COMPONENT="${DAML_SCRIPT_COMPONENT:-daml-script:3.5.2}" + +# Root for all scratch this suite creates. Under the repo (.tmp/) per +# AGENTS.md — never /tmp. +DPM_SCRATCH_DIR="${DPM_SCRATCH_DIR:-${DPM_REPO_ROOT}/.tmp/e2e-dpm}" + +# dpm_available succeeds when the DPM CLI is on PATH; tests use it to +# `skip` gracefully rather than hard-fail when dpm is absent. +dpm_available() { + command -v "$DPM" >/dev/null 2>&1 +} + +# dpm_build_component assembles a local DevKit component directory the +# tests install via a file-based component reference ({name, path}). +# Resolving from a local path keeps the suite hermetic (no OCI registry, +# no TLS) and exercises the binary built in THIS run. Echoes the absolute +# component dir on stdout. +# $1 = output dir (default: .tmp/e2e-dpm-component under the repo root) +dpm_build_component() { + local out_dir="${1:-${DPM_REPO_ROOT}/.tmp/e2e-dpm-component}" + local bin="${CDK_BIN:-${DPM_REPO_ROOT}/bin/canton-devkit}" + + if [ ! -x "$bin" ]; then + echo "binary not found at $bin -- run 'make build' first" >&2 + return 1 + fi + + rm -rf "$out_dir" + mkdir -p "$out_dir/bin" + cp "$bin" "$out_dir/bin/canton-devkit" + sed "s|@@BINARY_PATH@@|bin/canton-devkit|" \ + "${DPM_REPO_ROOT}/packaging/component.yaml.tmpl" > "$out_dir/component.yaml" + cp "${DPM_REPO_ROOT}/LICENSE" "$out_dir/LICENSE" + + ( cd "$out_dir" && pwd ) +} + +# dpm_make_project scaffolds a minimal, compilable Daml project that +# references the local DevKit component under test. Echoes the created +# project directory on stdout. +# $1 = absolute path to the DevKit component directory +dpm_make_project() { + local component_dir="$1" + local dir + mkdir -p "$DPM_SCRATCH_DIR" + dir="$(mktemp -d "${DPM_SCRATCH_DIR}/dar-XXXXXX")" + + mkdir -p "${dir}/daml" + cat > "${dir}/daml/Main.daml" <<'DAML' +module Main where + +import Daml.Script + +setup : Script () +setup = pure () +DAML + + cat > "${dir}/daml.yaml" < - + - + canton-devkit diff --git a/frontend/mock/fixtures/containers.json b/frontend/mock/fixtures/containers.json new file mode 100644 index 00000000..e23a6a03 --- /dev/null +++ b/frontend/mock/fixtures/containers.json @@ -0,0 +1,105 @@ +{ + "schema_version": 1, + "instance": "demo", + "containers": [ + { + "name": "localnet-1-ans-web-ui-app-provider", + "service": "ans-web-ui-app-provider", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/ans-web-ui:0.6.12" + }, + { + "name": "localnet-1-ans-web-ui-app-user", + "service": "ans-web-ui-app-user", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/ans-web-ui:0.6.12" + }, + { + "name": "localnet-1-canton", + "service": "canton", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/canton:0.6.12" + }, + { + "name": "localnet-1-nginx", + "service": "nginx", + "state": "running", + "status": "Up 23 hours", + "image": "nginx:1.27.0" + }, + { + "name": "localnet-1-postgres", + "service": "postgres", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "postgres:14" + }, + { + "name": "localnet-1-scan-web-ui", + "service": "scan-web-ui", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/scan-web-ui:0.6.12" + }, + { + "name": "localnet-1-splice", + "service": "splice", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/splice-app:0.6.12" + }, + { + "name": "localnet-1-sv-web-ui", + "service": "sv-web-ui", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/sv-web-ui:0.6.12" + }, + { + "name": "localnet-1-swagger-ui", + "service": "swagger-ui", + "state": "running", + "status": "Up 23 hours", + "image": "swaggerapi/swagger-ui" + }, + { + "name": "localnet-1-wallet-web-ui-app-provider", + "service": "wallet-web-ui-app-provider", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12" + }, + { + "name": "localnet-1-wallet-web-ui-app-user", + "service": "wallet-web-ui-app-user", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12" + }, + { + "name": "localnet-1-wallet-web-ui-sv", + "service": "wallet-web-ui-sv", + "state": "running", + "health": "healthy", + "status": "Up 23 hours (healthy)", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12" + } + ], + "healthy_count": 10, + "starting_count": 0, + "unhealthy_count": 0, + "restarting_count": 0, + "exited_count": 0 +} diff --git a/frontend/mock/fixtures/contract-detail.json b/frontend/mock/fixtures/contract-detail.json new file mode 100644 index 00000000..4cb23ea5 --- /dev/null +++ b/frontend/mock/fixtures/contract-detail.json @@ -0,0 +1,23 @@ +{ + "schema_version": 1, + "instance": "demo", + "role": "app-user", + "contract": { + "contract_id": "0076677cff95e66905e2e5c6c7ea53171836134ed216c422561d6ee5935e0f521cca1212208a4e466f276703d25651f7b6a57cf56270bd9bd3af142f08cca84c03624cea05", + "template_id": "727c0fb9711d59b506fbb3cff2342e55eb583bb88682a946220525c304feca4a:Splice.Wallet.Install:WalletAppInstall", + "package_name": "splice-wallet", + "payload": { + "dsoParty": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "endUserName": "app-user", + "endUserParty": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "validatorParty": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "observers": [], + "created_at": "2026-07-27T08:48:21Z", + "created_offset": 18, + "archived": false + } +} diff --git a/frontend/mock/fixtures/contracts.json b/frontend/mock/fixtures/contracts.json new file mode 100644 index 00000000..6c5577c9 --- /dev/null +++ b/frontend/mock/fixtures/contracts.json @@ -0,0 +1,171 @@ +{ + "schema_version": 1, + "instance": "demo", + "role": "app-user", + "ledger_end": 4939, + "contracts": [ + { + "contract_id": "0076677cff95e66905e2e5c6c7ea53171836134ed216c422561d6ee5935e0f521cca1212208a4e466f276703d25651f7b6a57cf56270bd9bd3af142f08cca84c03624cea05", + "template_id": "727c0fb9711d59b506fbb3cff2342e55eb583bb88682a946220525c304feca4a:Splice.Wallet.Install:WalletAppInstall", + "payload": { + "dsoParty": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "endUserName": "app-user", + "endUserParty": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "validatorParty": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "observers": [], + "created_at": "2026-07-27T08:48:21Z", + "package_name": "splice-wallet" + }, + { + "contract_id": "004fb8cc52ba0d1a171a058d1cada0cb37420b38f630668e151ebb168f301ef845ca1212204e8c164027c34d2539bf4ba9a6e0226ba154ed28b0c838df752695e52eb6ce0a", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:ValidatorRight", + "payload": { + "dso": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "user": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "validator": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "observers": [], + "created_at": "2026-07-27T08:48:23Z", + "package_name": "splice-amulet" + }, + { + "contract_id": "009a3d68f568c454fc232a21b0be56e845b9aa81730a5bdb11d272628b17b512f8ca121220f8c903a53c2b7d61b7bdcc17635cfb7cc38c876b850daa5c436da016bc25aa63", + "template_id": "727c0fb9711d59b506fbb3cff2342e55eb583bb88682a946220525c304feca4a:Splice.Wallet.TopUpState:ValidatorTopUpState", + "payload": { + "dso": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "lastPurchasedAt": 1785142133668339, + "memberId": "PAR::participant::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "migrationId": 0, + "synchronizerId": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "validator": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "observers": [], + "created_at": "2026-07-27T08:48:53Z", + "package_name": "splice-wallet" + }, + { + "contract_id": "00eca51173e9f95e4c641a58fc07e91cd73b184ddbff82c863fdf9cfdd936a659bca12122098e73d269f3681043c117e907b1feb9081afeef2ef0393d65dbb3539d5716bd0", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "payload": { + "dso": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "round": { + "number": 131 + }, + "validator": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55" + ], + "observers": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "created_at": "2026-07-28T07:23:42Z", + "package_name": "splice-amulet" + }, + { + "contract_id": "006fad6c8dfd103935a42345689d5736af11ce14dec5a502e377c93672c241a139ca121220b1307db3fb20425bc344303ed26b3359353e25647a8c28d6e333ee1ee0c81b27", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "payload": { + "dso": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "round": { + "number": 132 + }, + "validator": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55" + ], + "observers": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "created_at": "2026-07-28T07:38:51Z", + "package_name": "splice-amulet" + }, + { + "contract_id": "00934a2aacbfb1e32ab59ff94e3bd3b6831f38be8577d43c9f8fd3cbf1e2506125ca121220f57e2cbfb9ed739fc96ce62e4f0160464837fc33570ba3adcb530ab7ccde6a8b", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "payload": { + "amount": { + "createdAt": { + "number": 133 + }, + "initialAmount": "75470.1600000000", + "ratePerRound": { + "rate": "0.0038051800" + } + }, + "dso": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "owner": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "observers": [], + "created_at": "2026-07-28T07:39:40Z", + "package_name": "splice-amulet" + }, + { + "contract_id": "0009aeb751ccba5edc302dfbf29c00cbb1a2086dd1b792980c2054d169e9b10e29ca12122064d1d99d4e78169204644f90f4abc60a76e1d2e38f2e2ab99684cec6a69aa001", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "payload": { + "dso": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "faucetState": { + "firstReceivedFor": { + "number": 0 + }, + "lastReceivedFor": { + "number": 133 + }, + "numCouponsMissed": 0 + }, + "lastActiveAt": 1785224575217289, + "metadata": { + "contactPoint": "", + "lastUpdatedAt": 1785142103729685, + "version": "0.6.12" + }, + "sponsor": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "validator": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55" + ], + "observers": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "created_at": "2026-07-28T07:42:55Z", + "package_name": "splice-amulet" + }, + { + "contract_id": "00bc364ebb0ce42e26cb0acf6bdca59abd0f10d5972676609a0ed25925f1b187e5ca1212205f7e2464943993f76b9a1e27417665a8edba40dd56b4939dd37ede426513fa10", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "payload": { + "dso": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "round": { + "number": 133 + }, + "validator": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + }, + "signatories": [ + "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55" + ], + "observers": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "created_at": "2026-07-28T07:42:55Z", + "package_name": "splice-amulet" + } + ], + "limit": 50 +} diff --git a/frontend/mock/fixtures/dar-inspect.json b/frontend/mock/fixtures/dar-inspect.json new file mode 100644 index 00000000..bd3caef2 --- /dev/null +++ b/frontend/mock/fixtures/dar-inspect.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "dar_id": "token-dar", + "main_package_id": "pkg-token-001", + "packages": [ + { + "package_id": "pkg-token-001", + "name": "token", + "version": "1.0.0", + "modules": [ + { + "name": "Token", + "templates": ["Instrument", "Holding", "TransferOffer"] + } + ] + } + ] +} diff --git a/frontend/mock/fixtures/dar-vetting.json b/frontend/mock/fixtures/dar-vetting.json new file mode 100644 index 00000000..3bad65e9 --- /dev/null +++ b/frontend/mock/fixtures/dar-vetting.json @@ -0,0 +1,16 @@ +{ + "schema_version": 1, + "dar_id": "token-dar", + "roles": [ + { + "role": "app-user", + "vetted": true, + "package_count": 1 + }, + { + "role": "app-provider", + "vetted": false, + "package_count": 0 + } + ] +} diff --git a/frontend/mock/fixtures/dar.json b/frontend/mock/fixtures/dar.json new file mode 100644 index 00000000..7a638da9 --- /dev/null +++ b/frontend/mock/fixtures/dar.json @@ -0,0 +1,133 @@ +{ + "schema_version": 1, + "instance": "demo", + "role": "app-user", + "dars": [ + { + "main": "de2cc2f90eb523414ff54e899951dadd8789a4c07e0f71f6d6c9eaf57d412a54", + "name": "canton-builtin-admin-workflow-ping", + "version": "3.4.0", + "description": "System package" + }, + { + "main": "29317e3b7b165d2bbf16721bcca0ec4869e53eddb2738bddf790d61af28e0099", + "name": "splice-api-token-transfer-instruction-v2", + "version": "1.0.0", + "description": "splice-api-token-transfer-instruction-v2-1.0.0" + }, + { + "main": "1215d3ee8f3cb428d062d8708c2deca33c91ff99f5edefa70616646e57f93012", + "name": "splice-util-token-standard-wallet", + "version": "1.1.0", + "description": "splice-util-token-standard-wallet-1.1.0" + }, + { + "main": "74ee4c91b80c80b6860005ce627bdaed8f7fe6bdf3e60c9dbf3f48402d55126a", + "name": "splice-util-batched-markers", + "version": "1.0.3", + "description": "splice-util-batched-markers-1.0.3" + }, + { + "main": "9a8f41a2b1456d357dee5677565c21b9a5aa45b80f0ed6be469694445dd4f6e1", + "name": "splice-token-standard-utils", + "version": "2.0.0", + "description": "splice-token-standard-utils-2.0.0" + }, + { + "main": "866ea53bea2afc099c560ad4c386a1736859841b17c422836f133d605ee51616", + "name": "splice-wallet-payments", + "version": "0.1.22", + "description": "splice-wallet-payments-0.1.22" + }, + { + "main": "9cffe65feb664c9550937433067e9f969e3795c6fb38715e06a5e04fc1ae1f83", + "name": "splice-amulet-name-service", + "version": "0.1.23", + "description": "splice-amulet-name-service-0.1.23" + }, + { + "main": "051a3b0563a6fa4df4cb34448081e48b061e555aa1a265abf6ae8f3f4cafe439", + "name": "splice-api-token-allocation-v2", + "version": "1.0.0", + "description": "splice-api-token-allocation-v2-1.0.0" + }, + { + "main": "9818a0b5b827109de03a04c8f6151cde9d1e7fe5123dbb2dfeb0e52d7271287c", + "name": "splice-api-token-allocation-instruction-v2", + "version": "1.0.0", + "description": "splice-api-token-allocation-instruction-v2-1.0.0" + }, + { + "main": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a", + "name": "splice-amulet", + "version": "0.1.22", + "description": "splice-amulet-0.1.22" + }, + { + "main": "727c0fb9711d59b506fbb3cff2342e55eb583bb88682a946220525c304feca4a", + "name": "splice-wallet", + "version": "0.1.23", + "description": "splice-wallet-0.1.23" + }, + { + "main": "217edf88c015ee080de2132a208cf23c14e9a2773198c0d4078985ab25ad4be1", + "name": "splice-validator-lifecycle", + "version": "0.1.8", + "description": "splice-validator-lifecycle-0.1.8" + }, + { + "main": "4ded6b668cb3b64f7a88a30874cd41c75829f5e064b3fbbadf41ec7e8363354f", + "name": "splice-api-token-metadata-v1", + "version": "1.0.0", + "description": "splice-api-token-metadata-v1-1.0.0" + }, + { + "main": "6fe848530b2404017c4a12874c956ad7d5c8a419ee9b040f96b5c13172d2e193", + "name": "splice-api-token-allocation-request-v1", + "version": "1.0.0", + "description": "splice-api-token-allocation-request-v1-1.0.0" + }, + { + "main": "93c942ae2b4c2ba674fb152fe38473c507bda4e82b4e4c5da55a552a9d8cce1d", + "name": "splice-api-token-allocation-v1", + "version": "1.0.0", + "description": "splice-api-token-allocation-v1-1.0.0" + }, + { + "main": "adc16315a8943a8433886694720a2a000ae84c2315c4414bd6d0db4d1660de9c", + "name": "splice-api-token-allocation-request-v2", + "version": "1.0.0", + "description": "splice-api-token-allocation-request-v2-1.0.0" + }, + { + "main": "55ba4deb0ad4662c4168b39859738a0e91388d252286480c7331b3f71a517281", + "name": "splice-api-token-transfer-instruction-v1", + "version": "1.0.0", + "description": "splice-api-token-transfer-instruction-v1-1.0.0" + }, + { + "main": "4b7ecfc366d79ccc5ed07c80f26fe489cf2dfd43ce2856c06a78e6a048db7032", + "name": "splice-api-token-holding-v2", + "version": "1.0.0", + "description": "splice-api-token-holding-v2-1.0.0" + }, + { + "main": "5c1097a9bad0af4bcfe6d3fb0fe55112d3d11f18eae57ddfb14c20836fee226c", + "name": "splice-api-token-transfer-events-v2", + "version": "1.0.0", + "description": "splice-api-token-transfer-events-v2-1.0.0" + }, + { + "main": "718a0f77e505a8de22f188bd4c87fe74101274e9d4cb1bfac7d09aec7158d35b", + "name": "splice-api-token-holding-v1", + "version": "1.0.0", + "description": "splice-api-token-holding-v1-1.0.0" + }, + { + "main": "275064aacfe99cea72ee0c80563936129563776f67415ef9f13e4297eecbc520", + "name": "splice-api-token-allocation-instruction-v1", + "version": "1.0.0", + "description": "splice-api-token-allocation-instruction-v1-1.0.0" + } + ] +} diff --git a/frontend/mock/fixtures/doctor.json b/frontend/mock/fixtures/doctor.json new file mode 100644 index 00000000..45fe788a --- /dev/null +++ b/frontend/mock/fixtures/doctor.json @@ -0,0 +1,69 @@ +{ + "schema_version": 1, + "ok": true, + "sections": [ + { + "title": "System", + "checks": [ + { + "label": "Docker CLI", + "result": "pass" + }, + { + "label": "Docker daemon", + "result": "pass", + "detail": "v29.6.1" + }, + { + "label": "Docker Compose v2", + "result": "pass", + "detail": "v5.3.0" + }, + { + "label": "Host prerequisites (darwin)", + "result": "pass", + "detail": "Docker Desktop must be running" + }, + { + "label": "Platform support", + "result": "pass", + "detail": "darwin/arm64 is a supported platform" + } + ] + }, + { + "title": "Resources", + "checks": [ + { + "label": "Docker memory", + "result": "warn", + "detail": "8.84 GB available, 12.00 GB recommended for this version", + "remediation": [ + "Increase Docker Desktop's memory in Preferences → Resources → Memory." + ] + }, + { + "label": "Disk space", + "result": "pass", + "detail": "178.29 GB free at /Users/zzz/.canton-devkit/localnet" + } + ] + }, + { + "title": "Network", + "checks": [ + { + "label": "Ephemeral loopback ports", + "result": "pass", + "detail": "host can allocate ephemeral loopback ports (probed 4)" + }, + { + "label": "UI ports reachability (running instances)", + "result": "pass", + "detail": "3 UI endpoints answering HTTP across 1 running instance" + } + ] + } + ], + "summary": "0 issues · 1 warning — host is ready (advisories above)" +} diff --git a/frontend/mock/fixtures/instance-demo.json b/frontend/mock/fixtures/instance-demo.json new file mode 100644 index 00000000..c2cd5b3b --- /dev/null +++ b/frontend/mock/fixtures/instance-demo.json @@ -0,0 +1,224 @@ +{ + "schema_version": 1, + "name": "demo", + "splice_version": "0.6.12", + "status": "running", + "created_at": "2026-07-27T08:46:35Z", + "uptime": "22h 59m", + "compose_project": "canton-demo", + "docker_network": "localnet-1", + "container_prefix": "demo-", + "project_dir": "/Users/zzz/.canton-devkit/cache/splice-0.6.12-17fd29aa", + "data_dir": "/Users/zzz/.canton-devkit/localnet/localnet-1", + "services": [ + { + "name": "ans-web-ui-app-provider", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/ans-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "ans-web-ui-app-user", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/ans-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "canton", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/canton:0.6.12", + "state": "healthy", + "ports": "[{127.0.0.1 2901 61475 tcp} {127.0.0.1 2902 61476 tcp} {127.0.0.1 2975 61477 tcp} {127.0.0.1 3901 61478 tcp} {127.0.0.1 3902 61479 tcp} {127.0.0.1 3975 61480 tcp} {127.0.0.1 4901 61481 tcp} {127.0.0.1 4902 61482 tcp} {127.0.0.1 4975 61483 tcp} {127.0.0.1 10013 61484 tcp}]" + }, + { + "name": "nginx", + "image": "nginx:1.27.0", + "state": "healthy", + "ports": "[{127.0.0.1 61245 61245 tcp} {127.0.0.1 61246 61246 tcp} {127.0.0.1 61247 61247 tcp}]" + }, + { + "name": "postgres", + "image": "postgres:14", + "state": "healthy", + "ports": "[{127.0.0.1 5432 61249 tcp}]" + }, + { + "name": "scan-web-ui", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/scan-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "splice", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/splice-app:0.6.12", + "state": "healthy", + "ports": "[{127.0.0.1 2903 61485 tcp} {127.0.0.1 3903 61486 tcp} {127.0.0.1 4903 61487 tcp} {127.0.0.1 10013 61488 tcp}]" + }, + { + "name": "sv-web-ui", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/sv-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "swagger-ui", + "image": "swaggerapi/swagger-ui", + "state": "healthy", + "ports": "[{127.0.0.1 8080 61248 tcp}]" + }, + { + "name": "wallet-web-ui-app-provider", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "wallet-web-ui-app-user", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "wallet-web-ui-sv", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + } + ], + "endpoints": [ + { + "key": "app_provider_ui", + "label": "Wallet · app-provider", + "url": "http://wallet.app-provider.localnet-1.localhost:61246", + "port": 61246, + "scheme": "http", + "reachability": "ok" + }, + { + "key": "app_user_ui", + "label": "Wallet · app-user", + "url": "http://wallet.app-user.localnet-1.localhost:61245", + "port": 61245, + "scheme": "http", + "reachability": "ok" + }, + { + "key": "canton_metrics", + "label": "canton_metrics", + "url": "tcp://localhost:61484", + "port": 61484, + "scheme": "tcp" + }, + { + "key": "participant_admin_app-provider", + "label": "participant_admin_app-provider", + "url": "tcp://localhost:61479", + "port": 61479, + "scheme": "tcp" + }, + { + "key": "participant_admin_app-user", + "label": "participant_admin_app-user", + "url": "tcp://localhost:61476", + "port": 61476, + "scheme": "tcp" + }, + { + "key": "participant_admin_sv", + "label": "participant_admin_sv", + "url": "tcp://localhost:61482", + "port": 61482, + "scheme": "tcp" + }, + { + "key": "participant_json_app-provider", + "label": "participant_json_app-provider", + "url": "tcp://localhost:61480", + "port": 61480, + "scheme": "tcp" + }, + { + "key": "participant_json_app-user", + "label": "participant_json_app-user", + "url": "tcp://localhost:61477", + "port": 61477, + "scheme": "tcp" + }, + { + "key": "participant_json_sv", + "label": "participant_json_sv", + "url": "tcp://localhost:61483", + "port": 61483, + "scheme": "tcp" + }, + { + "key": "participant_ledger_app-provider", + "label": "participant_ledger_app-provider", + "url": "tcp://localhost:61478", + "port": 61478, + "scheme": "tcp" + }, + { + "key": "participant_ledger_app-user", + "label": "participant_ledger_app-user", + "url": "tcp://localhost:61475", + "port": 61475, + "scheme": "tcp" + }, + { + "key": "participant_ledger_sv", + "label": "participant_ledger_sv", + "url": "tcp://localhost:61481", + "port": 61481, + "scheme": "tcp" + }, + { + "key": "postgres", + "label": "Postgres", + "url": "postgresql://localhost:61249", + "port": 61249, + "scheme": "postgresql" + }, + { + "key": "splice_metrics", + "label": "splice_metrics", + "url": "tcp://localhost:61488", + "port": 61488, + "scheme": "tcp" + }, + { + "key": "sv_ui", + "label": "Wallet · sv", + "url": "http://wallet.sv.localnet-1.localhost:61247", + "port": 61247, + "scheme": "http", + "reachability": "ok" + }, + { + "key": "swagger_ui", + "label": "Swagger · JSON API", + "url": "http://localhost:61248", + "port": 61248, + "scheme": "http" + } + ], + "credentials": { + "app-provider": { + "role": "app-provider", + "user": "ledger-api-user", + "audience": "https://canton.network.global", + "jwt": "mock.app-provider.jwt" + }, + "app-user": { + "role": "app-user", + "user": "ledger-api-user", + "audience": "https://canton.network.global", + "jwt": "mock.app-user.jwt" + }, + "sv": { + "role": "sv", + "user": "ledger-api-user", + "audience": "https://canton.network.global", + "jwt": "mock.sv.jwt" + } + } +} diff --git a/frontend/mock/fixtures/instance-localnet-1.json b/frontend/mock/fixtures/instance-localnet-1.json new file mode 100644 index 00000000..2bba3599 --- /dev/null +++ b/frontend/mock/fixtures/instance-localnet-1.json @@ -0,0 +1,224 @@ +{ + "schema_version": 1, + "name": "localnet-1", + "splice_version": "0.6.12", + "status": "running", + "created_at": "2026-07-27T08:46:35Z", + "uptime": "11h 56m", + "compose_project": "canton-localnet-1", + "docker_network": "localnet-1", + "container_prefix": "localnet-1-", + "project_dir": "/Users/zzz/.canton-devkit/cache/splice-0.6.12-17fd29aa", + "data_dir": "/Users/zzz/.canton-devkit/localnet/localnet-1", + "services": [ + { + "name": "ans-web-ui-app-provider", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/ans-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "ans-web-ui-app-user", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/ans-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "canton", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/canton:0.6.12", + "state": "healthy", + "ports": "[{127.0.0.1 2901 61475 tcp} {127.0.0.1 2902 61476 tcp} {127.0.0.1 2975 61477 tcp} {127.0.0.1 3901 61478 tcp} {127.0.0.1 3902 61479 tcp} {127.0.0.1 3975 61480 tcp} {127.0.0.1 4901 61481 tcp} {127.0.0.1 4902 61482 tcp} {127.0.0.1 4975 61483 tcp} {127.0.0.1 10013 61484 tcp}]" + }, + { + "name": "nginx", + "image": "nginx:1.27.0", + "state": "healthy", + "ports": "[{127.0.0.1 61245 61245 tcp} {127.0.0.1 61246 61246 tcp} {127.0.0.1 61247 61247 tcp}]" + }, + { + "name": "postgres", + "image": "postgres:14", + "state": "healthy", + "ports": "[{127.0.0.1 5432 61249 tcp}]" + }, + { + "name": "scan-web-ui", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/scan-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "splice", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/splice-app:0.6.12", + "state": "healthy", + "ports": "[{127.0.0.1 2903 61485 tcp} {127.0.0.1 3903 61486 tcp} {127.0.0.1 4903 61487 tcp} {127.0.0.1 10013 61488 tcp}]" + }, + { + "name": "sv-web-ui", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/sv-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "swagger-ui", + "image": "swaggerapi/swagger-ui", + "state": "healthy", + "ports": "[{127.0.0.1 8080 61248 tcp}]" + }, + { + "name": "wallet-web-ui-app-provider", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "wallet-web-ui-app-user", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + }, + { + "name": "wallet-web-ui-sv", + "image": "ghcr.io/digital-asset/decentralized-canton-sync/docker/wallet-web-ui:0.6.12", + "state": "healthy", + "ports": "[{ 8080 0 tcp}]" + } + ], + "endpoints": [ + { + "key": "app_provider_ui", + "label": "Wallet · app-provider", + "url": "http://wallet.app-provider.localnet-1.localhost:61246", + "port": 61246, + "scheme": "http", + "reachability": "ok" + }, + { + "key": "app_user_ui", + "label": "Wallet · app-user", + "url": "http://wallet.app-user.localnet-1.localhost:61245", + "port": 61245, + "scheme": "http", + "reachability": "ok" + }, + { + "key": "canton_metrics", + "label": "canton_metrics", + "url": "tcp://localhost:61484", + "port": 61484, + "scheme": "tcp" + }, + { + "key": "participant_admin_app-provider", + "label": "participant_admin_app-provider", + "url": "tcp://localhost:61479", + "port": 61479, + "scheme": "tcp" + }, + { + "key": "participant_admin_app-user", + "label": "participant_admin_app-user", + "url": "tcp://localhost:61476", + "port": 61476, + "scheme": "tcp" + }, + { + "key": "participant_admin_sv", + "label": "participant_admin_sv", + "url": "tcp://localhost:61482", + "port": 61482, + "scheme": "tcp" + }, + { + "key": "participant_json_app-provider", + "label": "participant_json_app-provider", + "url": "tcp://localhost:61480", + "port": 61480, + "scheme": "tcp" + }, + { + "key": "participant_json_app-user", + "label": "participant_json_app-user", + "url": "tcp://localhost:61477", + "port": 61477, + "scheme": "tcp" + }, + { + "key": "participant_json_sv", + "label": "participant_json_sv", + "url": "tcp://localhost:61483", + "port": 61483, + "scheme": "tcp" + }, + { + "key": "participant_ledger_app-provider", + "label": "participant_ledger_app-provider", + "url": "tcp://localhost:61478", + "port": 61478, + "scheme": "tcp" + }, + { + "key": "participant_ledger_app-user", + "label": "participant_ledger_app-user", + "url": "tcp://localhost:61475", + "port": 61475, + "scheme": "tcp" + }, + { + "key": "participant_ledger_sv", + "label": "participant_ledger_sv", + "url": "tcp://localhost:61481", + "port": 61481, + "scheme": "tcp" + }, + { + "key": "postgres", + "label": "Postgres", + "url": "postgresql://localhost:61249", + "port": 61249, + "scheme": "postgresql" + }, + { + "key": "splice_metrics", + "label": "splice_metrics", + "url": "tcp://localhost:61488", + "port": 61488, + "scheme": "tcp" + }, + { + "key": "sv_ui", + "label": "Wallet · sv", + "url": "http://wallet.sv.localnet-1.localhost:61247", + "port": 61247, + "scheme": "http", + "reachability": "ok" + }, + { + "key": "swagger_ui", + "label": "Swagger · JSON API", + "url": "http://localhost:61248", + "port": 61248, + "scheme": "http" + } + ], + "credentials": { + "app-provider": { + "role": "app-provider", + "user": "ledger-api-user", + "audience": "https://canton.network.global", + "jwt": "mock.app-provider.jwt" + }, + "app-user": { + "role": "app-user", + "user": "ledger-api-user", + "audience": "https://canton.network.global", + "jwt": "mock.app-user.jwt" + }, + "sv": { + "role": "sv", + "user": "ledger-api-user", + "audience": "https://canton.network.global", + "jwt": "mock.sv.jwt" + } + } +} diff --git a/frontend/mock/fixtures/instances.json b/frontend/mock/fixtures/instances.json new file mode 100644 index 00000000..ec8476b3 --- /dev/null +++ b/frontend/mock/fixtures/instances.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "instances": [ + { + "name": "demo", + "status": "running", + "splice_version": "0.6.12", + "ports": "61245–61249", + "started_ago": "" + } + ] +} diff --git a/frontend/mock/fixtures/metrics-range.json b/frontend/mock/fixtures/metrics-range.json new file mode 100644 index 00000000..35d220af --- /dev/null +++ b/frontend/mock/fixtures/metrics-range.json @@ -0,0 +1,17 @@ +{ + "status": "success", + "data": { + "resultType": "matrix", + "result": [ + { + "metric": { "__name__": "canton_transactions_total", "instance": "demo" }, + "values": [ + [1717065600, "10"], + [1717065660, "12"], + [1717065720, "11"], + [1717065780, "14"] + ] + } + ] + } +} diff --git a/frontend/mock/fixtures/metrics-summary.json b/frontend/mock/fixtures/metrics-summary.json new file mode 100644 index 00000000..569d8d4f --- /dev/null +++ b/frontend/mock/fixtures/metrics-summary.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "instance": "demo", + "scope": "instance", + "metrics": { + "throughput_tps": 12.4, + "latency_p50_ms": 45, + "latency_p99_ms": 210, + "active_contracts": 1284 + }, + "latency": { + "p50_ms": 45, + "p99_ms": 210 + }, + "dashboards": { + "prometheus_ui": "http://127.0.0.1:9090", + "grafana_ui": "http://127.0.0.1:3000" + } +} diff --git a/frontend/mock/fixtures/parties.json b/frontend/mock/fixtures/parties.json new file mode 100644 index 00000000..0c1f8476 --- /dev/null +++ b/frontend/mock/fixtures/parties.json @@ -0,0 +1,12 @@ +{ + "parties": [ + { + "alias": "app-user", + "party_id": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "role": "app-user", + "is_local": true, + "created_at": "2026-07-27T20:42:40Z" + } + ], + "schema_version": 1 +} diff --git a/frontend/mock/fixtures/preflight.json b/frontend/mock/fixtures/preflight.json new file mode 100644 index 00000000..15b29fba --- /dev/null +++ b/frontend/mock/fixtures/preflight.json @@ -0,0 +1,49 @@ +{ + "schema_version": 1, + "ok": true, + "sections": [ + { + "title": "System", + "checks": [ + { + "label": "Docker CLI", + "result": "pass" + }, + { + "label": "Docker daemon", + "result": "pass", + "detail": "v29.6.1" + }, + { + "label": "Docker Compose v2", + "result": "pass", + "detail": "v5.3.0" + }, + { + "label": "Host prerequisites (darwin)", + "result": "pass", + "detail": "Docker Desktop must be running" + } + ] + }, + { + "title": "Resources", + "checks": [ + { + "label": "Docker memory", + "result": "warn", + "detail": "8.84 GB available, 12.00 GB recommended for this version", + "remediation": [ + "Increase Docker Desktop's memory in Preferences → Resources → Memory." + ] + }, + { + "label": "Disk space", + "result": "pass", + "detail": "178.29 GB free at /Users/zzz/.canton-devkit/localnet" + } + ] + } + ], + "summary": "host meets minimums for Splice 0.6.12 but raise resources for headroom" +} diff --git a/frontend/mock/fixtures/skills.json b/frontend/mock/fixtures/skills.json new file mode 100644 index 00000000..73d19d5e --- /dev/null +++ b/frontend/mock/fixtures/skills.json @@ -0,0 +1,41 @@ +{ + "schema_version": 1, + "skills": [ + { + "filename": "ci-localnet.md", + "name": "canton-ci-localnet", + "description": "Run app tests against a throwaway Canton LocalNet in CI (up → test → teardown). Use when the user wants to wire LocalNet into GitHub Actions / GitLab CI.", + "body": "---\nname: canton-ci-localnet\ndescription: Run app tests against a throwaway Canton LocalNet in CI (up → test → teardown). Use when the user wants to wire LocalNet into GitHub Actions / GitLab CI.\n---\n\n# LocalNet in CI\n\nStand up a disposable Canton LocalNet for integration tests, then tear\nit down — using canton-devkit's deterministic exit codes and\nreadiness-wait.\n\n## When to use\nThe user asks to \"test against Canton in CI\", \"add LocalNet to my\npipeline\", or \"run integration tests in GitHub Actions/GitLab\".\n\n## Safe workflow (the five beats)\n\n1. **Install** the pinned release (verify checksum against `SHA256SUMS`).\n2. **Preflight**: `canton-devkit localnet doctor` — fail fast if the\n runner's Docker host isn't ready.\n3. **Start** (blocks until healthy; no manual sleep):\n ```\n canton-devkit localnet up ci --version \n ```\n4. **Export + test**:\n ```\n canton-devkit localnet env ci --format github-env >> \"$GITHUB_ENV\"\n canton-devkit localnet dar upload ./dist/app.dar --instance ci # optional\n # run your tests against the exported endpoints\n ```\n5. **Teardown in an always-run step** so failures still clean up:\n ```\n canton-devkit localnet remove ci --force\n ```\n\n## Guardrails\n- Put teardown in `if: always()` (GitHub) / `after_script` (GitLab) so\n a failed test never leaves dangling containers/volumes.\n- Pin BOTH the devkit release and the Splice version for reproducible\n CI.\n- The runner needs Docker Engine + Compose v2 and ~8 GB RAM for Docker.\n\nCopy-pasteable workflow files live in `examples/ci/` in the repo.\n" + }, + { + "filename": "dar-upload.md", + "name": "canton-dar-upload", + "description": "Upload and inspect Daml DAR packages on a Canton LocalNet. Use when the user wants to deploy a .dar to local participants or list/inspect deployed packages.", + "body": "---\nname: canton-dar-upload\ndescription: Upload and inspect Daml DAR packages on a Canton LocalNet. Use when the user wants to deploy a .dar to local participants or list/inspect deployed packages.\n---\n\n# DAR upload & inspection\n\nDeploy and inspect Daml packages on a running LocalNet via\n`dpm localnet dar` (or `canton-devkit localnet dar`).\n\n## When to use\nThe user asks to \"upload my DAR\", \"deploy the package to LocalNet\",\n\"see what packages are installed\", or \"diff two package versions\".\n\n## Safe workflow\n\n1. **Confirm the instance is up**:\n ```\n dpm localnet status dev\n ```\n\n2. **Upload a DAR** (vets it so it's usable):\n ```\n dpm localnet dar upload ./dist/my-app.dar --instance dev\n ```\n Compilation is NOT this tool's job — build with `dpm build` /\n `daml build` first, then upload the resulting `.dar`.\n\n3. **List deployed packages**:\n ```\n dpm localnet dar list --instance dev\n ```\n Shows package id, name, version, and vetting status.\n\n4. **Inspect / compare**:\n ```\n dpm localnet dar info ./dist/my-app.dar # modules, templates, deps\n dpm localnet dar diff ./v1.dar ./v2.dar # SCU-aware structural diff\n dpm localnet dar download --instance dev\n ```\n\n## Guardrails\n- Build artefacts with Daml tooling, never hand-edit a `.dar`.\n- `dar diff` SCU signals are best-effort — authoritative upgrade\n validation is the Ledger API's job.\n- `dar remove` only unvets/removes where the participant admin API\n supports it.\n" + }, + { + "filename": "hot-deploy.md", + "name": "canton-hot-deploy", + "description": "Rebuild-and-reupload a DAR on source change for a fast local iteration loop. Use when the user wants hot-reload / watch-mode deployment to LocalNet.", + "body": "---\nname: canton-hot-deploy\ndescription: Rebuild-and-reupload a DAR on source change for a fast local iteration loop. Use when the user wants hot-reload / watch-mode deployment to LocalNet.\n---\n\n# Hot-deploy (watch mode)\n\nTighten the edit→build→deploy loop against a running LocalNet using\n`dpm localnet dar watch` and `dar build-upload`.\n\n## When to use\nThe user asks for \"hot reload\", \"auto-redeploy on change\", or \"rebuild\nand upload in one step\" while iterating on Daml code.\n\n## Safe workflow\n\n1. **One-shot build + upload** (delegates compilation to dpm/daml):\n ```\n dpm localnet dar build-upload --project . --instance dev\n ```\n Skipped with a clear message if `dpm`/`daml` isn't available.\n\n2. **Continuous watch** — rebuild + re-upload on every source change:\n ```\n dpm localnet dar watch --project . --instance dev\n ```\n Leave it running in a terminal; Ctrl-C to stop. Each change triggers\n a `dpm build` and re-upload to the selected participant(s).\n\n3. **Verify the new package landed**:\n ```\n dpm localnet dar list --instance dev\n ```\n\n## Guardrails\n- Watch mode shells out to `dpm build` — keep the project compiling, or\n each cycle just reports the build error and skips the upload.\n- For Smart Contract Upgrade (SCU) compatibility, bump the package\n version in `daml.yaml`; `dar diff` shows whether the change is\n upgrade-compatible.\n" + }, + { + "filename": "inspect-contracts.md", + "name": "canton-inspect-contracts", + "description": "Watch the Active Contract Set and list/replay transactions on a Canton LocalNet. Use when the user wants to see live contracts, debug transactions, or check per-party visibility.", + "body": "---\nname: canton-inspect-contracts\ndescription: Watch the Active Contract Set and list/replay transactions on a Canton LocalNet. Use when the user wants to see live contracts, debug transactions, or check per-party visibility.\n---\n\n# Inspect contracts & transactions\n\nRead live ledger state with `dpm localnet contracts` and `tx` (backed\nby Ledger API v2). These complement — they do not replace — Daml\nShell's one-shot lookups.\n\n## When to use\nThe user asks to \"watch contracts\", \"see transactions for a party\",\n\"what does party X see\", or \"debug a privacy/authorization issue\".\n\n## Safe workflow\n\n1. **Live-tail the ACS** (streaming creates/archives, like `kubectl -w`):\n ```\n dpm localnet contracts watch --name dev\n ```\n Filter with `--party

` and `--template `.\n\n2. **List transactions** with multi-dimensional filters:\n ```\n dpm localnet tx ls --name dev --party alice --template Token:Holding\n dpm localnet tx ls --name dev --from --to \n ```\n\n3. **Per-party visibility projection** (debug \"what this party sees\"):\n ```\n dpm localnet tx ls --name dev --party alice\n ```\n Filtering by `--party` projects the transaction stream through that\n party's visibility — the same transaction looks different to\n different parties, which is the point.\n\n4. **Replay a single transaction** by id or offset (tree shape — shows\n exercised choices, not just ACS delta):\n ```\n dpm localnet tx replay --name dev --id \n dpm localnet tx replay --name dev --offset \n dpm localnet tx replay --name dev --offset 42 --party alice --format json\n ```\n Exactly one of `--id` / `--offset` is required. Pairs well with\n `tx ls` — list to find the offset, then replay to see the events.\n\n## Guardrails\n- `--name` resolves the participant endpoint and per-role JWT from the\n registry automatically (same as the Web UI) — no `--endpoint` /\n `--token` needed. `--name` defaults to the only registered instance.\n Use `--role sv|app-provider|app-user` to read from a different\n participant (default: `app-user`).\n- Visibility is always projected through an explicit (participant,\n party) pair — there is no \"global ledger\" view; pick the party\n whose perspective you need. With no `--party`, the query projects\n through the JWT's own act/read parties.\n- For single-contract / single-transaction lookups and CSV export, use\n `dpm daml-shell` (`contract `, `transaction `, `active … |\n csv`) — this tool deliberately doesn't duplicate those.\n- Live watch reads the Ledger API directly; no PQS dependency, so\n archived-contract history beyond the live API is out of scope.\n" + }, + { + "filename": "localnet-lifecycle.md", + "name": "canton-localnet-lifecycle", + "description": "Start, inspect, and stop a Canton LocalNet with canton-devkit. Use when the user wants a local Canton/Daml network to develop or test against.", + "body": "---\nname: canton-localnet-lifecycle\ndescription: Start, inspect, and stop a Canton LocalNet with canton-devkit. Use when the user wants a local Canton/Daml network to develop or test against.\n---\n\n# Canton LocalNet lifecycle\n\nManage a local Canton network using `dpm localnet` (or `canton-devkit\nlocalnet` standalone). Every command is idempotent and returns\ndeterministic exit codes.\n\n## When to use\nThe user asks to \"spin up Canton locally\", \"start a LocalNet\", \"check\nif the network is healthy\", or \"tear it down\".\n\n## Safe workflow\n\n1. **Check the host first** (never modifies anything):\n ```\n dpm localnet doctor\n ```\n Exit 0 = ready; exit 2 = a prerequisite failed (it prints how to fix).\n\n2. **Start a named instance** (blocks until healthy):\n ```\n dpm localnet up dev\n ```\n Use `--version ` to pin a Splice version (`dpm localnet versions`\n lists curated tags). The instance name isolates instances so several\n can run.\n\n3. **Inspect**:\n ```\n dpm localnet status dev # health, ports, endpoints\n dpm localnet list # all instances + state\n dpm localnet logs dev --service canton # tail one service\n ```\n\n4. **Pause or stop without removing containers** (fast to resume):\n ```\n dpm localnet pause dev # freeze in place; resume with `resume`\n dpm localnet resume dev # thaw a paused instance (also: unpause)\n dpm localnet stop dev # graceful stop; restart with `start`\n dpm localnet start dev # start a stopped instance\n ```\n `pause`/`resume` freeze containers (RAM held, CPU freed).\n `stop`/`start` stop the containers (CPU and runtime freed) but keep\n them on disk, so `start` skips stack recreation. `start` on an\n instance whose containers were already removed falls back to a full\n `up` automatically.\n\n5. **Tear down** (stops and removes containers; data volumes kept):\n ```\n dpm localnet down dev\n ```\n\n## Guardrails\n- Always run `doctor` before `up` if the user reports trouble.\n- Reach for the lightest teardown that fits: `pause` for a short break,\n `stop` for a clean shutdown you'll resume soon, `down` to free\n container resources, `remove ` (alias: `clean`) only to\n reclaim data volumes and registry state (`--force` for a running\n instance).\n- Never pass secrets on the command line — JWTs come from `localnet env`.\n- The instance name is passed as a positional arg (`localnet up dev`).\n `--name` is still accepted for backward compatibility.\n" + }, + { + "filename": "token-flow.md", + "name": "canton-token-flow", + "description": "Create and exercise Canton Token Standard flows (create, mint, transfer, burn, balance) on a Canton LocalNet. Use when the user wants to test token operations locally.", + "body": "---\nname: canton-token-flow\ndescription: Create and exercise Canton Token Standard flows (create, mint, transfer, burn, balance) on a Canton LocalNet. Use when the user wants to test token operations locally.\n---\n\n# Token flows (Canton Token Standard)\n\nExercise token-standard operations on LocalNet via `dpm localnet token`.\nBoth generations are supported, routed per instrument: CIP-0056 (Final —\nexisting assets such as Canton Coin) for reads and transfers; creating a\nnew instrument uses Token Standard V2 (CIP-0112, alpha) and needs\n`--version token-standard-v2 --profile tokens-v2`. For LocalNet testing\nonly — not a production issuer/custodian/wallet.\n\n## When to use\nThe user asks to \"create a test token\", \"mint/transfer/burn tokens\", or\n\"check a wallet balance\" on their local network.\n\n## Safe workflow\n\n1. **Ensure LocalNet is up with the tokens-v2 profile** (the V2\n on-ledger surfaces need it; see `dpm localnet versions` for an\n alpha-channel Splice version that supports it):\n ```\n dpm localnet up dev --profile tokens-v2\n dpm localnet status dev\n ```\n\n2. **Create a token instrument**. Interactive wizard in a terminal\n (`dpm localnet token create --instance dev`), or fully flag-driven\n for agents and CI:\n ```\n dpm localnet token create --instance dev --non-interactive \\\n --name \"Rocket Token\" --symbol RTK --decimals 6 \\\n --initial-supply 1000000 --issuer \n ```\n\n3. **Common operations** (parties are full party ids — get them from\n `dpm localnet status dev`):\n ```\n dpm localnet token mint --instance dev --instrument RTK --to --amount 1000\n dpm localnet token transfer --instance dev --instrument RTK --from --to --amount 250\n dpm localnet token burn --instance dev --instrument RTK --from --amount 100 --yes\n dpm localnet token balance --instance dev --instrument RTK\n ```\n\n## Guardrails\n- This is a LocalNet faucet/testing surface, not production token\n infrastructure. Do not point it at MainNet.\n- Creating/minting/burning targets Token Standard V2 (CIP-0112, alpha)\n instruments; CIP-0056 instruments support reads and transfers only.\n- All operations go through the Ledger API / Registry API on the\n selected instance; verify with `token balance` after each step.\n" + } + ] +} diff --git a/frontend/mock/fixtures/splice-versions.json b/frontend/mock/fixtures/splice-versions.json new file mode 100644 index 00000000..d75bb82d --- /dev/null +++ b/frontend/mock/fixtures/splice-versions.json @@ -0,0 +1,217 @@ +{ + "schema_version": 1, + "latest_alias": "0.6.12", + "versions": [ + { + "tag": "token-standard-v2", + "status": "catalogued-only", + "major": "0.6", + "commit": "de911e38af78ec79b6f0e6a9515e104b1e4c3d62", + "note": "upstream no longer has this tag — kept for reproducibility" + }, + { + "tag": "next-cilr", + "status": "available", + "major": "", + "commit": "15a2efd329cede1f86cc075c708675cb05d4983b", + "note": "run scripts/add-splice-version.sh next-cilr to adopt" + }, + { + "tag": "0.6.14", + "status": "available", + "major": "", + "commit": "398919a5b13479877fd61587003ba7a4ba00091b", + "note": "run scripts/add-splice-version.sh 0.6.14 to adopt" + }, + { + "tag": "0.6.13", + "status": "available", + "major": "", + "commit": "52ccdd6841f3eda11fe86313e8cdf9540efedfb7", + "note": "run scripts/add-splice-version.sh 0.6.13 to adopt" + }, + { + "tag": "0.6.12", + "status": "latest", + "major": "0.6", + "commit": "17fd29aad170e8f20074aa64624c4506177ea7bd" + }, + { + "tag": "0.6.11", + "status": "supported", + "major": "0.6", + "commit": "fd93f86ac42ce3a08985dcd0baae530b4f235f60" + }, + { + "tag": "0.6.10", + "status": "supported", + "major": "0.6", + "commit": "63cfb340ce0f8f254386d2d5df58905d695de902" + }, + { + "tag": "0.6.9", + "status": "supported", + "major": "0.6", + "commit": "bc6a3587e7ea94230ba0c36c638945282c52b304" + }, + { + "tag": "0.6.8", + "status": "available", + "major": "", + "commit": "61a536092435aed2fabda60f0bad75b2c626d312", + "note": "run scripts/add-splice-version.sh 0.6.8 to adopt" + }, + { + "tag": "0.6.7", + "status": "available", + "major": "", + "commit": "c8d8d977794c514ed2ee9eb64de322a6779898f3", + "note": "run scripts/add-splice-version.sh 0.6.7 to adopt" + }, + { + "tag": "0.6.6", + "status": "available", + "major": "", + "commit": "995ec496fcf99346a7e7a1137f225a62e107b704", + "note": "run scripts/add-splice-version.sh 0.6.6 to adopt" + }, + { + "tag": "0.6.5", + "status": "available", + "major": "", + "commit": "8a844fc8c19dcd7f53f7fbfee7f4a08e3ecd40a9", + "note": "run scripts/add-splice-version.sh 0.6.5 to adopt" + }, + { + "tag": "0.6.4", + "status": "supported", + "major": "0.6", + "commit": "578b7822d62947763a48334d556aefebc7ffacec" + }, + { + "tag": "0.6.3", + "status": "supported", + "major": "0.6", + "commit": "6d50c039641736b6df5358af2b1fc6c123f28e18" + }, + { + "tag": "0.6.2", + "status": "available", + "major": "", + "commit": "29255dcdfe96649dbffcec00fee69ae43e0b7f46", + "note": "run scripts/add-splice-version.sh 0.6.2 to adopt" + }, + { + "tag": "0.6.1", + "status": "available", + "major": "", + "commit": "f9d605c84498384ec2d5138d62af2f40b14882ff", + "note": "run scripts/add-splice-version.sh 0.6.1 to adopt" + }, + { + "tag": "0.6.0", + "status": "available", + "major": "", + "commit": "9c06c8184b1ae35c8c3f49f4a158e235ca25fff8", + "note": "run scripts/add-splice-version.sh 0.6.0 to adopt" + }, + { + "tag": "0.5.18", + "status": "supported", + "major": "0.5", + "commit": "b162650cd18df5b96dddeffd9bd48be5b2ff37d7" + }, + { + "tag": "0.5.17", + "status": "available", + "major": "", + "commit": "c2a87b3edb929ee793189f469beb3b346a11dfa4", + "note": "run scripts/add-splice-version.sh 0.5.17 to adopt" + }, + { + "tag": "0.5.16", + "status": "available", + "major": "", + "commit": "22baff08a766cda47a71f1522876066b5e513ff5", + "note": "run scripts/add-splice-version.sh 0.5.16 to adopt" + }, + { + "tag": "0.5.15", + "status": "available", + "major": "", + "commit": "8abc8c04b55ba5091d8c7f18338fbce6e0130a72", + "note": "run scripts/add-splice-version.sh 0.5.15 to adopt" + }, + { + "tag": "0.5.14", + "status": "available", + "major": "", + "commit": "87c73c3532bd7ef1e7d62ea649563ed44a002414", + "note": "run scripts/add-splice-version.sh 0.5.14 to adopt" + }, + { + "tag": "0.5.13", + "status": "available", + "major": "", + "commit": "f9b0cf2e747b2d389d8eabccd6a6e522eb37bc75", + "note": "run scripts/add-splice-version.sh 0.5.13 to adopt" + }, + { + "tag": "0.5.12", + "status": "available", + "major": "", + "commit": "4925b3e3e3cba1a3bf01560b2ca2fa39e85e10a0", + "note": "run scripts/add-splice-version.sh 0.5.12 to adopt" + }, + { + "tag": "0.5.11", + "status": "available", + "major": "", + "commit": "2b9ebcff4d7b750f693e89a345873ff93601d40e", + "note": "run scripts/add-splice-version.sh 0.5.11 to adopt" + }, + { + "tag": "0.5.10", + "status": "available", + "major": "", + "commit": "2a9456cef72abcd9eec7bc5e68270ec04102be60", + "note": "run scripts/add-splice-version.sh 0.5.10 to adopt" + }, + { + "tag": "0.5.9", + "status": "available", + "major": "", + "commit": "bd08bc6988e1227051bbcc869e70051d3f60294e", + "note": "run scripts/add-splice-version.sh 0.5.9 to adopt" + }, + { + "tag": "0.5.8", + "status": "available", + "major": "", + "commit": "86b5df19a7b1b5ef807ae1850658e7ba0b9ccfd4", + "note": "run scripts/add-splice-version.sh 0.5.8 to adopt" + }, + { + "tag": "0.5.7", + "status": "available", + "major": "", + "commit": "6fa082ecad2c68b908f8b2ef7a9eb0d4bad64d93", + "note": "run scripts/add-splice-version.sh 0.5.7 to adopt" + }, + { + "tag": "0.5.6", + "status": "available", + "major": "", + "commit": "eee13bbc5f75ede8d99a03457bd14afa6bcb42be", + "note": "run scripts/add-splice-version.sh 0.5.6 to adopt" + }, + { + "tag": "0.5.5", + "status": "available", + "major": "", + "commit": "a26fa9ff0b4e54855c9a772b61c898d43f745518", + "note": "run scripts/add-splice-version.sh 0.5.5 to adopt" + } + ], + "upstream_fetched": true +} diff --git a/frontend/mock/fixtures/token-activity.json b/frontend/mock/fixtures/token-activity.json new file mode 100644 index 00000000..0488dca0 --- /dev/null +++ b/frontend/mock/fixtures/token-activity.json @@ -0,0 +1,759 @@ +{ + "aliases": { + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249": "app-user" + }, + "events": [ + { + "offset": 4914, + "update_id": "1220dabd1e2a9389e8f0bd9a51ce4db85c1883f16859af6f4df4b35d67879de24f32", + "record_time": "2026-07-28T07:39:40.274625Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4876, + "update_id": "122088a6a902cb71d5faeab40ef15c2a20e3232db3db7454f517b405bd9965db760e", + "record_time": "2026-07-28T07:29:13.676768Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4853, + "update_id": "12209dfaf3bab38d6c637998e2e02d8afa992156d064da9e71037068b77cbd4ccb49", + "record_time": "2026-07-28T07:23:11.33732Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4801, + "update_id": "1220c760c13f0b2c31c19a2492f7b53f8aff259d4a07679bfc4a73149b5760c2ec0d", + "record_time": "2026-07-28T07:08:15.04796Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4769, + "update_id": "122006696fa8c1dbb32d8ba7d561d96bb6842215000ecd8cbf00eede1bc4b77c695c", + "record_time": "2026-07-28T06:59:16.52628Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4735, + "update_id": "1220ff2ab152e20f86fe3a8371f72bd8be7a66bfc50f24eadf6631a8d960ad2f3693", + "record_time": "2026-07-28T06:49:40.547515Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4702, + "update_id": "122065cac9c8250741c890424ce5a6ed5baf764c690060434006d6c8c4d75bece9ec", + "record_time": "2026-07-28T06:40:10.156141Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4664, + "update_id": "1220a84b71c4cb3b4280b3a67938bc7acbb1a8d68a7a2a6f596d6d0e7e2694ded969", + "record_time": "2026-07-28T06:29:12.695874Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4618, + "update_id": "1220c45e4c669428333eee3ed47047dba3e40dd349428170f3bee1836dc8d47a7d00", + "record_time": "2026-07-28T06:15:53.672503Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4579, + "update_id": "1220b27a7eecb88a6cc45a6a01bd04f69e1c69e8ec9f79afdc0489d6324909e8b893", + "record_time": "2026-07-28T06:05:31.777376Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4559, + "update_id": "12208db4b656af0fd428b25c571dd1d811385ff9b6758a08d7003039e18c6bc4b6b9", + "record_time": "2026-07-28T05:59:55.563326Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4521, + "update_id": "12205be548103c9ddd687d3015fc887994be8a28f20ed1ba7bbf224485772c53f979", + "record_time": "2026-07-28T05:48:29.205463Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4481, + "update_id": "1220e70d6beb6f836bd0ff7c234ef0428913a4e2f0dc463d1a2e319892f44da14eb3", + "record_time": "2026-07-28T05:37:19.226435Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4442, + "update_id": "1220d699f0e0de4eba4fcd091d1296b5753b5432283b13c875a0701a98de0992a9ea", + "record_time": "2026-07-28T05:27:56.850622Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4391, + "update_id": "12204377aa21c22f1f394fd4feb279ef6f5b86fd59c8d84945c224c2dfd18a47f80f", + "record_time": "2026-07-28T05:13:35.082179Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4371, + "update_id": "122022c67458ebaa12e5f4a779917211744e104f540a83ba0c5b6894e787047c5634", + "record_time": "2026-07-28T05:08:45.803108Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4335, + "update_id": "122023780b80f8622066ba9e3c08741c296202f0e0b17c459160757ee92a3c6dc66d", + "record_time": "2026-07-28T04:58:11.848741Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4295, + "update_id": "12202d65f560e9c4aa973057bd2ce48257be8fe650ec1993663b17e1b4e01f877b8e", + "record_time": "2026-07-28T04:46:38.663513Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4260, + "update_id": "1220dc9d183e1a53f05d362816e00c63871e2c127e55a9057151b08e5dec541b0d3f", + "record_time": "2026-07-28T04:36:57.648808Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4226, + "update_id": "122094ebe84170cb013a59c8bd2d0b92ee7988af5c7d8dfbdfcb2917fdd4f45ca230", + "record_time": "2026-07-28T04:26:50.458943Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4177, + "update_id": "1220ec05ce3ed09fa00a2f5538858346ad670a14d48e849d0025603fbafc4e1d255e", + "record_time": "2026-07-28T04:12:14.306146Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4153, + "update_id": "1220ce64d6e31eca8ca5e54b3717fae97b70da547dce40d2963e969de120c710e1a3", + "record_time": "2026-07-28T04:06:00.288173Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4116, + "update_id": "1220df7d2ca1fe52b3a3068d57874bc65dba9352b4b39e58ce6d4eb1d448b663afe2", + "record_time": "2026-07-28T03:55:06.451422Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4074, + "update_id": "1220a2757503ddf01e2b25b297fc28890fdf6b02e9105bdf10731c4ace807bd41cbc", + "record_time": "2026-07-28T03:44:20.351724Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4026, + "update_id": "1220a2d72e413f260942f3f0bde45dfb6ce4d6f30b8361fdb4b08149bdede11f4f15", + "record_time": "2026-07-28T03:30:32.754803Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 4003, + "update_id": "12206195e028a53df7194789a80412d7f54445e09f2901cdb1f4f40338f4d10d0de4", + "record_time": "2026-07-28T03:24:40.512716Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3951, + "update_id": "12203888a540aeb21ee5be15babaa4af392338bf723739cdacf9a73b5c5904284d37", + "record_time": "2026-07-28T03:10:17.011829Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3913, + "update_id": "1220cb34bb522cec721bb603cc87d9bbd5fdb4eee36ca3dd1001c4c36131da13fa78", + "record_time": "2026-07-28T03:00:27.4455Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3877, + "update_id": "12201a566b7ba0d2d3b0e0c005c4cccb3158ed4dcc1ec1ad01268707c3fbe57f63ca", + "record_time": "2026-07-28T02:50:08.821147Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3848, + "update_id": "122009529f7a80fa1b946e29566bbcaa94e6097a1ff12564b0df07f4f2fd079faa92", + "record_time": "2026-07-28T02:41:43.112698Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3809, + "update_id": "1220b40a2e009049b17c959d96033b54225b681151d358e63bcb727deb3b226fd840", + "record_time": "2026-07-28T02:31:39.094352Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3767, + "update_id": "1220782484fb26d38b529edd0939d116fb0fdcd38c96eb68ecae04af071471b5ed1a", + "record_time": "2026-07-28T02:20:09.796649Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3729, + "update_id": "1220be38aef5e1bf98a7de80c4ca275cfccb1293cd982e07582b1f2713654a62a97e", + "record_time": "2026-07-28T02:10:51.287706Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3689, + "update_id": "1220d772311baaebc895b1591738da0bcb828228e0ef9acc0f7fc18e37f191624d9a", + "record_time": "2026-07-28T02:00:11.14193Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3652, + "update_id": "1220edbc0604b2d6af23c65be30a3e2c23f978a3e887341e727c7308e39adfe1d44b", + "record_time": "2026-07-28T01:49:13.597947Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3622, + "update_id": "1220c74b28e35bedf6164609f05395f6c39ba8d45eafce34c4ea78675fc10d9bac0e", + "record_time": "2026-07-28T01:40:17.735818Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3580, + "update_id": "12204681f6a6c715db532c7f9972ed37a5964b89873b6b09f8732891c8f0404cbbb0", + "record_time": "2026-07-28T01:28:13.308714Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3547, + "update_id": "12203d55d96ab254347f2096da7034426702277a98cf656e934f81198d609552b6e1", + "record_time": "2026-07-28T01:18:46.053639Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3510, + "update_id": "1220453a52f6d4afdca8081549c1422c7f2a0a5d1865c4637d15002ab7794df2a67d", + "record_time": "2026-07-28T01:08:12.902325Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3471, + "update_id": "1220208b9c1be157ddb16472448c91367d396c8f212924bbc8fb19bc65858e4c8509", + "record_time": "2026-07-28T00:58:04.665653Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3422, + "update_id": "122020f409f0d26fdcdea3d38ae0660054574c480c87c21a4e6e95323980954bb187", + "record_time": "2026-07-28T00:44:38.733398Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3388, + "update_id": "122049851b96e01cee488f88f8408a8664a904007329f5d0740be87a9914478ef81c", + "record_time": "2026-07-28T00:34:17.521437Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3369, + "update_id": "1220a9aa706a6e3f1429a49af0bdf2ced44bbf904a0387d4e13ce3993ce670870369", + "record_time": "2026-07-28T00:28:59.571093Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3329, + "update_id": "1220185e1204c307085588fb5674974cabf05444c9b1cb7b413ccb2ef654f244d5f6", + "record_time": "2026-07-28T00:18:10.746165Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3287, + "update_id": "122078e3e3031fcac83fb67afd92e20c1419f5c6e213f57281b18c2548214786b572", + "record_time": "2026-07-28T00:05:49.044996Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3246, + "update_id": "1220fc125deadc09812a4d54ff856c154c4da7c642daca4793be33900abbaa63e8ca", + "record_time": "2026-07-27T23:54:34.709621Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3208, + "update_id": "12209a79a2b71ef607512b6eed2919a1b0deafa017bcf407b92cd9922cb2100864bb", + "record_time": "2026-07-27T23:43:28.767219Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3171, + "update_id": "1220741c3eec3ecd63d4e7237d05639807755299f74d66be2a9c690967b15296b61d", + "record_time": "2026-07-27T23:32:11.254163Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3134, + "update_id": "1220cd79c59d6db479e2bb95cc8ab179a7532de87c676969f5a1514d37b7b1904046", + "record_time": "2026-07-27T23:22:09.947261Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + }, + { + "offset": 3102, + "update_id": "12209fcb8be20c27fb79c7be75d0f3de7d610982373ef888d7bf8d4ea5ffbeb01d11", + "record_time": "2026-07-27T23:12:53.625364Z", + "instrument_id": "Amulet", + "kind": "mint", + "source": "event_log", + "amount": "570", + "receivers": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "570" + } + ] + } + ], + "schema_version": 1, + "truncated": false +} diff --git a/frontend/mock/fixtures/token-holdings.json b/frontend/mock/fixtures/token-holdings.json new file mode 100644 index 00000000..344f9b67 --- /dev/null +++ b/frontend/mock/fixtures/token-holdings.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "source": "ledger", + "holdings": [ + { + "instrument_id": "Amulet", + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "amount": "75470.1600000000", + "source": "ledger" + } + ] +} diff --git a/frontend/mock/fixtures/token-summary.json b/frontend/mock/fixtures/token-summary.json new file mode 100644 index 00000000..caf06d71 --- /dev/null +++ b/frontend/mock/fixtures/token-summary.json @@ -0,0 +1,21 @@ +{ + "aliases": { + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249": "app-user" + }, + "schema_version": 1, + "summary": { + "instrument_id": "Amulet", + "admin": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "total_supply": "75470.1600000000", + "holder_count": 1, + "contract_count": 1, + "holders": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "balance": "75470.1600000000", + "contract_count": 1, + "pct_of_supply": "100.0" + } + ] + } +} diff --git a/frontend/mock/fixtures/tokens-matrix.json b/frontend/mock/fixtures/tokens-matrix.json new file mode 100644 index 00000000..5b465dd0 --- /dev/null +++ b/frontend/mock/fixtures/tokens-matrix.json @@ -0,0 +1,35 @@ +{ + "aliases": { + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249": "app-user" + }, + "matrix": { + "parties": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "instruments": [ + { + "admin": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "instrument_id": "Amulet", + "symbol": "Amulet", + "standard": "Splice Amulet", + "generation": "v2", + "on_ledger": true + } + ], + "cells": [ + { + "party": "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249", + "instrument_id": "Amulet", + "amount": "75470.1600000000" + } + ], + "totals": [ + { + "party": "", + "instrument_id": "Amulet", + "amount": "75470.1600000000" + } + ] + }, + "schema_version": 1 +} diff --git a/frontend/mock/fixtures/tokens.json b/frontend/mock/fixtures/tokens.json new file mode 100644 index 00000000..03755cf3 --- /dev/null +++ b/frontend/mock/fixtures/tokens.json @@ -0,0 +1,13 @@ +{ + "instruments": [ + { + "admin": "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "instrument_id": "Amulet", + "symbol": "Amulet", + "standard": "Splice Amulet", + "generation": "v2", + "on_ledger": true + } + ], + "schema_version": 1 +} diff --git a/frontend/mock/fixtures/transactions.json b/frontend/mock/fixtures/transactions.json new file mode 100644 index 00000000..e3d56b7c --- /dev/null +++ b/frontend/mock/fixtures/transactions.json @@ -0,0 +1,1761 @@ +{ + "schema_version": 1, + "instance": "demo", + "role": "app-user", + "ledger_end": 4939, + "transactions": [ + { + "kind": "transaction", + "offset": 4927, + "update_id": "12209d3e92cc06e931157ed7d2db237b3e4bf97f1e8f1baedbe87e696e6d2162cba8", + "command_id": "f6f40423-c7ec-42cc-8066-60b7a2593b48", + "record_time": "2026-07-28T07:42:55.267004Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00e6889b718cbf704eb42f24f472ff39f85a5b230220def358f5ae09637e8e1edfca1212204de933043dde6428e0c3f602d9c05f436a8c724e2476056e7029f4edd5e4f579", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0009aeb751ccba5edc302dfbf29c00cbb1a2086dd1b792980c2054d169e9b10e29ca12122064d1d99d4e78169204644f90f4abc60a76e1d2e38f2e2ab99684cec6a69aa001", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00bc364ebb0ce42e26cb0acf6bdca59abd0f10d5972676609a0ed25925f1b187e5ca1212205f7e2464943993f76b9a1e27417665a8edba40dd56b4939dd37ede426513fa10", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4914, + "update_id": "1220dabd1e2a9389e8f0bd9a51ce4db85c1883f16859af6f4df4b35d67879de24f32", + "command_id": "8d7cf09f-0712-4a94-bde5-97dbdc6ce48b", + "record_time": "2026-07-28T07:39:40.274625Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00df6afe989b1a8f256772854c96b99b82dc077c0da15fedb6cc4db5a903b4e78fca121220af4409f6105384754dd630fcc16c865e59746ae13f144074ce18b484201cc8fa", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00243e207841e0fc01e086c2239dd2fff87b1ee3cae9c77a3edeac18d2626bd0a6ca121220bed5c3843f208562981616df66fcccac1a5a7b7010329a57087e873dd60db298", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00934a2aacbfb1e32ab59ff94e3bd3b6831f38be8577d43c9f8fd3cbf1e2506125ca121220f57e2cbfb9ed739fc96ce62e4f0160464837fc33570ba3adcb530ab7ccde6a8b", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4909, + "update_id": "12200b8c88ecad2ce5f6d7112d47f033512e6a92739d8031474f81bb918d67bd803d", + "command_id": "0937b99a-0ef2-49ea-aea6-f0c1ee171d51", + "record_time": "2026-07-28T07:38:51.180708Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "007dbb6a7da0ffe05b6e66e14f7992ebbc92c0e901cbfb8acdf5e9c7b5affb7fe1ca12122063ab4ae95c52187ecb2e51520953153b9ee5a4ab1fe88054b19be9cb9d49ab6a", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00e6889b718cbf704eb42f24f472ff39f85a5b230220def358f5ae09637e8e1edfca1212204de933043dde6428e0c3f602d9c05f436a8c724e2476056e7029f4edd5e4f579", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "006fad6c8dfd103935a42345689d5736af11ce14dec5a502e377c93672c241a139ca121220b1307db3fb20425bc344303ed26b3359353e25647a8c28d6e333ee1ee0c81b27", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4876, + "update_id": "122088a6a902cb71d5faeab40ef15c2a20e3232db3db7454f517b405bd9965db760e", + "command_id": "5039449e-1287-4d78-8583-a6e5aad24d47", + "record_time": "2026-07-28T07:29:13.676768Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0059361bf04e704fec391969e7bd4adc6174b6258db3b091764cae392d6c0e41f9ca121220ac11aabcf13f26b24b9e3ad9550ac0be41d582f7c0e4e802582086e8ac8a535e", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00f422959201efeda15e96f045c29333252081705902689b6c280798bc7b191581ca12122081b6245bd9e32911a4dba930d1d560827106689862b18f8a92a7bda38d66cfb0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00df6afe989b1a8f256772854c96b99b82dc077c0da15fedb6cc4db5a903b4e78fca121220af4409f6105384754dd630fcc16c865e59746ae13f144074ce18b484201cc8fa", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4856, + "update_id": "1220828693b794fe8603576122769fd2f8e8a7071783742f79f19e7c48dcf13e3eed", + "command_id": "1ba4cc0f-0a06-4e82-b082-3ecb92004b67", + "record_time": "2026-07-28T07:23:42.193936Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0077314affff96b627d7f71ed508ae53bc4917349f5cfe0915a77335152465898aca121220de83b6556a00d7851d615b6696db85248f54f7e13283b3c581440cdbde35da8c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "007dbb6a7da0ffe05b6e66e14f7992ebbc92c0e901cbfb8acdf5e9c7b5affb7fe1ca12122063ab4ae95c52187ecb2e51520953153b9ee5a4ab1fe88054b19be9cb9d49ab6a", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00eca51173e9f95e4c641a58fc07e91cd73b184ddbff82c863fdf9cfdd936a659bca12122098e73d269f3681043c117e907b1feb9081afeef2ef0393d65dbb3539d5716bd0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4853, + "update_id": "12209dfaf3bab38d6c637998e2e02d8afa992156d064da9e71037068b77cbd4ccb49", + "command_id": "f96954cb-0472-4591-a35c-6e89e75b1fe2", + "record_time": "2026-07-28T07:23:11.33732Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00d758423fe174376c7dce70a7e846d66e1d18a0fe6a0a187c8e8d969048541e46ca121220ee9f2609cf7cc349f7471d87fc382e7e4589e6e866bb33aaf222f9e3981805c2", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00f82af430262f00329edb15ee931a9e0473d71d37a6d8071aad5f67d6e61cbf02ca1212208a3ffdced6ceb80b57f373349b7415f107abd457055afb65817c7135b4310530", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0059361bf04e704fec391969e7bd4adc6174b6258db3b091764cae392d6c0e41f9ca121220ac11aabcf13f26b24b9e3ad9550ac0be41d582f7c0e4e802582086e8ac8a535e", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4830, + "update_id": "1220880ee461cb4fa619e25f4d77b42826ec427c25d8abb6fc6f2ca2d484f52c4e17", + "command_id": "368e22b8-9d45-4f7c-a536-c7ddf7884153", + "record_time": "2026-07-28T07:17:17.422019Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00d7a8d085ef80de87dcc8b1e54cb2a9dd47e67bd64a96f10b5819aade18cbc55eca12122078443fe716a7d8ce5cd9c122927aa61d7721f1a351ce506e916667bb165471a8", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0077314affff96b627d7f71ed508ae53bc4917349f5cfe0915a77335152465898aca121220de83b6556a00d7851d615b6696db85248f54f7e13283b3c581440cdbde35da8c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00243e207841e0fc01e086c2239dd2fff87b1ee3cae9c77a3edeac18d2626bd0a6ca121220bed5c3843f208562981616df66fcccac1a5a7b7010329a57087e873dd60db298", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4801, + "update_id": "1220c760c13f0b2c31c19a2492f7b53f8aff259d4a07679bfc4a73149b5760c2ec0d", + "command_id": "202593d2-1496-4a23-ae67-6ce9a411b55b", + "record_time": "2026-07-28T07:08:15.04796Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00710dec894a4edfca82a96425afc15ebc0708147a113a4f2b7adf218fe55e2ca6ca121220e30f3c503627d25828e024260939362e76ea77149f5cb6da21e54b7fb84a23e1", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00c1a2903668d6b13b32e32ca53aa8833ebda6008d29f69d668b6857b510e938b6ca1212200a99e4bc68f253436ad0620d61586a2b3a1cca2209889f581aa9aa813ee1a7fb", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00d758423fe174376c7dce70a7e846d66e1d18a0fe6a0a187c8e8d969048541e46ca121220ee9f2609cf7cc349f7471d87fc382e7e4589e6e866bb33aaf222f9e3981805c2", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4784, + "update_id": "1220414336de9dc8a65245fe2085669c0f5678ecdc055a3bd75c6a8c60ab38775877", + "command_id": "535621ea-866d-4f2c-bf5c-bf91a347051d", + "record_time": "2026-07-28T07:03:54.22995Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00d6fc83585ab14e90b577ef688aa450290863a22cb042a248996ddae70561d08eca121220e5f064750f64d6738241612721efaa545af6d7f25108b46bcc2ebfac279983c4", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00d7a8d085ef80de87dcc8b1e54cb2a9dd47e67bd64a96f10b5819aade18cbc55eca12122078443fe716a7d8ce5cd9c122927aa61d7721f1a351ce506e916667bb165471a8", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00f422959201efeda15e96f045c29333252081705902689b6c280798bc7b191581ca12122081b6245bd9e32911a4dba930d1d560827106689862b18f8a92a7bda38d66cfb0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4769, + "update_id": "122006696fa8c1dbb32d8ba7d561d96bb6842215000ecd8cbf00eede1bc4b77c695c", + "command_id": "74b91592-6117-4d66-b0b5-dba4c0fcc2c0", + "record_time": "2026-07-28T06:59:16.52628Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "003b5e9489c3e3eff6239459a3cfba44a13e1c1ac9c3a1566ea17fc41eb5e0c363ca121220820af37d24a57dd45fc1edb4144077a7adbeb8cdb523f89e88d1b95608ddcb58", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00b13e41b4889b072452ce9366dbf859a003bb50daa9d85e8431d1a324aa7236c0ca1212209de6d15aa7df9a467c04dddf9d653012b9b8b3a9af28708098c678556888151f", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00710dec894a4edfca82a96425afc15ebc0708147a113a4f2b7adf218fe55e2ca6ca121220e30f3c503627d25828e024260939362e76ea77149f5cb6da21e54b7fb84a23e1", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4748, + "update_id": "12208c9e20a54e19b35fe22de65717de0defb0653ba43f320491b3a50d684bceb315", + "command_id": "20f180df-76eb-41f0-b609-047e3d6e6473", + "record_time": "2026-07-28T06:53:03.366815Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00295f5dd790038524d70e87dafa934c0d4e6db5e590f156031ccd028c01de8c6fca121220ec14cba1da2ea26d6187ff2b453748bd191365b8135a6c268b467e8739c45c64", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00d6fc83585ab14e90b577ef688aa450290863a22cb042a248996ddae70561d08eca121220e5f064750f64d6738241612721efaa545af6d7f25108b46bcc2ebfac279983c4", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00f82af430262f00329edb15ee931a9e0473d71d37a6d8071aad5f67d6e61cbf02ca1212208a3ffdced6ceb80b57f373349b7415f107abd457055afb65817c7135b4310530", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4735, + "update_id": "1220ff2ab152e20f86fe3a8371f72bd8be7a66bfc50f24eadf6631a8d960ad2f3693", + "command_id": "c667de8d-5d76-44d9-8095-13310b1b4035", + "record_time": "2026-07-28T06:49:40.547515Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00b768b32ce5b60db2fed14a690e46b9d192e866f20d3f39e1dce31572bd4321daca121220345e30a8c2fade9f9088810547c5f831a8658f7d2a3659fe8c562b2690b6ff0b", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00f9a5edb2072b465aa702eacd26f8fcba087d88ee79a0770f3ebbf7fe7663cb2cca12122011873962c67d159db5e1e71aba9d5180f2cf363d6136694f1814d80bb993b447", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "003b5e9489c3e3eff6239459a3cfba44a13e1c1ac9c3a1566ea17fc41eb5e0c363ca121220820af37d24a57dd45fc1edb4144077a7adbeb8cdb523f89e88d1b95608ddcb58", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4705, + "update_id": "12200a6d984c60a5856bdb1593a36ba30438c6659a5a34c1515f97639c484946ef46", + "command_id": "f1b44a7f-02a0-49a1-b418-ff08fee1df4c", + "record_time": "2026-07-28T06:40:39.680257Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "001663b5b9c7d1f0b0aca98788b75676665b59870ff9992abd3d1924fe00013051ca121220a92d8016ca7bbed193e7d649b68e44c3aa09df2276fa838bc21d266b340e9880", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00295f5dd790038524d70e87dafa934c0d4e6db5e590f156031ccd028c01de8c6fca121220ec14cba1da2ea26d6187ff2b453748bd191365b8135a6c268b467e8739c45c64", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00c1a2903668d6b13b32e32ca53aa8833ebda6008d29f69d668b6857b510e938b6ca1212200a99e4bc68f253436ad0620d61586a2b3a1cca2209889f581aa9aa813ee1a7fb", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4702, + "update_id": "122065cac9c8250741c890424ce5a6ed5baf764c690060434006d6c8c4d75bece9ec", + "command_id": "e0e12735-1687-4aef-a42b-8b3345f9cfc8", + "record_time": "2026-07-28T06:40:10.156141Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "007ae50204dc9a5fd7831568abf93c69f506059fc26d30962b71180d7d9b79b247ca1212202cc3901742ad62eb5423b90dbe378504398ef987738b9f3863e80f992d0669f0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "009752aed75d4c5f362cac2d83aaf32c747fd00de658d97bc89c385cc9d264e6faca121220a5331548c6fe480827c50134e95e9455ec525fba14faf9439fcf26d4440cce1c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00b768b32ce5b60db2fed14a690e46b9d192e866f20d3f39e1dce31572bd4321daca121220345e30a8c2fade9f9088810547c5f831a8658f7d2a3659fe8c562b2690b6ff0b", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4677, + "update_id": "12205698da17eaed6e105d78fa2896a219854af3be9e140e8d831f70e9bdc6e21b28", + "command_id": "64a445a6-0631-4139-a16e-1aac5fab1b3a", + "record_time": "2026-07-28T06:32:32.598427Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "004aa57f0931d220222de70ec32d91014b481750160caa93a0db77b5cd41c0fd0dca1212206f68dfa3ef2599afa56a4fa4f52c4299a04f9635e237b6124a132e036ba02ae0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "001663b5b9c7d1f0b0aca98788b75676665b59870ff9992abd3d1924fe00013051ca121220a92d8016ca7bbed193e7d649b68e44c3aa09df2276fa838bc21d266b340e9880", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00b13e41b4889b072452ce9366dbf859a003bb50daa9d85e8431d1a324aa7236c0ca1212209de6d15aa7df9a467c04dddf9d653012b9b8b3a9af28708098c678556888151f", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4664, + "update_id": "1220a84b71c4cb3b4280b3a67938bc7acbb1a8d68a7a2a6f596d6d0e7e2694ded969", + "command_id": "8f85b3de-92be-446c-89f5-f6a479a40dde", + "record_time": "2026-07-28T06:29:12.695874Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00d00edaba1707cec79423548d1bf73c7ac9181dde1090e67bd0a6f524b7581238ca1212208c19584d7e34b005e27b951233a91d2421c1e9dd4295388c19c344f23613580b", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00513d951be3816e3f24cf6473ee41e2605ab3bd2739e4aa1873559aeccc738657ca1212206ac45e355b206c79fa40611c0d2e57f07dfba3e487102ede0565e7447b59f99f", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "007ae50204dc9a5fd7831568abf93c69f506059fc26d30962b71180d7d9b79b247ca1212202cc3901742ad62eb5423b90dbe378504398ef987738b9f3863e80f992d0669f0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4621, + "update_id": "12208ad9cc82782dc6b09f7b04e2543cbd1c9ee019a673afa74a89a62eca703b2a17", + "command_id": "293cd547-080e-4e48-85bf-e00f5ae79ec7", + "record_time": "2026-07-28T06:16:04.915063Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0034ec08619ec15f2ec40a03927f83dafe300aa6fc8a41a07bfed791f77b85f993ca1212205f8e4d978db453a21674c753f206aad0cb5e86bf6ec0e46362456aae58cffaaf", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "004aa57f0931d220222de70ec32d91014b481750160caa93a0db77b5cd41c0fd0dca1212206f68dfa3ef2599afa56a4fa4f52c4299a04f9635e237b6124a132e036ba02ae0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00f9a5edb2072b465aa702eacd26f8fcba087d88ee79a0770f3ebbf7fe7663cb2cca12122011873962c67d159db5e1e71aba9d5180f2cf363d6136694f1814d80bb993b447", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4618, + "update_id": "1220c45e4c669428333eee3ed47047dba3e40dd349428170f3bee1836dc8d47a7d00", + "command_id": "113e6c0b-2124-4ff8-bb7f-bc77bed083ec", + "record_time": "2026-07-28T06:15:53.672503Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0063b2e9f09bf9dcdbbae3801eeb215e5bbb5209ecbadb9d152901aa53b71cb5e0ca1212208be864f2c708c12d2c1b1c71288aac70f80c522f5c79dde7ee5cd8aacd118707", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "0006f44fb141a40f2ec67f7391a87be64e30a230e32869a93e053943b1dfb2eeccca121220da3202b15da22baaf1543867fa0763d543921e82e16f4fbc5fc205bb2de57879", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00d00edaba1707cec79423548d1bf73c7ac9181dde1090e67bd0a6f524b7581238ca1212208c19584d7e34b005e27b951233a91d2421c1e9dd4295388c19c344f23613580b", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4588, + "update_id": "12208e245438fe86619df08169c5fac58d164688babe3accb0d4bc279f9f90122e0d", + "command_id": "da5b36a3-8535-4d98-97f1-1985421bb6df", + "record_time": "2026-07-28T06:07:48.009003Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00a95746da7a5c50efaac3b5c52f1b751541416b368fbe0d733c75514e7c959f13ca121220d2bf9f5b536046e6c2a98007e885311aab7fdcd11942076249fe6f97d179c1f0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0034ec08619ec15f2ec40a03927f83dafe300aa6fc8a41a07bfed791f77b85f993ca1212205f8e4d978db453a21674c753f206aad0cb5e86bf6ec0e46362456aae58cffaaf", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "009752aed75d4c5f362cac2d83aaf32c747fd00de658d97bc89c385cc9d264e6faca121220a5331548c6fe480827c50134e95e9455ec525fba14faf9439fcf26d4440cce1c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4579, + "update_id": "1220b27a7eecb88a6cc45a6a01bd04f69e1c69e8ec9f79afdc0489d6324909e8b893", + "command_id": "035aadfb-dae9-4b0e-af7c-dfb05ddcc696", + "record_time": "2026-07-28T06:05:31.777376Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00d4f2f0a11dc1b0632b2193e90f154ba6d8c39c0a2ec9f3c72d9f0a40a34f852eca121220b00fe6c7b80bf12d36edb2be3105a56806495d5f518b54fd0df5de66341e1525", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "009958a2a6408abbb0ad9a68cc17d710f6f1033b7bbec3611c90db4e49ada4781bca1212204dd1b1c9b3548fe0994cc0e97910f65f54559ad10955b09cb9ddd046d4809cce", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0063b2e9f09bf9dcdbbae3801eeb215e5bbb5209ecbadb9d152901aa53b71cb5e0ca1212208be864f2c708c12d2c1b1c71288aac70f80c522f5c79dde7ee5cd8aacd118707", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4566, + "update_id": "12208a67e8319e3a113bd915a38251476b658736f7dd9ab870ca18fa188eba1fafe3", + "command_id": "a3ee524b-43b8-4de9-9af9-bccf6b83e5a5", + "record_time": "2026-07-28T06:01:36.64828Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "005a0ba328f43f272ceaff6b05ce49a948fb30e75948f22af724c8e0bd3cd0df8bca1212206fdb75346b78221475338e7c37d50117f574c321f05e64cd52d3d2a7dd086756", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00a95746da7a5c50efaac3b5c52f1b751541416b368fbe0d733c75514e7c959f13ca121220d2bf9f5b536046e6c2a98007e885311aab7fdcd11942076249fe6f97d179c1f0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00513d951be3816e3f24cf6473ee41e2605ab3bd2739e4aa1873559aeccc738657ca1212206ac45e355b206c79fa40611c0d2e57f07dfba3e487102ede0565e7447b59f99f", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4559, + "update_id": "12208db4b656af0fd428b25c571dd1d811385ff9b6758a08d7003039e18c6bc4b6b9", + "command_id": "d4574bee-af0b-4888-9c35-6873187ee3e7", + "record_time": "2026-07-28T05:59:55.563326Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "001fa04926be31334943bc80eb769e47e7c269469ab5e784fe50b3ce8f643ceee6ca121220f4329846f5bbd2fd19cd6eb6fe1bb37750cb26c75ac76318c563b75ad27d68c3", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00405388a546d364f342fd686094f4e056e985ad995c33ff302fc6406e85d80cfcca12122062a9bfd82ccac20d92069a2c292677f8cba972d7cbb0953fc0a40846aefffb64", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00d4f2f0a11dc1b0632b2193e90f154ba6d8c39c0a2ec9f3c72d9f0a40a34f852eca121220b00fe6c7b80bf12d36edb2be3105a56806495d5f518b54fd0df5de66341e1525", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4521, + "update_id": "12205be548103c9ddd687d3015fc887994be8a28f20ed1ba7bbf224485772c53f979", + "command_id": "6e0e1ba9-9913-408d-a6a2-74dbe21ac9b9", + "record_time": "2026-07-28T05:48:29.205463Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "009dfb12b9b9224b1ed86bb042a22799886204643fec3c0b9b98fac6c7b353f139ca1212203e46d2b453900af86618d633064fa6833e94fbd0e28ee976530b15f23954ee4c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "004ff69f2539b046aa3013b4c759c78b92f81fa862989c9d97cada7001cfc49aabca121220949e071836bfcb0c7ff20e9e412605065f79e1ef22093b5e85a8ad36ae3547a2", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "001fa04926be31334943bc80eb769e47e7c269469ab5e784fe50b3ce8f643ceee6ca121220f4329846f5bbd2fd19cd6eb6fe1bb37750cb26c75ac76318c563b75ad27d68c3", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4518, + "update_id": "1220d029d6e17da1bc10c14e9855e9abe7c25c616642645f3741dde4484e358edc64", + "command_id": "9ed3eae0-8a26-471a-b730-2dc9240620f5", + "record_time": "2026-07-28T05:48:22.953844Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00aaa8483a2e606a525f9d2413bc387023175cf6b7925b272795c4428fafd699e7ca121220efe8aa3636a1e7198aa6bbaefa0eb1a009ca1ad12cd2a0e4b0bbfb877a44d551", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "005a0ba328f43f272ceaff6b05ce49a948fb30e75948f22af724c8e0bd3cd0df8bca1212206fdb75346b78221475338e7c37d50117f574c321f05e64cd52d3d2a7dd086756", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0006f44fb141a40f2ec67f7391a87be64e30a230e32869a93e053943b1dfb2eeccca121220da3202b15da22baaf1543867fa0763d543921e82e16f4fbc5fc205bb2de57879", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4481, + "update_id": "1220e70d6beb6f836bd0ff7c234ef0428913a4e2f0dc463d1a2e319892f44da14eb3", + "command_id": "efd136a9-7b85-4a89-9f74-7c213c16b8ce", + "record_time": "2026-07-28T05:37:19.226435Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "002adcf3dbd8bb6f1fd5d48f9f585e88612de8e493d38ec06c02b0932c5f8367aeca1212203f769d94abbaa3be7184333cc14816171229d92786a7a3ce8e86886dd524f9a7", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "0094994881fcfb838c4d1922e57ea1d6ae2599fae0fbb88f4946b56e1e2b20cc3dca121220f19feb5b4a70e1bbd0590949f8becd0de93edd264673b9fa76c8e2a46de4cc80", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "009dfb12b9b9224b1ed86bb042a22799886204643fec3c0b9b98fac6c7b353f139ca1212203e46d2b453900af86618d633064fa6833e94fbd0e28ee976530b15f23954ee4c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4474, + "update_id": "12205cecbb7e42f3d7df6673c3df20c87dcdc268e0cac83d25737cdc35ace6867beb", + "command_id": "746b8083-3cae-4de8-a268-841fe100b553", + "record_time": "2026-07-28T05:36:01.593388Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00d23364e7e2aeaadf600d0ceb978b9790b326a1fb3fbabd328e935dfabb19b70dca121220fe531388ddff3c9642838bb9ba756681244d0c6f9ce632c69288e4de26d924c0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00aaa8483a2e606a525f9d2413bc387023175cf6b7925b272795c4428fafd699e7ca121220efe8aa3636a1e7198aa6bbaefa0eb1a009ca1ad12cd2a0e4b0bbfb877a44d551", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "009958a2a6408abbb0ad9a68cc17d710f6f1033b7bbec3611c90db4e49ada4781bca1212204dd1b1c9b3548fe0994cc0e97910f65f54559ad10955b09cb9ddd046d4809cce", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4449, + "update_id": "1220b827adead8cdd4af01c560bc5f6ad9b2856c3958bc3e0e754bcef62250c2743b", + "command_id": "b7145923-2f19-4ab9-8b4d-2bc28f6b8362", + "record_time": "2026-07-28T05:29:22.843108Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00772268e314cc763c87f2d3cba004dc563efcc8aa871d0561087c48ce6bb556f6ca121220b6f4e19ad4c176b8c28b8c7ee553edeaa0694f18f9550f128a7455fb65442351", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00d23364e7e2aeaadf600d0ceb978b9790b326a1fb3fbabd328e935dfabb19b70dca121220fe531388ddff3c9642838bb9ba756681244d0c6f9ce632c69288e4de26d924c0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00405388a546d364f342fd686094f4e056e985ad995c33ff302fc6406e85d80cfcca12122062a9bfd82ccac20d92069a2c292677f8cba972d7cbb0953fc0a40846aefffb64", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4442, + "update_id": "1220d699f0e0de4eba4fcd091d1296b5753b5432283b13c875a0701a98de0992a9ea", + "command_id": "23381d8c-a34d-42ed-ab64-f240b70fcc62", + "record_time": "2026-07-28T05:27:56.850622Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00481164cf1685496029d630c1f8f965b506d1e7206e7c40f5f5377e250fbf9f51ca121220937b57aa2f817effb7633c1a09de3925f94bede34ac845ef581b17afe8577ee5", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00e8418619591c7cbb925203b6df63857be94ef8ed4925007a73e36f5520afc3d1ca121220a75892b9122c4aad4c9e473b7ac2334beeb766c0ddfb0661a8a610073af7e1a6", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "002adcf3dbd8bb6f1fd5d48f9f585e88612de8e493d38ec06c02b0932c5f8367aeca1212203f769d94abbaa3be7184333cc14816171229d92786a7a3ce8e86886dd524f9a7", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4394, + "update_id": "1220d71262b347d9792368699ba673d2dbcdf9f9b03293d2777423321667ffe6dbb4", + "command_id": "286e1112-e919-4417-aadd-57ffe2306e92", + "record_time": "2026-07-28T05:13:52.764035Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00849aff102fd453f0fd09c075fad71b807de944b5e80de129ed4a573d45e094f8ca121220f1edced450f34c20da96c1f14f787d26b11c92517bac13541d2745d3b16a3792", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00772268e314cc763c87f2d3cba004dc563efcc8aa871d0561087c48ce6bb556f6ca121220b6f4e19ad4c176b8c28b8c7ee553edeaa0694f18f9550f128a7455fb65442351", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "004ff69f2539b046aa3013b4c759c78b92f81fa862989c9d97cada7001cfc49aabca121220949e071836bfcb0c7ff20e9e412605065f79e1ef22093b5e85a8ad36ae3547a2", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4391, + "update_id": "12204377aa21c22f1f394fd4feb279ef6f5b86fd59c8d84945c224c2dfd18a47f80f", + "command_id": "f57f0bd0-3d69-47a7-914b-695bd9e83540", + "record_time": "2026-07-28T05:13:35.082179Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0053636993c7fd1e7e9aab900d15f2489f7eef554b3c04f7d3774853c65a7eb94aca121220786ea97d458647226525da639cf96544898c3caa9622e1102571cdd87716d841", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "0088cd92ecebfbad3a115c6260413f17bc3950206520247a19305d827329c72731ca121220048abcbaa6901dcc5032e558ce25c7351ebbac132f8c4e390a2214903a42c67a", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00481164cf1685496029d630c1f8f965b506d1e7206e7c40f5f5377e250fbf9f51ca121220937b57aa2f817effb7633c1a09de3925f94bede34ac845ef581b17afe8577ee5", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4380, + "update_id": "1220b036abe8e25c8fc81d85bc49d3768e1f5de7f2bef3c4bc21d67beaa85afbdaef", + "command_id": "385591fd-9ad2-4611-9577-dbd5ca248f06", + "record_time": "2026-07-28T05:10:24.901469Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00c2c0548cb998488c2b3a2a092473fbd4fe980b9e62e85b13fa9c78f09c336d29ca1212202e627de9d31ed66e3ea8a590ef15b32dba6c30b1ab1bfcef36f61d021464d626", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00849aff102fd453f0fd09c075fad71b807de944b5e80de129ed4a573d45e094f8ca121220f1edced450f34c20da96c1f14f787d26b11c92517bac13541d2745d3b16a3792", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0094994881fcfb838c4d1922e57ea1d6ae2599fae0fbb88f4946b56e1e2b20cc3dca121220f19feb5b4a70e1bbd0590949f8becd0de93edd264673b9fa76c8e2a46de4cc80", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4371, + "update_id": "122022c67458ebaa12e5f4a779917211744e104f540a83ba0c5b6894e787047c5634", + "command_id": "ace265e5-224f-4a92-b8a6-5633adadfaae", + "record_time": "2026-07-28T05:08:45.803108Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "004a9e10eb4289637049daa06eaf3b90d4c4ad70bdf216ea8cc278004a3eab4c58ca121220c051358aee8b87c2d43b25640a9f5aad45240f6dadb259952ed6be7ec7e89593", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "001d30883f3d2348b105ce8d371856ab10f9a85c67f8db59e4240a9f8e69e2474bca12122054fc4d90cadb70aba2818ea886519672ed48f34f16f01e52f8b8ab8df98a67f4", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0053636993c7fd1e7e9aab900d15f2489f7eef554b3c04f7d3774853c65a7eb94aca121220786ea97d458647226525da639cf96544898c3caa9622e1102571cdd87716d841", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4338, + "update_id": "1220ff6b43463abaa9295bd6b28751fb79d4d11eb9267d803010d1122b1c4753383c", + "command_id": "6bd6b7d4-0b17-436d-b5df-b3f0e58cf38e", + "record_time": "2026-07-28T04:58:31.951803Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00f7c3e0b09b8178e728b353d18e400f57e33c60417ba55307baf813ea3b7c2354ca121220f984b08338930d71cd27c21385f282cef3fc93eeb9afac9405fa2bf7069925a9", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00c2c0548cb998488c2b3a2a092473fbd4fe980b9e62e85b13fa9c78f09c336d29ca1212202e627de9d31ed66e3ea8a590ef15b32dba6c30b1ab1bfcef36f61d021464d626", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00e8418619591c7cbb925203b6df63857be94ef8ed4925007a73e36f5520afc3d1ca121220a75892b9122c4aad4c9e473b7ac2334beeb766c0ddfb0661a8a610073af7e1a6", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4335, + "update_id": "122023780b80f8622066ba9e3c08741c296202f0e0b17c459160757ee92a3c6dc66d", + "command_id": "16c47518-fecd-43a4-b41d-f03219bb99f5", + "record_time": "2026-07-28T04:58:11.848741Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "008d7bf1c6a44e4953e884ee24771d24ab5ea20e3659dfda4967a6c5cba9800edbca121220dc0195440b4b823fd91c2814a646856cf8ef6ba320a924df932f72f8167fb211", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "001c6be7a366f9fcf6b324eba5be3253065e5eddf1adcb709f2c044c472cfdc933ca121220f622f3bcbc7b04af4fae13b076ce87dd420ee7bf197de9872518ced40598fe9c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "004a9e10eb4289637049daa06eaf3b90d4c4ad70bdf216ea8cc278004a3eab4c58ca121220c051358aee8b87c2d43b25640a9f5aad45240f6dadb259952ed6be7ec7e89593", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4310, + "update_id": "122096e0354f119865fa14b2da0cfc08b14d837fefc4132cf20169fe488091bf0479", + "command_id": "d508fb4a-0207-4560-97f4-11b4953bd722", + "record_time": "2026-07-28T04:51:02.888829Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "007732be4c5f094a161191268cc9d34b42d4eb850f30209a175bcdfebe580cdb4bca121220626c94fa9bd2ad2f118392148f1377a9bbec4efbe24a09b836cc479182202aaa", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00f7c3e0b09b8178e728b353d18e400f57e33c60417ba55307baf813ea3b7c2354ca121220f984b08338930d71cd27c21385f282cef3fc93eeb9afac9405fa2bf7069925a9", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0088cd92ecebfbad3a115c6260413f17bc3950206520247a19305d827329c72731ca121220048abcbaa6901dcc5032e558ce25c7351ebbac132f8c4e390a2214903a42c67a", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4295, + "update_id": "12202d65f560e9c4aa973057bd2ce48257be8fe650ec1993663b17e1b4e01f877b8e", + "command_id": "3041e914-7357-4575-a6a0-6f22bad5ce05", + "record_time": "2026-07-28T04:46:38.663513Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "007b34dd3c864ae78b7e4d46c5be263ff99d77531a89cbcc706adf2d967eec3df5ca12122024010fd4c1d5ff3585f2a6841cfe38a816b790f46ba02c17b7651a93942b59a2", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "003d94ec59800e463d54b2c821dbb108bce8c9a7b574298e8bdeb6621407e74e2eca1212208a0023f27bf611a671089267a0f795bbc072e0404f617894a0781a1fa8d3e9df", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "008d7bf1c6a44e4953e884ee24771d24ab5ea20e3659dfda4967a6c5cba9800edbca121220dc0195440b4b823fd91c2814a646856cf8ef6ba320a924df932f72f8167fb211", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4267, + "update_id": "1220215d5962544eab146e8a082aaf53c9591372e07708a6143edd529adb819ddc8b", + "command_id": "6d17c6c1-2b5d-4c25-b403-432f9ef93ea9", + "record_time": "2026-07-28T04:38:25.656902Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00564bd65d605ea3542b8b7daa447ca1fc384b174e756783168fc4265f23fa9e6eca1212200d37ff90f172b881e697cd47c8a22240d8e5f12047e9d0028e971e64c8a0785d", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "007732be4c5f094a161191268cc9d34b42d4eb850f30209a175bcdfebe580cdb4bca121220626c94fa9bd2ad2f118392148f1377a9bbec4efbe24a09b836cc479182202aaa", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "001d30883f3d2348b105ce8d371856ab10f9a85c67f8db59e4240a9f8e69e2474bca12122054fc4d90cadb70aba2818ea886519672ed48f34f16f01e52f8b8ab8df98a67f4", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4260, + "update_id": "1220dc9d183e1a53f05d362816e00c63871e2c127e55a9057151b08e5dec541b0d3f", + "command_id": "9db8e584-eebd-4623-9853-7de5cfd67785", + "record_time": "2026-07-28T04:36:57.648808Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "003ae464ba922741bc925a93eb8d32774f0418bab3ef17b076f924d58257d88f7cca1212204bb320fd21713f62bb291d4c61f1edacde338401b316e1265267738cc2f1605d", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00e660cb8c1b3c778399e39119856cef49e7a237cb26a149a4cd66b1569faddf44ca121220c07c7e5512a80288bbd7e64b9e73df760466683a5e3f35a17eefbfc0c7949501", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "007b34dd3c864ae78b7e4d46c5be263ff99d77531a89cbcc706adf2d967eec3df5ca12122024010fd4c1d5ff3585f2a6841cfe38a816b790f46ba02c17b7651a93942b59a2", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4237, + "update_id": "1220487bbcd321d7122c0bf92427f9715859efc56c731f05ab7c2d38af405e286181", + "command_id": "e8d2800b-68eb-4bb6-b928-988abedeae2b", + "record_time": "2026-07-28T04:29:27.969311Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0079e40349256e42931d6679a56e738d692232e05ac1a6b1055469a1fc70859dbbca121220106e71d5379ef197378423ef25b3c77c00415a0f95d803268050ba1c1d80374f", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00564bd65d605ea3542b8b7daa447ca1fc384b174e756783168fc4265f23fa9e6eca1212200d37ff90f172b881e697cd47c8a22240d8e5f12047e9d0028e971e64c8a0785d", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "001c6be7a366f9fcf6b324eba5be3253065e5eddf1adcb709f2c044c472cfdc933ca121220f622f3bcbc7b04af4fae13b076ce87dd420ee7bf197de9872518ced40598fe9c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4226, + "update_id": "122094ebe84170cb013a59c8bd2d0b92ee7988af5c7d8dfbdfcb2917fdd4f45ca230", + "command_id": "398581cd-568c-495a-9d42-673d6f8ef857", + "record_time": "2026-07-28T04:26:50.458943Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "005d9a0f527e9d05e7e4bb6f731919c7b922d7e1def876e20cd95683355b35c19aca1212207cf5c5b25c941e5363c762c583d2f2f3c1c7816d38c76c640e17812e96d68372", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "000a724080b15feee336b1962ba397d8b089b83da381a06bf26bd6f7ea6ffb60ddca121220507a809cea9d972a5cbb4a3957f43f2372356b9bf044cbabb02dad4669ad8336", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "003ae464ba922741bc925a93eb8d32774f0418bab3ef17b076f924d58257d88f7cca1212204bb320fd21713f62bb291d4c61f1edacde338401b316e1265267738cc2f1605d", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4186, + "update_id": "12206b816e06ed08cc837649952bda4f4c9e23a778d509eb41d138c5dbc55745b5a6", + "command_id": "840d361d-67fa-493f-b4a9-74ed05b9399f", + "record_time": "2026-07-28T04:14:49.73215Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0016311db86e69bf737c04f22e53e1e929c46e7cf1c0aaa9bd8689dbef0f338c2bca12122084e158f627b4150570cc304a8d7a86b5323624ac37459b81ef80e90b01ea5f7e", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0079e40349256e42931d6679a56e738d692232e05ac1a6b1055469a1fc70859dbbca121220106e71d5379ef197378423ef25b3c77c00415a0f95d803268050ba1c1d80374f", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "003d94ec59800e463d54b2c821dbb108bce8c9a7b574298e8bdeb6621407e74e2eca1212208a0023f27bf611a671089267a0f795bbc072e0404f617894a0781a1fa8d3e9df", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4177, + "update_id": "1220ec05ce3ed09fa00a2f5538858346ad670a14d48e849d0025603fbafc4e1d255e", + "command_id": "18eb7d54-9fe1-46bf-a9d5-116337b71ce4", + "record_time": "2026-07-28T04:12:14.306146Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00f230fdb6a7a1d9bbf868fb7dd1dc993f7a94db8f1b139d372244452542dfab10ca121220bac93c961b39bd43d2d11cce8ccb9fcf7b82e91cf5a3ab435163ebb56b3276f0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00ac1f9bc56e5dc195fc7363eed22a7fa91c0156f82cdbfbc66ee8a7d6c8526d6bca1212209350303e8ea82c4aa003462d622fe8249b191d0386256107b8626963c14ab68c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "005d9a0f527e9d05e7e4bb6f731919c7b922d7e1def876e20cd95683355b35c19aca1212207cf5c5b25c941e5363c762c583d2f2f3c1c7816d38c76c640e17812e96d68372", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4158, + "update_id": "1220fa8fb2f8d12eeb451f2911b7959e04a6dbbd7018578abc68abd6e5aec6e0507e", + "command_id": "5d096174-6db0-420f-a5ce-51b59edbc57c", + "record_time": "2026-07-28T04:06:50.192529Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00952f436a8f3048eec0a7ef6dc7c0945e9a2b3f515b95104a1d60c0ebb6d4970fca121220207a6a4c100e9e5da0b4395c96bb3a698975cdc1790d0cbcbf91227c14732d2e", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0016311db86e69bf737c04f22e53e1e929c46e7cf1c0aaa9bd8689dbef0f338c2bca12122084e158f627b4150570cc304a8d7a86b5323624ac37459b81ef80e90b01ea5f7e", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00e660cb8c1b3c778399e39119856cef49e7a237cb26a149a4cd66b1569faddf44ca121220c07c7e5512a80288bbd7e64b9e73df760466683a5e3f35a17eefbfc0c7949501", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4153, + "update_id": "1220ce64d6e31eca8ca5e54b3717fae97b70da547dce40d2963e969de120c710e1a3", + "command_id": "1a6b14aa-06e3-4a1e-a000-2b33881b69fd", + "record_time": "2026-07-28T04:06:00.288173Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00b24f02b3884fe4b46cb1c8daa77959d8bcdb828b0df2f458a4c4d7569fefe8fdca1212203d66fd99002da06afb696cd6b3e83dd49d220de66bdf1d9afb78728457c7c861", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "000b6ccd503e9913267ba6bd365957d85e9b95a95411927f2509528cf2eadb4f29ca121220d5c5ee4670f304c155ca8c9683186005c39a582e74d8c9726b7a18aa62ee7194", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00f230fdb6a7a1d9bbf868fb7dd1dc993f7a94db8f1b139d372244452542dfab10ca121220bac93c961b39bd43d2d11cce8ccb9fcf7b82e91cf5a3ab435163ebb56b3276f0", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4116, + "update_id": "1220df7d2ca1fe52b3a3068d57874bc65dba9352b4b39e58ce6d4eb1d448b663afe2", + "command_id": "9212ef65-cb7c-4821-b0d3-50a572f6e167", + "record_time": "2026-07-28T03:55:06.451422Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00ead4809eb02cef3a30ca093960e3a0a694647fff052753e1791810c4bd41da47ca12122081526429094a57e33fa9d439859cd974d11b6eefb0e3ba169856d0429262b0e9", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "0030e582e86c88f9c5bfc4aacbee661ad7c9fa7b20622549a4b6fdf3c9376d14e1ca12122059ced6f00c8b51a071a6399557ce0a6a1e5315a972da65fcbb3ae561c6325082", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00b24f02b3884fe4b46cb1c8daa77959d8bcdb828b0df2f458a4c4d7569fefe8fdca1212203d66fd99002da06afb696cd6b3e83dd49d220de66bdf1d9afb78728457c7c861", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4109, + "update_id": "12209d2bef413e55c824bcdb38ee4e9213c291b00c766eafb824808999bae60d9f66", + "command_id": "27f5133d-6268-48a8-8fa0-3bf895c65576", + "record_time": "2026-07-28T03:53:54.758134Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0030a5931f474c491fa005687d628cde55ed23e9ff0d7a7fd673394d95aa6506e8ca121220bcc7f7aae208c1b97024f186a085cfa1194c2bda069df17f76b9083b8721ffc1", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00952f436a8f3048eec0a7ef6dc7c0945e9a2b3f515b95104a1d60c0ebb6d4970fca121220207a6a4c100e9e5da0b4395c96bb3a698975cdc1790d0cbcbf91227c14732d2e", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "000a724080b15feee336b1962ba397d8b089b83da381a06bf26bd6f7ea6ffb60ddca121220507a809cea9d972a5cbb4a3957f43f2372356b9bf044cbabb02dad4669ad8336", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4094, + "update_id": "1220ebf8745bf1ca3f0791341217e29b61e7a5de09213f0f3b71520a69ac2cd7c44c", + "command_id": "e5eaad37-7281-43d9-bf00-2d84289470f6", + "record_time": "2026-07-28T03:50:01.814518Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00c350b46a7654088421e58b43eb9bd61263d9246164371754eae56bd5d5ce22f0ca121220c95e040304d3a7e69424ae4a39b3eaec1032d7505394e3e248d2ebe079772e16", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0030a5931f474c491fa005687d628cde55ed23e9ff0d7a7fd673394d95aa6506e8ca121220bcc7f7aae208c1b97024f186a085cfa1194c2bda069df17f76b9083b8721ffc1", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00ac1f9bc56e5dc195fc7363eed22a7fa91c0156f82cdbfbc66ee8a7d6c8526d6bca1212209350303e8ea82c4aa003462d622fe8249b191d0386256107b8626963c14ab68c", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4074, + "update_id": "1220a2757503ddf01e2b25b297fc28890fdf6b02e9105bdf10731c4ace807bd41cbc", + "command_id": "46490696-12d1-4ee9-8930-2c3212a3fe6e", + "record_time": "2026-07-28T03:44:20.351724Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0075a6974525c1ea0aa04c754d6a2436cca0bba6ac7d25d382fa8e1e54f12c04c7ca121220547cc5692f2b7db629bf1b57a5f72c8c6ce2b48a4647e2b6ec32b278788e6550", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00f1105a038d9dbdf49b6c4ce372584aa17bb7948c4aafca2b21c010c5b35c7bd5ca1212205fb8b9bc8c6c10c5d3d093578bbabf9c8872a2cb14f66d22856c355b406344b4", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00ead4809eb02cef3a30ca093960e3a0a694647fff052753e1791810c4bd41da47ca12122081526429094a57e33fa9d439859cd974d11b6eefb0e3ba169856d0429262b0e9", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4051, + "update_id": "12205d050df2f58f48fb2314316d5f8ede1b277915948666052eb809808af6f8084c", + "command_id": "3f793e78-aa88-4eba-9fe2-908737ed446d", + "record_time": "2026-07-28T03:37:55.322855Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "00e756e3b71e88a2cc282926da458d5a1cb0f84be93ac9541a34dc3a116b53019aca1212209fd0d43282350a86f417f6aa59f08ba7a58012d59d29fe312861e66c198ba553", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "00c350b46a7654088421e58b43eb9bd61263d9246164371754eae56bd5d5ce22f0ca121220c95e040304d3a7e69424ae4a39b3eaec1032d7505394e3e248d2ebe079772e16", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "000b6ccd503e9913267ba6bd365957d85e9b95a95411927f2509528cf2eadb4f29ca121220d5c5ee4670f304c155ca8c9683186005c39a582e74d8c9726b7a18aa62ee7194", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + }, + { + "kind": "transaction", + "offset": 4026, + "update_id": "1220a2d72e413f260942f3f0bde45dfb6ce4d6f30b8361fdb4b08149bdede11f4f15", + "command_id": "59eb5b89-64ab-421a-8f58-df1382f56cf7", + "record_time": "2026-07-28T03:30:32.754803Z", + "synchronizer": "global-domain::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55", + "event_count": 3, + "events": [ + { + "kind": "archive", + "contract_id": "0097e03fa4e1b8f314c564be47a648058811b7bd4878e467754e4054cb9775279cca121220d02ab69745fdecac8c0772a23a321a528ab1e96ad8a046264c1769a7e60fa271", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "archive", + "contract_id": "00ae70652fa2b84f73bf4789fe61929da0b2fbff6c570779f7506df3ff4c7d8cbbca1212206ac7492517f8ff67f2c2a1b40eed0f846e853848d8e9a45394be797f51d6b731", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "create", + "contract_id": "0075a6974525c1ea0aa04c754d6a2436cca0bba6ac7d25d382fa8e1e54f12c04c7ca121220547cc5692f2b7db629bf1b57a5f72c8c6ce2b48a4647e2b6ec32b278788e6550", + "template": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.Amulet:Amulet", + "witnesses": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] + } + ], + "count": 50, + "scanned_from": 0, + "window_truncated": false +} diff --git a/frontend/mock/fixtures/tx-replay.json b/frontend/mock/fixtures/tx-replay.json new file mode 100644 index 00000000..76d9d973 --- /dev/null +++ b/frontend/mock/fixtures/tx-replay.json @@ -0,0 +1,48 @@ +{ + "schema_version": 1, + "instance": "demo", + "parties": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "update_id": "12209d3e92cc06e931157ed7d2db237b3e4bf97f1e8f1baedbe87e696e6d2162cba8", + "offset": 4927, + "effective_at": "2026-07-28T07:42:55Z", + "event_count": 3, + "events": [ + { + "kind": "exercised", + "node_id": 0, + "contract_id": "00e6889b718cbf704eb42f24f472ff39f85a5b230220def358f5ae09637e8e1edfca1212204de933043dde6428e0c3f602d9c05f436a8c724e2476056e7029f4edd5e4f579", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "choice": "ValidatorLicense_RecordValidatorLivenessActivity", + "acting_parties": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ], + "consuming": true + }, + { + "kind": "created", + "node_id": 2, + "contract_id": "0009aeb751ccba5edc302dfbf29c00cbb1a2086dd1b792980c2054d169e9b10e29ca12122064d1d99d4e78169204644f90f4abc60a76e1d2e38f2e2ab99684cec6a69aa001", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLicense", + "signatories": [ + "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55" + ], + "observers": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + }, + { + "kind": "created", + "node_id": 3, + "contract_id": "00bc364ebb0ce42e26cb0acf6bdca59abd0f10d5972676609a0ed25925f1b187e5ca1212205f7e2464943993f76b9a1e27417665a8edba40dd56b4939dd37ede426513fa10", + "template_id": "fb10433a48c24f30076a7aee03a3e314b7ab02fe9a22e2069e3af92d2b6ac88a:Splice.ValidatorLicense:ValidatorLivenessActivityRecord", + "signatories": [ + "DSO::122045dfff30d2dc721ec558fc0f41872c18fbb44a0865e17a75fbc6a46f902f0f55" + ], + "observers": [ + "app_user_localnet_1-localparty-1::122049adfabecad759e6eb37a5eae894955c5bd7f3325e2ef434b9f2808ca02ab249" + ] + } + ] +} diff --git a/frontend/mock/fixtures/version.json b/frontend/mock/fixtures/version.json new file mode 100644 index 00000000..096af47f --- /dev/null +++ b/frontend/mock/fixtures/version.json @@ -0,0 +1,4 @@ +{ + "name": "canton-devkit", + "schema_version": 1 +} diff --git a/frontend/mock/http.ts b/frontend/mock/http.ts new file mode 100644 index 00000000..b410b9ce --- /dev/null +++ b/frontend/mock/http.ts @@ -0,0 +1,72 @@ +import type { ServerResponse } from "node:http"; + +export function jsonResponse(res: ServerResponse, status: number, body: unknown): void { + const payload = JSON.stringify(body); + res.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }); + res.end(payload); +} + +export function textResponse(res: ServerResponse, status: number, body: string): void { + res.writeHead(status, { + "Content-Type": "text/plain; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); +} + +export function noContent(res: ServerResponse): void { + res.writeHead(204); + res.end(); +} + +export function notFound(res: ServerResponse): void { + jsonResponse(res, 404, { + code: "NOT_FOUND", + error: "not found", + }); +} + +export function parseJsonBody(raw: string): T | undefined { + if (!raw) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } +} + +export async function readRequestBody(req: { on: Function }): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +export function sendSseEvent( + res: ServerResponse, + data: unknown, + opts?: { id?: string; event?: string }, +): void { + if (opts?.id) res.write(`id: ${opts.id}\n`); + if (opts?.event) res.write(`event: ${opts.event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); +} + +export function beginSse(res: ServerResponse): void { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); +} + +export function scheduleKeepalive(res: ServerResponse, intervalMs = 30000): NodeJS.Timeout { + return setInterval(() => { + res.write(": keepalive\n\n"); + }, intervalMs); +} diff --git a/frontend/mock/plugin.ts b/frontend/mock/plugin.ts new file mode 100644 index 00000000..f32b23b3 --- /dev/null +++ b/frontend/mock/plugin.ts @@ -0,0 +1,32 @@ +import type { Connect } from "vite"; +import { createMockRouter } from "./router.ts"; + +export function mockApiPlugin(fixtureDir?: string): Connect.NextHandleFunction { + const router = createMockRouter(fixtureDir); + + const middleware: Connect.NextHandleFunction = (req, res, next) => { + const url = req.url ?? ""; + if ( + !url.startsWith("/api") && + !url.startsWith("/events") && + url !== "/healthz" + ) { + next(); + return; + } + router.handle(req, res, url); + }; + + return middleware; +} + +export function mockApiPluginVite(fixtureDir?: string) { + return { + name: "canton-devkit-mock-api", + configureServer(server: { middlewares: { use: (fn: Connect.NextHandleFunction) => void } }) { + server.middlewares.use(mockApiPlugin(fixtureDir)); + // eslint-disable-next-line no-console + console.log("\n Mock API enabled — no Go backend required\n"); + }, + }; +} diff --git a/frontend/mock/router.test.ts b/frontend/mock/router.test.ts new file mode 100644 index 00000000..a6eb0700 --- /dev/null +++ b/frontend/mock/router.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { EventEmitter } from "node:events"; +import { createMockRouter } from "./router.ts"; + +function mockReq(method: string, body = ""): IncomingMessage { + const req = new EventEmitter() as IncomingMessage & EventEmitter; + req.method = method; + process.nextTick(() => { + if (body) req.emit("data", Buffer.from(body)); + req.emit("end"); + }); + return req; +} + +function mockRes(): ServerResponse & { status: number; headers: Record; body: string } { + const res = { + status: 0, + headers: {} as Record, + body: "", + writeHead(status: number, headers?: Record) { + this.status = status; + if (headers) Object.assign(this.headers, headers); + }, + end(chunk?: string | Buffer) { + if (chunk) this.body += String(chunk); + }, + write(chunk: string) { + this.body += chunk; + }, + on() { + return this; + }, + }; + return res as ServerResponse & typeof res; +} + +describe("mock router", () => { + it("GET /api/version returns schema 1", () => { + const router = createMockRouter(); + const res = mockRes(); + router.handle(mockReq("GET"), res, "/api/version"); + expect(res.status).toBe(200); + const body = JSON.parse(res.body); + expect(body.schema_version).toBe(1); + }); + + it("GET /api/instances returns seeded demo instance", () => { + const router = createMockRouter(); + const res = mockRes(); + router.handle(mockReq("GET"), res, "/api/instances"); + const body = JSON.parse(res.body); + expect(body.instances.some((i: { name: string }) => i.name === "demo")).toBe(true); + }); + + it("POST /api/instances returns 202 and adds instance", async () => { + const router = createMockRouter(); + const res = mockRes(); + router.handle( + mockReq("POST", JSON.stringify({ name: "new-mock", version: "0.6.4" })), + res, + "/api/instances", + ); + await new Promise((r) => setTimeout(r, 10)); + expect(res.status).toBe(202); + const body = JSON.parse(res.body); + expect(body.events_url).toContain("/api/instances/new-mock/events"); + + const listRes = mockRes(); + router.handle(mockReq("GET"), listRes, "/api/instances"); + const list = JSON.parse(listRes.body); + expect(list.instances.some((i: { name: string }) => i.name === "new-mock")).toBe(true); + }); + + it("unmatched route returns 404 envelope", () => { + const router = createMockRouter(); + const res = mockRes(); + router.handle(mockReq("GET"), res, "/api/unknown-endpoint"); + expect(res.status).toBe(404); + const body = JSON.parse(res.body); + expect(body.code).toBe("NOT_FOUND"); + }); +}); diff --git a/frontend/mock/router.ts b/frontend/mock/router.ts new file mode 100644 index 00000000..82458cc9 --- /dev/null +++ b/frontend/mock/router.ts @@ -0,0 +1,612 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { + jsonResponse, + noContent, + notFound, + parseJsonBody, + readRequestBody, + textResponse, +} from "./http.ts"; +import { + DEFAULT_INSTANCE, + SCHEMA_VERSION, + createStore, + findInstanceSummary, + instanceNames, + removeInstance, + setInstanceStatus, + upsertInstanceSummary, + type MockStore, +} from "./store.ts"; +import { + handleContractsStreamSse, + handleDarWatchSse, + handleInstanceProgressSse, + queueCreateProgress, +} from "./sse.ts"; + +export type MockRouter = ReturnType; + +export function createMockRouter(fixtureDir?: string) { + const store = createStore(fixtureDir); + return { + store, + handle(req: IncomingMessage, res: ServerResponse, rawUrl: string): boolean { + const url = new URL(rawUrl, "http://127.0.0.1"); + const path = url.pathname; + const method = (req.method ?? "GET").toUpperCase(); + + if (path === "/healthz" && method === "GET") { + textResponse(res, 200, "ok"); + return true; + } + + if (path === "/api/version" && method === "GET") { + jsonResponse(res, 200, store.version); + return true; + } + + if (path === "/events" && method === "GET") { + // Global hub — minimal keepalive-only stream. + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + }); + const timer = setInterval(() => res.write(": keepalive\n\n"), 30000); + res.on("close", () => clearInterval(timer)); + return true; + } + + if (path === "/api/instances" && method === "GET") { + jsonResponse(res, 200, store.instances); + return true; + } + + if (path === "/api/instances" && method === "POST") { + void handleCreateInstance(req, res, store); + return true; + } + + if (path === "/api/instances/restore" && method === "POST") { + void handleRestore(req, res, store); + return true; + } + + const instMatch = path.match(/^\/api\/instances\/([^/]+)(\/.*)?$/); + if (instMatch) { + const name = decodeURIComponent(instMatch[1]); + const rest = instMatch[2] ?? ""; + if (handleInstanceRoute(req, res, store, name, rest, method, url)) return true; + } + + if (path === "/api/doctor" && method === "GET") { + jsonResponse(res, 200, store.doctor); + return true; + } + + if (path === "/api/preflight" && method === "GET") { + jsonResponse(res, 200, store.preflight); + return true; + } + + if (path === "/api/splice/versions" && method === "GET") { + jsonResponse(res, 200, store.spliceVersions); + return true; + } + + if (path === "/api/skills" && method === "GET") { + jsonResponse(res, 200, store.skills); + return true; + } + + if (path === "/api/skills/install" && method === "POST") { + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + installed: ["improve-e2e-tests"], + }); + return true; + } + + if (path === "/api/dar/watch/publish" && method === "POST") { + noContent(res); + return true; + } + + if (path === "/api/dar/watch/events" && method === "GET") { + const instance = url.searchParams.get("instance") ?? DEFAULT_INSTANCE; + const dar = url.searchParams.get("dar") ?? "token-dar"; + handleDarWatchSse(res, instance, dar); + return true; + } + + if (path.startsWith("/api/tokens")) { + if (handleTokensRoute(req, res, store, path, method, url)) return true; + } + + if (path.startsWith("/api/parties")) { + if (handlePartiesRoute(req, res, store, path, method, url)) return true; + } + + notFound(res); + return true; + }, + }; +} + +async function handleCreateInstance( + req: IncomingMessage, + res: ServerResponse, + store: MockStore, +): Promise { + const body = parseJsonBody<{ name?: string; version?: string }>(await readRequestBody(req)); + const name = body?.name ?? `mock-${Date.now()}`; + const version = body?.version ?? "0.6.4"; + upsertInstanceSummary(store, { + name, + status: "creating", + splice_version: version, + ports: "", + started_ago: "just now", + }); + queueCreateProgress(store, name); + jsonResponse(res, 202, { + schema_version: SCHEMA_VERSION, + instance: name, + events_url: `/api/instances/${encodeURIComponent(name)}/events`, + }); +} + +async function handleRestore( + req: IncomingMessage, + res: ServerResponse, + store: MockStore, +): Promise { + await readRequestBody(req); + const name = `restored-${Date.now()}`; + upsertInstanceSummary(store, { + name, + status: "running", + splice_version: "0.6.4", + ports: "", + started_ago: "just now", + }); + jsonResponse(res, 200, { name, restored: true }); +} + +function handleInstanceRoute( + req: IncomingMessage, + res: ServerResponse, + store: MockStore, + name: string, + rest: string, + method: string, + url: URL, +): boolean { + if (rest === "" && method === "GET") { + if (!findInstanceSummary(store, name)) { + notFound(res); + return true; + } + const detail = structuredClone(store.instanceDetail); + detail.name = name; + jsonResponse(res, 200, detail); + return true; + } + + if (rest === "" && method === "DELETE") { + removeInstance(store, name); + noContent(res); + return true; + } + + if (rest === "/events" && method === "GET") { + handleInstanceProgressSse(res, store, name); + return true; + } + + if (rest === "/up" && method === "DELETE") { + noContent(res); + return true; + } + + if (rest === "/up" && method === "POST") { + setInstanceStatus(store, name, "running"); + queueCreateProgress(store, name); + jsonResponse(res, 202, { + schema_version: SCHEMA_VERSION, + instance: name, + events_url: `/api/instances/${encodeURIComponent(name)}/events`, + }); + return true; + } + + if (rest === "/recreate" && method === "POST") { + setInstanceStatus(store, name, "creating"); + queueCreateProgress(store, name); + jsonResponse(res, 202, { + schema_version: SCHEMA_VERSION, + instance: name, + events_url: `/api/instances/${encodeURIComponent(name)}/events`, + }); + return true; + } + + if (rest === "/start" && method === "POST") { + setInstanceStatus(store, name, "running"); + noContent(res); + return true; + } + + if ( + (rest === "/stop" || rest === "/down" || rest === "/pause") && + method === "POST" + ) { + setInstanceStatus(store, name, rest === "/pause" ? "paused" : "stopped"); + noContent(res); + return true; + } + + if (rest === "/resume" && method === "POST") { + setInstanceStatus(store, name, "running"); + noContent(res); + return true; + } + + if (rest === "/observability" && method === "POST") { + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + instance: name, + prometheus: true, + grafana: true, + enabled: true, + prometheus_ui: "http://127.0.0.1:9090", + grafana_ui: "http://127.0.0.1:3000", + }); + return true; + } + + if (rest === "/containers" && method === "GET") { + const body = structuredClone(store.containers); + body.instance = name; + jsonResponse(res, 200, body); + return true; + } + + const logsMatch = rest.match(/^\/containers\/([^/]+)\/logs$/); + if (logsMatch && method === "GET") { + const container = decodeURIComponent(logsMatch[1]); + textResponse( + res, + 200, + `[mock] logs for ${container}\n2026-05-30T10:00:00Z INFO participant started\n`, + ); + return true; + } + + const restartMatch = rest.match(/^\/containers\/([^/]+)\/restart$/); + if (restartMatch && method === "POST") { + noContent(res); + return true; + } + + if (rest === "/jwt" && method === "POST") { + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + token: "mock.jwt.token", + party: "alice::abc", + audience: "https://canton.network.global", + role: "app-user", + warning_dev_secret: "LocalNet HS256 secret — dev only", + expires_in_seconds: 3600, + }); + return true; + } + + if (rest === "/app-config" && method === "GET") { + const format = url.searchParams.get("format") ?? "json"; + if (format === "env") { + textResponse( + res, + 200, + "CANTON_PARTICIPANT_URL=http://127.0.0.1:60475\nCANTON_APP_USER_JWT=mock.jwt.token\n", + ); + return true; + } + if (format === "yaml") { + textResponse( + res, + 200, + "participant_url: http://127.0.0.1:60475\napp_user_jwt: mock.jwt.token\n", + ); + return true; + } + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + instance: name, + vars: { + CANTON_PARTICIPANT_URL: "http://127.0.0.1:60475", + CANTON_APP_USER_JWT: "mock.jwt.token", + }, + }); + return true; + } + + if (rest === "/contracts" && method === "GET") { + const body = structuredClone(store.contracts); + body.instance = name; + jsonResponse(res, 200, body); + return true; + } + + if (rest === "/contracts/stream" && method === "GET") { + handleContractsStreamSse(res); + return true; + } + + const contractDetailMatch = rest.match(/^\/contracts\/([^/]+)$/); + if (contractDetailMatch && method === "GET") { + const detail = structuredClone(store.contractDetail); + if (detail.contract && typeof detail.contract === "object") { + (detail.contract as Record).contract_id = decodeURIComponent( + contractDetailMatch[1], + ); + } + jsonResponse(res, 200, detail); + return true; + } + + if (rest === "/transactions" && method === "GET") { + const body = structuredClone(store.transactions); + body.instance = name; + jsonResponse(res, 200, body); + return true; + } + + const txReplayMatch = rest.match(/^\/transactions\/([^/]+)\/replay$/); + if (txReplayMatch && method === "GET") { + const replay = structuredClone(store.txReplay); + replay.update_id = decodeURIComponent(txReplayMatch[1]); + jsonResponse(res, 200, replay); + return true; + } + + if (rest === "/dar" && method === "GET") { + const body = structuredClone(store.dar); + body.instance = name; + jsonResponse(res, 200, body); + return true; + } + + if (rest === "/dar" && method === "POST") { + void readRequestBody(req).then(() => { + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + uploaded: [{ id: "new-dar", name: "uploaded", version: "1.0.0" }], + }); + }); + return true; + } + + const darInspectMatch = rest.match(/^\/dar\/([^/]+)\/inspect$/); + if (darInspectMatch && method === "GET") { + const body = structuredClone(store.darInspect); + body.dar_id = decodeURIComponent(darInspectMatch[1]); + jsonResponse(res, 200, body); + return true; + } + + if (rest === "/dar/diff" && method === "GET") { + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + added: [], + removed: [], + changed: [], + }); + return true; + } + + const darVettingGetMatch = rest.match(/^\/dar\/([^/]+)\/vetting$/); + if (darVettingGetMatch && method === "GET") { + const body = structuredClone(store.darVetting); + body.dar_id = decodeURIComponent(darVettingGetMatch[1]); + jsonResponse(res, 200, body); + return true; + } + + const darVettingPostMatch = rest.match(/^\/dar\/([^/]+)\/vetting\/([^/]+)$/); + if (darVettingPostMatch && method === "POST") { + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + dar_id: decodeURIComponent(darVettingPostMatch[1]), + role: decodeURIComponent(darVettingPostMatch[2]), + vetted: true, + }); + return true; + } + + if (rest === "/metrics/summary" && method === "GET") { + const body = structuredClone(store.metricsSummary); + body.instance = name; + jsonResponse(res, 200, body); + return true; + } + + if (rest === "/metrics/range" && method === "GET") { + jsonResponse(res, 200, store.metricsRange); + return true; + } + + if (rest === "/metrics" && method === "GET") { + jsonResponse(res, 200, { status: "success", data: { result: [] } }); + return true; + } + + const snapshotMatch = rest === "/snapshot" && method === "POST"; + if (snapshotMatch) { + void readRequestBody(req).then(() => { + res.writeHead(200, { + "Content-Type": "application/gzip", + "Content-Disposition": `attachment; filename="${name}-snapshot.tar.gz"`, + }); + res.end(Buffer.from("mock-snapshot")); + }); + return true; + } + + return false; +} + +function handleTokensRoute( + _req: IncomingMessage, + res: ServerResponse, + store: MockStore, + path: string, + method: string, + url: URL, +): boolean { + if (path === "/api/tokens" && method === "GET") { + jsonResponse(res, 200, store.tokens); + return true; + } + + if (path === "/api/tokens/matrix" && method === "GET") { + jsonResponse(res, 200, store.tokensMatrix); + return true; + } + + if (path === "/api/tokens" && method === "POST") { + jsonResponse(res, 201, { + schema_version: SCHEMA_VERSION, + symbol: "NEW", + name: "New Token", + instrument_id: "NEW", + }); + return true; + } + + if (path === "/api/tokens/demo" && method === "POST") { + jsonResponse(res, 201, { + schema_version: SCHEMA_VERSION, + symbol: "DEMO", + instrument_id: "DEMO", + minted: "1000", + }); + return true; + } + + const symbolMatch = path.match(/^\/api\/tokens\/([^/]+)(\/.*)?$/); + if (!symbolMatch) return false; + const symbol = decodeURIComponent(symbolMatch[1]); + const sub = symbolMatch[2] ?? ""; + + if (sub === "" && method === "GET") { + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + symbol, + instrument_id: symbol, + admin: "alice::abc", + }); + return true; + } + + if (sub === "/summary" && method === "GET") { + jsonResponse(res, 200, store.tokenSummary); + return true; + } + + if (sub === "/activity" && method === "GET") { + jsonResponse(res, 200, store.tokenActivity); + return true; + } + + if (sub === "/holdings" && method === "GET") { + jsonResponse(res, 200, store.tokenHoldings); + return true; + } + + if (sub === "/transfer" && method === "POST") { + if (url.searchParams.get("plan") === "1") { + jsonResponse(res, 200, { + schema_version: SCHEMA_VERSION, + plan: { + instrument: symbol, + from: "bob", + amount: "100", + inputs: [{ contract_id: "00abc123", amount: "100.0" }], + total_input: "100.0", + change: "0.0", + sufficient: true, + }, + }); + return true; + } + jsonResponse(res, 201, { schema_version: SCHEMA_VERSION, transfer_id: "tx-mock-1" }); + return true; + } + + if ( + (sub === "/mint" || sub === "/burn" || sub === "/faucet") && + method === "POST" + ) { + jsonResponse(res, sub === "/mint" ? 201 : 200, { + schema_version: SCHEMA_VERSION, + symbol, + amount: "100", + }); + return true; + } + + const acceptMatch = path.match(/^\/api\/tokens\/transfers\/([^/]+)\/accept$/); + if (acceptMatch && method === "POST") { + jsonResponse(res, 200, { schema_version: SCHEMA_VERSION, accepted: true }); + return true; + } + + return false; +} + +function handlePartiesRoute( + req: IncomingMessage, + res: ServerResponse, + store: MockStore, + path: string, + method: string, + _url: URL, +): boolean { + if (path === "/api/parties" && method === "GET") { + jsonResponse(res, 200, store.parties); + return true; + } + + if (path === "/api/parties" && method === "POST") { + void readRequestBody(req).then((raw) => { + const body = parseJsonBody<{ alias?: string }>(raw); + const party = { + alias: body?.alias ?? "new-party", + party_id: `${body?.alias ?? "new"}::mock`, + role: "app-user", + is_local: true, + created_at: new Date().toISOString(), + }; + const list = (store.parties.parties as unknown[]) ?? []; + list.push(party); + store.parties.parties = list; + jsonResponse(res, 201, { schema_version: SCHEMA_VERSION, ...party }); + }); + return true; + } + + const deleteMatch = path.match(/^\/api\/parties\/([^/]+)$/); + if (deleteMatch && method === "DELETE") { + const alias = decodeURIComponent(deleteMatch[1]); + store.parties.parties = ((store.parties.parties as { alias: string }[]) ?? []).filter( + (p) => p.alias !== alias, + ); + noContent(res); + return true; + } + + return false; +} + +export { instanceNames }; diff --git a/frontend/mock/seed-utils.ts b/frontend/mock/seed-utils.ts new file mode 100644 index 00000000..8d364f8d --- /dev/null +++ b/frontend/mock/seed-utils.ts @@ -0,0 +1,197 @@ +export const SCHEMA_VERSION = 1; + +export interface SeedOptions { + baseUrl: string; + instance: string; + role: string; + asName?: string; + dryRun: boolean; +} + +export interface SeedTarget { + file: string; + path: string; + optional?: boolean; +} + +export function buildSeedTargets(opts: SeedOptions): SeedTarget[] { + const { instance, role } = opts; + const inst = encodeURIComponent(instance); + const q = (params: Record) => { + const sp = new URLSearchParams(params); + return `?${sp.toString()}`; + }; + + return [ + { file: "version.json", path: "/api/version" }, + { file: "instances.json", path: "/api/instances" }, + { file: `instance-${opts.asName ?? instance}.json`, path: `/api/instances/${inst}` }, + { file: "containers.json", path: `/api/instances/${inst}/containers`, optional: true }, + { + file: "contracts.json", + path: `/api/instances/${inst}/contracts${q({ role, limit: "50" })}`, + optional: true, + }, + { + file: "transactions.json", + path: `/api/instances/${inst}/transactions${q({ role, limit: "50" })}`, + optional: true, + }, + { file: "dar.json", path: `/api/instances/${inst}/dar${q({ role })}`, optional: true }, + { + file: "metrics-summary.json", + path: `/api/instances/${inst}/metrics/summary`, + optional: true, + }, + { + file: "metrics-range.json", + path: `/api/instances/${inst}/metrics/range${q({ + query: "canton_transactions_total", + window: "1h", + step: "60", + })}`, + optional: true, + }, + { + file: "tokens.json", + path: `/api/tokens${q({ instance, role })}`, + optional: true, + }, + { + file: "tokens-matrix.json", + path: `/api/tokens/matrix${q({ instance, role })}`, + optional: true, + }, + { + file: "parties.json", + path: `/api/parties${q({ instance, role })}`, + optional: true, + }, + { file: "doctor.json", path: "/api/doctor" }, + { file: "preflight.json", path: "/api/preflight" }, + { file: "splice-versions.json", path: "/api/splice/versions" }, + { file: "skills.json", path: "/api/skills", optional: true }, + ]; +} + +export function buildDerivedTargets( + opts: SeedOptions, + contracts: { contracts?: Array<{ contract_id?: string }> }, + transactions: { transactions?: Array<{ update_id?: string }> }, + dars: { dars?: Array<{ id?: string }> }, + tokens: { instruments?: Array<{ symbol?: string }> }, +): SeedTarget[] { + const { instance, role } = opts; + const inst = encodeURIComponent(instance); + const q = (params: Record) => `?${new URLSearchParams(params).toString()}`; + const out: SeedTarget[] = []; + + const firstContract = contracts.contracts?.[0]?.contract_id; + if (firstContract) { + out.push({ + file: "contract-detail.json", + path: `/api/instances/${inst}/contracts/${encodeURIComponent(firstContract)}${q({ role })}`, + optional: true, + }); + } + + const firstTx = transactions.transactions?.[0]?.update_id; + if (firstTx) { + out.push({ + file: "tx-replay.json", + path: `/api/instances/${inst}/transactions/${encodeURIComponent(firstTx)}/replay${q({ role })}`, + optional: true, + }); + } + + const firstDar = dars.dars?.[0]?.id; + if (firstDar) { + const darEnc = encodeURIComponent(firstDar); + out.push({ + file: "dar-inspect.json", + path: `/api/instances/${inst}/dar/${darEnc}/inspect${q({ role })}`, + optional: true, + }); + out.push({ + file: "dar-vetting.json", + path: `/api/instances/${inst}/dar/${darEnc}/vetting`, + optional: true, + }); + } + + const firstSymbol = tokens.instruments?.[0]?.symbol; + if (firstSymbol) { + const sym = encodeURIComponent(firstSymbol); + const base = q({ instance, role }); + out.push( + { file: "token-summary.json", path: `/api/tokens/${sym}/summary${base}`, optional: true }, + { file: "token-activity.json", path: `/api/tokens/${sym}/activity${base}`, optional: true }, + { file: "token-holdings.json", path: `/api/tokens/${sym}/holdings${base}`, optional: true }, + ); + } + + return out; +} + +/** Rewrite instance name fields so pulled data can be served as a stable mock name. */ +export function rewriteInstanceName(data: unknown, from: string, to: string): unknown { + if (from === to) return data; + const walk = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(walk); + if (node && typeof node === "object") { + const out: Record = {}; + for (const [k, v] of Object.entries(node)) { + if (k === "name" && v === from) out[k] = to; + else if (k === "instance" && v === from) out[k] = to; + else if (k === "compose_project" && typeof v === "string" && v.includes(from)) { + out[k] = v.replaceAll(from, to); + } else if (k === "container_prefix" && typeof v === "string" && v.startsWith(from)) { + out[k] = v.replace(from, to); + } else out[k] = walk(v); + } + return out; + } + return node; + }; + return walk(data); +} + +export function validateSchema(body: unknown): boolean { + return ( + !!body && + typeof body === "object" && + "schema_version" in body && + (body as { schema_version: number }).schema_version === SCHEMA_VERSION + ); +} + +export function parseSeedArgs(argv: string[]): SeedOptions | { error: string } { + let baseUrl = "http://127.0.0.1:7777"; + let instance = ""; + let role = "app-user"; + let asName: string | undefined; + let dryRun = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--base-url" && argv[i + 1]) baseUrl = argv[++i]; + else if (arg === "--instance" && argv[i + 1]) instance = argv[++i]; + else if (arg === "--role" && argv[i + 1]) role = argv[++i]; + else if (arg === "--as" && argv[i + 1]) asName = argv[++i]; + else if (arg === "--dry-run") dryRun = true; + else if (arg === "--help" || arg === "-h") { + return { + error: [ + "Usage: npm run mock:seed -- --instance [options]", + " --base-url URL Backend base URL (default http://127.0.0.1:7777)", + " --role ROLE Role for scoped endpoints (default app-user)", + " --as NAME Rewrite instance name in output (e.g. demo)", + " --dry-run Print targets without writing files", + ].join("\n"), + }; + } + } + + if (!instance) return { error: "--instance is required" }; + return { baseUrl: baseUrl.replace(/\/$/, ""), instance, role, asName, dryRun }; +} diff --git a/frontend/mock/seed.test.ts b/frontend/mock/seed.test.ts new file mode 100644 index 00000000..bc1cfad0 --- /dev/null +++ b/frontend/mock/seed.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { + buildDerivedTargets, + buildSeedTargets, + rewriteInstanceName, + validateSchema, +} from "./seed-utils.ts"; + +describe("seed-utils", () => { + const baseOpts = { + baseUrl: "http://127.0.0.1:7777", + instance: "mynet", + role: "app-user", + dryRun: false, + }; + + it("buildSeedTargets includes core endpoints", () => { + const targets = buildSeedTargets(baseOpts); + expect(targets.find((t) => t.file === "version.json")?.path).toBe("/api/version"); + expect(targets.find((t) => t.file === "instance-mynet.json")?.path).toBe( + "/api/instances/mynet", + ); + expect(targets.find((t) => t.file === "contracts.json")?.path).toContain("role=app-user"); + }); + + it("buildDerivedTargets adds contract and token drill-down", () => { + const derived = buildDerivedTargets( + baseOpts, + { contracts: [{ contract_id: "00abc" }] }, + { transactions: [{ update_id: "u1" }] }, + { dars: [{ id: "dar-1" }] }, + { instruments: [{ symbol: "RTK" }] }, + ); + expect(derived.some((t) => t.file === "contract-detail.json")).toBe(true); + expect(derived.some((t) => t.file === "tx-replay.json")).toBe(true); + expect(derived.some((t) => t.file === "dar-inspect.json")).toBe(true); + expect(derived.some((t) => t.file === "token-summary.json")).toBe(true); + }); + + it("rewriteInstanceName renames instance fields", () => { + const input = { + name: "mynet", + instance: "mynet", + instances: [{ name: "mynet" }], + compose_project: "canton-mynet", + container_prefix: "mynet-", + }; + const out = rewriteInstanceName(input, "mynet", "demo") as typeof input; + expect(out.name).toBe("demo"); + expect(out.instance).toBe("demo"); + expect(out.instances[0].name).toBe("demo"); + expect(out.compose_project).toBe("canton-demo"); + expect(out.container_prefix).toBe("demo-"); + }); + + it("validateSchema accepts schema_version 1", () => { + expect(validateSchema({ schema_version: 1 })).toBe(true); + expect(validateSchema({ schema_version: 2 })).toBe(false); + expect(validateSchema(null)).toBe(false); + }); +}); diff --git a/frontend/mock/seed.ts b/frontend/mock/seed.ts new file mode 100644 index 00000000..69a39cde --- /dev/null +++ b/frontend/mock/seed.ts @@ -0,0 +1,143 @@ +#!/usr/bin/env node +import { writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + buildDerivedTargets, + buildSeedTargets, + parseSeedArgs, + rewriteInstanceName, + validateSchema, + type SeedOptions, + type SeedTarget, +} from "./seed-utils.ts"; +import { FIXTURES_DIR } from "./store.ts"; + +const mockDir = dirname(fileURLToPath(import.meta.url)); +const fixturesDir = join(mockDir, "fixtures"); + +interface FetchResult { + ok: boolean; + status: number; + body: unknown; +} + +async function fetchJson(baseUrl: string, path: string): Promise { + const resp = await fetch(`${baseUrl}${path}`, { + headers: { Accept: "application/json" }, + }); + const text = await resp.text(); + let body: unknown; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = undefined; + } + return { ok: resp.ok, status: resp.status, body }; +} + +function applyRename(body: unknown, opts: SeedOptions): unknown { + if (!opts.asName || opts.asName === opts.instance) return body; + return rewriteInstanceName(body, opts.instance, opts.asName); +} + +async function writeTarget( + opts: SeedOptions, + target: SeedTarget, + fetched: Map, +): Promise { + const url = `${opts.baseUrl}${target.path}`; + if (opts.dryRun) { + // eslint-disable-next-line no-console + console.log(`[dry-run] ${target.file} <= ${url}`); + return true; + } + + const result = await fetchJson(opts.baseUrl, target.path); + if (!result.ok) { + const msg = `WARN skip ${target.file}: HTTP ${result.status}`; + // eslint-disable-next-line no-console + console.warn(msg); + return false; + } + + if (!validateSchema(result.body) && target.file !== "metrics-range.json") { + // eslint-disable-next-line no-console + console.warn(`WARN skip ${target.file}: schema_version mismatch`); + return false; + } + + const data = applyRename(result.body, opts); + fetched.set(target.file, data); + const outPath = join(fixturesDir, target.file); + writeFileSync(outPath, `${JSON.stringify(data, null, 2)}\n`, "utf8"); + // eslint-disable-next-line no-console + console.log(`wrote ${target.file}`); + return true; +} + +async function main(): Promise { + const parsed = parseSeedArgs(process.argv.slice(2)); + if ("error" in parsed) { + // eslint-disable-next-line no-console + console.error(parsed.error); + return 1; + } + const opts = parsed; + + // Verify backend is reachable. + const version = await fetchJson(opts.baseUrl, "/api/version"); + if (!version.ok) { + // eslint-disable-next-line no-console + console.error(`Backend unreachable at ${opts.baseUrl} (HTTP ${version.status})`); + return 1; + } + + const targets = buildSeedTargets(opts); + const fetched = new Map(); + let instanceDetailOk = false; + + for (const target of targets) { + const ok = await writeTarget(opts, target, fetched); + if (target.path.startsWith(`/api/instances/${encodeURIComponent(opts.instance)}`) && + !target.path.includes("/containers") && + !target.path.includes("/contracts") && + !target.path.includes("/transactions") && + !target.path.includes("/dar") && + !target.path.includes("/metrics") && + target.file.startsWith("instance-")) { + instanceDetailOk = ok; + } + } + + if (!instanceDetailOk && !opts.dryRun) { + // eslint-disable-next-line no-console + console.error(`Instance "${opts.instance}" not found on backend`); + return 1; + } + + const contracts = (fetched.get("contracts.json") ?? {}) as { + contracts?: Array<{ contract_id?: string }>; + }; + const transactions = (fetched.get("transactions.json") ?? {}) as { + transactions?: Array<{ update_id?: string }>; + }; + const dars = (fetched.get("dar.json") ?? {}) as { dars?: Array<{ id?: string }> }; + const tokens = (fetched.get("tokens.json") ?? {}) as { + instruments?: Array<{ symbol?: string }>; + }; + + const derived = buildDerivedTargets(opts, contracts, transactions, dars, tokens); + for (const target of derived) { + await writeTarget(opts, target, fetched); + } + + if (opts.dryRun) { + // eslint-disable-next-line no-console + console.log(`Fixtures dir: ${FIXTURES_DIR}`); + } + + return 0; +} + +main().then((code) => process.exit(code)); diff --git a/frontend/mock/sse.ts b/frontend/mock/sse.ts new file mode 100644 index 00000000..e68e427b --- /dev/null +++ b/frontend/mock/sse.ts @@ -0,0 +1,76 @@ +import type { ServerResponse } from "node:http"; +import { + beginSse, + scheduleKeepalive, + sendSseEvent, +} from "./http.ts"; +import type { MockStore } from "./store.ts"; + +const CREATE_PROGRESS: Array> = [ + { kind: "step.started", step: "preflight" }, + { kind: "step.finished", step: "preflight" }, + { kind: "step.started", step: "compose_up" }, + { kind: "step.progress", step: "compose_up", percent: 50 }, + { kind: "step.finished", step: "compose_up" }, + { kind: "done", detail: "Instance is running" }, +]; + +export function handleInstanceProgressSse( + res: ServerResponse, + store: MockStore, + instance: string, +): void { + beginSse(res); + const keepalive = scheduleKeepalive(res); + const queued = store.progressQueues.get(instance); + const events = queued?.length ? queued : CREATE_PROGRESS; + store.progressQueues.delete(instance); + + let i = 0; + const tick = () => { + if (i >= events.length) return; + sendSseEvent(res, events[i], { id: String(i + 1) }); + i += 1; + if (i < events.length) setTimeout(tick, 200); + }; + tick(); + + res.on("close", () => clearInterval(keepalive)); +} + +export function handleContractsStreamSse(res: ServerResponse): void { + beginSse(res); + const keepalive = scheduleKeepalive(res); + sendSseEvent( + res, + { + event: "created", + contract_id: "00abc999", + template: "Token:Holding", + signatories: ["bob::def"], + observers: [], + offset: 1300, + at: Date.now(), + update_id: "u1300", + }, + { event: "contracts" }, + ); + res.on("close", () => clearInterval(keepalive)); +} + +export function handleDarWatchSse(res: ServerResponse, instance: string, darId: string): void { + beginSse(res); + const keepalive = scheduleKeepalive(res); + sendSseEvent(res, { + instance, + dar_id: darId || "token-dar", + event: "watch_started", + at: Math.floor(Date.now() / 1000), + detail: "Mock DAR watch active", + }); + res.on("close", () => clearInterval(keepalive)); +} + +export function queueCreateProgress(store: MockStore, instance: string): void { + store.progressQueues.set(instance, structuredClone(CREATE_PROGRESS)); +} diff --git a/frontend/mock/store.ts b/frontend/mock/store.ts new file mode 100644 index 00000000..3a8b1570 --- /dev/null +++ b/frontend/mock/store.ts @@ -0,0 +1,179 @@ +import { readFileSync, existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const SCHEMA_VERSION = 1; +export const DEFAULT_INSTANCE = "demo"; + +const mockDir = dirname(fileURLToPath(import.meta.url)); +export const FIXTURES_DIR = join(mockDir, "fixtures"); + +export type JsonRecord = Record; + +function loadFixture(name: string, fallback: T): T { + const path = join(FIXTURES_DIR, name); + if (!existsSync(path)) return structuredClone(fallback); + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +export interface MockStore { + version: JsonRecord; + instances: JsonRecord; + instanceDetail: JsonRecord; + containers: JsonRecord; + contracts: JsonRecord; + contractDetail: JsonRecord; + transactions: JsonRecord; + txReplay: JsonRecord; + dar: JsonRecord; + darInspect: JsonRecord; + darVetting: JsonRecord; + metricsSummary: JsonRecord; + metricsRange: JsonRecord; + tokens: JsonRecord; + tokensMatrix: JsonRecord; + tokenSummary: JsonRecord; + tokenActivity: JsonRecord; + tokenHoldings: JsonRecord; + parties: JsonRecord; + doctor: JsonRecord; + preflight: JsonRecord; + spliceVersions: JsonRecord; + skills: JsonRecord; + /** In-flight create/up progress keyed by instance name. */ + progressQueues: Map; +} + +const emptyList = { schema_version: SCHEMA_VERSION, instances: [] as JsonRecord[] }; + +export function createStore(fixtureDir = FIXTURES_DIR): MockStore { + const load = (name: string, fallback: T) => { + const path = join(fixtureDir, name); + if (!existsSync(path)) return structuredClone(fallback); + return JSON.parse(readFileSync(path, "utf8")) as T; + }; + + return { + version: load("version.json", { + name: "canton-devkit (mock)", + schema_version: SCHEMA_VERSION, + }), + instances: load("instances.json", emptyList), + instanceDetail: load("instance-demo.json", { + schema_version: SCHEMA_VERSION, + name: DEFAULT_INSTANCE, + status: "running", + splice_version: "0.6.4", + created_at: new Date().toISOString(), + compose_project: `canton-${DEFAULT_INSTANCE}`, + docker_network: DEFAULT_INSTANCE, + container_prefix: `${DEFAULT_INSTANCE}-`, + project_dir: "/tmp/mock", + data_dir: "/tmp/mock/data", + endpoints: [], + }), + containers: load("containers.json", { + schema_version: SCHEMA_VERSION, + instance: DEFAULT_INSTANCE, + containers: [], + healthy_count: 0, + starting_count: 0, + unhealthy_count: 0, + restarting_count: 0, + exited_count: 0, + }), + contracts: load("contracts.json", { + schema_version: SCHEMA_VERSION, + instance: DEFAULT_INSTANCE, + contracts: [], + count: 0, + }), + contractDetail: load("contract-detail.json", { + schema_version: SCHEMA_VERSION, + contract: {}, + }), + transactions: load("transactions.json", { + schema_version: SCHEMA_VERSION, + instance: DEFAULT_INSTANCE, + transactions: [], + count: 0, + }), + txReplay: load("tx-replay.json", { schema_version: SCHEMA_VERSION, events: [] }), + dar: load("dar.json", { + schema_version: SCHEMA_VERSION, + instance: DEFAULT_INSTANCE, + dars: [], + }), + darInspect: load("dar-inspect.json", { schema_version: SCHEMA_VERSION, packages: [] }), + darVetting: load("dar-vetting.json", { schema_version: SCHEMA_VERSION, roles: [] }), + metricsSummary: load("metrics-summary.json", { + schema_version: SCHEMA_VERSION, + instance: DEFAULT_INSTANCE, + metrics: {}, + }), + metricsRange: load("metrics-range.json", { status: "success", data: { result: [] } }), + tokens: load("tokens.json", { schema_version: SCHEMA_VERSION, instruments: [] }), + tokensMatrix: load("tokens-matrix.json", { schema_version: SCHEMA_VERSION, matrix: {} }), + tokenSummary: load("token-summary.json", { schema_version: SCHEMA_VERSION, summary: {} }), + tokenActivity: load("token-activity.json", { schema_version: SCHEMA_VERSION, events: [] }), + tokenHoldings: load("token-holdings.json", { + schema_version: SCHEMA_VERSION, + source: "ledger", + holdings: [], + }), + parties: load("parties.json", { schema_version: SCHEMA_VERSION, parties: [] }), + doctor: load("doctor.json", { schema_version: SCHEMA_VERSION, ok: true, sections: [] }), + preflight: load("preflight.json", { schema_version: SCHEMA_VERSION, ok: true, sections: [] }), + spliceVersions: load("splice-versions.json", { + schema_version: SCHEMA_VERSION, + latest_alias: "0.6.4", + versions: [], + }), + skills: load("skills.json", { schema_version: SCHEMA_VERSION, skills: [] }), + progressQueues: new Map(), + }; +} + +export function instanceNames(store: MockStore): string[] { + const list = store.instances.instances; + if (!Array.isArray(list)) return []; + return list + .map((i) => (typeof i === "object" && i && "name" in i ? String(i.name) : "")) + .filter(Boolean); +} + +export function findInstanceSummary(store: MockStore, name: string): JsonRecord | undefined { + const list = store.instances.instances; + if (!Array.isArray(list)) return undefined; + return list.find( + (i) => typeof i === "object" && i && "name" in i && String(i.name) === name, + ) as JsonRecord | undefined; +} + +export function upsertInstanceSummary(store: MockStore, summary: JsonRecord): void { + const list = (store.instances.instances as JsonRecord[]) ?? []; + const name = String(summary.name); + const idx = list.findIndex((i) => String(i.name) === name); + if (idx >= 0) list[idx] = summary; + else list.push(summary); + store.instances.instances = list; +} + +export function removeInstance(store: MockStore, name: string): boolean { + const before = instanceNames(store).length; + store.instances.instances = (store.instances.instances as JsonRecord[]).filter( + (i) => String(i.name) !== name, + ); + store.progressQueues.delete(name); + return instanceNames(store).length < before; +} + +export function setInstanceStatus(store: MockStore, name: string, status: string): void { + const summary = findInstanceSummary(store, name); + if (summary) summary.status = status; + if (String(store.instanceDetail.name) === name) { + store.instanceDetail.status = status; + } +} + +export { loadFixture }; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 436108b2..7c5ded31 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -21,6 +21,7 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^6.0.2", "jsdom": "^29.1.1", + "tsx": "^4.23.1", "typescript": "^5.6.3", "vite": "^8.0.16", "vitest": "^4.1.8" @@ -275,39 +276,484 @@ } }, "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.1", + "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@exodus/bytes": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", @@ -594,6 +1040,40 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", @@ -1122,6 +1602,48 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -1992,6 +2514,25 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -2007,9 +2548,9 @@ } }, "node_modules/undici": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz", - "integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index e4154171..2832e678 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,8 @@ "description": "M2 Web UI for canton-devkit. Built with Vite + React + TypeScript. Output is consumed by internal/ui/assets.go via go:embed.", "scripts": { "dev": "vite", + "dev:mock": "VITE_MOCK_API=1 vite", + "mock:seed": "tsx mock/seed.ts", "build": "tsc --noEmit && vite build", "preview": "vite preview", "lint": "tsc --noEmit", @@ -27,8 +29,9 @@ "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^6.0.2", "jsdom": "^29.1.1", + "tsx": "^4.23.1", "typescript": "^5.6.3", "vite": "^8.0.16", "vitest": "^4.1.8" } -} \ No newline at end of file +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a76b3edb..e9a3ff14 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,29 +4,21 @@ import { SCHEMA_VERSION, fetchVersion } from "./api"; import { Shell } from "./shell/Shell"; import { InstanceSelectionProvider } from "./shell/useInstanceSelection"; import { ErrorBoundary } from "./shell/ErrorBoundary"; +import { ConfirmHost } from "./components/ConfirmDialog"; import { Dashboard } from "./screens/Dashboard"; import { DoctorScreen } from "./screens/DoctorScreen"; import { Placeholder } from "./screens/Placeholder"; import { MetricsScreen } from "./screens/MetricsScreen"; import { DARScreen } from "./screens/DARScreen"; +import { AnalyzerScreen } from "./screens/AnalyzerScreen"; import { ExplorerScreen } from "./screens/ExplorerScreen"; import { WalletScreen } from "./screens/WalletScreen"; import { AgentSkillsScreen } from "./screens/AgentSkillsScreen"; import { TokensScreen } from "./screens/TokensScreen"; -import { W } from "./tokens"; +import { W, fs } from "./tokens"; -// App boots by doing the schema-version handshake against the -// backend. Until the handshake completes (or fails) we render a -// minimal loading panel — we never want to render UI that -// silently mis-decodes a v2 backend. -// -// Handshake outcomes: -// - match: render the shell + routed screens -// - mismatch: refuse to render, tell the user to restart -// - network error: refuse to render, suggest the binary isn't running -// -// All three outcomes show the same loopback-only / dev-binary -// guidance so the user knows what's expected of their host. +// Boots with a schema-version handshake and renders the shell only on a +// match, so the bundle never mis-decodes a mismatched backend's responses. export function App() { const [status, setStatus] = useState<"loading" | "ready" | "mismatch" | "offline">( "loading", @@ -58,19 +50,14 @@ export function App() { + {/* One confirm-dialog host; confirmDialog() from anywhere resolves against it. */} + ); } -// RoutedSurface lives inside the Router so it can use -// useLocation() — its pathname becomes the boundary's reset key, -// so a crash in /explorer doesn't follow you to /overview when -// you navigate away. -// -// One boundary per route element (rather than one around all -// Routes) so a crash in /explorer keeps the topbar interactive -// AND keeps the sibling /metrics route renderable when the user -// navigates to it. +// Each route gets its own ErrorBoundary keyed by pathname, so a crash on +// one screen neither follows the user nor takes down the shell. function RoutedSurface() { const loc = useLocation(); return ( @@ -80,6 +67,7 @@ function RoutedSurface() { } /> } /> } /> + } /> } /> } /> } /> @@ -108,7 +96,7 @@ function BootGate({ status, serverVersion }: BootGateProps) { const cardStyle: React.CSSProperties = { background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 12, + borderRadius: 8, padding: 24, maxWidth: 480, color: W.text, @@ -155,7 +143,7 @@ function BootGate({ status, serverVersion }: BootGateProps) { return (

-

{title}

+

{title}

{body}
diff --git a/frontend/src/api.test.ts b/frontend/src/api.test.ts index ba834ad5..2b71378d 100644 --- a/frontend/src/api.test.ts +++ b/frontend/src/api.test.ts @@ -26,8 +26,7 @@ import { // detail, remediation per the handler shape) // 4. POST request bodies trigger Content-Type: application/json // via the conditional header — issueJwt is the canonical -// caller and its include_jwt query toggle is part of the -// redacted-by-default contract. +// caller always receives a usable LocalNet JWT. describe("SCHEMA_VERSION", () => { // Pinned constant. The Go test internal/ui/frontend_schema_test.go @@ -152,7 +151,7 @@ describe("apiFetch", () => { describe("issueJwt", () => { afterEach(() => vi.unstubAllGlobals()); - it("appends ?include_jwt=true only when explicitly opted in", async () => { + it("always requests the raw LocalNet JWT", async () => { // Use mockImplementation so each call gets a fresh Response — // fetch's body is one-shot and the second .text() throws // "Body is unusable" if we share a single instance. @@ -162,8 +161,7 @@ describe("issueJwt", () => { new Response( JSON.stringify({ schema_version: 1, - token: "", - redacted: true, + token: "header.payload.signature", party: "alice::abc", audience: "https://canton.network.global", role: "app-provider", @@ -175,25 +173,25 @@ describe("issueJwt", () => { ); vi.stubGlobal("fetch", fetchSpy); - await issueJwt("demo", { role: "app-provider" }, false); - await issueJwt("demo", { role: "app-provider" }, true); + await issueJwt("demo", { role: "app-provider" }); + await issueJwt("demo", { role: "app-provider" }); expect(fetchSpy).toHaveBeenCalledTimes(2); - const [redactedURL] = fetchSpy.mock.calls[0]; - const [revealedURL] = fetchSpy.mock.calls[1]; - expect(redactedURL).toBe("/api/instances/demo/jwt"); - expect(revealedURL).toBe("/api/instances/demo/jwt?include_jwt=true"); + const [firstURL] = fetchSpy.mock.calls[0]; + const [secondURL] = fetchSpy.mock.calls[1]; + expect(firstURL).toBe("/api/instances/demo/jwt"); + expect(secondURL).toBe("/api/instances/demo/jwt"); }); it("url-encodes the instance name (defence against path traversal)", async () => { const fetchSpy = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ schema_version: 1, token: "", party: "x", audience: "y", role: "z", warning_dev_secret: "" }), { + new Response(JSON.stringify({ schema_version: 1, token: "header.payload.signature", party: "x", audience: "y", role: "z", warning_dev_secret: "" }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); vi.stubGlobal("fetch", fetchSpy); - await issueJwt("../etc/passwd", { role: "app-provider" }, false); + await issueJwt("../etc/passwd", { role: "app-provider" }); const [url] = fetchSpy.mock.calls[0]; // %2F = '/', so '..%2Fetc%2Fpasswd' — never reaches the // server as a literal slash that could be mis-routed. diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d1d6050b..75149e6b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,19 +1,10 @@ -// API client for the canton-devkit Web UI backend. -// -// Two contracts the backend (internal/ui) defines and this file -// consumes: -// -// 1. SCHEMA_VERSION handshake on bootstrap. Every top-level -// response and event carries `schema_version`. If it doesn't -// match what this bundle was built against, the UI refuses -// to render and tells the user to restart `dpm localnet ui`. -// -// 2. Error envelope shape: { code, error, detail?, remediation? }. -// We surface `error` as a toast and `remediation` as a -// follow-up action. -// -// All API calls go through `apiFetch` so the schema check, error -// envelope, and credentials posture stay in one place. +// API client for the canton-devkit Web UI backend. Two backend +// contracts this file consumes: +// 1. SCHEMA_VERSION handshake — every top-level response/event carries +// `schema_version`; a mismatch means the bundle is stale. +// 2. Error envelope { code, error, detail?, remediation? }. +// All calls go through `apiFetch` so the schema check + envelope decoding +// stay in one place. export const SCHEMA_VERSION = 1; @@ -39,11 +30,8 @@ interface ApiErrorBody { remediation?: string[]; } -// apiFetch is the single chokepoint. Every API call routes through -// here so: -// - same-origin policy is enforced (we never call cross-origin) -// - error envelope is decoded uniformly -// - schema version drift surfaces consistently +// apiFetch is the single chokepoint: same-origin only, uniform error +// envelope decoding, consistent schema-drift handling. export async function apiFetch(path: string, init?: RequestInit): Promise { if (!path.startsWith("/")) { throw new Error(`apiFetch path must be absolute, got ${path}`); @@ -52,19 +40,22 @@ export async function apiFetch(path: string, init?: RequestInit): Promise ...init, headers: { Accept: "application/json", - ...(init?.body ? { "Content-Type": "application/json" } : {}), + // A FormData body must NOT carry an explicit Content-Type — the + // browser sets multipart/form-data with the boundary itself. + // Forcing application/json here would corrupt the multipart parse + // server-side (the analyzer's .dar upload relies on this). + ...(init?.body && !(init.body instanceof FormData) + ? { "Content-Type": "application/json" } + : {}), ...init?.headers, }, }); const text = await resp.text(); - // A proxy or a panicking server can return a non-empty body that - // is NOT our JSON envelope (an HTML 502 page, a plain-text stack - // trace). Parsing must not throw a raw SyntaxError here — that - // would escape every screen's `e instanceof ApiError` branch and - // surface as an opaque "failed to load" with no status/code. Fall - // back to `undefined` so the !resp.ok branch still emits a proper - // ApiError (carrying the HTTP status), and a malformed 2xx body - // surfaces as `undefined` rather than crashing the caller. + // A proxy or panicking server can return a non-JSON body (HTML 502 + // page, plain-text stack trace). A raw SyntaxError here would escape + // every screen's `e instanceof ApiError` branch, so fall back to + // `undefined`: !resp.ok still throws a proper ApiError with the HTTP + // status, and a malformed 2xx decodes to `undefined` without crashing. let body: unknown; try { body = text ? (JSON.parse(text) as unknown) : undefined; @@ -102,10 +93,19 @@ export interface ListResponse { // Instance mirrors internal/api/types.Instance (subset; full shape // has Services/Endpoints/Parties/Credentials from the live probe). export interface Endpoint { + /** Stable logical port name from state.json (app_user_ui, sv_ui, …). */ + key: string; label: string; url: string; port?: number; scheme?: string; + /** + * Status-time HTTP probe verdict for browser-UI endpoints. Absent + * when the endpoint was not probed (non-UI schemes, instance not + * running). + */ + reachability?: "ok" | "unreachable"; + reachability_detail?: string; } export interface Instance { @@ -235,13 +235,10 @@ export interface JwtRequest { } // JwtResponse mirrors internal/ui/handlers/auth.go jwtResponse. -// `token` is the redaction placeholder ("") unless -// the request was made with ?include_jwt=true; `redacted` -// signals which path. +// `token` always contains the raw LocalNet JWT. export interface JwtResponse { schema_version: number; token: string; - redacted?: boolean; party: string; audience: string; role: string; @@ -249,18 +246,14 @@ export interface JwtResponse { expires_in_seconds?: number; } -// issueJwt posts to the JWT endpoint. `includeJwt=true` triggers -// the raw-token mode — UI surfaces it ONLY after the user clicks -// "show token" so the response stays redacted-by-default for -// screenshot shares. +// issueJwt posts to the JWT endpoint. LocalNet returns the raw +// dev-only token directly. export function issueJwt( name: string, req: JwtRequest, - includeJwt: boolean, ): Promise { - const qs = includeJwt ? "?include_jwt=true" : ""; return apiFetch( - `/api/instances/${encodeURIComponent(name)}/jwt${qs}`, + `/api/instances/${encodeURIComponent(name)}/jwt`, { method: "POST", body: JSON.stringify(req), @@ -275,6 +268,9 @@ export type AppConfigFormat = "env" | "json" | "yaml"; // text — the env / yaml endpoints emit text/plain so apiFetch's // JSON-decode path would error. Inline a small fetch here that // returns the body verbatim. +// +// App config includes raw LocalNet JWTs so it can be copied directly +// into a dApp's environment. export async function fetchAppConfigText( name: string, format: "env" | "yaml", @@ -344,6 +340,9 @@ export interface CreateInstanceRequest { // port_base > 0 pins deterministic host ports from this base // (`dpm localnet up --port-base`). Omit / 0 → auto-allocate. port_base?: number; + // observability_mode maps to `--observability-mode`: auto | shared | + // per-instance. Omit → auto. + observability_mode?: string; } // CreateInstanceAcceptedResponse is what POST /api/instances @@ -469,43 +468,24 @@ export const fetchDoctor = (version?: string) => : "/api/doctor", ); -// stopInstance invokes POST /api/instances/{name}/down — runs -// `docker compose down` against the named instance, preserving -// Docker volumes and the registry entry (status=stopped). -// Synchronous on the wire (down is fast, ~10-30s on the happy -// path); the call blocks until the server returns 204 or 5xx. -// -// On failure, the server's error envelope includes a one-line -// summary the modal shows to the user; the full output goes to -// the server log. -// snapshot / restore. -// -// downloadSnapshot triggers POST /api/instances/:name/snapshot and -// hands the gzipped tar to the browser via an click. We -// don't use fetch() + Blob here for one reason: a snapshot can be -// 100s of MB, and putting the whole body into JS memory just to hand -// it back to the browser is wasteful. The form-submit trick keeps the -// response entirely in the browser's download pipeline. +// ── snapshot / restore ───────────────────────────────────── + +// downloadSnapshot POSTs to /api/instances/:name/snapshot via a +// hidden-iframe form submit instead of fetch()+Blob: a snapshot can be +// 100s of MB, and the form submit keeps the body entirely in the +// browser's download pipeline instead of JS memory. // -// Error surfacing: on success the server replies with -// Content-Disposition: attachment, so the browser hands the body to -// the download manager and the hidden iframe never navigates — no -// `load` event fires. On failure (instance not found, docker error, -// 5xx) the server replies with an inline JSON error body and NO -// Content-Disposition, so the iframe DOES navigate to it and fires a -// `load` event. We listen for that asymmetry: a `load` on the iframe -// means the download did not happen and an error document was -// rendered instead, so we reject with an ApiError the caller can -// toast. A successful download is detected by absence (a settle -// timeout resolves once the dispatch is clean), since we cannot read -// the cross-document iframe body. +// Error detection relies on an asymmetry: on success the server sends +// Content-Disposition: attachment, the body goes to the download +// manager, and the iframe never navigates (no `load` event). On +// failure the server sends an inline JSON error document, the iframe +// navigates to it, and `load` fires — we can't read the cross-document +// body, so we reject with a generic ApiError. Success is detected by +// absence: a settle timeout resolves once no `load` arrived. // -// Returns a Promise that REJECTS with an ApiError when the server -// returned an error instead of a download, and otherwise resolves -// once the request has been dispatched (not when the download -// completes — the browser owns that). The hidden iframe is reused -// across downloads (one per document), and each call attaches a -// one-shot `load` listener so handlers don't accumulate. +// Resolves once the request is dispatched (the browser owns download +// completion). The iframe is reused across downloads; each call +// attaches a one-shot `load` listener so handlers don't accumulate. const DOWNLOAD_SETTLE_MS = 1200; export function downloadSnapshot(name: string): Promise { @@ -513,10 +493,6 @@ export function downloadSnapshot(name: string): Promise { const form = document.createElement("form"); form.method = "POST"; form.action = `/api/instances/${encodeURIComponent(name)}/snapshot`; - // Hidden iframe target avoids navigating away from the SPA on - // success. Browsers attach the download attribute on the response - // headers (Content-Disposition), so the iframe never actually - // renders anything — the file goes straight to the downloads bar. form.target = "_dpm_dl"; let frame = document.querySelector( 'iframe[name="_dpm_dl"]', @@ -531,13 +507,9 @@ export function downloadSnapshot(name: string): Promise { let settled = false; let settleTimer: ReturnType | undefined; const onLoad = () => { - // The iframe navigated → the server returned an inline - // document (the JSON error body) rather than an attachment. - // A successful download hands the body to the download - // manager and never navigates the iframe, so a `load` here - // means the snapshot did NOT download. We can't read the - // cross-document body safely, so surface a generic-but-honest - // error instead of failing silently. + // Iframe navigated → the server returned an inline error + // document rather than an attachment; the snapshot did NOT + // download. if (settled) return; settled = true; if (settleTimer !== undefined) clearTimeout(settleTimer); @@ -603,7 +575,7 @@ export function restoreSnapshot( if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText) as RestoreResponse); - } catch (e) { + } catch { reject( new ApiError(xhr.status, { code: "UNKNOWN", @@ -732,11 +704,7 @@ export interface MetricsSummary { }; } -// fetchMetricsSummary returns the headline panel data. The -// caller MUST handle ApiError with body.code === "OBSERVABILITY_PROFILE_OFF" -// to render the "raise observability" empty state — that's not -// a hard failure, just a missing profile. -// DAR Manager. +// ── DAR Manager ───────────────────────────────────────── // // The Web UI lists DARs uploaded to a participant. Role defaults // to app-user (the common dev target). The backend reads the @@ -818,7 +786,7 @@ export function uploadDARs( if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText) as DARUploadResponse); - } catch (e) { + } catch { reject( new ApiError(xhr.status, { code: "UNKNOWN", @@ -1021,10 +989,8 @@ export function subscribeDARWatch( const ev = JSON.parse(e.data) as DARWatchEvent; onEvent(ev); } catch { - // Drop malformed payloads silently — the backend pins the - // event-enum on the publish side, so a parse failure here - // would indicate a wire-format change worth surfacing in - // the network tab, not the UI. + // Drop malformed payloads — a parse failure indicates a + // wire-format change, visible in the network tab, not the UI. } }); return es; @@ -1258,6 +1224,9 @@ export const fetchTxReplay = ( ); }; +// fetchMetricsSummary returns the headline panel data. Callers MUST +// handle ApiError code "OBSERVABILITY_PROFILE_OFF" as the "raise +// observability" empty state — a missing profile, not a hard failure. export const fetchMetricsSummary = (name: string, signal?: AbortSignal) => apiFetch( `/api/instances/${encodeURIComponent(name)}/metrics/summary`, @@ -1328,7 +1297,11 @@ export const fetchMetricsRange = ( ); }; -export async function stopInstance(name: string): Promise { +// downInstance invokes POST /api/instances/{name}/down — runs +// `docker compose down`, REMOVING containers and networks while +// preserving Docker volumes and the registry entry (status=stopped). +// Synchronous on the wire: blocks until the server returns 204 or 5xx. +export async function downInstance(name: string): Promise { const resp = await fetch( `/api/instances/${encodeURIComponent(name)}/down`, { method: "POST" }, @@ -1345,6 +1318,48 @@ export async function stopInstance(name: string): Promise { } } +// stopInstance invokes POST /api/instances/{name}/stop — runs +// `docker compose stop`: a graceful halt that KEEPS the containers in +// place for a fast `startInstance`. Distinct from downInstance (which +// removes them). Synchronous: 204 on success. Valid only when running +// or paused. +export async function stopInstance(name: string): Promise { + await postInstanceAction(name, "stop"); +} + +// startInstance invokes POST /api/instances/{name}/start — the +// intelligent "get it running" verb. The backend returns: +// +// - 204 — fast `docker compose start` completed (containers were +// present); the caller just refetches. +// - 202 + events_url — the containers were gone, so the backend fell +// back to a full bring-up; the caller hands events_url to the +// progress modal (same shape as resumeInstance). +// +// Returns the accepted response on 202, or null on 204. +export async function startInstance( + name: string, +): Promise { + const resp = await fetch( + `/api/instances/${encodeURIComponent(name)}/start`, + { method: "POST" }, + ); + if (!resp.ok) { + const text = await resp.text(); + let body: ApiErrorBody = { code: "UNKNOWN", error: resp.statusText }; + try { + body = JSON.parse(text); + } catch { + /* non-JSON; keep default */ + } + throw new ApiError(resp.status, body); + } + if (resp.status === 202) { + return (await resp.json()) as ResumeAcceptedResponse; + } + return null; +} + // pauseInstance / resumeInstance invoke POST /api/instances/{name}/pause // | /resume — docker compose pause/unpause. Near-instant; 204 // on success. Pause is valid only when running, resume only when paused. @@ -1513,15 +1528,6 @@ interface CancelledEvent { reason?: string; } -// ApiErrorBody is re-exported here because cancelInstanceUp uses -// it directly (it bypasses apiFetch for the raw fetch path). -interface ApiErrorBody { - code: string; - error: string; - detail?: string; - remediation?: string[]; -} - // ── Agent Skills ───────────────────────────────────────── // Mirrors internal/skills.Skill + the /api/skills handler. The same // embedded docs back the CLI `localnet skills` command. @@ -1561,13 +1567,9 @@ export const installSkills = (target: "claude" | "codex", force = false) => body: JSON.stringify({ target, force }), }); -// ------------------------------------------------------------------- -// Tokens — Web UI client for /api/tokens. -// -// Mirrors registry.TokenRef + the request shapes the backend handlers -// expect. Every action is instance-scoped (`?instance=`); error mapping -// matches handlers.mapTokenError so the UI can switch on the code. -// ------------------------------------------------------------------- +// Tokens — Web UI client for /api/tokens. Mirrors api/types shapes; +// every action is instance-scoped (`?instance=`) and error codes match +// handlers.mapTokenError. export interface TokenRef { name: string; @@ -1580,10 +1582,9 @@ export interface TokenRef { status: string; } -// HoldingSource mirrors api/types.HoldingSource: "ledger" = summed -// from the live ACS (real on-ledger balance); "registry" = the -// registry pseudo-balance fallback shown when no live participant is -// reachable (NOT on-ledger truth — the UI labels these rows). +// HoldingSource: "ledger" = summed from the live ACS (real balance); +// "registry" = pseudo-balance fallback when no participant is reachable +// (NOT on-ledger truth — the UI labels these rows). export type HoldingSource = "ledger" | "registry"; export interface TokenHolding { @@ -1601,23 +1602,92 @@ export interface TokensListResponse { export interface TokenHoldingsResponse { schema_version: number; - // Response-level provenance — matches every row's source. The UI - // renders one disclaimer banner when this is "registry". + // Response-level provenance (matches every row's source); the UI + // renders one disclaimer banner when "registry". source: HoldingSource; holdings: TokenHolding[]; // true when the live ACS scan stopped at its safety cap (partial view). truncated?: boolean; } -// DEFAULT_ROLE matches the backend default in roleFromQuery — keep the -// two in sync so the live-ledger endpoint discovery and JWT minting -// pick the same participant for unrestricted UI calls. +// --- V2 foundation shapes ------------------------------------------- +// Mirror internal/api/types/tokens.go; each is wrapped in a feature's +// own versioned response when it lands. + +export type TokenActivitySource = "event_log" | "transaction"; + +export interface TokenTransferLeg { + transfer_leg_id: string; + side: string; + otherside: string; + amount: string; + instrument_id: string; +} + +// One row of a V2 instrument's holdings-change history. +export interface TokenActivityEvent { + source: TokenActivitySource; + update_id: string; + offset: number; + record_time: string; + instrument_id: string; + account: string; + admin: string; + consumed_holding_count: number; + created_holding_count: number; + transfer_legs: TokenTransferLeg[]; + reason?: string; +} + +export type AllocationStatus = + | "pending" + | "ready" + | "settled" + | "cancelled" + | "withdrawn"; + +// A V2 DvP allocation detail. +export interface Allocation { + contract_id: string; + status: AllocationStatus; + settlement_id: string; + admin: string; + authorizer: string; + executors: string[]; + committed: boolean; + settlement_deadline?: string; + transfer_legs: TokenTransferLeg[]; + created_at?: string; +} + +// List-row form of an Allocation. +export interface AllocationSummary { + contract_id: string; + status: AllocationStatus; + settlement_id: string; + authorizer: string; + leg_count: number; + committed: boolean; +} + +export interface BatchActionResult { + kind: string; + ok: boolean; + detail?: string; +} + +// Outcome of a BatchingUtility_ExecuteBatch exercise. +export interface BatchResult { + update_id: string; + actions: BatchActionResult[]; + ok: boolean; +} + +// Matches the backend default in roleFromQuery — keep in sync. const DEFAULT_ROLE = "app-user"; -// tokenQuery builds `?instance=...&role=...` (role omitted when it -// would just repeat the default). Centralised so every token endpoint -// forwards the role the same way the backend's handleTokenMint / -// handleTokenTransfer already expect. +// tokenQuery builds `?instance=...&role=...` (role omitted when it just +// repeats the default), so every token endpoint forwards role uniformly. function tokenQuery(instance: string, role?: string): string { const params = new URLSearchParams({ instance }); if (role && role !== DEFAULT_ROLE) params.set("role", role); @@ -1627,6 +1697,18 @@ function tokenQuery(instance: string, role?: string): string { export const fetchTokens = (instance: string, role?: string) => apiFetch(`/api/tokens?${tokenQuery(instance, role)}`); +// GET /api/tokens/identity: the act-as identities plus the one the +// request used. Backs the Tokens screen's identity switcher. +export interface TokenIdentity { + schema_version: number; + instance: string; + available_roles: Role[]; + current_role: Role; +} + +export const fetchTokenIdentity = (instance: string, role?: string) => + apiFetch(`/api/tokens/identity?${tokenQuery(instance, role)}`); + export const fetchHoldings = ( instance: string, symbol: string, @@ -1641,28 +1723,25 @@ export const fetchHoldings = ( ); }; -// token workspace — ACS-derived lenses (instrument discovery, -// balance matrix, per-holding UTXO rows). These hit the live ledger; -// when no endpoint is recorded the backend falls back to the recorded -// token list (so `instruments` may be absent — callers handle both). +// ACS-derived lenses (instrument discovery, balance matrix, per-holding +// UTXO rows). These hit the live ledger; with no endpoint the backend +// falls back to the recorded token list (`instruments` may be absent). -// InstrumentRef is an on-chain-discovered instrument (workspace.go). +// An on-chain-discovered instrument. export interface InstrumentRef { admin: string; instrument_id: string; name?: string; symbol?: string; decimals?: number; - // standard is the human label ("Splice Amulet" / "Token Standard V1 - // (CIP-0056)" / "Token Standard V2 (CIP-0112)"); generation is the - // machine tag ("v1"/"v2") the UI gates mint/burn on — never gate on - // the display string. + // standard is the human label; generation ("v1"/"v2") is the machine + // tag the UI gates mint/burn on — never gate on the display string. standard?: string; generation?: string; on_ledger: boolean; } -// HoldingContract is one HoldingV2 UTXO. A balance = sum of these. +// One HoldingV2 UTXO; a balance = sum of these. export interface HoldingContract { contract_id: string; party: string; @@ -1696,8 +1775,7 @@ interface MatrixResponse { matrix: BalanceMatrix; } -// PartyRef — one registered party alias → its on-ledger party id. -// The workspace's god-mode party registry. +// One registered party alias → its on-ledger party id. export interface PartyRef { alias: string; party_id: string; @@ -1711,15 +1789,13 @@ interface PartiesResponse { parties: PartyRef[]; } -// fetchParties lists the instance's registered party aliases (seeding the -// role parties when a live endpoint is available). +// Lists the instance's registered party aliases. export const fetchParties = (instance: string, role = "app-user") => apiFetch( `/api/parties?instance=${encodeURIComponent(instance)}&role=${encodeURIComponent(role)}`, ).then((r) => r.parties); -// createParty allocates a party under an alias and grants the role's user -// act/read-as for it. +// Allocates a party under an alias and grants the role's user act/read-as. export const createParty = (instance: string, alias: string, role = "app-user") => apiFetch(`/api/parties?instance=${encodeURIComponent(instance)}`, { method: "POST", @@ -1734,8 +1810,7 @@ export const removeParty = (instance: string, alias: string) => { method: "DELETE" }, ); -// AliasMap is partyID → alias, built from fetchParties for client-side -// labelling of party ids in the matrix / holdings / activity views. +// partyID → alias, for client-side labelling of party ids. export type AliasMap = Record; export const aliasMapFrom = (parties: PartyRef[]): AliasMap => { @@ -1749,9 +1824,8 @@ interface HoldingContractsResponse { contracts: HoldingContract[]; } -// fetchInstruments returns ACS-discovered instruments. Falls back to the -// recorded TokenRef list (mapped into InstrumentRef shape) when the -// backend couldn't reach the ledger. +// Returns ACS-discovered instruments, falling back to the recorded +// TokenRef list (mapped to InstrumentRef) when the ledger is unreachable. export async function fetchInstruments( instance: string, role = "app-user", @@ -1778,9 +1852,8 @@ export const fetchMatrix = (instance: string, role = "app-user") => `/api/tokens/matrix?instance=${encodeURIComponent(instance)}&role=${encodeURIComponent(role)}`, ).then((r) => r.matrix); -// HolderRow / InstrumentSummary — the instrument-first KPI view. -// Supply + holder/contract counts + per-holder distribution, -// derived from one ACS scan (workspace.go). +// Instrument KPI view: supply + holder/contract counts + per-holder +// distribution, from one ACS scan. export interface HolderRow { party: string; balance: string; @@ -1813,10 +1886,9 @@ export const fetchInstrumentSummary = ( )}&role=${encodeURIComponent(role)}`, ).then((r) => r.summary); -// PartyDelta / ActivityEvent — the instrument activity feed -// (Activity tab). Each event is one ledger transaction netted into -// senders/receivers + a kind (mint | burn | transfer), reconstructed -// from HoldingV2 create/archive events (no off-ledger registry). +// Instrument activity feed. Each event is one movement netted into +// senders/receivers + a kind (mint | burn | transfer), sourced from the +// admin's EventLog events or HoldingV2 create/archive netting. export interface PartyDelta { party: string; amount: string; @@ -1828,6 +1900,7 @@ export interface ActivityEvent { record_time: string; instrument_id: string; kind: "mint" | "burn" | "transfer"; + source: TokenActivitySource; amount: string; senders?: PartyDelta[]; receivers?: PartyDelta[]; @@ -1836,19 +1909,32 @@ export interface ActivityEvent { interface ActivityResponse { schema_version: number; events: ActivityEvent[]; + // true when the backend's ledger scan hit its safety cap + // (maxActivityScan) before the window end — the feed is the newest + // slice of a clipped scan, not the complete history. + truncated?: boolean; +} + +// What the Activity tab consumes: newest-first events + the truncation +// flag, so the UI distinguishes "reached the end" from "scan was capped". +export interface ActivityPage { + events: ActivityEvent[]; + truncated: boolean; } +// Returns an instrument's movement feed, newest-first (offset +// descending), plus the truncation flag. `limit` caps the rows. export const fetchActivity = ( instance: string, symbol: string, role = "app-user", limit = 50, -) => +): Promise => apiFetch( `/api/tokens/${encodeURIComponent(symbol)}/activity?instance=${encodeURIComponent( instance, )}&role=${encodeURIComponent(role)}&limit=${limit}`, - ).then((r) => r.events); + ).then((r) => ({ events: r.events, truncated: !!r.truncated })); export const fetchHoldingContracts = ( instance: string, @@ -1875,13 +1961,10 @@ export interface TokenCreateInput { issuer: string; } -// idempotencyHeader returns a fresh per-submission Idempotency-Key -// header for the value-moving token POSTs. The server dedupes retries -// of the SAME key (idempotency.go), so we mint one key per logical -// submission — a user-initiated retry that re-invokes the API function -// gets a new key (a genuinely new attempt), while a transport-level -// retry of the same fetch would reuse it. crypto.randomUUID is -// available in every browser the UI targets (and in jsdom under test). +// A fresh per-submission Idempotency-Key for value-moving token POSTs. +// The server dedupes retries of the SAME key, so one key per logical +// submission: a user-initiated retry mints a new key (a new attempt); a +// transport-level retry of the same fetch reuses it. function idempotencyHeader(): Record { return { "Idempotency-Key": crypto.randomUUID() }; } @@ -1900,8 +1983,7 @@ export const createToken = ( }, ); -// DemoResult mirrors token.DemoResult (POST /api/tokens/demo) and the CLI -// `token demo --format json`: the created instrument, the issuer party, +// POST /api/tokens/demo result: the created instrument, issuer party, // and (when seeded) the funded holder. export interface DemoResult { token: TokenRef; @@ -1910,15 +1992,12 @@ export interface DemoResult { seeded: boolean; } -// launchDemoToken provisions a live, transferable demo token in one call -// — an issuer party, a V2 instrument with initial supply, and (by -// default) a funded holder so a transfer works immediately. Throws -// ApiError(412, NEEDS_V2_LOCALNET) when the instance has no live V2 -// endpoint, so the caller can disable the button with a "start a V2 -// instance first" hint. +// Provisions a live demo token in one call (issuer + V2 instrument whose +// supply is minted to a holder party). Throws ApiError(412, NEEDS_V2_LOCALNET) +// when the instance has no live V2 endpoint. export const launchDemoToken = ( instance: string, - opts?: { symbol?: string; initial_supply?: string; decimals?: number; seed_holder?: boolean }, + opts?: { symbol?: string; initial_supply?: string; decimals?: number }, role?: string, ): Promise => apiFetch(`/api/tokens/demo?${tokenQuery(instance, role)}`, { @@ -1940,10 +2019,9 @@ export const mintToken = ( idempotencyHeader(), ); -// transferToken submits a live transfer and returns the created -// TransferInstruction id (Offer kind) so the caller can drive the -// receiver-side Accept. settled is true when auto-accept already chained -// the accept (or a Direct/self transfer needed none). +// Submits a live transfer and returns the created TransferInstruction id +// (Offer kind) so the caller can drive the receiver-side Accept. settled +// is true when auto-accept chained it (or a Direct/self transfer needed none). export const transferToken = async ( instance: string, symbol: string, @@ -1953,13 +2031,28 @@ export const transferToken = async ( reason?: string, role?: string, autoAccept?: boolean, + // atomic (with autoAccept) batches transfer+accept into one + // all-or-nothing BatchingUtilityV2 transaction; on-ledger test tokens + // only. EXPERIMENTAL and not yet supported on current Splice — the + // accept leg can't reference the transfer leg's instruction within one + // batch, so the server errors and nothing commits. Default + // (undefined/false) keeps the working sequential offer→accept path. + // Mirrors the CLI's --atomic flag (also experimental). + atomic?: boolean, ): Promise<{ transferInstructionId: string; settled: boolean }> => { const resp = await fetch( `/api/tokens/${encodeURIComponent(symbol)}/transfer?${tokenQuery(instance, role)}`, { method: "POST", headers: { "Content-Type": "application/json", ...idempotencyHeader() }, - body: JSON.stringify({ from, to, amount, reason: reason ?? "", auto_accept: !!autoAccept }), + body: JSON.stringify({ + from, + to, + amount, + reason: reason ?? "", + auto_accept: !!autoAccept, + atomic: !!atomic, + }), }, ); if (!resp.ok) { @@ -1978,8 +2071,8 @@ export const transferToken = async ( return { transferInstructionId: body.transfer_instruction_id ?? "", settled: !!body.settled }; }; -// faucetToken funds a party from a well-known source, -// auto-accepted. Empty source defaults to the role's funded party. +// Funds a party from a well-known source, auto-accepted. Empty source +// defaults to the role's funded party. export const faucetToken = ( instance: string, symbol: string, @@ -1994,9 +2087,8 @@ export const faucetToken = ( idempotencyHeader(), ); -// TransferPlan — dry-run coin selection. Which Holding -// contracts a transfer would consume, the change, and whether the -// sender can cover it. Read-only; no ledger mutation. +// Dry-run coin selection: which Holding contracts a transfer consumes, +// the change, and whether the sender can cover it. Read-only. export interface TransferPlan { instrument: string; from: string; @@ -2058,14 +2150,113 @@ export const acceptTransfer = ( idempotencyHeader(), ); -// apiFetchVoid is a thin POST wrapper for 204-returning handlers. The -// mint/transfer/burn/accept endpoints return 204 on success and an -// ApiError on failure — no body to decode either way. -// -// extraHeaders lets value-moving callers attach an Idempotency-Key so a -// network-blip retry or double-click can't mint/transfer/burn twice — -// the server-side idempotency middleware (internal/ui/handlers/ -// idempotency.go) is opt-in on exactly that header. +// --- V2 DvP allocations --------------------------------------------- +// The allocate / list / settle-withdraw-cancel endpoints; same RunX +// orchestration the `token allocate/allocations/settle` CLI verbs call. + +interface AllocationsListResponse { + schema_version: number; + allocations: AllocationSummary[]; + aliases: AliasMap; +} + +// Lists ready-to-settle V2 allocations (optionally filtered to one +// authorizer), plus the alias map for labelling party ids. +export async function fetchAllocations( + instance: string, + party?: string, + role?: string, +): Promise<{ allocations: AllocationSummary[]; aliases: AliasMap }> { + const params = new URLSearchParams({ instance }); + if (party) params.set("party", party); + if (role && role !== DEFAULT_ROLE) params.set("role", role); + const r = await apiFetch( + `/api/tokens/allocations?${params}`, + ); + return { allocations: r.allocations ?? [], aliases: r.aliases ?? {} }; +} + +export interface AllocateInput { + from: string; + to: string; + amount: string; + executor?: string; + settlement_deadline?: string; + committed?: boolean; +} + +// Creates a V2 allocation and returns the resulting Allocation (or +// pending AllocationInstruction) contract id. +export async function allocateToken( + instance: string, + symbol: string, + body: AllocateInput, + role?: string, +): Promise<{ allocationId: string }> { + const resp = await fetch( + `/api/tokens/${encodeURIComponent(symbol)}/allocate?${tokenQuery(instance, role)}`, + { + method: "POST", + headers: { "Content-Type": "application/json", ...idempotencyHeader() }, + body: JSON.stringify(body), + }, + ); + if (!resp.ok) { + let parsed: ApiErrorBody = { code: "UNKNOWN", error: resp.statusText }; + try { + parsed = await resp.json(); + } catch { + /* keep default */ + } + throw new ApiError(resp.status, parsed); + } + const b = (await resp.json().catch(() => ({}))) as { allocation_id?: string }; + return { allocationId: b.allocation_id ?? "" }; +} + +// withdraw / cancel exercise the matching per-allocation choice; both +// return void on 2xx. Settlement (SettlementFactory_SettleBatch) is not yet +// functional on LocalNet, so no settle action is exposed. +export const withdrawAllocation = ( + instance: string, + allocationID: string, + party?: string, + role?: string, +): Promise => + apiFetchVoid( + allocationActionURL(instance, allocationID, "withdraw", party, role), + {}, + idempotencyHeader(), + ); + +export const cancelAllocation = ( + instance: string, + allocationID: string, + party?: string, + role?: string, +): Promise => + apiFetchVoid( + allocationActionURL(instance, allocationID, "cancel", party, role), + {}, + idempotencyHeader(), + ); + +function allocationActionURL( + instance: string, + allocationID: string, + action: "withdraw" | "cancel", + party?: string, + role?: string, +): string { + return `/api/tokens/allocations/${encodeURIComponent(allocationID)}/${action}?${tokenQuery( + instance, + role, + )}${party ? `&party=${encodeURIComponent(party)}` : ""}`; +} + +// A thin POST wrapper for 204-returning handlers (mint/transfer/burn/ +// accept: 204 on success, ApiError on failure). extraHeaders carries the +// Idempotency-Key so a retry or double-click can't submit twice. async function apiFetchVoid( path: string, body: unknown, @@ -2085,3 +2276,124 @@ async function apiFetchVoid( } throw new ApiError(resp.status, parsed); } + +// ── Analyzer (daml-analyzer) ───────────────────────────── +// +// Static, cross-package interaction analysis of a compiled Daml +// package (.dar), backed by github.com/Certora/daml-analyzer. These +// interfaces mirror internal/api/types/analyzer.go EXACTLY (snake_case +// wire tags); the Go package maps the upstream analyzer's camelCase +// output onto them. + +// AnalyzerPackage is the analyzed package's identity. +export interface AnalyzerPackage { + name: string; + version: string; + package_id: string; + lf_version?: string; +} + +// AnalyzerPackageRef names a dependency package. +export interface AnalyzerPackageRef { + name: string; + version: string; + package_id: string; +} + +// AnalyzerSummary aggregates the interactions by type and by target +// package. +export interface AnalyzerSummary { + total_interactions: number; + by_type: Record; + by_target_package: Record; +} + +// AnalyzerSource is the (best-effort) source location of an interaction. +export interface AnalyzerSource { + package?: string; + file?: string; + start_line?: number; +} + +// AnalyzerEndpoint is one side of an interaction (caller or target): +// the package + module and, where applicable, the template / interface +// / choice. consuming is set on choice targets. +export interface AnalyzerEndpoint { + package: string; + version: string; + package_id: string; + module: string; + template?: string; + interface?: string; + choice?: string; + consuming?: boolean; +} + +// AnalyzerInteraction is a single cross-package interaction: a caller +// in the analyzed package reaching a target in a dependency. +export interface AnalyzerInteraction { + type: string; + source?: AnalyzerSource; + caller: AnalyzerEndpoint; + target: AnalyzerEndpoint; +} + +// AnalyzerReport is the full analysis of one package. +export interface AnalyzerReport { + analyzed_package: AnalyzerPackage; + dependencies: AnalyzerPackageRef[]; + summary: AnalyzerSummary; + interactions: AnalyzerInteraction[]; +} + +// AnalyzerResponse is the top-level shape for POST /api/analyzer/analyze +// and GET /api/instances/{name}/analyzer/{id}. +export interface AnalyzerResponse { + schema_version: number; + instance?: string; + dar_name?: string; + package_id?: string; + report: AnalyzerReport | null; +} + +// AnalyzerStatusResponse reports whether an analysis can run here and which +// runtime serves it ("component" = the DPM component, "docker" = the pinned +// image, "bin" = an explicit executable), so the UI can show a clean "not +// configured" state instead of failing mid-flight. +export interface AnalyzerStatusResponse { + schema_version: number; + available: boolean; + runtime?: string; + source?: string; + detail?: string; +} + +// fetchAnalyzerStatus probes the host once for a usable runtime. The screen +// gates the whole surface on `available`. +export const fetchAnalyzerStatus = () => + apiFetch("/api/analyzer/status"); + +// analyzeDeployedDar analyzes a DAR already deployed to an instance, +// by its main package id. role selects the participant to read from. +export const analyzeDeployedDar = ( + instance: string, + id: string, + role?: Role, +) => + apiFetch( + `/api/instances/${encodeURIComponent(instance)}/analyzer/${encodeURIComponent( + id, + )}?role=${role ?? "app-user"}`, + ); + +// analyzeUploadedDar analyzes an ad-hoc .dar file the user drops in — +// no instance required. The multipart boundary is set by the browser; +// apiFetch skips its JSON Content-Type for FormData bodies. +export const analyzeUploadedDar = (file: File) => { + const fd = new FormData(); + fd.append("dar", file); + return apiFetch("/api/analyzer/analyze", { + method: "POST", + body: fd, + }); +}; diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx new file mode 100644 index 00000000..e26c0c63 --- /dev/null +++ b/frontend/src/components/Button.tsx @@ -0,0 +1,68 @@ +// Visuals in index.css under .bd-btn. Variant contract: +// primary — at most one dominant action per view/dialog. +// secondary — the default (bordered, quiet). +// ghost — low-emphasis inline actions. +// danger — destructive AND irreversible only; recoverable +// actions like Stop/Pause stay secondary. +// Sizes: sm 28px (default, row/table), md 36px (forms, dialog footers). + +import type { + CSSProperties, + MouseEventHandler, + ReactNode, +} from "react"; + +export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger"; +export type ButtonSize = "sm" | "md"; + +interface ButtonProps { + variant?: ButtonVariant; + size?: ButtonSize; + /** Icon slot — pass an icons.tsx glyph. */ + icon?: ReactNode; + disabled?: boolean; + fullWidth?: boolean; + type?: "button" | "submit"; + title?: string; + "aria-label"?: string; + onClick?: MouseEventHandler; + style?: CSSProperties; + children?: ReactNode; +} + +export function Button({ + variant = "secondary", + size = "sm", + icon, + disabled = false, + fullWidth = false, + type = "button", + title, + onClick, + style, + children, + ...rest +}: ButtonProps) { + const cls = [ + "bd-btn", + `bd-btn--${variant}`, + `bd-btn--${size}`, + fullWidth ? "bd-btn--full" : "", + ] + .filter(Boolean) + .join(" "); + return ( + + ); +} diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx new file mode 100644 index 00000000..65225099 --- /dev/null +++ b/frontend/src/components/ConfirmDialog.tsx @@ -0,0 +1,137 @@ +// Promise-based confirm dialog: +// if (!(await confirmDialog({ title, body, confirmLabel, danger }))) return; +// A single ConfirmHost (mounted in App) handles the dispatched event. + +import { useEffect, useState } from "react"; +import { W, wMono, wSans, R, EASE, FAST, fs } from "../tokens"; +import { Button } from "./Button"; + +export interface ConfirmOptions { + title: string; + body: string; + /** Optional monospace detail line (the exact command / effect). */ + detail?: string; + confirmLabel?: string; + danger?: boolean; +} + +interface Pending extends ConfirmOptions { + resolve: (ok: boolean) => void; +} + +const EVENT = "cdk-confirm"; + +export function confirmDialog(opts: ConfirmOptions): Promise { + return new Promise((resolve) => { + window.dispatchEvent( + new CustomEvent(EVENT, { detail: { ...opts, resolve } }), + ); + }); +} + +export function ConfirmHost() { + const [p, setP] = useState(null); + + useEffect(() => { + function onReq(e: Event) { + setP((e as CustomEvent).detail); + } + window.addEventListener(EVENT, onReq); + return () => window.removeEventListener(EVENT, onReq); + }, []); + + useEffect(() => { + if (!p) return; + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") settle(false); + else if (e.key === "Enter") settle(true); + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [p]); + + if (!p) return null; + function settle(ok: boolean) { + p?.resolve(ok); + setP(null); + } + + return ( +
settle(false)} + style={{ + position: "fixed", + inset: 0, + zIndex: 200, + background: "color-mix(in srgb, #000 44%, transparent)", + display: "flex", + alignItems: "flex-start", + justifyContent: "center", + paddingTop: "18vh", + fontFamily: wSans, + animation: `cdk-fade ${FAST} ${EASE}`, + }} + > +
e.stopPropagation()} + style={{ + width: "min(440px, 92vw)", + background: W.surface, + border: `1px solid ${W.borderHi}`, + borderRadius: R.dialog, + overflow: "hidden", + }} + > +
+

+ {p.title} +

+

+ {p.body} +

+ {p.detail && ( +
+ {p.detail} +
+ )} +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/CopyPartyId.tsx b/frontend/src/components/CopyPartyId.tsx new file mode 100644 index 00000000..3873c63d --- /dev/null +++ b/frontend/src/components/CopyPartyId.tsx @@ -0,0 +1,48 @@ +// Click-to-copy for a full party id (alias::fingerprint). Copies the +// WHOLE id (never a truncation) since it must be pasted verbatim, and +// flashes "Copied!" for ~1.5s. + +import { useEffect, useRef, useState } from "react"; +import { Button } from "./Button"; +import { IcCheck, IcCopy } from "./icons"; + +export function CopyPartyId({ + partyId, + label = "Copy party id", +}: { + /** The full party id to copy — always copied in full, never truncated. */ + partyId: string; + /** Accessible label; defaults to "Copy party id". */ + label?: string; +}) { + const [copied, setCopied] = useState(false); + const timer = useRef | undefined>(undefined); + + useEffect(() => () => clearTimeout(timer.current), []); + + function copy() { + // clipboard may be unavailable (non-localhost http) or denied; a + // failed copy is a silent no-op rather than a thrown error. + try { + navigator.clipboard?.writeText(partyId).catch(() => {}); + } catch { + /* no clipboard API */ + } + setCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 1500); + } + + return ( + + ); +} diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx index 60814616..1606759b 100644 --- a/frontend/src/components/MetricCard.tsx +++ b/frontend/src/components/MetricCard.tsx @@ -1,26 +1,18 @@ import type { Point } from "./charts/types"; import { Sparkline } from "./charts/Sparkline"; -import { W, wMono } from "../tokens"; +import { IcArrowUp } from "./icons"; +import { W, wMono, wideCaps, R, fs } from "../tokens"; -// MetricCard — the 4-up strip at the top of the Metrics screen. -// One headline number + a delta vs the prior window + an inline -// sparkline so the value reads against its trend. -// -// Loading and error states are first-class — when the upstream -// PromQL fetch is in flight the card shows a skeleton; when it -// fails the card shows the error without taking down the whole -// grid. +// Headline number + delta vs prior window + inline sparkline. export interface MetricCardProps { title: string; unit?: string; - /** Current value (the big number). undefined → loading. */ + /** undefined → loading. */ value: number | undefined; /** Delta vs prior window. undefined hides the badge. */ delta?: number; - /** "up arrow good" or "down arrow good" — affects delta colour. */ deltaPolarity?: "up-is-good" | "down-is-good" | "neutral"; - /** Tiny chart embedded in the card. */ sparkline?: Point[]; sparklineColor?: string; /** When set, replaces the value + sparkline with the error message. */ @@ -35,7 +27,7 @@ export function MetricCard({ delta, deltaPolarity = "up-is-good", sparkline, - sparklineColor = "#7CB5F7", + sparklineColor = "#8FA3EE", error, format = defaultFormat, }: MetricCardProps) { @@ -47,7 +39,7 @@ export function MetricCard({ const good = (deltaSign > 0 && deltaPolarity === "up-is-good") || (deltaSign < 0 && deltaPolarity === "down-is-good"); - deltaColor = good ? "#62E2A0" : "#F08FB5"; + deltaColor = good ? W.ok : W.err; } return ( @@ -55,11 +47,10 @@ export function MetricCard({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: R.card, padding: 14, display: "flex", flexDirection: "column", - gap: 6, minWidth: 0, }} > @@ -69,15 +60,14 @@ export function MetricCard({ alignItems: "center", justifyContent: "space-between", gap: 8, + marginBottom: 6, }} > {title} @@ -86,12 +76,22 @@ export function MetricCard({ - {deltaSign > 0 ? "▲" : deltaSign < 0 ? "▼" : "—"}{" "} + {deltaSign > 0 ? ( + + ) : deltaSign < 0 ? ( + + ) : ( + "—" + )} {format(Math.abs(delta))} {unit && {" " + unit}} @@ -99,7 +99,7 @@ export function MetricCard({ {error ? ( -
+
{error}
) : ( @@ -118,10 +118,11 @@ export function MetricCard({ — @@ -131,21 +132,22 @@ export function MetricCard({ {format(value)} {unit && ( - {unit} + {unit} )} )}
-
+
{sparkline ? ( ) : ( @@ -172,7 +174,7 @@ function Skeleton({ width, height, background: W.border, - borderRadius: 4, + borderRadius: R.control, opacity: 0.4, }} /> diff --git a/frontend/src/components/MonoId.tsx b/frontend/src/components/MonoId.tsx new file mode 100644 index 00000000..bd2a6090 --- /dev/null +++ b/frontend/src/components/MonoId.tsx @@ -0,0 +1,67 @@ +// Middle-truncates a ledger id (head…tail) so the discriminating suffix +// stays visible; full value on hover, copies on click. + +import { useState, type CSSProperties } from "react"; +import { W, wMono, fs } from "../tokens"; + +function truncateMid(s: string, head: number, tail: number): string { + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} + +interface MonoIdProps { + value: string; + head?: number; + tail?: number; + full?: boolean; + size?: number | string; + color?: string; + style?: CSSProperties; +} + +export function MonoId({ + value, + head = 8, + tail = 6, + full = false, + size = fs.meta, + color = W.text2, + style, +}: MonoIdProps) { + const [copied, setCopied] = useState(false); + const shown = full ? value : truncateMid(value, head, tail); + const copy = () => { + // clipboard may be unavailable (non-localhost http) or denied; failed copy is a no-op. + try { + navigator.clipboard?.writeText(value).catch(() => {}); + setCopied(true); + window.setTimeout(() => setCopied(false), 1100); + } catch { + /* no clipboard API */ + } + }; + return ( + + ); +} diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx new file mode 100644 index 00000000..3f28e7ca --- /dev/null +++ b/frontend/src/components/Skeleton.tsx @@ -0,0 +1,85 @@ +// Loading placeholders shaped like the real table to avoid layout shift. + +import { useEffect, useState, type CSSProperties } from "react"; +import { W, R } from "../tokens"; + +// Delays true until `ms` so a fast fetch never flashes a skeleton. +export function useLoadingDelay(active: boolean, ms = 160): boolean { + const [shown, setShown] = useState(false); + useEffect(() => { + if (!active) { + setShown(false); + return; + } + const t = window.setTimeout(() => setShown(true), ms); + return () => window.clearTimeout(t); + }, [active, ms]); + return shown; +} + +export function SkeletonBar({ + width = "100%", + height = 12, + style, +}: { + width?: number | string; + height?: number; + style?: CSSProperties; +}) { + return ( + + ); +} + +// Pass the real table's column widths so the skeleton lines up. +export function SkeletonTable({ + columns, + rows = 4, + rowHeight = 38, + label = "Loading", +}: { + columns: (number | string)[]; + rows?: number; + rowHeight?: number; + label?: string; +}) { + return ( +
+ {Array.from({ length: rows }).map((_, r) => ( +
+ {columns.map((w, c) => ( +
+ +
+ ))} +
+ ))} +
+ ); +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 00000000..c39d7f2d --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,101 @@ +// Pairs a colored dot with a text label so color is never the only cue. + +import type { CSSProperties } from "react"; +import { W, tint, R, fs } from "../tokens"; +import { Dot } from "./icons"; + +type Tone = "ok" | "warn" | "danger" | "muted"; + +const MAP: Record = { + running: { label: "Running", tone: "ok" }, + healthy: { label: "Healthy", tone: "ok" }, + ready: { label: "Ready", tone: "ok" }, + stopped: { label: "Stopped", tone: "muted" }, + exited: { label: "Exited", tone: "muted" }, + creating: { label: "Creating", tone: "warn" }, + starting: { label: "Starting", tone: "warn" }, + stopping: { label: "Stopping", tone: "warn" }, + restarting: { label: "Restarting", tone: "warn" }, + partial: { label: "Partial", tone: "warn" }, + paused: { label: "Paused", tone: "warn" }, + stalled: { label: "Stalled", tone: "warn" }, + failed: { label: "Failed", tone: "danger" }, + error: { label: "Error", tone: "danger" }, + dead: { label: "Dead", tone: "danger" }, + live: { label: "Live", tone: "ok" }, + reconnecting: { label: "Reconnecting", tone: "warn" }, + truncated: { label: "Truncated", tone: "warn" }, + idle: { label: "Idle", tone: "muted" }, +}; + +function toneColor(tone: Tone): string { + return tone === "ok" + ? W.ok + : tone === "warn" + ? W.warn + : tone === "danger" + ? W.err + : W.dim; +} + +function resolve(status: string): { label: string; color: string } { + const hit = MAP[status.toLowerCase()]; + if (hit) return { label: hit.label, color: toneColor(hit.tone) }; + const label = status.charAt(0).toUpperCase() + status.slice(1); + return { label, color: W.dim }; +} + +interface StatusBadgeProps { + status: string; + /** "text" = dot + label; "pill" = bordered tinted chip. */ + variant?: "text" | "pill"; + pulse?: boolean; + style?: CSSProperties; +} + +export function StatusBadge({ + status, + variant = "text", + pulse = false, + style, +}: StatusBadgeProps) { + const { label, color } = resolve(status); + if (variant === "pill") { + return ( + + + {label} + + ); + } + return ( + + + {label} + + ); +} diff --git a/frontend/src/components/charts/AreaChart.tsx b/frontend/src/components/charts/AreaChart.tsx index aa3089dc..46749e6f 100644 --- a/frontend/src/components/charts/AreaChart.tsx +++ b/frontend/src/components/charts/AreaChart.tsx @@ -1,5 +1,5 @@ -import { useMemo, useState } from "react"; -import { W, wMono } from "../../tokens"; +import { useId, useMemo, useState } from "react"; +import { W, wMono, fs } from "../../tokens"; import type { Point, Series } from "./types"; import { extent, linearScale, niceTicks } from "./scale"; @@ -40,6 +40,10 @@ export function AreaChart({ const innerW = Math.max(1, width - PADDING.left - PADDING.right); const innerH = Math.max(1, height - PADDING.top - PADDING.bottom); const hasData = series.points.length > 0; + // Unique, id-safe gradient handle. Deriving it from series.label breaks + // when the label has spaces (e.g. "ACS lookup buffer"): url(#area-ACS + // lookup buffer) is an invalid reference, so the fill falls back to black. + const gradId = `area-${useId().replace(/:/g, "")}`; const { x, y, xTicks, yTicks } = useMemo(() => { if (!hasData) { @@ -113,7 +117,7 @@ export function AreaChart({ style={{ display: "block" }} > - + @@ -138,7 +142,7 @@ export function AreaChart({ x={-6} y={yy + 3} textAnchor="end" - fontSize={9} + fontSize={fs.micro} fill={W.dim} fontFamily={wMono} > @@ -154,7 +158,7 @@ export function AreaChart({ x={x(t)} y={innerH + 14} textAnchor={i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle"} - fontSize={9} + fontSize={fs.micro} fill={W.dim} fontFamily={wMono} > @@ -164,7 +168,7 @@ export function AreaChart({ {hasData ? ( <> - + @@ -224,10 +228,10 @@ export function AreaChart({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: 2, padding: "3px 7px", fontFamily: wMono, - fontSize: 10.5, + fontSize: fs.micro, color: W.text, textAlign: "center", }} @@ -236,7 +240,7 @@ export function AreaChart({ {format(hover.p.v)} {yLabel ? " " + yLabel : ""}
-
{fmtTime(hover.p.t)}
+
{fmtTime(hover.p.t)}
)} diff --git a/frontend/src/components/charts/BarChart.tsx b/frontend/src/components/charts/BarChart.tsx index cec83615..edc51468 100644 --- a/frontend/src/components/charts/BarChart.tsx +++ b/frontend/src/components/charts/BarChart.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { W, wMono } from "../../tokens"; +import { W, wMono, fs } from "../../tokens"; import { extent, niceTicks } from "./scale"; // BarChart — categorical bar chart. Used for per-template throughput, @@ -26,7 +26,7 @@ export function BarChart({ bars, width = 320, height, - defaultColor = "#7CB5F7", + defaultColor = "#8FA3EE", format = (v) => (Math.abs(v) >= 1000 ? v.toFixed(0) : v.toFixed(1)), }: Props) { // Default height grows with bar count so dense lists don't squish. @@ -65,7 +65,7 @@ export function BarChart({ x={width / 2} y={computedHeight / 2} textAnchor="middle" - fontSize={11} + fontSize={fs.label} fill={W.dim} fontFamily={wMono} > @@ -101,7 +101,7 @@ export function BarChart({ x={x(t)} y={innerH + 14} textAnchor="middle" - fontSize={9} + fontSize={fs.micro} fill={W.dim} fontFamily={wMono} > @@ -119,7 +119,7 @@ export function BarChart({ x={-8} y={yy + barH / 2 + 3} textAnchor="end" - fontSize={11} + fontSize={fs.label} fill={W.text2} > {b.label} @@ -135,7 +135,7 @@ export function BarChart({ diff --git a/frontend/src/components/charts/Heatmap.tsx b/frontend/src/components/charts/Heatmap.tsx index ef1c72dd..6874e58c 100644 --- a/frontend/src/components/charts/Heatmap.tsx +++ b/frontend/src/components/charts/Heatmap.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { W, wMono } from "../../tokens"; +import { W, wMono, fs } from "../../tokens"; // Heatmap — 2D density grid. Rows are e.g. latency buckets; // columns are time buckets. Cells coloured by intensity 0..1. @@ -35,7 +35,7 @@ export function Heatmap({ colLabels, width = 320, height = 160, - color = "#7CB5F7", + color = "#8FA3EE", }: Props) { const innerW = Math.max(1, width - PADDING.left - PADDING.right); const innerH = Math.max(1, height - PADDING.top - PADDING.bottom); @@ -64,7 +64,7 @@ export function Heatmap({ x={width / 2} y={height / 2} textAnchor="middle" - fontSize={11} + fontSize={fs.label} fill={W.dim} fontFamily={wMono} > @@ -108,7 +108,7 @@ export function Heatmap({ x={-6} y={r * cellH + cellH / 2 + 3} textAnchor="end" - fontSize={9.5} + fontSize={fs.micro} fill={W.dim} fontFamily={wMono} > @@ -123,7 +123,7 @@ export function Heatmap({ x={c * cellW + cellW / 2} y={innerH + 14} textAnchor={c === 0 ? "start" : c === cols - 1 ? "end" : "middle"} - fontSize={9} + fontSize={fs.micro} fill={W.dim} fontFamily={wMono} > diff --git a/frontend/src/components/charts/MultiLine.tsx b/frontend/src/components/charts/MultiLine.tsx index b4cfc58c..e9f0723e 100644 --- a/frontend/src/components/charts/MultiLine.tsx +++ b/frontend/src/components/charts/MultiLine.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from "react"; -import { W, wMono } from "../../tokens"; +import { W, wMono, fs } from "../../tokens"; import type { Series } from "./types"; import { extent, linearScale, niceTicks } from "./scale"; @@ -102,7 +102,7 @@ export function MultiLine({ x={-6} y={yy + 3} textAnchor="end" - fontSize={9} + fontSize={fs.micro} fill={W.dim} fontFamily={wMono} > @@ -119,7 +119,7 @@ export function MultiLine({ textAnchor={ i === 0 ? "start" : i === xTicks.length - 1 ? "end" : "middle" } - fontSize={9} + fontSize={fs.micro} fill={W.dim} fontFamily={wMono} > @@ -164,7 +164,7 @@ export function MultiLine({ x={innerW / 2} y={innerH / 2} textAnchor="middle" - fontSize={11} + fontSize={fs.label} fill={W.dim} fontFamily={wMono} > @@ -191,7 +191,7 @@ export function MultiLine({ gap: 14, flexWrap: "wrap", padding: "4px 12px 0", - fontSize: 11, + fontSize: fs.label, fontFamily: wMono, color: W.text2, }} diff --git a/frontend/src/components/charts/charts.test.tsx b/frontend/src/components/charts/charts.test.tsx index 0ccc04aa..1979be66 100644 --- a/frontend/src/components/charts/charts.test.tsx +++ b/frontend/src/components/charts/charts.test.tsx @@ -129,7 +129,7 @@ describe("AreaChart", () => { { it("renders empty-state message when there are no points", () => { render( , ); expect(screen.getByText(/no data in this window/i)).toBeTruthy(); @@ -167,12 +167,12 @@ describe("MultiLine", () => { series={[ { label: "median", - color: "#5BD7C5", + color: "#6480E6", points: [{ t: 1, v: 100 }, { t: 2, v: 110 }], }, { label: "p99", - color: "#F5BF55", + color: "#DDB25E", points: [{ t: 1, v: 200 }, { t: 2, v: 220 }], }, ]} @@ -237,7 +237,7 @@ describe("Heatmap", () => { describe("Sparkline", () => { it("renders an SVG with width/height even when there's no data", () => { - const { container } = render(); + const { container } = render(); const svg = container.querySelector("svg"); expect(svg).toBeTruthy(); expect(svg?.getAttribute("width")).toBe("120"); @@ -251,7 +251,7 @@ describe("Sparkline", () => { { t: 2, v: 2 }, { t: 3, v: 3 }, ]} - color="#7CB5F7" + color="#8FA3EE" />, ); const paths = container.querySelectorAll("svg path"); diff --git a/frontend/src/components/charts/types.ts b/frontend/src/components/charts/types.ts index 60ec7585..775e2730 100644 --- a/frontend/src/components/charts/types.ts +++ b/frontend/src/components/charts/types.ts @@ -73,15 +73,16 @@ export function decodePrometheusRange( })); } -// Curated chart palette. Keeps a chart with 6+ series readable — -// neighbouring lines never share the same hue family. Picked to be -// accessible on the project's dark surface tokens. +// Curated chart palette — the design system's dataviz ramp (cobalt +// first, teal second, then supporting hues). Keeps a chart with 6+ +// series readable: neighbouring lines never share the same hue +// family, and every stop is legible on the dark surface tokens. export const CHART_PALETTE = [ - "#7CB5F7", // blue - "#5BD7C5", // teal - "#C4A8F5", // purple - "#F5BF55", // amber - "#F08FB5", // pink - "#62E2A0", // green - "#E8A14E", // orange + "#6480E6", // cobalt + "#7BD2C6", // teal + "#93A7F0", // cobalt-light + "#DDB25E", // amber + "#189E8C", // teal-deep + "#7CC89A", // green + "#C8971F", // amber-deep ]; diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 00000000..86733515 --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,276 @@ +// 16×16 stroke glyphs on currentColor. All icons are aria-hidden +// decoration; the accessible name belongs to the surrounding control. + +import type { CSSProperties, ReactNode } from "react"; + +export interface IconProps { + /** Rendered box in px. Defaults to 14 (button-slot size). */ + size?: number; + style?: CSSProperties; +} + +function I({ size = 14, style, children }: IconProps & { children: ReactNode }) { + return ( + + {children} + + ); +} + +export const IcPlay = (p: IconProps) => ( + + + +); + +export const IcPause = (p: IconProps) => ( + + + +); + +export const IcStop = (p: IconProps) => ( + + + +); + +export const IcEject = (p: IconProps) => ( + + + + +); + +export const IcRefresh = (p: IconProps) => ( + + + + +); + +export const IcCheck = (p: IconProps) => ( + + + +); + +export const IcCopy = (p: IconProps) => ( + + + + +); + +export const IcX = (p: IconProps) => ( + + + +); + +export const IcAlert = (p: IconProps) => ( + + + + + +); + +export const IcDownload = (p: IconProps) => ( + + + + +); + +export const IcUpload = (p: IconProps) => ( + + + + +); + +export const IcArrowUp = (p: IconProps) => ( + + + +); + +export const IcArrowRight = (p: IconProps) => ( + + + +); + +export const IcChevronDown = (p: IconProps) => ( + + + +); + +export const IcChevronRight = (p: IconProps) => ( + + + +); + +export const IcPlus = (p: IconProps) => ( + + + +); + +export const IcBolt = (p: IconProps) => ( + + + +); + +export const IcFlame = (p: IconProps) => ( + + + +); + +export const IcDroplet = (p: IconProps) => ( + + + +); + +export const IcOverview = (p: IconProps) => ( + + + + + + +); + +export const IcDoctor = (p: IconProps) => ( + + + +); + +export const IcWallet = (p: IconProps) => ( + + + + + +); + +export const IcExplorer = (p: IconProps) => ( + + + + +); + +export const IcPackage = (p: IconProps) => ( + + + + +); + +// Two package nodes joined by an interaction edge, under a magnifier — +// the "cross-package interaction analysis" motif for the Analyzer tab. +export const IcAnalyzer = (p: IconProps) => ( + + + + + + + +); + +export const IcMetrics = (p: IconProps) => ( + + + + +); + +export const IcTokens = (p: IconProps) => ( + + + + + +); + +export const IcAgent = (p: IconProps) => ( + + + + + +); + +export const IcSun = (p: IconProps) => ( + + + + +); + +export const IcMoon = (p: IconProps) => ( + + + +); + +export const IcCommand = (p: IconProps) => ( + + + +); + +export const IcBook = (p: IconProps) => ( + + + + +); + +export function Dot({ + color, + size = 6, + pulse = false, + style, +}: { + color: string; + size?: number; + pulse?: boolean; + style?: CSSProperties; +}) { + return ( + + ); +} diff --git a/frontend/src/fonts/OFL-Archivo.txt b/frontend/src/fonts/OFL-Archivo.txt new file mode 100644 index 00000000..8597481e --- /dev/null +++ b/frontend/src/fonts/OFL-Archivo.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Archivo Project Authors (https://github.com/Omnibus-Type/Archivo) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/fonts/OFL-JetBrainsMono.txt b/frontend/src/fonts/OFL-JetBrainsMono.txt new file mode 100644 index 00000000..5ceee002 --- /dev/null +++ b/frontend/src/fonts/OFL-JetBrainsMono.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/frontend/src/fonts/archivo-italic.woff2 b/frontend/src/fonts/archivo-italic.woff2 new file mode 100644 index 00000000..625819b5 Binary files /dev/null and b/frontend/src/fonts/archivo-italic.woff2 differ diff --git a/frontend/src/fonts/archivo.woff2 b/frontend/src/fonts/archivo.woff2 new file mode 100644 index 00000000..7af3f7db Binary files /dev/null and b/frontend/src/fonts/archivo.woff2 differ diff --git a/frontend/src/fonts/jetbrains-mono-italic.woff2 b/frontend/src/fonts/jetbrains-mono-italic.woff2 new file mode 100644 index 00000000..4d3d6ca3 Binary files /dev/null and b/frontend/src/fonts/jetbrains-mono-italic.woff2 differ diff --git a/frontend/src/fonts/jetbrains-mono.woff2 b/frontend/src/fonts/jetbrains-mono.woff2 new file mode 100644 index 00000000..c3f0666e Binary files /dev/null and b/frontend/src/fonts/jetbrains-mono.woff2 differ diff --git a/frontend/src/index.css b/frontend/src/index.css index 17738210..ee66bc9a 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,13 +1,101 @@ -/* Base styles. The mockup JSX inlines styles per element; we - lift only the body/layout-defaults here and let components - own their own styling via the tokens. This keeps the - import-graph honest: tokens.ts is the single source of truth, - global CSS is just zero-out. */ +/* Base styles + design-system token sheet: every semantic color as a + CSS variable with a dark default (:root) and light override + (:root[data-theme="light"]), resolved per-theme by tokens.ts W.*. */ +/* Typefaces self-hosted so the UI renders identically offline. */ +@font-face { + font-family: "Archivo"; + src: url("./fonts/archivo.woff2") format("woff2-variations"); + font-weight: 100 900; + font-stretch: 62% 125%; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Archivo"; + src: url("./fonts/archivo-italic.woff2") format("woff2-variations"); + font-weight: 100 900; + font-stretch: 62% 125%; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("./fonts/jetbrains-mono.woff2") format("woff2-variations"); + font-weight: 100 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "JetBrains Mono"; + src: url("./fonts/jetbrains-mono-italic.woff2") format("woff2-variations"); + font-weight: 100 800; + font-style: italic; + font-display: swap; +} + +/* Raw ramps (theme-independent). */ +:root { + --gray-25: #fcfcfd; --gray-50: #f7f8fa; --gray-100: #eff1f5; + --gray-200: #e2e5ec; --gray-300: #cdd2dd; --gray-400: #9ba3b5; + --gray-500: #6c7488; --gray-600: #4d5567; --gray-700: #384050; + --gray-800: #232a39; --gray-900: #141a28; --gray-950: #0b0f1a; + --blue-50: #eef1fd; --blue-100: #dce3fb; --blue-200: #bcc9f6; + --blue-300: #93a7f0; --blue-400: #6480e6; --blue-500: #5661db; + --blue-600: #2946ce; --blue-700: #2138a8; --blue-800: #1d2f85; + --blue-900: #1a2861; --blue-950: #101836; + --teal-300: #7bd2c6; --teal-500: #189e8c; + --green-500: #2e9e5b; --amber-500: #d89117; --red-500: #d24a38; + + --ease-out: cubic-bezier(0.2, 0.6, 0.2, 1); + --duration-fast: 120ms; +} + +/* Dark theme (default) — Carbon Slate. All pairs verified WCAG AA. */ :root { color-scheme: dark; - --bg: #0B0E13; - --text: #E6E9EE; + --bg-page: #0f1012; --bg-sunken: #0b0c0e; --bg-surface: #16171a; + --bg-raised: #1e1f23; --bg-inset: #101113; + --text-primary: #e4e5e8; --text-secondary: #aeb1b8; + --text-muted: #75787f; --text-faint: #6e7178; + --border-subtle: #1e1f23; --border-default: #2a2c31; --border-strong: #3a3d44; + --hover-tint: #1a1b1f; --active-tint: #212227; + --accent: #8b93f2; --accent-hover: #9aa1f5; --accent-active: #a6acf6; + --accent-subtle: #1b1d2e; --accent-muted: #23263c; --accent-text: #a6acf6; + --on-accent: #12121a; + --accent-solid: #4e57d6; --accent-solid-hover: #5a62de; --on-accent-solid: #ffffff; + --link: #a6acf6; --link-hover: #c0c4f9; + --ok-text: #5bc98c; --ok-bg: #0f2118; --ok-border: #1e3a2a; + --warn-text: #e8b24c; --warn-bg: #221a0b; --warn-border: #3e3115; + --danger-text: #f07b72; --danger-bg: #241210; --danger-border: #45201a; + --danger: #e5604f; --danger-hover: #f07b72; + --info-text: #a9baf2; --info-bg: #16182b; --info-border: #2a2e48; + --dot-grid: radial-gradient(circle at 1px 1px, #2a2c31 1px, transparent 1px); +} + +/* Light theme — Carbon Slate. */ +:root[data-theme="light"] { + color-scheme: light; + --bg-page: #fbfbfc; --bg-sunken: #f7f7f8; --bg-surface: #ffffff; + --bg-raised: #f4f5f6; --bg-inset: #eeeff1; + --text-primary: #1b1c1f; --text-secondary: #54575e; + --text-muted: #82868e; --text-faint: #8a8d95; + --border-subtle: #eeeff1; --border-default: #e1e3e6; --border-strong: #c6c9ce; + --hover-tint: #f4f5f6; --active-tint: #eeeff1; + --accent: #4a52c9; --accent-hover: #3d45be; --accent-active: #333ba8; + --accent-subtle: #eef0fd; --accent-muted: #dde0fa; --accent-text: #3d45be; + --on-accent: #ffffff; + --accent-solid: #4a52c9; --accent-solid-hover: #3d45be; --on-accent-solid: #ffffff; + --link: #4a52c9; --link-hover: #333ba8; + --ok-text: #157c45; --ok-bg: #edf7f0; --ok-border: #bce0c9; + --warn-text: #8a6410; --warn-bg: #fcf5e8; --warn-border: #ebd9a9; + --danger-text: #b93a2e; --danger-bg: #fbefed; --danger-border: #efc5bd; + --danger: #b93a2e; --danger-hover: #962e20; + --info-text: #3d45be; --info-bg: #eef0fd; --info-border: #bcc9f6; + --dot-grid: radial-gradient(circle at 1px 1px, #e2e5ec 1px, transparent 1px); } * { @@ -20,11 +108,15 @@ body, margin: 0; padding: 0; height: 100%; - background: var(--bg); - color: var(--text); - font-family: "IBM Plex Sans", "Inter Tight", system-ui, sans-serif; - font-size: 14px; + background: var(--bg-page); + color: var(--text-primary); + font-family: "Archivo", -apple-system, "Segoe UI", "Helvetica Neue", + Arial, sans-serif; + /* 100% so rem honors the browser/OS font-size preference. */ + font-size: 100%; line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } button { @@ -37,40 +129,182 @@ a { text-decoration: none; } -/* a11y: keyboard focus rings. - * - * :focus-visible — only when the focus came from keyboard (Tab, - * arrow keys) or programmatic .focus(). Mouse clicks don't paint - * the ring, matching what sighted users expect from native UI. - * - * The 2px brand-coloured outline is high-contrast against every - * surface in the dark palette. Offset 2px so it doesn't merge - * into the element's own border. */ +::selection { + background: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--text-primary); +} + +/* Themed scrollbars — raised thumb on a transparent track. */ +::-webkit-scrollbar { + width: 11px; + height: 11px; +} +::-webkit-scrollbar-thumb { + background: var(--border-strong); + border-radius: 999px; + border: 3px solid transparent; + background-clip: content-box; +} +::-webkit-scrollbar-thumb:hover { + background: var(--text-faint); + background-clip: content-box; +} +::-webkit-scrollbar-track { + background: transparent; +} + +/* a11y: keyboard-only focus rings via :focus-visible; offset 2px so the + ring doesn't merge into the element's own border. */ :focus { outline: none; } :focus-visible { - outline: 2px solid #5BD7C5; + outline: 2px solid var(--blue-500); outline-offset: 2px; + border-radius: 2px; +} + +/* Sidebar nav items — hover/active tints live here since inline styles + * can't express :hover. */ +.side-nav-link { + display: flex; + align-items: center; + gap: 10px; + padding: 7px 10px; + margin: 1px 0; border-radius: 4px; + font-size: 0.8125rem; + color: var(--text-secondary); + transition: background var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); +} + +.side-nav-link:hover { + background: var(--hover-tint); + color: var(--text-primary); +} + +.side-nav-link.active, +.side-nav-link.active:hover { + background: var(--accent-subtle); + color: var(--accent-text); + font-weight: 500; +} + +.side-nav-link svg { + color: var(--text-faint); + flex: none; +} +.side-nav-link:hover svg { + color: var(--text-muted); +} +.side-nav-link.active svg { + color: var(--accent); +} + +/* Button system (components/Button.tsx) — hover/active tints live here + * since inline styles can't express :hover. */ +.bd-btn { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + font-family: inherit; + font-weight: 500; + border-radius: 2px; + border: 1px solid transparent; + cursor: pointer; + white-space: nowrap; + transition: background var(--duration-fast) var(--ease-out), + border-color var(--duration-fast) var(--ease-out), + color var(--duration-fast) var(--ease-out); } -/* Skip-to-content link (shell/Shell.tsx::SkipLink). Visually - * hidden until focused via Tab — first focusable element on the - * page so a keyboard user can jump past the sidebar to the main - * content. The .focus state pulls it on-screen as a pill. */ +.bd-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.bd-btn--sm { + height: 28px; + padding: 0 10px; + font-size: 0.75rem; +} + +.bd-btn--md { + height: 36px; + padding: 0 14px; + font-size: 0.8125rem; +} + +.bd-btn--full { + width: 100%; +} + +.bd-btn--primary { + background: var(--accent-solid); + color: var(--on-accent-solid); +} +.bd-btn--primary:hover:not(:disabled) { + background: var(--accent-solid-hover); +} +.bd-btn--primary:active:not(:disabled) { + background: var(--accent-active); +} + +.bd-btn--secondary { + background: var(--bg-surface); + border-color: var(--border-default); + color: var(--text-primary); +} +.bd-btn--secondary:hover:not(:disabled) { + background: var(--hover-tint); + border-color: var(--border-strong); +} +.bd-btn--secondary:active:not(:disabled) { + background: var(--active-tint); +} + +.bd-btn--ghost { + background: transparent; + color: var(--text-secondary); +} +.bd-btn--ghost:hover:not(:disabled) { + background: var(--hover-tint); + color: var(--text-primary); +} +.bd-btn--ghost:active:not(:disabled) { + background: var(--active-tint); +} + +.bd-btn--danger { + background: var(--danger); + color: #fff; +} +.bd-btn--danger:hover:not(:disabled) { + background: var(--danger-hover); +} + +.bd-btn__icon { + display: inline-flex; + flex: none; +} + +/* Skip-to-content link — visually hidden until focused via Tab so a + * keyboard user can jump past the sidebar to main content. */ .skip-link { position: absolute; top: -100px; left: 8px; - background: #5BD7C5; - color: #082018; + background: var(--accent-solid); + color: var(--on-accent-solid); padding: 8px 14px; font-weight: 600; - border-radius: 6px; + border-radius: 2px; z-index: 1000; - transition: top 0.15s ease-out; + transition: top var(--duration-fast) var(--ease-out); } .skip-link:focus, @@ -79,20 +313,34 @@ a { outline: none; } -/* Connection-health pill (shell/Shell.tsx::HealthPill). Pulses - when the topbar pill is in a degraded state. Kept here rather - than inline because @keyframes can't be expressed in a React - style object. */ +/* Connection-health pill pulse (shell/Shell.tsx::HealthPill). */ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } } -/* Respect prefers-reduced-motion — the pulse is ambient and - not load-bearing for the state communication (color carries - the signal too). */ +/* Modal/overlay entrance (ConfirmDialog). */ +@keyframes cdk-fade { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +/* Skeleton shimmer (components/Skeleton), gated below by prefers-reduced-motion. */ +@keyframes cdk-shimmer { + 0% { background-position: -180% 0; } + 100% { background-position: 180% 0; } +} + +/* prefers-reduced-motion: color still carries the state signal. */ @media (prefers-reduced-motion: reduce) { @keyframes pulse { 0%, 100% { opacity: 1; } } + @keyframes cdk-shimmer { + 0%, 100% { background-position: 0 0; } + } + @keyframes cdk-fade { + from { opacity: 1; } + to { opacity: 1; } + } } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 217dea6d..6466e9b4 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,6 +3,10 @@ import ReactDOM from "react-dom/client"; import { BrowserRouter } from "react-router-dom"; import "./index.css"; import { App } from "./App"; +import { initTheme } from "./theme"; + +// Apply the persisted theme before first paint to avoid a flash. +initTheme(); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/frontend/src/screens/AgentSkillsScreen.tsx b/frontend/src/screens/AgentSkillsScreen.tsx index b9036acd..e725cf1a 100644 --- a/frontend/src/screens/AgentSkillsScreen.tsx +++ b/frontend/src/screens/AgentSkillsScreen.tsx @@ -5,14 +5,12 @@ import { installSkills, type Skill, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, FAST, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { IcAlert, IcCheck, IcX } from "../components/icons"; -// AgentSkillsScreen — . -// -// Browses the bundled AI-agent skill docs (served by /api/skills, -// the SAME embedded markdown the CLI `localnet skills` command -// ships) and offers one-click install into ~/.claude/skills or -// ~/.codex/skills. CLI ↔ UI parity: both surfaces read internal/skills. +// Browses the bundled agent skill docs and installs them into +// ~/.claude/skills or ~/.codex/skills. export function AgentSkillsScreen() { const [state, setState] = useState< | { kind: "loading" } @@ -76,7 +74,7 @@ export function AgentSkillsScreen() { return (
-

Loading skills…

+

Loading skills…

); } @@ -84,7 +82,7 @@ export function AgentSkillsScreen() { return (
-

{state.error}

+

{state.error}

); } @@ -103,7 +101,6 @@ export function AgentSkillsScreen() { >
- {/* Install bar */}
- + Install all {state.skills.length} skills into: doInstall("codex")} /> {install.kind === "done" && ( - - ✓ {install.count} installed → {install.dir} + + {install.count} installed → {install.dir} )} {install.kind === "done" && install.skipped.length > 0 && ( @@ -142,39 +148,36 @@ export function AgentSkillsScreen() { alignItems: "center", gap: 8, color: W.warn, - fontSize: 12, + fontSize: fs.meta, fontFamily: wMono, }} > - ⚠ {install.skipped.length} preserved (locally modified):{" "} - {install.skipped.join(", ")} - + )} {install.kind === "err" && ( - - ✗ {install.message} + + {install.message} )}
- {/* Two-pane: list | preview */}
@@ -203,15 +206,15 @@ export function AgentSkillsScreen() { width: "100%", textAlign: "left", padding: "10px 14px", - background: isActive ? W.surface2 : "transparent", + background: isActive ? tint(W.brand, 12) : "transparent", border: "none", - borderLeft: `2px solid ${isActive ? W.brand : "transparent"}`, cursor: "pointer", color: isActive ? W.text : W.text2, + transition: `background-color ${FAST}`, }} > -
{s.name}
-
+
{s.name}
+
{s.description}
@@ -223,7 +226,7 @@ export function AgentSkillsScreen() { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, overflow: "auto", padding: "16px 20px", }} @@ -235,7 +238,7 @@ export function AgentSkillsScreen() { whiteSpace: "pre-wrap", wordBreak: "break-word", fontFamily: wMono, - fontSize: 12.5, + fontSize: fs.meta, lineHeight: 1.6, color: W.text2, }} @@ -255,24 +258,24 @@ function Header() { return (
-

Agent Skills

+

Agent Skills

editor-agnostic
-
+
Safe `dpm localnet` workflows for AI agents. Same docs as the CLI - `localnet skills` command — install into your agent and let it - drive DevKit. + `localnet skills` command. Install into your agent and let it drive + DevKit.
); @@ -288,23 +291,13 @@ function InstallButton({ onClick: () => void; }) { return ( - + ); } diff --git a/frontend/src/screens/AnalyzerScreen.test.tsx b/frontend/src/screens/AnalyzerScreen.test.tsx new file mode 100644 index 00000000..ca01084e --- /dev/null +++ b/frontend/src/screens/AnalyzerScreen.test.tsx @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { InstanceSelectionProvider } from "../shell/useInstanceSelection"; +import { AnalyzerScreen } from "./AnalyzerScreen"; + +afterEach(() => vi.unstubAllGlobals()); + +const REPORT = { + analyzed_package: { name: "pkg-app", version: "1.0.0", package_id: "aa11", lf_version: "2.2" }, + dependencies: [{ name: "pkg-registry", version: "1.0.0", package_id: "bb22" }], + summary: { total_interactions: 1, by_type: { Exercise: 1 }, by_target_package: { "pkg-registry": 1 } }, + interactions: [ + { + type: "Exercise", + source: { package: "pkg-app", file: "App.daml", start_line: 12 }, + caller: { package: "pkg-app", version: "1.0.0", package_id: "aa11", module: "App", choice: "TransferAsset" }, + target: { package: "pkg-registry", version: "1.0.0", package_id: "bb22", module: "Registry", choice: "UpdateOwner", consuming: true }, + }, + ], +}; + +function stubFetch(status: { available: boolean; runtime?: string; source?: string; detail?: string }) { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + const json = (body: unknown) => + Promise.resolve(new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } })); + if (url.startsWith("/api/version")) return json({ name: "canton-devkit", schema_version: 1 }); + if (url.startsWith("/api/instances/demo/analyzer/")) return json({ schema_version: 1, instance: "demo", package_id: "aa11", dar_name: "pkg-app-1.0.0.dar", report: REPORT }); + if (url.startsWith("/api/instances/demo/dar")) return json({ schema_version: 1, instance: "demo", role: "app-user", dars: [{ main: "aa11", name: "pkg-app", version: "1.0.0" }] }); + if (url.startsWith("/api/instances")) return json({ schema_version: 1, instances: [{ name: "demo", status: "running" }] }); + if (url.startsWith("/api/analyzer/status")) + return json({ schema_version: 1, available: status.available, runtime: status.runtime ?? (status.available ? "component" : ""), source: status.source ?? (status.available ? "dpm component 0.1.0" : ""), detail: status.detail ?? "" }); + return Promise.resolve(new Response(null, { status: 204 })); + }), + ); +} + +function renderScreen() { + return render( + + + + + , + ); +} + +describe("AnalyzerScreen", () => { + it("renders highlights, then the summary pivot and interactions", async () => { + stubFetch({ available: true }); + renderScreen(); + // the deployed DAR appears as a button; clicking it loads the report + const darBtn = await screen.findByRole("button", { name: /pkg-app 1\.0\.0/ }, { timeout: 4000 }); + await userEvent.click(darBtn); + + // Highlights is the default view — the consuming exercise is surfaced. + expect(await screen.findByText(/consuming exercise/i)).toBeInTheDocument(); + + // Summary pivot lists the target package with its per-type counts. + await userEvent.click(screen.getByRole("button", { name: "summary" })); + expect(await screen.findByText("pkg-registry")).toBeInTheDocument(); + expect(screen.getByText("TARGET PACKAGE")).toBeInTheDocument(); + + // Interactions carries the caller/target plus a source column. + await userEvent.click(screen.getByRole("button", { name: "interactions" })); + expect(await screen.findByText(/TransferAsset/)).toBeInTheDocument(); + expect(screen.getByText(/UpdateOwner/)).toBeInTheDocument(); + expect(screen.getByText("SOURCE")).toBeInTheDocument(); + expect(screen.getByText("App.daml:12")).toBeInTheDocument(); + }); + + it("filters interactions when a summary row is clicked", async () => { + stubFetch({ available: true }); + renderScreen(); + const darBtn = await screen.findByRole("button", { name: /pkg-app 1\.0\.0/ }, { timeout: 4000 }); + await userEvent.click(darBtn); + await userEvent.click(screen.getByRole("button", { name: "summary" })); + await userEvent.click(await screen.findByText("pkg-registry")); + // clicking the row jumps to the filtered interaction list + expect(await screen.findByText(/Filtered to/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); + }); + + it("shows a not-configured notice when the analyzer is unavailable", async () => { + stubFetch({ available: false, detail: "install the analyzer as a DPM component" }); + renderScreen(); + expect(await screen.findByText("Analyzer not configured")).toBeInTheDocument(); + expect(screen.getByText("oci://ghcr.io/certora/daml-analyzer:0.1.0")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/screens/AnalyzerScreen.tsx b/frontend/src/screens/AnalyzerScreen.tsx new file mode 100644 index 00000000..e4ae4de5 --- /dev/null +++ b/frontend/src/screens/AnalyzerScreen.tsx @@ -0,0 +1,856 @@ +import { useEffect, useMemo, useState } from "react"; +import { + fetchAnalyzerStatus, + fetchDARList, + analyzeDeployedDar, + analyzeUploadedDar, + ApiError, + type Role, + type AnalyzerStatusResponse, + type AnalyzerReport, + type AnalyzerInteraction, + type AnalyzerEndpoint, + type DARRow, +} from "../api"; +import { useInstanceSelection } from "../shell/useInstanceSelection"; +import { W, wMono, tableCaps, R, tint, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; + +// Cross-package interaction analysis (Certora daml-analyzer). Analyze DARs +// deployed to the selected instance or uploaded ad hoc; several reports can +// be loaded at once so they can be compared. The views mirror the upstream +// analyzer's own viewer: highlights, a target-package summary pivot, the +// package graph, the raw interaction list, and a two-report diff. + +const ROLES: Role[] = ["app-user", "app-provider", "sv"]; +type Tab = "highlights" | "summary" | "graph" | "interactions" | "diff"; +const TABS: Tab[] = ["highlights", "summary", "graph", "interactions", "diff"]; + +type Loaded = { id: string; darName: string; report: AnalyzerReport }; +// Filter applied from the summary pivot: a target package, optionally +// narrowed to one interaction type. +type Filter = { pkg: string; type?: string } | null; + +export function AnalyzerScreen() { + const sel = useInstanceSelection(); + const name = sel.selected; + const [role, setRole] = useState("app-user"); + const [status, setStatus] = useState(null); + const [dars, setDars] = useState([]); + const [reports, setReports] = useState([]); + const [activeId, setActiveId] = useState(""); + const [tab, setTab] = useState("highlights"); + const [filter, setFilter] = useState(null); + const [busy, setBusy] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let off = false; + fetchAnalyzerStatus() + .then((s) => !off && setStatus(s)) + .catch(() => !off && setStatus(null)); + return () => { + off = true; + }; + }, []); + + useEffect(() => { + if (!name) { + setDars([]); + return; + } + let off = false; + fetchDARList(name, role) + .then((r) => !off && setDars(r.dars)) + .catch(() => !off && setDars([])); + return () => { + off = true; + }; + }, [name, role]); + + function add(darName: string, report: AnalyzerReport) { + const id = `${darName}#${report.analyzed_package.package_id.slice(0, 8)}`; + setReports((prev) => [...prev.filter((r) => r.id !== id), { id, darName, report }]); + setActiveId(id); + setFilter(null); + } + + async function run(what: string, p: Promise<{ dar_name?: string; report: AnalyzerReport | null }>) { + setBusy(what); + setErr(null); + try { + const resp = await p; + if (resp.report) add(resp.dar_name || what, resp.report); + } catch (e) { + setErr(e instanceof ApiError ? e.message : String(e)); + } finally { + setBusy(null); + } + } + + // Upload analyses run sequentially so a directory of DARs surfaces one + // report per file without flooding the backend. + async function runUploads(files: File[]) { + setErr(null); + for (const f of files) { + setBusy(`upload:${f.name}`); + try { + const resp = await analyzeUploadedDar(f); + if (resp.report) add(resp.dar_name || f.name, resp.report); + } catch (e) { + setErr(e instanceof ApiError ? e.message : String(e)); + } + } + setBusy(null); + } + + const active = reports.find((r) => r.id === activeId) ?? reports[reports.length - 1]; + const gated = status !== null && !status.available; + + return ( +
+
+

Analyzer

+ + Cross-package interaction analysis · Certora daml-analyzer + + + {ROLES.map((r) => ( + + ))} + +
+ + {gated && status && } + + {!gated && ( + <> +
+ + {status?.source && ( + + via {status.runtime === "component" ? "DPM component" : status.runtime} · {status.source} + + )} +
+ + {name && dars.length > 0 && ( +
+
+ Deployed DARs on {role} +
+
+ {dars.map((d) => ( + + ))} +
+
+ )} + + {err &&
{err}
} + + {reports.length > 1 && ( +
+ {reports.map((r) => ( + + ))} +
+ )} + + {active && ( + + )} + + {!active && !err && !busy && ( +

+ Pick a deployed DAR above{name ? "" : " (select an instance)"} or upload one or more .dar files. +

+ )} + + )} +
+ ); +} + +function NotConfigured({ status }: { status: AnalyzerStatusResponse }) { + return ( +
+
Analyzer not configured
+
+ {status.detail || "The analyzer runtime is not available in this environment."} +
+
+ Install it as a DPM component: add{" "} + oci://ghcr.io/certora/daml-analyzer:0.1.0 to daml.yaml, + then run dpm install package. +
+
+ ); +} + +function ReportPanel({ + loaded, + all, + tab, + setTab, + filter, + setFilter, +}: { + loaded: Loaded; + all: Loaded[]; + tab: Tab; + setTab: (t: Tab) => void; + filter: Filter; + setFilter: (f: Filter) => void; +}) { + const report = loaded.report; + const p = report.analyzed_package; + const s = report.summary; + + return ( +
+
+

{p.name}

+ + {p.version} · LF {p.lf_version ?? "?"} + + {loaded.darName && {loaded.darName}} + + + +
+ +
+ + + +
+ +
+ {TABS.map((t) => ( + + ))} +
+ + {tab === "highlights" && { setFilter(f); setTab("interactions"); }} />} + {tab === "summary" && ( + { + setFilter(f); + setTab("interactions"); + }} + /> + )} + {tab === "graph" && } + {tab === "interactions" && ( + setFilter(null)} /> + )} + {tab === "diff" && } +
+ ); +} + +// --- Highlights ---------------------------------------------------------- + +// Derived "what matters here" findings: the destructive and structural +// interactions an operator should notice first. +function Highlights({ report, onJump }: { report: AnalyzerReport; onJump: (f: Filter) => void }) { + const ix = report.interactions; + const consuming = ix.filter((i) => i.target.consuming); + const impls = ix.filter((i) => i.type === "ImplementsInterface"); + const creates = ix.filter((i) => i.type === "Create"); + const byPkg = report.summary.by_target_package; + const top = Object.entries(byPkg).sort((a, b) => b[1] - a[1])[0]; + const noSource = ix.filter((i) => !i.source?.file).length; + + const items: Array<{ tone: string; label: string; detail: string; pkg?: string; type?: string }> = []; + if (consuming.length) { + items.push({ + tone: W.warn, + label: `${consuming.length} consuming exercise${consuming.length > 1 ? "s" : ""}`, + detail: "archives a contract in another package — the destructive cross-package calls", + type: "Exercise", + }); + } + if (impls.length) { + items.push({ + tone: W.teal, + label: `${impls.length} interface implementation${impls.length > 1 ? "s" : ""}`, + detail: "this package implements interfaces owned by a dependency", + type: "ImplementsInterface", + }); + } + if (creates.length) { + items.push({ + tone: W.brandText, + label: `${creates.length} cross-package create${creates.length > 1 ? "s" : ""}`, + detail: "writes contracts defined by another package", + type: "Create", + }); + } + if (top) { + items.push({ + tone: W.brandText, + label: `${top[0]} is the most-reached package`, + detail: `${top[1]} of ${report.summary.total_interactions} interactions target it`, + pkg: top[0], + }); + } + if (noSource) { + items.push({ + tone: W.faint, + label: `${noSource} interaction${noSource > 1 ? "s" : ""} without a source location`, + detail: "compiled without source info — file:line is unavailable for these", + }); + } + + if (!items.length) { + return

No notable findings — no cross-package interactions.

; + } + return ( +
+ {items.map((it, i) => ( + + ))} +
+ ); +} + +// --- Summary pivot ------------------------------------------------------- + +// Rows = target packages, columns = interaction types, with totals — the +// analyzer viewer's primary view. Click a row to see every finding against +// that package; click a cell to narrow to one interaction type. +function SummaryPivot({ report, onPick }: { report: AnalyzerReport; onPick: (f: Filter) => void }) { + const [q, setQ] = useState(""); + const types = useMemo( + () => [...new Set(report.interactions.map((i) => i.type))].sort(), + [report], + ); + const rows = useMemo(() => { + const m = new Map; total: number }>(); + for (const it of report.interactions) { + const e = m.get(it.target.package) ?? { version: it.target.version, counts: {}, total: 0 }; + e.counts[it.type] = (e.counts[it.type] ?? 0) + 1; + e.total++; + m.set(it.target.package, e); + } + return [...m.entries()] + .map(([pkg, e]) => ({ pkg, ...e })) + .filter((r) => r.pkg.toLowerCase().includes(q.trim().toLowerCase())) + .sort((a, b) => b.total - a.total); + }, [report, q]); + + const colTotal = (t: string) => rows.reduce((n, r) => n + (r.counts[t] ?? 0), 0); + const grand = rows.reduce((n, r) => n + r.total, 0); + + return ( +
+ setQ(e.target.value)} + placeholder="Filter target packages…" + style={{ + background: W.inset, + border: `1px solid ${W.border}`, + borderRadius: R.control, + color: W.text, + padding: "5px 10px", + fontSize: fs.data, + marginBottom: 10, + minWidth: 240, + }} + /> +
+ + + + + + {types.map((t) => ( + + ))} + + + + + {rows.map((r) => ( + + + + {types.map((t) => ( + + ))} + + + ))} + + + + ))} + + + +
TARGET PACKAGEVERSION + {t.toUpperCase()} + TOTAL
onPick({ pkg: r.pkg })}> + {r.pkg} + {r.version} r.counts[t] && onPick({ pkg: r.pkg, type: t })} + style={{ + ...td, + textAlign: "right", + fontFamily: wMono, + color: r.counts[t] ? W.text : W.faint, + cursor: r.counts[t] ? "pointer" : "default", + }} + > + {r.counts[t] ?? "·"} + + {r.total} +
Σ total + {types.map((t) => ( + + {colTotal(t)} + + {grand} +
+
+
+ ); +} + +// --- Interactions -------------------------------------------------------- + +function InteractionsTable({ + report, + filter, + clearFilter, +}: { + report: AnalyzerReport; + filter: Filter; + clearFilter: () => void; +}) { + const rows = report.interactions.filter( + (it) => + !filter || + ((!filter.pkg || it.target.package === filter.pkg) && (!filter.type || it.type === filter.type)), + ); + return ( +
+ {filter && ( +
+ + Filtered to{" "} + + {filter.pkg || "all packages"} + {filter.type ? ` · ${filter.type}` : ""} + {" "} + — {rows.length} of {report.interactions.length} + + +
+ )} +
+ + + + + + + + + + + + {rows.map((it, i) => ( + + + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
TYPECALLERTARGETSOURCEPACKAGE
+ {it.type} + {endpointLabel(it.caller)} + {endpointLabel(it.target)} + {it.target.consuming ? (consuming) : null} + + {sourceLabel(it)} + + +
+ No interactions match this filter. +
+
+
+ ); +} + +// --- Diff ---------------------------------------------------------------- + +// Compares two loaded reports by interaction identity, so an upgrade shows +// what a new DAR version added, dropped, or kept. +function DiffView({ current, all }: { current: Loaded; all: Loaded[] }) { + const others = all.filter((r) => r.id !== current.id); + const [baseId, setBaseId] = useState(others[0]?.id ?? ""); + const base = all.find((r) => r.id === baseId) ?? others[0]; + if (!base) return

Load a second DAR to compare.

; + + const a = new Map(base.report.interactions.map((i) => [ixKey(i), i])); + const b = new Map(current.report.interactions.map((i) => [ixKey(i), i])); + const added = [...b.entries()].filter(([k]) => !a.has(k)).map(([, v]) => v); + const removed = [...a.entries()].filter(([k]) => !b.has(k)).map(([, v]) => v); + const kept = [...b.keys()].filter((k) => a.has(k)).length; + + return ( +
+
+ Compare against + + + {base.report.analyzed_package.version} → {current.report.analyzed_package.version} + +
+ +
+ + + +
+ + {added.length === 0 && removed.length === 0 ? ( +

No cross-package interaction changes.

+ ) : ( +
+ + + {added.map((it, i) => ( + + ))} + {removed.map((it, i) => ( + + ))} + +
+
+ )} +
+ ); +} + +function DiffRow({ it, sign, tone }: { it: AnalyzerInteraction; sign: string; tone: string }) { + return ( + + {sign} + {it.type} + {endpointLabel(it.caller)} + + {it.target.package}·{endpointLabel(it.target)} + + + ); +} + +// --- Graph --------------------------------------------------------------- + +type TargetAgg = { pkg: string; version: string; total: number; types: string[] }; + +function aggregateTargets(report: AnalyzerReport): TargetAgg[] { + const m = new Map }>(); + for (const it of report.interactions) { + const e = m.get(it.target.package) ?? { version: it.target.version, total: 0, types: new Set() }; + e.total++; + e.types.add(it.type); + m.set(it.target.package, e); + } + return [...m.entries()] + .map(([pkg, e]) => ({ pkg, version: e.version, total: e.total, types: [...e.types] })) + .sort((a, b) => b.total - a.total); +} + +function GraphView({ report }: { report: AnalyzerReport }) { + const src = report.analyzed_package; + const targets = aggregateTargets(report); + if (!targets.length) { + return

No cross-package interactions to graph.

; + } + const NW = 176; + const NH = 48; + const GAP = 20; + const LX = 6; + const RX = 384; + const PADY = 6; + const VBW = 574; + const H = targets.length * NH + Math.max(0, targets.length - 1) * GAP + PADY * 2; + const srcCY = H / 2; + return ( +
+ + {targets.map((t, i) => { + const cy = PADY + i * (NH + GAP) + NH / 2; + const x1 = LX + NW; + const mx = (x1 + RX) / 2; + return ( + + + + {t.total}× + + + + ); + })} + + +
+ ); +} + +function GraphNode({ + x, + cy, + w, + h, + name, + version, + sub, + accent, +}: { + x: number; + cy: number; + w: number; + h: number; + name: string; + version: string; + sub?: string; + accent?: boolean; +}) { + const y = cy - h / 2; + return ( + + + + {name} {version} + + {sub && ( + + {sub.length > 30 ? sub.slice(0, 29) + "…" : sub} + + )} + + ); +} + +// --- helpers ------------------------------------------------------------- + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +// caller reads as module.choice/template; target as module.interface/template. +function endpointLabel(e: AnalyzerEndpoint): string { + const leaf = e.choice || e.interface || e.template; + return leaf ? `${e.module}.${leaf}` : e.module; +} + +// file:line when the package carries source info, else a muted dash. +function sourceLabel(it: AnalyzerInteraction): string { + const s = it.source; + if (!s?.file) return "·"; + return s.start_line ? `${s.file}:${s.start_line}` : s.file; +} + +// Stable identity for diffing: same call from the same caller to the same +// target is the "same" interaction across versions. +function ixKey(it: AnalyzerInteraction): string { + return [it.type, endpointLabel(it.caller), it.target.package, endpointLabel(it.target)].join("|"); +} + +function downloadReport(loaded: Loaded) { + const blob = new Blob([JSON.stringify(loaded.report, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${loaded.report.analyzed_package.name}-${loaded.report.analyzed_package.version}.json`; + a.click(); + URL.revokeObjectURL(url); +} + +const th: React.CSSProperties = { ...tableCaps, fontSize: fs.label, padding: "6px 10px 6px 0" }; +const td: React.CSSProperties = { padding: "6px 10px 6px 0", color: W.text2, verticalAlign: "top" }; + +function uploadStyle(disabled: boolean): React.CSSProperties { + return { + display: "inline-flex", + alignItems: "center", + gap: 6, + background: W.brand, + color: W.onAccent, + borderRadius: R.control, + padding: "6px 14px", + fontSize: fs.data, + fontWeight: 600, + cursor: disabled ? "default" : "pointer", + opacity: disabled ? 0.6 : 1, + }; +} diff --git a/frontend/src/screens/BackupRestore.test.tsx b/frontend/src/screens/BackupRestore.test.tsx index 0b3cf56c..53dfbc23 100644 --- a/frontend/src/screens/BackupRestore.test.tsx +++ b/frontend/src/screens/BackupRestore.test.tsx @@ -76,10 +76,9 @@ describe("BackupRestore card", () => { }); it("surfaces a download-failure banner when the server returns an error instead of a file", async () => { - // Regression: the hidden-iframe download swallowed server errors - // (instance gone, docker failure, 5xx) — the button just flashed - // and the user assumed success. The iframe navigates to the JSON - // error body and fires `load`; the card must show an alert. + // The hidden-iframe download must not swallow server errors + // (instance gone, docker failure, 5xx): the iframe navigates to the + // JSON error body and fires `load`; the card must show an alert. vi.spyOn(HTMLFormElement.prototype, "submit").mockImplementation( function (this: HTMLFormElement) { const frame = document.querySelector( @@ -194,10 +193,8 @@ describe("BackupRestore card", () => { }); it("resyncs target name when the parent switches instances", async () => { - // Repro for the user-reported bug: open the UI on "dev", click - // pebble, BackupRestore's targetName state was stuck at "dev" - // because useState only honors its initial value on first mount. - // After the fix, switching instances must update the input. + // useState only honors its initial value on first mount, so + // switching instances must update the input explicitly. const { rerender } = render(); const input = screen.getByLabelText( /restore target instance name/i, diff --git a/frontend/src/screens/BackupRestore.tsx b/frontend/src/screens/BackupRestore.tsx index a6995485..594f0e0e 100644 --- a/frontend/src/screens/BackupRestore.tsx +++ b/frontend/src/screens/BackupRestore.tsx @@ -5,36 +5,11 @@ import { restoreSnapshot, type RestoreResponse, } from "../api"; -import { W, wMono } from "../tokens"; - -// Backup & restore card. -// -// Two actions in one card: -// 1. Download snapshot — POST /api/instances/:name/snapshot; -// browser saves the tar via Content-Disposition. Single click, -// no extra dialog; the server picks a stable filename. -// 2. Restore from snapshot — drag-drop OR file picker, with an -// optional target-name override and a `--force` checkbox for -// cross-version restores. -// -// The card lives inside InstanceDetail so the "current instance" -// context is already known. Restore-from-here uploads to /restore -// with name=currentInstance by default, but the user can type a -// different name (unblocks the cross-name case properly; -// today it works modulo the volume-rename limitation documented -// in that ticket). -// -// # CLI ↔ UI parity (AGENTS.md) -// -// Mirrors `localnet snapshot --name X --to ` and -// `localnet restore --name X --from [--force]`. Same -// server-side validation, same error taxonomy (the toast text -// comes from the same ErrorCode list). +import { W, wMono, tint, R, FAST, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { IcCheck, IcDownload } from "../components/icons"; interface Props { - // The instance this card lives under. Snapshot downloads - // ALWAYS use this name. Restore defaults to this name but lets - // the user override. instanceName: string; } @@ -53,14 +28,7 @@ export function BackupRestore({ instanceName }: Props) { const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); - // Sync targetName when the user navigates between instances. - // Without this, the card mounted under "dev" keeps the initial - // value forever — clicking into "pebble" still shows "dev" in - // the target-name input. useState only honors its initial value - // on first mount; the parent's prop change must be reflected - // explicitly. Also resets the result banner on switch so a - // success message from a previous restore doesn't bleed across - // instances. + // Resync on instance switch: useState keeps its first-mount value. useEffect(() => { setTargetName(instanceName); setRestore({ kind: "idle" }); @@ -69,19 +37,11 @@ export function BackupRestore({ instanceName }: Props) { }, [instanceName]); async function onDownload() { - // The snapshot is application-consistent on both surfaces: the backend - // pauses the instance's node containers for the duration of the dump - // (the same quiesce the CLI does), so there is no crash-consistency - // caveat for this card to surface. setDownloading(true); setDownloadError(null); try { await downloadSnapshot(instanceName); } catch (e) { - // downloadSnapshot rejects when the server returned an error - // document instead of a file (instance gone, docker failure, - // 5xx). Without surfacing it the button would just flash and - // the user would assume the download succeeded. setDownloadError( e instanceof ApiError ? e.message : "snapshot download failed", ); @@ -92,10 +52,8 @@ export function BackupRestore({ instanceName }: Props) { async function onFileChosen(file: File | null) { if (!file) return; - // Yellow Y15: client-side size cap. Snapshots can be large but - // 4 GiB is the practical ceiling for an XHR upload (browsers - // buffer the whole body in memory). Refuse client-side rather - // than OOM the tab on a stray drop. + // XHR buffers the whole body in memory; refuse >4 GiB client-side + // rather than OOM the tab. const MAX_TARBALL_BYTES = 4 * 1024 * 1024 * 1024; if (file.size > MAX_TARBALL_BYTES) { setRestore({ @@ -126,10 +84,8 @@ export function BackupRestore({ instanceName }: Props) {
@@ -141,24 +97,24 @@ export function BackupRestore({ instanceName }: Props) { gap: 12, }} > -
+
Backup & restore
- - tar archive of docker volumes + registry state + + logical database dump + registry state
- {/* Download row */}
- - + {downloading ? "Preparing…" : "Download snapshot"} + + mirrors{" "} dpm localnet snapshot --name {instanceName} --to ./ @@ -167,17 +123,16 @@ export function BackupRestore({ instanceName }: Props) {
- {/* Download error banner */} {downloadError && (
@@ -185,12 +140,11 @@ export function BackupRestore({ instanceName }: Props) {
)} - {/* Restore row */}
{restore.kind === "uploading" ? ( @@ -235,7 +189,7 @@ export function BackupRestore({ instanceName }: Props) {
Drop a .tgz here or click to choose
-
+
will restore to instance{" "} {targetName || "—"} @@ -252,7 +206,6 @@ export function BackupRestore({ instanceName }: Props) { onChange={(e) => void onFileChosen(e.target.files?.[0] ?? null)} /> - {/* Options row */}
- {/* Result banner */} {restore.kind === "success" && (
- ✓ Restored{" "} + + Restored + {" "} {restore.response.name} @@ -333,11 +289,11 @@ export function BackupRestore({ instanceName }: Props) { role="alert" style={{ marginTop: 10, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, - borderRadius: 6, + borderRadius: R.control, padding: "8px 12px", - fontSize: 12, + fontSize: fs.meta, color: W.err, }} > @@ -366,7 +322,7 @@ function UploadProgress({ style={{ height: 6, background: W.border, - borderRadius: 3, + borderRadius: R.control, overflow: "hidden", }} > @@ -382,16 +338,3 @@ function UploadProgress({
); } - -function btn(accent: string, busy: boolean): React.CSSProperties { - return { - background: "transparent", - color: busy ? W.dim : accent, - border: `1px solid ${busy ? W.dim : accent}`, - borderRadius: 6, - padding: "5px 14px", - fontSize: 12, - fontWeight: 600, - cursor: busy ? "wait" : "pointer", - }; -} diff --git a/frontend/src/screens/ContainerHealth.tsx b/frontend/src/screens/ContainerHealth.tsx index 938f46a7..73806616 100644 --- a/frontend/src/screens/ContainerHealth.tsx +++ b/frontend/src/screens/ContainerHealth.tsx @@ -5,22 +5,14 @@ import { fetchContainers, restartContainer, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tableCaps, tint, R, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { Dot, IcRefresh } from "../components/icons"; +import { confirmDialog } from "../components/ConfirmDialog"; import { ContainerLogsModal } from "./ContainerLogsModal"; -// ContainerHealth — live per-container status panel. Polls -// /api/instances/{name}/containers every POLL_MS so the user -// sees real-time docker truth (e.g. "canton: Up 27 seconds -// (healthy)" — recently restarted) instead of the coarse -// registry status enum (running / failed / etc). -// -// Answers the user's question: "is canton in a restart loop, -// or is splice just slow on first init, or did postgres crash?" -// The registry can't tell you any of that — docker can. -// -// Renders nothing when the instance has no docker project -// (returns 503 from the backend) — the InstanceDetail card -// stays usable; the panel just doesn't show. +// Live per-container status, polled every POLL_MS. Renders nothing +// when the instance has no docker project (backend returns 503). const POLL_MS = 3000; @@ -31,25 +23,26 @@ export function ContainerHealth({ name }: { name: string }) { | { kind: "err"; message: string; status: number } | { kind: "absent" } // 503 — no docker project / daemon down >({ kind: "loading" }); - // Selected container for the logs modal. Null = closed. const [logsOpen, setLogsOpen] = useState(null); - // Tracks which container restart is in flight (one at a time - // is fine — UI disables that row's button + shows spinner). - // Inline string-set so multiple rapid clicks on different - // rows can each show their own pending state. const [restarting, setRestarting] = useState>(new Set()); const [restartErr, setRestartErr] = useState(null); async function onRestart(container: string) { - if (!confirm(`Restart ${container}? Container will be stopped + started; in-flight requests may drop.`)) { + if ( + !(await confirmDialog({ + title: "Restart container?", + body: `Stops then starts ${container}. In-flight requests to it may drop.`, + detail: `docker restart ${container}`, + confirmLabel: "Restart", + danger: true, + })) + ) { return; } setRestarting((s) => new Set([...s, container])); setRestartErr(null); try { await restartContainer(name, container); - // Poll loop picks up the new "Up X seconds" automatically; - // no manual refresh needed. } catch (e) { setRestartErr( `Restart ${container} failed: ` + @@ -64,8 +57,6 @@ export function ContainerHealth({ name }: { name: string }) { } } - // Poll loop. Restarts when name changes; tears down on - // unmount via the cleanup closure. useEffect(() => { let cancelled = false; let timer: ReturnType | null = null; @@ -107,7 +98,7 @@ export function ContainerHealth({ name }: { name: string }) { marginTop: 16, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: R.card, padding: 14, }} > @@ -119,10 +110,10 @@ export function ContainerHealth({ name }: { name: string }) { marginBottom: 10, }} > -
+
Container health
- + live · polled every {POLL_MS / 1000}s @@ -132,7 +123,7 @@ export function ContainerHealth({ name }: { name: string }) { {state.kind === "loading" && ( -
Querying docker…
+
Querying docker…
)} {state.kind === "err" && ( @@ -140,11 +131,11 @@ export function ContainerHealth({ name }: { name: string }) { role="alert" style={{ color: W.err, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, - borderRadius: 6, + borderRadius: R.control, padding: "6px 10px", - fontSize: 12, + fontSize: fs.meta, }} > Docker probe failed: {state.message} @@ -156,11 +147,11 @@ export function ContainerHealth({ name }: { name: string }) { role="alert" style={{ color: W.err, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, border: `1px solid ${W.err}`, - borderRadius: 6, + borderRadius: R.control, padding: "6px 10px", - fontSize: 12, + fontSize: fs.meta, marginBottom: 8, }} > @@ -187,6 +178,12 @@ export function ContainerHealth({ name }: { name: string }) { ); } +const colHeader: React.CSSProperties = { + ...tableCaps, + color: W.dim, + fontSize: fs.label, +}; + function ContainersTable({ containers, onPickLogs, @@ -200,48 +197,31 @@ function ContainersTable({ }) { if (containers.length === 0) { return ( -
+
No containers found for this compose project.
); } - // Stable sort: unhealthy/restarting first, then starting, then - // healthy/running. Makes the failure-mode rows pop to the top. - const sorted = [...containers].sort((a, b) => { - return severity(a) - severity(b); - }); + const sorted = [...containers].sort((a, b) => severity(a) - severity(b)); return (
-
- ● -
-
- service -
-
- state -
-
- status -
-
- actions -
+
+
Service
+
State
+
Status
+
Actions
{sorted.map((c) => { - const { color, glyph } = signalFor(c); + const color = signalFor(c); const onLogs = (e: React.MouseEvent) => { - // Stop propagation so the click that opens the modal can't - // also be interpreted as a backdrop click on the modal's - // overlay (which would close it immediately). e.stopPropagation(); onPickLogs(c.name); }; @@ -250,10 +230,7 @@ function ContainersTable({ onRestart(c.name); }; const isRestarting = restarting.has(c.name); - // display:contents rows can't carry click handlers, so - // each cell gets its own onClick + cursor:pointer. The - // restart button cell stops propagation so clicking the - // button doesn't ALSO open the logs modal. + // display:contents rows can't carry a click handler, so each cell wires its own. const cellBase: React.CSSProperties = { cursor: "pointer", padding: "2px 0", @@ -264,7 +241,17 @@ function ContainersTable({ style={{ display: "contents" }} title={`Click columns to view logs for ${c.name}`} > -
{glyph}
+
+ +
{c.service}
@@ -274,26 +261,18 @@ function ContainersTable({ · {c.health} )}
-
{c.status}
-
-
+
+ + {isRestarting ? "restarting…" : "restart"} +
); @@ -319,12 +298,13 @@ function SummaryPills({ counts }: { counts: ContainersResponse }) { key={label} style={{ padding: "2px 8px", - borderRadius: 999, - border: `1px solid ${color}`, - background: `${color}1A`, + borderRadius: R.control, + border: `1px solid ${tint(color, 34)}`, + background: tint(color, 13), color, - fontSize: 10.5, + fontSize: fs.micro, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", }} > {n} {label} @@ -334,28 +314,23 @@ function SummaryPills({ counts }: { counts: ContainersResponse }) { ); } -// severity orders rows so failure-mode containers come first. -// Lower number = higher priority (sorts earlier). +// Lower sorts earlier, so failure-mode containers come first. function severity(c: { state: string; health?: string }): number { if (c.state === "restarting") return 0; if (c.state === "dead" || c.state === "exited") return 1; if (c.health === "unhealthy") return 2; if (c.health === "starting") return 3; if (c.state === "paused") return 4; - return 5; // healthy / running with no healthcheck + return 5; } -function signalFor(c: { state: string; health?: string }): { - color: string; - glyph: string; -} { - if (c.state === "restarting") return { color: W.warn, glyph: "↻" }; - if (c.state === "dead" || c.state === "exited") return { color: W.err, glyph: "✕" }; - if (c.state === "paused") return { color: W.dim, glyph: "⏸" }; - if (c.health === "unhealthy") return { color: W.err, glyph: "⊗" }; - if (c.health === "starting") return { color: W.brand, glyph: "●" }; - if (c.health === "healthy") return { color: W.ok, glyph: "✓" }; - // running with no healthcheck - if (c.state === "running") return { color: W.ok, glyph: "●" }; - return { color: W.dim, glyph: "·" }; +function signalFor(c: { state: string; health?: string }): string { + if (c.state === "restarting") return W.warn; + if (c.state === "dead" || c.state === "exited") return W.err; + if (c.state === "paused") return W.dim; + if (c.health === "unhealthy") return W.err; + if (c.health === "starting") return W.brand; + if (c.health === "healthy") return W.ok; + if (c.state === "running") return W.ok; + return W.dim; } diff --git a/frontend/src/screens/ContainerLogsModal.tsx b/frontend/src/screens/ContainerLogsModal.tsx index 6c6cd039..72f9085e 100644 --- a/frontend/src/screens/ContainerLogsModal.tsx +++ b/frontend/src/screens/ContainerLogsModal.tsx @@ -1,15 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { ApiError, fetchContainerLogs } from "../api"; -import { W, wMono, wSans } from "../tokens"; +import { W, wMono, wSans, tint, R, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { IcX } from "../components/icons"; -// ContainerLogsModal — opens when the user clicks a row in -// ContainerHealth. Polls docker logs for the selected container -// at LOG_POLL_MS, renders in a terminal-styled
.
-//
-// Tail size + since duration are user-tunable via the toolbar.
-// Auto-scroll-to-bottom is on by default but disabled if the
-// user scrolls up (so manual review of older lines isn't
-// disrupted by the next poll).
+// Polls docker logs for the selected container at LOG_POLL_MS. Tail
+// and since are toolbar-tunable; auto-scroll disables once the user
+// scrolls up.
 
 const LOG_POLL_MS = 3000;
 
@@ -28,14 +25,10 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
   const [loading, setLoading] = useState(false);
   const preRef = useRef(null);
   const autoScrollRef = useRef(true);
-  // Track whether mousedown started on the overlay itself. Without
-  // this, the click that opened the modal (mousedown on a row cell,
-  // mouseup after the modal mounted) can land on the overlay and
-  // immediately close it. Only close when mousedown AND click both
-  // originated on the overlay.
+  // Close only when both mousedown AND click landed on the overlay, else
+  // the opening click (mouseup after the modal mounts) closes it instantly.
   const downOnOverlayRef = useRef(false);
 
-  // Esc closes — same gate as the other modals.
   useEffect(() => {
     if (!open) return;
     function onKey(e: KeyboardEvent) {
@@ -45,8 +38,6 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
     return () => window.removeEventListener("keydown", onKey);
   }, [open, onClose]);
 
-  // Poll. Restarts when any input (instance/container/tail/since)
-  // changes; clears on close/unmount.
   useEffect(() => {
     if (!open) return;
     let cancelled = false;
@@ -78,9 +69,6 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
     };
   }, [open, instance, container, tail, since]);
 
-  // Auto-scroll to bottom on body change, unless the user has
-  // scrolled up. Tracking via a ref so we don't add a state
-  // update for every scroll event.
   useEffect(() => {
     if (!preRef.current || !autoScrollRef.current) return;
     preRef.current.scrollTop = preRef.current.scrollHeight;
@@ -112,10 +100,10 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
       
e.stopPropagation()} onClick={(e) => e.stopPropagation()} style={modalStyle}>
-
+
Logs · {container}
-
+
{instance} · polled every {LOG_POLL_MS / 1000}s {loading && "· refreshing"}
@@ -126,9 +114,13 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props since={since} setSince={setSince} /> - +
{err && ( @@ -136,10 +128,10 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props role="alert" style={{ color: W.err, - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, borderBottom: `1px solid ${W.err}`, padding: "8px 16px", - fontSize: 12, + fontSize: fs.meta, }} > {err} @@ -155,7 +147,7 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props background: W.bg, color: W.text2, fontFamily: wMono, - fontSize: 11, + fontSize: fs.label, lineHeight: 1.55, whiteSpace: "pre-wrap", wordBreak: "break-all", @@ -184,7 +176,7 @@ function Toolbar({ }) { return (
-
+
Allow uncurated Splice tags -
+
Resolve --version{" "} upstream when not in the catalogue. DevKit hasn't reviewed - those bits — experiments only. + those bits. Experiments only.
@@ -766,7 +713,7 @@ function FormBody({ marginTop: 8, padding: "8px 12px", background: W.surface2, - borderRadius: 8, + borderRadius: 4, }} > -
+
Fixed port base -
+
Pin deterministic host ports from this base ( --port-base) for reproducible multi-instance / CI layouts. Empty = auto-allocate. @@ -799,12 +747,12 @@ function FormBody({
@@ -829,13 +777,13 @@ function SubmittingBody({ name }: { name: string }) { padding: "32px 24px", textAlign: "center", color: W.dim, - fontSize: 13, + fontSize: fs.data, }} >
Validating + queuing {name}
-
+
Server will hand back an events URL we'll subscribe to next.
@@ -856,16 +804,18 @@ function ProgressBody({
- ⚠ {m} + + {m} +
))}
@@ -879,7 +829,7 @@ function ProgressBody({
{progress.terminal.length > 0 && (
- + Terminal output · {progress.terminal.length} line(s)
@@ -934,7 +884,7 @@ function ErrorBody({
       role="alert"
       style={{
         padding: "20px 22px",
-        background: `${W.err}10`,
+        background: `${tint(W.err, 6)}`,
         color: W.text,
       }}
     >
@@ -945,7 +895,7 @@ function ErrorBody({
             marginTop: 10,
             paddingLeft: 18,
             color: W.text2,
-            fontSize: 12.5,
+            fontSize: fs.meta,
             lineHeight: 1.6,
           }}
         >
@@ -958,23 +908,23 @@ function ErrorBody({
   );
 }
 
-// ── pieces ────────────────────────────────────────────────────────
-
 function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
   if (banner.kind === "running") {
     return (
       
- ● streaming step events + + streaming step events +
); } @@ -983,15 +933,17 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
- ✓ {banner.detail || "ready"} + + {banner.detail || "ready"} +
); } @@ -1001,16 +953,20 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
- ✗ {banner.summary ?? "failed"} + + {banner.summary ?? "failed"} + {banner.cause && ( -
+
{banner.cause}
)} @@ -1019,11 +975,10 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { style={{ marginTop: 8, padding: "8px 10px", - background: W.surface2, - borderRadius: 6, + background: tint(W.warn, 8), + borderRadius: R.control, color: W.text2, - fontSize: 11.5, - borderLeft: `3px solid ${W.warn}`, + fontSize: fs.label, }} > {remediation.title} @@ -1037,19 +992,20 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
); } - // cancelled return (
- ⏹ cancelled{banner.reason ? ` — ${banner.reason}` : ""} + + Cancelled{banner.reason ? `. ${banner.reason}` : ""} +
); } @@ -1058,13 +1014,13 @@ function StepRow({ label, state }: { label: string; state: StepState }) { const icon = (() => { switch (state.status) { case "done": - return ; + return ; case "active": - return ; + return ; case "fail": - return ; + return ; default: - return ; + return ; } })(); const color = @@ -1081,18 +1037,29 @@ function StepRow({ label, state }: { label: string; state: StepState }) { display: "flex", gap: 10, padding: "6px 4px", - borderBottom: `1px dashed ${W.border}`, - fontSize: 12.5, + borderBottom: `1px solid ${W.border}`, + fontSize: fs.meta, }} > - {icon} + + {icon} +
{label}
{(state.detail || state.summary) && (
dropdown. The previous implementation rendered a custom -// scrollable button-list when versions were present and fell back to a -// free-text when the API returned empty (loading / network -// error). Users reported the empty-state textbox felt like a regression -// from the prior dropdown UX, and typing an arbitrary tag silently -// routed to the upstream-resolution path that the curated dropdown is -// meant to prevent. This rewrite uses a real , never a -// textbox" invariant. +// Always a {v.tag} {v.status === "latest" ? " (latest)" : ""} - {v.major ? ` — major ${v.major}` : ""} + {v.major ? ` · major ${v.major}` : ""} ))} ); } -// compareSpliceTags orders two Splice version tags like a localeCompare -// (negative ⇒ a is older/lower than b), but semver-aware so a final -// release outranks its own pre-release. -// -// localeCompare(…, {numeric:true}) gets this wrong: "0.6.4" is a prefix -// of "0.6.4-rc.1", so a string collation sorts the rc AFTER the release -// — inverting precedence (semver says 0.6.4 > 0.6.4-rc.1). Non-semver -// tags ("token-standard-v2", "next-cilr") have no precedence to reason -// about, so they fall back to numeric localeCompare. Exported for the -// regression test. +// Semver-aware ordering (negative ⇒ a older than b) so a release +// outranks its own pre-release; plain localeCompare sorts "0.6.4-rc.1" +// after "0.6.4". Non-semver tags fall back to numeric localeCompare. export function compareSpliceTags(a: string, b: string): number { const pa = parseSemverTag(a); const pb = parseSemverTag(b); @@ -1216,7 +1161,7 @@ export function compareSpliceTags(a: string, b: string): number { for (let i = 0; i < 3; i++) { if (pa.core[i] !== pb.core[i]) return pa.core[i] - pb.core[i]; } - // Same x.y.z: a release (no pre-release) is newer than any pre-release. + // Same x.y.z: a release is newer than any pre-release. if (pa.pre === null && pb.pre === null) return 0; if (pa.pre === null) return 1; if (pb.pre === null) return -1; @@ -1229,10 +1174,8 @@ function parseSemverTag(tag: string): { core: [number, number, number]; pre: str return { core: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null }; } -// comparePrerelease applies the semver pre-release precedence rules: -// dot-separated identifiers compared left-to-right; numeric identifiers -// numerically and ranked below alphanumerics; a shorter run loses to a -// longer one when otherwise equal. +// Semver pre-release precedence: dot-separated identifiers left-to-right, +// numeric ranked below alphanumeric, shorter run loses when otherwise equal. function comparePrerelease(a: string, b: string): number { const as = a.split("."); const bs = b.split("."); @@ -1255,43 +1198,24 @@ function comparePrerelease(a: string, b: string): number { return 0; } -// selectStyle is inlined rather than spread from `inputStyle` because -// `inputStyle` is declared further down in this file — relying on -// hoisting here triggers a TDZ "used before declaration" error under -// Vite/SWC's strict ES module ordering. Visual parity with inputStyle -// is intentional (same dark-theme tokens, same border radius) plus -// `appearance: "auto"` so the native OS dropdown caret stays visible. -// Without the explicit appearance, some browsers drop the caret when -// a custom borderRadius/background is applied — making the field look -// like a disabled text input, which is the regression this commit fixes. +// Inlined, not spread from inputStyle (declared below): referencing it +// here would hit the ES-module TDZ. appearance:"auto" keeps the native +// dropdown caret some browsers drop with a custom border/background. const selectStyle: React.CSSProperties = { width: "100%", background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 6, + borderRadius: R.control, padding: "7px 10px", - fontSize: 13, + fontSize: fs.data, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", outline: "none", cursor: "pointer", appearance: "auto", }; -// PreflightPanel renders the system-requirements check inline in -// the form. Five visual modes: -// -// idle — no render -// loading — pill saying "checking docker memory + disk…" -// err — neutral note; doesn't block ("server probe failed, -// the server-side gate will still run on submit") -// ok+pass — tiny green confirmation pill -// ok+warn — amber box with warning checks (proceeding allowed) -// blocked — red box: every fail check + its remediation; -// warns rendered as amber subnotes. Create disabled. -// -// The component never reads the version directly — it just -// renders whatever the parent's effect produced. Clean separation. function PreflightPanel({ state }: { state: PreflightState }) { if (state.kind === "idle") return null; if (state.kind === "loading") { @@ -1302,12 +1226,15 @@ function PreflightPanel({ state }: { state: PreflightState }) { background: W.surface2, color: W.dim, border: `1px solid ${W.border}`, - borderRadius: 7, - fontSize: 11.5, + borderRadius: 2, + fontSize: fs.label, fontFamily: wMono, }} > - ⠋ checking system requirements (docker memory · disk · daemon)… + + checking system requirements (docker + memory · disk · daemon)… +
); } @@ -1319,8 +1246,8 @@ function PreflightPanel({ state }: { state: PreflightState }) { background: W.surface2, color: W.dim, border: `1px solid ${W.border}`, - borderRadius: 7, - fontSize: 11.5, + borderRadius: 2, + fontSize: fs.label, }} > Pre-flight probe couldn't reach the server ({state.message}). The @@ -1338,26 +1265,34 @@ function PreflightPanel({ state }: { state: PreflightState }) { const warns = allChecks.filter((c) => c.check.result === "warn"); const blocked = state.kind === "blocked"; const accent = blocked ? W.err : warns.length > 0 ? W.warn : W.ok; + const headingIcon = blocked ? ( + + ) : warns.length > 0 ? ( + + ) : ( + + ); const heading = blocked - ? "✗ Host doesn't meet this version's requirements" + ? "Host doesn't meet this version's requirements" : warns.length > 0 - ? "⚠ Host meets minimums — but raise resources for headroom" - : "✓ Host is ready for this version"; + ? "Host meets minimums. Raise resources for headroom." + : "Host is ready for this version"; if (!blocked && warns.length === 0) { - // Compact success pill — don't clutter the form. return (
- {heading} · {state.report.summary} + + {headingIcon} {heading} · {state.report.summary} +
); } @@ -1366,17 +1301,26 @@ function PreflightPanel({ state }: { state: PreflightState }) { role={blocked ? "alert" : undefined} style={{ padding: "10px 12px", - background: `${accent}10`, + background: tint(accent, 6), border: `1px solid ${accent}`, - borderRadius: 8, - fontSize: 12, + borderRadius: R.control, + fontSize: fs.meta, }} > -
- {heading} +
+ {headingIcon} {heading}
{state.report.summary && ( -
+
{state.report.summary}
)} @@ -1388,18 +1332,26 @@ function PreflightPanel({ state }: { state: PreflightState }) { padding: "6px 8px", background: W.bg, border: `1px solid ${W.border}`, - borderRadius: 6, + borderRadius: 2, }} > -
+
- {check.result === "fail" ? "✗" : "⚠"} {check.label} + {check.result === "fail" ? ( + + ) : ( + + )}{" "} + {check.label} · {section}
@@ -1407,7 +1359,7 @@ function PreflightPanel({ state }: { state: PreflightState }) {
@@ -1451,30 +1403,26 @@ function Field({ ); } function Elapsed({ startedAt }: { startedAt: number }) { - // Tick once per second to keep the elapsed counter live. The - // setInterval is cheap; clearing on unmount avoids leaks when - // the modal closes mid-up. const [, force] = useState(0); useEffect(() => { const t = setInterval(() => force((n) => n + 1), 1000); @@ -1486,8 +1434,6 @@ function Elapsed({ startedAt }: { startedAt: number }) { return <>{m}:{String(s).padStart(2, "0")} elapsed; } -// ── styles ──────────────────────────────────────────────────────── - const overlayStyle: React.CSSProperties = { position: "fixed", inset: 0, @@ -1504,8 +1450,8 @@ const modalStyle: React.CSSProperties = { width: "min(680px, 92vw)", background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 12, - boxShadow: "0 24px 64px rgba(0,0,0,0.6)", + borderRadius: R.dialog, + boxShadow: "0 10px 32px rgba(0,0,0,0.24)", overflow: "hidden", }; @@ -1514,22 +1460,10 @@ const inputStyle: React.CSSProperties = { background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 6, + borderRadius: R.control, padding: "7px 10px", - fontSize: 13, + fontSize: fs.data, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", outline: "none", }; - -function btnStyle(color: string, primary: boolean): React.CSSProperties { - return { - background: primary ? color : "transparent", - color: primary ? "#082018" : color, - border: `1px solid ${color}`, - borderRadius: 6, - padding: "6px 14px", - fontSize: 12, - fontWeight: 600, - cursor: primary ? "pointer" : "pointer", - }; -} diff --git a/frontend/src/screens/CreatingPanel.tsx b/frontend/src/screens/CreatingPanel.tsx index b3741bca..937df3da 100644 --- a/frontend/src/screens/CreatingPanel.tsx +++ b/frontend/src/screens/CreatingPanel.tsx @@ -6,68 +6,38 @@ import { scrubInstance, type StepName, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { Dot, IcAlert, IcCheck, IcRefresh, IcX } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; import { type ProgressState, type StepState, useCreateProgress, } from "./useCreateProgress"; -// CreatingPanel — shown above the InstanceDetail/DeveloperSetup -// cards when the selected instance is in status="creating". -// Subscribes to /api/instances/{name}/events and renders the -// same step rows the create modal uses, so a user who clicks -// an in-flight instance sees what step the bring-up is on. -// -// Two scenarios: -// -// 1. Live up in progress (goroutine still publishing): the -// SSE stream replays buffered events + live ones; the -// panel shows real-time progress just like the modal. -// -// 2. Zombie creating (registry says creating but no goroutine -// is publishing — e.g. server restart killed the goroutine -// mid-flight, leaving the registry entry orphaned): no -// events arrive. After a grace period the panel surfaces -// a "looks stalled" hint with a cleanup CTA. -// -// We deliberately re-use useCreateProgress so the step-render -// logic stays in one place; CreateLocalNetModal and this panel -// both consume the same state shape. +// Shown when the selected instance is status="creating". Renders live +// SSE bring-up progress, or — if no event arrives within ZOMBIE_GRACE_MS +// (e.g. a server restart orphaned the entry) — a stalled hint + cleanup. -const ZOMBIE_GRACE_MS = 3000; // wait this long before showing "stalled" hint +const ZOMBIE_GRACE_MS = 3000; interface Props { name: string; - // Called when the user clicks "Refresh list" after a cancel or - // a stalled-state detection — the Dashboard re-fetches so the - // row's status updates. onRefresh: () => void; } export function CreatingPanel({ name, onRefresh }: Props) { - // EventSource URL is per-instance; the hook handles subscribe - // + unsubscribe on name change. const eventsUrl = `/api/instances/${encodeURIComponent(name)}/events`; const progress = useCreateProgress(eventsUrl); - // Zombie detection: if no event has arrived by ZOMBIE_GRACE_MS - // we surface the "stalled" affordance. Derived freshly on every - // render rather than via setTimeout — a setTimeout closure - // captures progress.startedAt at effect-setup time and never - // re-checks it, so events arriving 4 seconds later (slow - // network, slow first publish) would leave the panel - // permanently "stalled" even though the stream is flowing. - // - // The mountedAt ref pegs the start time once per (name); we - // re-render every second via the elapsed-time ticker, so the - // derived check stays current without any timer of its own. + // Derived per render, not via setTimeout: a timeout closure would + // capture startedAt once and never re-check, wedging late events as + // "stalled". mountedAtRef pegs the start; the 1s ticker below refreshes. const mountedAtRef = useRef(Date.now()); useEffect(() => { mountedAtRef.current = Date.now(); }, [name]); - // Tick once per second so the zombie-grace check + the elapsed - // counter both re-evaluate. Cheap; clearInterval on unmount. const [, forceTick] = useState(0); useEffect(() => { const t = setInterval(() => forceTick((n) => n + 1), 1000); @@ -77,9 +47,8 @@ export function CreatingPanel({ name, onRefresh }: Props) { progress.startedAt === null && Date.now() - mountedAtRef.current > ZOMBIE_GRACE_MS; - // Cancel in the LIVE path: ask the goroutine to stop. - // Backend publishes kind=cancelled, then the goroutine sees - // ctx.Done() and writes status=failed via its existing path. + // Live path: ask the goroutine to stop; it publishes kind=cancelled + // then writes status=failed. async function onCancelLive() { try { await cancelInstanceUp(name); @@ -89,18 +58,13 @@ export function CreatingPanel({ name, onRefresh }: Props) { } } - // Cancel in the ZOMBIE path: there is no live goroutine, so - // /up cancel would 404. Scrub the registry entry instead so the - // row disappears from the list. The user explicitly asked for - // cleanup; we honor it. + // Zombie path: no live goroutine, so /up cancel would 404 — scrub the + // registry entry instead. async function onScrub() { try { await scrubInstance(name); onRefresh(); } catch { - // If scrub fails (e.g. 409 because the backend decided the - // entry is now running), refresh anyway so the user sees - // current state. onRefresh(); } } @@ -111,7 +75,7 @@ export function CreatingPanel({ name, onRefresh }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: R.card, padding: 16, }} > @@ -123,10 +87,10 @@ export function CreatingPanel({ name, onRefresh }: Props) { marginBottom: 12, }} > -
+
Bring-up in progress
- + {name} @@ -144,16 +108,24 @@ export function CreatingPanel({ name, onRefresh }: Props) {
- ⚠ {m} + + {m} +
))}
@@ -166,14 +138,14 @@ export function CreatingPanel({ name, onRefresh }: Props) { justifyContent: "flex-end", }} > - +
)} {progress.terminal.length > 0 && (
- + Terminal output · {progress.terminal.length} line(s)
 {
     switch (state.status) {
       case "done":
-        return ;
+        return ;
       case "active":
-        return ;
+        return ;
       case "fail":
-        return ;
+        return ;
       default:
-        return ;
+        return ;
     }
   })();
   const color =
@@ -251,18 +223,30 @@ function StepRow({ label, state }: { label: string; state: StepState }) {
         display: "flex",
         gap: 10,
         padding: "6px 4px",
-        borderBottom: `1px dashed ${W.border}`,
-        fontSize: 12.5,
+        borderBottom: `1px solid ${W.border}`,
+        fontSize: fs.meta,
       }}
     >
-      {icon}
+      
+        {icon}
+      
       
{label}
{(state.detail || state.summary) && (
@@ -303,41 +287,20 @@ function BannerPill({ zombie: boolean; }) { if (zombie) { - return looks stalled; + return ; } switch (banner.kind) { case "done": - return ready; + return ; case "failed": - return failed; + return ; case "cancelled": - return cancelled; + return ; default: - return streaming; + return ; } } -function Pill({ color, children }: { color: string; children: React.ReactNode }) { - return ( - - ● {children} - - ); -} - function ZombieHint({ name, onScrub, @@ -349,13 +312,14 @@ function ZombieHint({ }) { return (
@@ -366,7 +330,7 @@ function ZombieHint({ the SSE stream is silent. The most likely causes:
    -
  • The bring-up finished after the page loaded — refresh to pick up the new state.
  • +
  • The bring-up finished after the page loaded. Refresh to pick up the new state.
  • The server was restarted mid-bring-up, orphaning the entry. Click Remove entry to scrub it from the @@ -374,24 +338,13 @@ function ZombieHint({
- - + +
); } - -const cancelBtnStyle: React.CSSProperties = { - background: "transparent", - color: W.warn, - border: `1px solid ${W.warn}`, - borderRadius: 6, - padding: "5px 12px", - fontSize: 12, - fontWeight: 600, - cursor: "pointer", -}; diff --git a/frontend/src/screens/DARDiff.tsx b/frontend/src/screens/DARDiff.tsx index 939e490c..0ae4d157 100644 --- a/frontend/src/screens/DARDiff.tsx +++ b/frontend/src/screens/DARDiff.tsx @@ -1,20 +1,18 @@ -// DAR structural diff viewer. -// -// Renders /api/instances/:name/dar/diff between two DARs as a set -// of expandable sections: modules added/removed, templates -// added/removed/changed, interfaces added/removed/changed. No -// dependency on a third-party diff library — the JSON shape is -// small enough that a hand-rolled list-with-colour reads cleanly. -// -// Embedded as a sidebar drawer inside DARScreen when the user -// picks two DARs to compare. +// Structural diff between two DARs, as expandable added/removed/changed +// sections for modules, templates, and interfaces. import { useEffect, useState } from "react"; import { fetchDARDiff, type DARDiffResponse, type Role, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tableCaps, R, tint, fs } from "../tokens"; +import { MonoId } from "../components/MonoId"; +import { + IcArrowRight, + IcChevronDown, + IcChevronRight, +} from "../components/icons"; interface Props { instance: string; @@ -75,10 +73,19 @@ export function DARDiff({ instance, a, b, role }: Props) {
- + + +
-
+
{totalDelta === 0 ? "no structural changes" : `${totalDelta} structural change${totalDelta === 1 ? "" : "s"}`} @@ -191,9 +198,9 @@ export function DARDiff({ instance, a, b, role }: Props) { const paneStyle: React.CSSProperties = { background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 8, + borderRadius: R.card, padding: 12, - fontSize: 12, + fontSize: fs.meta, maxHeight: "60vh", overflowY: "auto", }; @@ -201,7 +208,7 @@ const paneStyle: React.CSSProperties = { const mono: React.CSSProperties = { fontFamily: wMono, color: W.text, - fontSize: 11.5, + fontSize: fs.label, }; function Side({ @@ -213,20 +220,20 @@ function Side({ }) { if (!side) { return ( - + {label}: unknown ); } return ( - - {label}: + + {label}: {side.name}@{side.version} - - {side.main.slice(0, 8)}… - + ); } @@ -236,13 +243,13 @@ type Tone = "add" | "rm" | "chg" | "info"; function toneColour(t: Tone): { bg: string; fg: string } { switch (t) { case "add": - return { bg: "#62E2A022", fg: "#62E2A0" }; + return { bg: tint(W.ok, 13), fg: W.ok }; case "rm": - return { bg: `${W.err}22`, fg: W.err }; + return { bg: `${tint(W.err, 13)}`, fg: W.err }; case "chg": - return { bg: `${W.warn}22`, fg: W.warn }; + return { bg: `${tint(W.warn, 13)}`, fg: W.warn }; case "info": - return { bg: `${W.brand}1A`, fg: W.brand }; + return { bg: `${tint(W.brand, 10)}`, fg: W.brand }; } } @@ -270,14 +277,19 @@ function Section({ background: "transparent", border: "none", color: c.fg, - fontSize: 11.5, - fontWeight: 600, + fontSize: fs.label, cursor: "pointer", padding: "2px 0", - letterSpacing: 0.6, + ...tableCaps, + display: "inline-flex", + alignItems: "center", + gap: 6, }} > - {open ? "▾" : "▸"} {title} ({items.length}) + {open ? : } + + {title} ({items.length}) + {open && (
@@ -314,17 +326,17 @@ function ChipGroup({ return ( {label && ( - {label}: + {label}: )} {labels.map((l) => ( diff --git a/frontend/src/screens/DARPackageTree.tsx b/frontend/src/screens/DARPackageTree.tsx index 08cede8d..ac5360ce 100644 --- a/frontend/src/screens/DARPackageTree.tsx +++ b/frontend/src/screens/DARPackageTree.tsx @@ -1,13 +1,5 @@ -// DAR package-tree explorer. -// -// Renders a /api/instances/:name/dar/:id/inspect response as an -// expandable tree: package → module → (template | interface | data -// type). Choices and methods are leaf-level nodes shown as inline -// chips. -// -// The component is deliberately self-contained — it fetches its own -// data, owns its expand/collapse state, and renders without any -// shared layout primitive. Embedded as a drawer inside DARScreen. +// Expandable package → module → (template | interface | data type) tree +// for a DAR inspect response, with choices and methods as inline chips. import { useEffect, useState } from "react"; import { fetchDARInspect, @@ -16,7 +8,16 @@ import { type DARPackageInspect, type Role, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, R, tint, fs } from "../tokens"; +import { MonoId } from "../components/MonoId"; +import { IcChevronDown, IcChevronRight } from "../components/icons"; + +// Middle-truncate for ids inside a toggle button, where a MonoId (itself +// a button) would nest interactive elements. +function midId(s: string, head = 10, tail = 6): string { + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} interface Props { instance: string; @@ -30,8 +31,6 @@ export function DARPackageTree({ instance, mainID, role }: Props) { | { kind: "ok"; data: DARInspectResponse } | { kind: "err"; msg: string } >({ kind: "loading" }); - // Expanded package ids — start with the main package expanded so - // the most useful tree is visible on first render. const [expandedPkgs, setExpandedPkgs] = useState>(new Set()); const [expandedModules, setExpandedModules] = useState>( new Set(), @@ -44,7 +43,6 @@ export function DARPackageTree({ instance, mainID, role }: Props) { .then((data) => { if (cancelled) return; setState({ kind: "ok", data }); - // Auto-expand the main package. const main = data.packages.find((p) => p.is_main); if (main) setExpandedPkgs(new Set([main.package_id])); }) @@ -90,12 +88,21 @@ export function DARPackageTree({ instance, mainID, role }: Props) { return (
-
- {state.data.packages.length} package - {state.data.packages.length === 1 ? "" : "s"} · sha256{" "} - - {state.data.sha256.slice(0, 12)}… - +
+ + {state.data.packages.length} package + {state.data.packages.length === 1 ? "" : "s"} · sha256 + +
{state.data.packages.map((pkg) => ( - - {modules.length === 0 ? "·" : expanded ? "▾" : "▸"} + + {modules.length === 0 ? ( + "·" + ) : expanded ? ( + + ) : ( + + )} - - {pkg.name || pkg.package_id.slice(0, 12)} + + {pkg.name || midId(pkg.package_id)} {pkg.version && ( - + {pkg.version} )} - - {pkg.lf_version} · {pkg.package_id.slice(0, 10)}… + + {pkg.lf_version} · {midId(pkg.package_id)} {expanded && @@ -195,11 +234,24 @@ function ModuleNode({ aria-expanded={expanded} style={treeRowStyle(false)} > - - {total === 0 ? "·" : expanded ? "▾" : "▸"} + + {total === 0 ? ( + "·" + ) : expanded ? ( + + ) : ( + + )} {mod.name} - + {tplCount}T · {ifCount}I · {dtCount}D @@ -207,7 +259,7 @@ function ModuleNode({
{(mod.templates ?? []).map((t) => (
- template{" "} + template{" "} {t.name} {t.choices && t.choices.length > 0 && ( @@ -220,7 +272,7 @@ function ModuleNode({ ))} {(mod.interfaces ?? []).map((i) => (
- interface{" "} + interface{" "} {i.name} {i.choices && i.choices.length > 0 && ( @@ -240,7 +292,7 @@ function ModuleNode({ ))} {(mod.data_types ?? []).map((dt) => (
- data{" "} + data{" "} {dt}
))} @@ -257,7 +309,7 @@ function treeRowStyle(highlight: boolean): React.CSSProperties { background: "transparent", border: "none", padding: "3px 6px", - fontSize: 12, + fontSize: fs.meta, fontFamily: "inherit", color: highlight ? W.brand : W.text, cursor: "pointer", @@ -268,7 +320,7 @@ function treeRowStyle(highlight: boolean): React.CSSProperties { const leafRow: React.CSSProperties = { padding: "2px 6px", - fontSize: 11.5, + fontSize: fs.label, color: W.text2, }; @@ -280,7 +332,9 @@ function Chip({ kind: "choice" | "method"; }) { const tone = - kind === "choice" ? { bg: `${W.brand}1A`, fg: W.brand } : { bg: "#7BB7FF22", fg: "#7BB7FF" }; + kind === "choice" + ? { bg: tint(W.brand, 10), fg: W.brand } + : { bg: tint(W.mag, 13), fg: W.mag }; return ( diff --git a/frontend/src/screens/DARScreen.test.tsx b/frontend/src/screens/DARScreen.test.tsx index 6bef887e..58f74575 100644 --- a/frontend/src/screens/DARScreen.test.tsx +++ b/frontend/src/screens/DARScreen.test.tsx @@ -316,9 +316,8 @@ describe("DARScreen", () => { }); it("renders REAL per-participant vetting dots in the package list, not a hardcoded badge", async () => { - // Every list row used to show a green "vetted" badge regardless of - // ledger state. Now each row's column reflects the real GET …/vetting - // response per participant. + // Each row's column must reflect the real GET …/vetting response + // per participant, not a static badge. vi.stubGlobal("EventSource", FakeEventSource as unknown as typeof EventSource); const mainA = "a".repeat(64); const mainB = "b".repeat(64); diff --git a/frontend/src/screens/DARScreen.tsx b/frontend/src/screens/DARScreen.tsx index de837fd0..e92c6019 100644 --- a/frontend/src/screens/DARScreen.tsx +++ b/frontend/src/screens/DARScreen.tsx @@ -14,27 +14,24 @@ import { type Role, } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { W, wMono } from "../tokens"; +import { W, wMono, tableCaps, R, tint, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; +import { + Dot, + IcAlert, + IcArrowRight, + IcCheck, + IcUpload, + IcX, +} from "../components/icons"; import { DARPackageTree } from "./DARPackageTree"; import { DARDiff } from "./DARDiff"; -// DARScreen — production layout. -// -// Matches docs/design/mockups/webui-dar.jsx: -// - LEFT (320px) drag-drop upload + per-participant vetting -// toggles + Watch-mode card -// - MIDDLE package list with custom row layout -// (Package · Version · Package-id · Vetting · ⋯) -// - RIGHT (360px) inspect drawer with hash/lf/uploaded + (future) -// diff vs prior version -// -// Vetting is real end-to-end: the package-list column (VettingCell) -// and the inspect-drawer toggles (VettingPanel) both read live -// per-participant state from GET …/dar/{id}/vetting and POST to the -// vet/unvet endpoint — a package unvetted via `dar remove` now shows -// grey, not a hardcoded green badge. The Watch-mode card reflects -// live SSE events from a `dpm localnet dar watch` process when one is -// running, and stays "Idle" otherwise. +// Three-column DAR manager: upload + vetting + watch (left), package +// list (middle), inspect tree / structural diff (right). Vetting is +// live per-participant end-to-end. const ROLES: Role[] = ["app-user", "app-provider", "sv"]; @@ -49,13 +46,8 @@ export function DARScreen() { const sel = useInstanceSelection(); const name = sel.selected; const [role, setRole] = useState("app-user"); - // vetTargets — which participants the upload fans out to. The - // backend dials each in parallel and returns a per-role result. - // Default ON for all three so the common "vet everywhere" - // workflow is one drag-and-drop. The selected `role` above - // drives the package LIST (read endpoint), not the upload set; - // they're orthogonal — the user can read one participant's - // packages while uploading to a different subset. + // Participants an upload fans out to (parallel, backend-side). + // Orthogonal to `role`, which drives only the package LIST. const [vetTargets, setVetTargets] = useState>({ "app-user": true, "app-provider": true, @@ -71,19 +63,13 @@ export function DARScreen() { | { kind: "err"; error: string } >({ kind: "loading" }); const [selectedHash, setSelectedHash] = useState(null); - // Diff mode: when a "compare with" target is picked, the right - // drawer renders the DARDiff component instead of the inspect - // tree. Compare hashes are kept separately so the user can - // toggle the comparison off without losing their primary - // selection. + // Separate from selectedHash so toggling the comparison off keeps the + // primary selection. const [compareHash, setCompareHash] = useState(null); const [upload, setUpload] = useState({ kind: "idle" }); const [dragOver, setDragOver] = useState(false); const [filter, setFilter] = useState<"all" | "app">("all"); const [tick, setTick] = useState(0); // bump to refetch after upload - // vetting — per-participant vetting state for each listed DAR, keyed - // by main package id. Populated lazily by a bounded batch fetch after - // the list loads (see effect below). const [vetting, setVetting] = useState>({}); const fileInputRef = useRef(null); @@ -138,10 +124,7 @@ export function DARScreen() { }); return; } - // Client-side size cap mirrors the backend's multipart cap in - // internal/ui/handlers/dar.go (darUploadMax = 64 MiB). Reject - // here so a 100 MiB DAR doesn't start uploading and fail - // server-side after a wasted progress bar. + // Mirrors the backend multipart cap (darUploadMax, dar.go). const MAX_DAR_BYTES = 64 * 1024 * 1024; const tooBig = arr.find((f) => f.size > MAX_DAR_BYTES); if (tooBig) { @@ -181,8 +164,6 @@ export function DARScreen() { if (state.kind !== "ok") return [] as DARRow[]; let list = state.data.dars; if (filter === "app") { - // "app DARs only" filter — exclude the canton-builtin / - // splice system packages so the user sees just their stuff. list = list.filter( (d) => !d.name.startsWith("canton-builtin-") && @@ -193,28 +174,18 @@ export function DARScreen() { return list; }, [state, filter]); - // Reset the vetting cache whenever the instance changes or the list - // is refetched (after an upload). Keying the cache by main id means a - // role switch — which doesn't change which DARs exist, only which - // participant's list we read — reuses already-fetched verdicts. useEffect(() => { setVetting({}); }, [name, tick]); - // Lazily fetch REAL per-participant vetting for each visible row. We - // probe per DAR (the vetting endpoint fans out to all three - // participants server-side) so the list column reflects ledger - // state, not a hardcoded badge. Bounded: at most one in-flight fetch - // per main id, marked "loading" before dispatch so we never - // double-fetch on re-render. + // Lazily fetch per-participant vetting for each visible row; rows are + // marked "loading" in one batch so re-renders never double-fetch. const visibleMains = useMemo(() => rows.map((d) => d.main).join(","), [rows]); useEffect(() => { if (!name || state.kind !== "ok") return; let cancelled = false; const toFetch = rows.filter((d) => vetting[d.main] === undefined); if (toFetch.length === 0) return; - // Mark all pending rows loading in one batch so the cells show "…" - // immediately and the guard above stops re-dispatch. setVetting((prev) => { const next = { ...prev }; for (const d of toFetch) next[d.main] = { kind: "loading" }; @@ -237,8 +208,7 @@ export function DARScreen() { return () => { cancelled = true; }; - // visibleMains captures the row-set identity; vetting is read via - // the functional updater so it isn't a dependency (would loop). + // vetting read via functional updater to keep it out of the deps (would loop). // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, state.kind, visibleMains]); @@ -273,10 +243,10 @@ export function DARScreen() { }} >
-

+

DAR Manager

-
+
{state.kind === "ok" ? `${state.data.dars.length} packages on ${role} participant` : "loading…"} @@ -285,7 +255,7 @@ export function DARScreen() {
- {state.kind === "loading" && Loading DAR list…} + {state.kind === "loading" && } {state.kind === "err" && } {state.kind === "port-missing" && ( - {/* LEFT — upload + vetting + watch mode */}
{upload.kind === "uploading" ? ( ) : ( <> -
+
+ +
Drop DAR here
-
- or click to browse · multi-file ok +
+ or click to browse · multiple .dar accepted
)} @@ -397,21 +373,29 @@ export function DARScreen() { marginTop: 12, padding: "8px 10px", background: W.border, - borderRadius: 6, - fontSize: 11.5, + borderRadius: 2, + fontSize: fs.label, color: selectedRoles.length === 0 ? W.warn : W.text2, lineHeight: 1.5, }} > {selectedRoles.length === 0 ? ( <> - Pick at least one - target. Drops will be refused until a participant is - selected. + + Pick at least one target. + {" "} + Drops will be refused until a participant is selected. ) : ( <> - Each dropped DAR + Each dropped DAR uploads in parallel to {selectedRoles.length}{" "} participant{selectedRoles.length === 1 ? "" : "s"} with @@ -437,12 +421,11 @@ export function DARScreen() {
- {/* MIDDLE — package list */}
@@ -457,11 +440,11 @@ export function DARScreen() { >
Packages on {role} participant
-
+
{filter === "app" ? "filter: app DARs only" : "all packages"}{" "} · {rows.length} results
@@ -481,7 +464,6 @@ export function DARScreen() {
- {/* Column header */}
@@ -503,7 +483,7 @@ export function DARScreen() {
{rows.length === 0 && ( -
+
No packages match the current filter.
)} @@ -522,7 +502,7 @@ export function DARScreen() { style={{ padding: "10px 14px", color: W.dim, - fontSize: 11.5, + fontSize: fs.label, display: "flex", justifyContent: "space-between", borderTop: `1px solid ${W.border}`, @@ -533,7 +513,6 @@ export function DARScreen() {
- {/* RIGHT — inspect drawer / diff viewer */} (null); const [active, setActive] = useState(false); - // tick — bumped every 10s so the "ago" label refreshes without a - // useless full re-render every second. const [, setNow] = useState(Date.now()); useEffect(() => { @@ -585,42 +558,44 @@ function WatchModeCard({ instance }: { instance: string }) { display: "flex", flexDirection: "column", gap: 8, - fontSize: 12, + fontSize: fs.meta, fontFamily: wMono, }} >
+ {active ? "Watching" : "Idle"} {last && ( - {last.event} + {last.event} )}
-
+
Start a watcher with:
       
         {row.name}
       
-      
-        {row.version}
-      
       
-        {row.main.slice(0, 12)}…{row.main.slice(-6)}
+        {row.version}
       
+      
       
     
); } -// VettingCell renders the per-participant vetting state for one DAR as -// a compact "U P S" trio of dots — green vetted, grey unvetted, amber -// "?" when that participant couldn't be probed. A package unvetted via -// `dar remove` shows grey here, matching the CLI `dar list --vetting` -// column and the per-participant toggles in the inspect drawer. +// Per-participant vetting as a "U P S" dot trio: green vetted, grey +// unvetted, amber "?" when a participant couldn't be probed. function VettingCell({ vet }: { vet: VetState | undefined }) { if (!vet || vet.kind === "loading") { return ( - + ); } if (vet.kind === "err" || vet.rows.length === 0) { return ( unknown @@ -745,13 +705,13 @@ function VettingCell({ vet }: { vet: VetState | undefined }) { alignItems: "center", gap: 8, fontFamily: wMono, - fontSize: 10.5, + fontSize: fs.micro, }} > {vet.rows.map((r) => { const abbr = r.role === "app-user" ? "U" : r.role === "app-provider" ? "P" : "S"; - const color = r.error ? W.warn : r.vetted ? "#62E2A0" : W.dim; + const color = r.error ? W.warn : r.vetted ? W.ok : W.dim; const title = r.error ? `${r.role}: ${r.error}` : `${r.role}: ${r.vetted ? "vetted" : "not vetted"}`; @@ -761,15 +721,7 @@ function VettingCell({ vet }: { vet: VetState | undefined }) { title={title} style={{ display: "flex", alignItems: "center", gap: 3, color }} > - + {abbr} {r.error ? "?" : ""} @@ -802,14 +754,16 @@ function InspectDrawer({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, - padding: 32, - textAlign: "center", + borderRadius: R.card, + padding: 14, + textAlign: "left", color: W.dim, - fontSize: 13, + fontSize: fs.body, + lineHeight: 1.5, }} > - Select a package to inspect. + Select a package to inspect its tree, per-participant vetting, and + structural diff.
); } @@ -818,27 +772,38 @@ function InspectDrawer({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, overflow: "hidden", }} >
- + {row.name} - + {row.version}
{row.description && row.description !== `${row.name}-${row.version}` && ( -
+
{row.description}
)}
- +
+ pkg-id + +
{row.description && ( @@ -853,14 +818,15 @@ function InspectDrawer({ > {compareWith ? ( <> - + back to inspect +
@@ -880,20 +846,7 @@ function InspectDrawer({ ); } -const smallBtn: React.CSSProperties = { - background: "transparent", - border: `1px solid ${W.border}`, - color: W.text2, - borderRadius: 5, - padding: "3px 10px", - fontSize: 11.5, - fontFamily: wMono, - cursor: "pointer", -}; - -// CompareSelector renders a small "compare with…" dropdown of every -// DAR currently visible in the list (excluding the active one). -// Picking a target flips the drawer into diff mode. +// "compare with…" dropdown; picking a target flips the drawer to diff mode. function CompareSelector({ allRows, currentMain, @@ -907,7 +860,7 @@ function CompareSelector({ if (others.length === 0) return null; return (
- Compare with + Compare with )} - + ); } -// SummaryBanner colors itself by the worst result: failing → red, -// warning → amber, all-pass → brand. Mirrors the CLI's colored summary -// Box so the two surfaces read the same. +// Colored by the worst result: fail → red, warn → amber, all-pass → brand. function SummaryBanner({ report }: { report: PreflightReport }) { const warned = report.sections.some((s) => s.checks.some((c) => c.result === "warn"), ); const accent = !report.ok ? W.err : warned ? W.warn : W.ok; - const glyph = !report.ok ? "✗" : warned ? "⚠" : "✓"; + const glyph = !report.ok ? ( + + ) : warned ? ( + + ) : ( + + ); return (
- {glyph} + + {glyph} + {report.summary || (report.ok ? "host is ready" : "host is not ready")}
); @@ -249,11 +273,9 @@ function Section({

@@ -263,7 +285,7 @@ function Section({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: R.card, overflow: "hidden", }} > @@ -288,17 +310,23 @@ function CheckRow({ check, last }: { check: PreflightCheck; last: boolean }) { >
-
+
{check.label} @@ -324,7 +353,7 @@ function CheckRow({ check, last }: { check: PreflightCheck; last: boolean }) { margin: "8px 0 0", paddingLeft: 18, color: W.text2, - fontSize: 11.5, + fontSize: fs.label, lineHeight: 1.6, }} > @@ -339,13 +368,22 @@ function CheckRow({ check, last }: { check: PreflightCheck; last: boolean }) { } function ResultGlyph({ result }: { result: PreflightCheck["result"] }) { - const map: Record = { - pass: "✓", - warn: "⚠", - fail: "✗", - skip: "○", - }; - return {map[result]}; + const color = glyphColor(result); + const inner = + result === "pass" ? ( + + ) : result === "warn" ? ( + + ) : result === "fail" ? ( + + ) : ( + + ); + return ( + + {inner} + + ); } function glyphColor(result: PreflightCheck["result"]): string { diff --git a/frontend/src/screens/ExplorerScreen.tsx b/frontend/src/screens/ExplorerScreen.tsx index 37199ca3..fc1af0dd 100644 --- a/frontend/src/screens/ExplorerScreen.tsx +++ b/frontend/src/screens/ExplorerScreen.tsx @@ -14,39 +14,31 @@ import { type TransactionsListResponse, } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { TX_KIND_COLOR, W, wMono } from "../tokens"; +import { Button } from "../components/Button"; +import { Dot, IcRefresh } from "../components/icons"; +import { MonoId } from "../components/MonoId"; +import { StatusBadge } from "../components/StatusBadge"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; +import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps, tint, R, FAST, fs } from "../tokens"; import { ContractDetailDrawer } from "./ContractDetailDrawer"; import { TxReplayDrawer } from "./TxReplayDrawer"; -// ExplorerScreen — production layout. -// -// - ProjectionBar at top: participant + party pills, view toggle -// (Contracts/Transactions/Timeline), live status + count strip -// - 3-column body grid: -// LEFT (232px) filter sidebar — Templates + Parties chips -// with counts, plus a manual snapshot refresh -// CENTER (1fr) ACS table with custom AcsRow layout -// (template · cid · party · amount · age · sig·obs) -// + search box with "/" hotkey + active row + -// archived dimming -// RIGHT (380px) detail drawer — pills, template+version, -// CID, payload, witnesses -// -// The ACS table is a live snapshot + SSE delta stream: an initial -// snapshot fills it, an EventSource applies create/archive deltas, and -// a 30s timer reconciles drift. The Transactions view supports the -// same party/template/offset filters the CLI `tx ls` has, and each -// transaction row can be replayed as a per-party visibility projection -// (the Web UI counterpart of `tx replay`). - const ROLES: Role[] = ["app-user", "app-provider", "sv"]; +// Template/party dot palette, ordered so neighbouring indices differ in +// hue; no red (reserved for errors). const PALETTE = [ - "#5BD7C5", "#7CB5F7", "#C4A8F5", "#F5BF55", - "#E8A14E", "#F08FB5", "#62E2A0", "#E37C7C", + "#6480E6", "#7BD2C6", "#DDB25E", "#7CC89A", + "#93A7F0", "#C8971F", "#189E8C", "#9BA3B5", ]; type View = "contracts" | "transactions" | "timeline"; +// Honour the OS reduced-motion setting for the timeline glyph fades. +const prefersReducedMotion = + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + export function ExplorerScreen() { const sel = useInstanceSelection(); const name = sel.selected; @@ -63,25 +55,13 @@ export function ExplorerScreen() { const [activeParties, setActiveParties] = useState>(new Set()); const [search, setSearch] = useState(""); const [selectedCid, setSelectedCid] = useState(null); - // Live-stream connection status. "live" once the - // EventSource has fired at least one frame; "reconnecting" when - // the browser has dropped the connection and is retrying; - // "truncated" when the backend hit its 10k event cap. const [streamStatus, setStreamStatus] = useState< "idle" | "live" | "reconnecting" | "truncated" >("idle"); const searchRef = useRef(null); - // refreshSnapshot is the single chokepoint for "fill the table - // from the snapshot endpoint." Used by: - // - the initial mount effect - // - the 30-second reconciliation timer - // - the SSE error / truncated recovery path - // - // Callers signal `quiet=true` for background refreshes so we - // don't flash the loading panel; the table is repopulated - // in-place. The initial mount uses `quiet=false` so users see - // "Snapshotting ACS…" before the first paint. + // quiet=true (reconciliation timer, SSE recovery) repopulates in place; + // quiet=false (initial mount) shows the loading panel first. const refreshSnapshot = useCallback( async (instance: string, asRole: Role, quiet: boolean) => { if (!quiet) { @@ -126,9 +106,7 @@ export function ExplorerScreen() { error: e instanceof ApiError ? e.message : "failed to load ACS", }); } - // Quiet background reconciliation failures are swallowed — - // the user keeps the last-known good state, and the next - // tick (or SSE event) will try again. + // Quiet background failures are swallowed; next tick retries. } }, [], @@ -136,36 +114,18 @@ export function ExplorerScreen() { useEffect(() => { if (!name) return; - let cancelled = false; setSelectedCid(null); - void (async () => { - if (cancelled) return; - await refreshSnapshot(name, role, false); - })(); - return () => { - cancelled = true; - }; + void refreshSnapshot(name, role, false); }, [name, role, refreshSnapshot]); - // Live SSE subscription. Mounted once the snapshot has - // loaded; tears down when the instance / role changes or the - // screen unmounts. The EventSource browser primitive auto- - // reconnects on transient failures (3s default backoff); we - // detect the dropped-connection by listening for `error` and do - // a snapshot refetch to recover any missed events. - // - // Events are applied via a Map in setState, - // which dedupes create-then-archive races: if an archive arrives - // before the matching create, the archive removes nothing - // (the row isn't in the table); if create arrives first, the - // archive then removes it. Either ordering converges to the same - // final state. + // Live SSE subscription, mounted once the snapshot has loaded. Deltas + // apply via a Map so create/archive races converge to + // the same state regardless of arrival order. useEffect(() => { if (!name) return; if (state.kind !== "ok") return; - // Resume from the snapshot's `ledger_end` so the handoff is a - // single atomic offset boundary — no events get skipped between - // the snapshot fetch and the stream open. + // Resume from ledger_end so no events are skipped between the + // snapshot fetch and the stream open. const es = openContractsStream(name, role, state.data.ledger_end); let opened = false; const onMessage = (raw: MessageEvent) => { @@ -179,8 +139,6 @@ export function ExplorerScreen() { } if (payload.event === "truncated") { setStreamStatus("truncated"); - // Backend stopped sending — reconcile and we'll re-open - // when the user picks a different instance. void refreshSnapshot(name, role, true); return; } @@ -216,10 +174,8 @@ export function ExplorerScreen() { }; es.addEventListener("contracts", onMessage as EventListener); es.onerror = () => { - // EventSource auto-reconnects unless we close it. Surface - // the visible reconnecting state and trigger a snapshot - // reconciliation so any missed events backfill — the - // browser may have been suspended (lid-close) for minutes. + // EventSource auto-reconnects; reconcile via snapshot since the + // browser may have been suspended for minutes. setStreamStatus("reconnecting"); if (opened) { void refreshSnapshot(name, role, true); @@ -230,15 +186,12 @@ export function ExplorerScreen() { es.close(); setStreamStatus("idle"); }; - // We intentionally depend on state.kind (not state) so the - // subscription is set up exactly once per "we have a snapshot" - // transition and not torn down on every contract-list change. + // Depend on state.kind (not state) so the subscription resets once + // per snapshot transition, not on every contract-list change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, role, state.kind, refreshSnapshot]); - // Periodic reconciliation. Every 30s we re-pull the - // snapshot to correct any drift the SSE deltas missed (network - // hiccups, browser suspend, backend restart). Quiet — no UI flash. + // Every 30s, re-pull the snapshot to correct drift the SSE deltas missed. useEffect(() => { if (!name) return; if (state.kind !== "ok") return; @@ -249,10 +202,7 @@ export function ExplorerScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, role, state.kind, refreshSnapshot]); - // Keyboard: / focuses search; Esc clears selection. The "/" - // shortcut must NOT trigger when the user is typing into any - // editable surface — INPUT, TEXTAREA, or a contenteditable - // element. + // "/" focuses search (unless already in an editable); Esc clears selection. useEffect(() => { const onKey = (e: KeyboardEvent) => { const active = document.activeElement as HTMLElement | null; @@ -272,7 +222,7 @@ export function ExplorerScreen() { return () => window.removeEventListener("keydown", onKey); }, [selectedCid]); - // Derive template + party facets from the (unfiltered) ACS. + // Template + party facets from the unfiltered ACS. const facets = useMemo(() => { if (state.kind !== "ok") return { templates: [], parties: [] }; const tpl = new Map(); @@ -288,7 +238,7 @@ export function ExplorerScreen() { return { templates: colored(tpl), parties: colored(pty) }; }, [state]); - // Filter the ACS in render. Search matches template, cid, payload JSON, party. + // Search matches template, cid, payload JSON, and party. const filtered = useMemo(() => { if (state.kind !== "ok") return []; const needle = search.trim().toLowerCase(); @@ -324,10 +274,7 @@ export function ExplorerScreen() { [state, selectedCid], ); - // J/K navigation between rows. Driven over the - // currently *filtered* view so the user follows what they see, - // not the underlying ACS order. The drawer registers its own - // keydown listener (Esc + j/k) and invokes these callbacks. + // Navigate over the filtered view (what the user sees), not the ACS order. const goPrev = useCallback(() => { if (!selectedCid) return; const i = filtered.findIndex((c) => c.contract_id === selectedCid); @@ -354,8 +301,8 @@ export function ExplorerScreen() { return (
-

Explorer

-

+

Explorer

+

Live Active Contract Set, transaction history, and per-party visibility.

@@ -371,8 +318,13 @@ export function ExplorerScreen() { streamStatus={streamStatus} /> - {state.kind === "loading" && Snapshotting ACS…} - {state.kind === "err" && } + {state.kind === "loading" && } + {state.kind === "err" && ( + void refreshSnapshot(name, role, false)} + /> + )} {state.kind === "port-missing" && ( - {/* LEFT — filter sidebar */}
- {/* The ACS is a point-in-time snapshot kept live by the - SSE delta stream + a 30s reconciliation timer. A - manual refresh re-pulls it immediately — there is no - time-range to pick (the dead Live/5m/1h/24h buttons - were removed). */} - +
- stream · {streamStatus} + + Stream + +
- ledger end · {state.data.ledger_end ?? "—"} + + Ledger end + + + {state.data.ledger_end ?? "—"} +
- {/* CENTER — ACS table */}
@@ -494,10 +453,10 @@ export function ExplorerScreen() { }} >
-
+
Active Contract Set
-
+
{filtered.length} of {state.data.contracts.length} contracts ·{" "} {streamStatus === "live" ? "streaming creates and archives" @@ -520,9 +479,9 @@ export function ExplorerScreen() { background: W.border, border: `1px solid ${W.border}`, color: W.text, - fontSize: 12, + fontSize: fs.meta, padding: "5px 32px 5px 10px", - borderRadius: 6, + borderRadius: 2, width: 240, }} aria-label="Filter contracts" @@ -533,12 +492,12 @@ export function ExplorerScreen() { right: 8, top: 5, color: W.dim, - fontSize: 10, + fontSize: fs.micro, fontFamily: wMono, background: W.surface, border: `1px solid ${W.border}`, padding: "0 4px", - borderRadius: 3, + borderRadius: 2, }} > / @@ -546,7 +505,6 @@ export function ExplorerScreen() {
- {/* Column header row */}
Template - Cid - Owner / signatory - Payload + Contract Id + Owner / Signatory + Payload Age Sig · Obs
- {filtered.length === 0 && ( -
- No contracts match the current filters. -
- )} + {filtered.length === 0 && + (() => { + const hasAcsFilters = + activeTemplates.size > 0 || + activeParties.size > 0 || + search.trim() !== ""; + return ( +
+ {hasAcsFilters ? ( + <> + + No contracts match these filters.{" "} + {state.data.contracts.length.toLocaleString()} in the + snapshot. + + + + ) : ( + <> + + The active contract set is empty. Create a contract to + populate it. + + + dpm localnet tx submit + + + )} +
+ ); + })()}
{filtered.map((c) => ( - + Showing {filtered.length} of {state.data.contracts.length} ·{" "} {streamStatus === "live" ? "live" : "snapshot"} @ offset{" "} {state.data.ledger_end ?? "—"} @@ -604,23 +611,20 @@ export function ExplorerScreen() { ↑↓ navigate · ↵ open · / focus search · esc close
- - {/* RIGHT — detail drawer */} - {selected ? ( - setSelectedCid(null)} - onPrev={goPrev} - onNext={goNext} - /> - ) : ( - - )}
)} + {state.kind === "ok" && view === "contracts" && selected && ( + setSelectedCid(null)} + onPrev={goPrev} + onNext={goNext} + /> + )} + {state.kind === "ok" && view === "transactions" && ( )} @@ -631,8 +635,6 @@ export function ExplorerScreen() { ); } -// ─────── Sub-components ──────────────────────────────────────── - function ProjectionBar({ instance, role, @@ -652,28 +654,12 @@ function ProjectionBar({ ledgerEnd: number | null; streamStatus: "idle" | "live" | "reconnecting" | "truncated"; }) { - const pillColor = - streamStatus === "live" - ? "#62E2A0" - : streamStatus === "reconnecting" - ? "#F5BF55" - : streamStatus === "truncated" - ? "#F08FB5" - : "#7A8B95"; - const pillLabel = - streamStatus === "live" - ? "live" - : streamStatus === "reconnecting" - ? "reconnecting" - : streamStatus === "truncated" - ? "truncated" - : "idle"; return (
Projecting through @@ -700,9 +684,9 @@ function ProjectionBar({ border: `1px solid ${W.border}`, color: W.text, fontFamily: wMono, - fontSize: 11.5, + fontSize: fs.label, padding: "5px 10px", - borderRadius: 6, + borderRadius: 2, }} > participant{" "} @@ -718,9 +702,9 @@ function ProjectionBar({ border: `1px solid ${W.border}`, color: W.text, fontFamily: wMono, - fontSize: 11.5, + fontSize: fs.label, padding: "5px 10px", - borderRadius: 6, + borderRadius: 2, cursor: "pointer", }} > @@ -736,7 +720,7 @@ function ProjectionBar({ borderLeft: `1px solid ${W.border}`, paddingLeft: 16, color: W.dim, - fontSize: 11.5, + fontSize: fs.label, lineHeight: 1.4, }} > @@ -755,7 +739,7 @@ function ProjectionBar({ style={{ display: "flex", background: W.border, - borderRadius: 8, + borderRadius: 4, padding: 3, border: `1px solid ${W.border}`, }} @@ -766,21 +750,22 @@ function ProjectionBar({ onClick={() => onViewChange(v)} style={{ padding: "5px 12px", - fontSize: 12, - borderRadius: 5, + fontSize: fs.meta, + borderRadius: R.control, border: "none", background: v === view ? W.brand : "transparent", - color: v === view ? "#082018" : W.dim, + color: v === view ? W.onAccent : W.dim, fontWeight: v === view ? 600 : 500, cursor: "pointer", textTransform: "capitalize", + transition: `background-color ${FAST}, color ${FAST}`, }} > {v} ))}
- {pillLabel} +
); } @@ -801,34 +786,35 @@ function FilterChip({ return ( @@ -871,26 +857,19 @@ function AcsRow({ gap: 14, padding: "9px 14px", alignItems: "center", - background: active ? `${W.brand}10` : "transparent", - borderLeft: active ? `2px solid ${W.brand}` : "2px solid transparent", - paddingLeft: active ? 12 : 14, + background: active ? tint(W.brand, 12) : "transparent", borderBottom: `1px solid ${W.border}`, cursor: "pointer", + transition: `background-color ${FAST}`, }} >
+ + + -
- - {row.contract_id.slice(0, 14)}… - + {row.signatories[0]?.split("::")[0] ?? "—"} - + {payloadPreview} {row.created_at ? ago(row.created_at) : "—"} - + {row.signatories.length}·{row.observers.length} @@ -949,133 +924,8 @@ function AcsRow({ ); } -function DetailDrawer({ row }: { row: ContractRow | null }) { - if (!row) { - return ( -
- Select a contract to inspect. -
- ); - } - return ( -
-
-
- active - - visible to {row.signatories.length + row.observers.length} - -
-
- - {row.template_id.split(":").slice(1).join(":")} - - {row.package_name && ( - - {row.package_name} - - )} -
-
- {row.contract_id} -
-
-
-
-          {JSON.stringify(row.payload ?? {}, null, 2)}
-        
-
-
- {row.signatories.length === 0 ? ( - None - ) : ( - row.signatories.map((p) => ( -
- {p} -
- )) - )} -
- {row.observers.length > 0 && ( -
- {row.observers.map((p) => ( -
- {p} -
- ))} -
- )} - {row.created_at && ( -
-
- {row.created_at} -
-
- {ago(row.created_at)} -
-
- )} -
- ); -} - -// TransactionsView — table of recent ledger updates (transactions, -// reassignments, topology events) projected from -// UpdateService.GetUpdates. Each transaction row expands inline to -// show its event tree and can be replayed as a per-party visibility -// projection. The filter bar mirrors the CLI `tx ls --party / -// --template / --from / --to`: filters are applied server-side -// over the offset window, so a contract outside the row cap can still -// be found by narrowing the query — not just hidden by a client-side -// filter over an already-truncated snapshot. +// Filters are applied server-side over the offset window, so narrowing +// the query can surface rows beyond the row cap. function TransactionsView({ name, role }: { name: string; role: Role }) { const [state, setState] = useState< | { kind: "loading" } @@ -1086,9 +936,8 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { >({ kind: "loading" }); const [openId, setOpenId] = useState(null); const [replayId, setReplayId] = useState(null); - // Draft filter inputs (raw strings) vs the applied filters used in - // the fetch effect. Applying on submit (not keystroke) avoids a - // round-trip per character and a focus-stealing re-render storm. + // Draft inputs vs applied filters; applying on submit avoids a + // round-trip per keystroke. const [draft, setDraft] = useState(emptyDraft); const [applied, setApplied] = useState({}); @@ -1132,7 +981,7 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { }, [name, role, applied]); // Party options for the replay drawer's "visible to" selector, - // derived from the witnesses present in the loaded rows. + // from witnesses in the loaded rows. const partyOptions = useMemo(() => { if (state.kind !== "ok") return []; const set = new Set(); @@ -1157,10 +1006,21 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { const body = (() => { if (state.kind === "loading") { - return Loading updates stream…; + return ( + + ); } if (state.kind === "err") { - return ; + return ( + setApplied((f) => ({ ...f }))} + /> + ); } if (state.kind === "port-missing") { return ( @@ -1186,7 +1046,7 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, overflow: "hidden", }} > @@ -1200,10 +1060,17 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { }} >
-
+
Transactions
-
+
{state.data.transactions.length} updates · newest first ·{" "} {hasFilters ? "filtered · " : ""} scanned from {state.data.scanned_from?.toLocaleString() ?? "—"} to @@ -1220,10 +1087,8 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { gap: 14, padding: "9px 14px", color: W.dim, - fontSize: 10.5, - letterSpacing: 1.4, - textTransform: "uppercase", - fontWeight: 600, + fontSize: fs.micro, + ...tableCaps, borderBottom: `1px solid ${W.border}`, }} > @@ -1237,10 +1102,34 @@ function TransactionsView({ name, role }: { name: string; role: Role }) {
{state.data.transactions.length === 0 && ( -
- {hasFilters - ? "No updates matched the filters in the scanned window." - : "No updates in the current ledger window."} +
+ {hasFilters ? ( + <> + No updates matched these filters in the scanned window. + + + ) : ( + <> + No updates in the current ledger window. + + dpm localnet tx ls + + + )}
)} @@ -1269,7 +1158,7 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { style={{ padding: "10px 14px", color: W.dim, - fontSize: 11.5, + fontSize: fs.label, borderTop: `1px solid ${W.border}`, }} > @@ -1289,33 +1178,21 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { onClear={clearFilters} active={!!hasFilters} /> - {replayId ? ( -
- {body} - setReplayId(null)} - /> -
- ) : ( - body + {body} + {replayId && ( + setReplayId(null)} + /> )}
); } -// TxFilterDraft holds the raw filter-bar inputs. party/template are -// comma-separated free text; from/to are offset strings. +// party/template are comma-separated free text; from/to are offset strings. interface TxFilterDraft { party: string; template: string; @@ -1325,10 +1202,7 @@ interface TxFilterDraft { const emptyDraft: TxFilterDraft = { party: "", template: "", from: "", to: "" }; -// parseDraft converts the raw inputs into the typed TransactionFilters -// the API client forwards. Blank fields drop out; non-numeric -// from/to are ignored (the input is type=number, so this is belt + -// braces). +// Blank fields drop out; non-numeric from/to are ignored. function parseDraft(d: TxFilterDraft): TransactionFilters { const split = (s: string) => s @@ -1379,10 +1253,10 @@ function TxFilterBar({ background: W.border, border: `1px solid ${W.border}`, color: W.text, - fontSize: 12, + fontSize: fs.meta, fontFamily: wMono, padding: "5px 8px", - borderRadius: 6, + borderRadius: 2, width, }} /> @@ -1392,7 +1266,7 @@ function TxFilterBar({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: "10px 14px", marginBottom: 12, display: "flex", @@ -1404,10 +1278,8 @@ function TxFilterBar({ Filters @@ -1416,36 +1288,13 @@ function TxFilterBar({ {input("template", "Module:Entity (comma-sep)", 200)} {input("from", "from offset", 110, true)} {input("to", "to offset", 110, true)} - + {active && ( - + )}
); @@ -1462,7 +1311,6 @@ function TxRowComponent({ onToggle: () => void; onReplay?: () => void; }) { - const kindColor = TX_KIND_COLOR; return ( <>
{tx.kind} - - {tx.offset.toLocaleString()} - - {tx.command_id ?? tx.update_id?.slice(0, 16) ?? "—"} + {tx.offset.toLocaleString()} + {tx.command_id ? ( + + ) : tx.update_id ? ( + + ) : ( + + )} {tx.workflow_id || (tx.synchronizer ? `→ ${tx.synchronizer}` : "—")} - + {tx.record_time ? hhmmss(tx.record_time) : "—"} {tx.event_count ?? "—"} - + {onReplay ? ( - + ) : ( - + )}
{open && tx.events && tx.events.length > 0 && (
= { - create: "#62E2A0", - archive: "#F08FB5", - exercise: "#7CB5F7", + create: "#7CC89A", + archive: "#7BD2C6", + exercise: "#8FA3EE", }; return (
@@ -1614,25 +1461,18 @@ function EventTreeNode({ ? ev.template.split(":").slice(1).join(":") : "—"} - - {ev.contract_id.slice(0, 16)}… - +
); } -// TimelineView — time-axis strip showing every update as a coloured -// glyph along the offset/time axis. Clicking a glyph highlights it + -// shows quick metadata in a side card. Useful for "what happened in -// the last minute" debugging. function TimelineView({ name, role }: { name: string; role: Role }) { const [state, setState] = useState< | { kind: "loading" } @@ -1641,11 +1481,12 @@ function TimelineView({ name, role }: { name: string; role: Role }) { | { kind: "port-missing"; remediation: string } | { kind: "err"; error: string } >({ kind: "loading" }); - // Click = persistent selection. Hover = preview (only renders - // detail when nothing is selected). Click again to clear, Esc - // also clears. + // Click pins a selection; hover previews when nothing is pinned. const [selectedIdx, setSelectedIdx] = useState(null); const [hoverIdx, setHoverIdx] = useState(null); + // Bumped by the error-state Retry to re-run the fetch effect. + const [nonce, setNonce] = useState(0); + const reload = useCallback(() => setNonce((n) => n + 1), []); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -1694,10 +1535,14 @@ function TimelineView({ name, role }: { name: string; role: Role }) { return () => { cancelled = true; }; - }, [name, role]); + }, [name, role, nonce]); - if (state.kind === "loading") return Loading timeline…; - if (state.kind === "err") return ; + if (state.kind === "loading") + return ( + + ); + if (state.kind === "err") + return ; if (state.kind === "port-missing") return ( + <>
@@ -1749,16 +1582,15 @@ function TimelineView({ name, role }: { name: string; role: Role }) { borderBottom: `1px solid ${W.border}`, }} > -
+
Timeline
-
+
{txs.length} updates · {buckets.length}-bucket density strip · hover any glyph for details
- {/* Activity strip */}
{buckets.map((b, i) => { @@ -1778,11 +1610,8 @@ function TimelineView({ name, role }: { name: string; role: Role }) { style={{ flex: 1, height: h, - background: - b.count === 0 - ? W.border - : `linear-gradient(180deg, ${W.brand}66 0%, ${W.brand} 100%)`, - borderRadius: 2, + background: b.count === 0 ? W.border : W.brand, + borderRadius: R.control, }} /> ); @@ -1793,7 +1622,7 @@ function TimelineView({ name, role }: { name: string; role: Role }) { style={{ padding: "4px 14px 0", color: W.dim, - fontSize: 10.5, + fontSize: fs.micro, fontFamily: wMono, display: "flex", justifyContent: "space-between", @@ -1809,7 +1638,6 @@ function TimelineView({ name, role }: { name: string; role: Role }) { )}
- {/* Event glyph row */}
{ const color = tx.kind === "transaction" - ? "#62E2A0" + ? TX_KIND_COLOR.transaction : tx.kind === "reassignment" - ? "#7CB5F7" - : "#C4A8F5"; + ? TX_KIND_COLOR.reassignment + : TX_KIND_COLOR.topology; return ( ); @@ -1874,102 +1698,105 @@ function TimelineView({ name, role }: { name: string; role: Role }) { style={{ padding: "10px 14px", color: W.dim, - fontSize: 11.5, + fontSize: fs.label, display: "flex", justifyContent: "space-between", borderTop: `1px solid ${W.border}`, }} > - - - + + + {selectedIdx !== null - ? "Selected — click again or press Esc to clear." + ? "Pinned. Click again or press Esc to clear." : "Hover for preview · click to pin."}
- {/* Side panel — hovered detail */} -
- {focused ? ( - <> -
-
- - {focused.kind} - - - offset {focused.offset.toLocaleString()} - + {focused && ( +
+
+
+ + {focused.kind} + + + offset {focused.offset.toLocaleString()} + +
+ {focused.record_time && ( +
+ {focused.record_time}
- {focused.record_time && ( -
- {focused.record_time} -
- )} -
- {focused.command_id && ( -
- {focused.command_id} -
)} - {focused.workflow_id && ( -
- {focused.workflow_id} -
- )} - {focused.events && focused.events.length > 0 && ( -
- {focused.events.map((ev, i) => ( - - ))} -
- )} - - ) : ( -
- Hover any glyph on the left to inspect it. -
- )} -
-
+
+ {focused.command_id && ( +
+ {focused.command_id} +
+ )} + {focused.workflow_id && ( +
+ {focused.workflow_id} +
+ )} + {focused.events && focused.events.length > 0 && ( +
+ {focused.events.map((ev, i) => ( + + ))} +
+ )} +
+ )} + ); } @@ -1977,7 +1804,7 @@ function LegendDot({ color, label }: { color: string; label: string }) { return ( - {label} + {label} ); } @@ -1988,8 +1815,9 @@ function Mono({ children }: { children: React.ReactNode }) { style={{ fontFamily: wMono, color: W.text2, - fontSize: 11, - wordBreak: "break-all", + fontSize: fs.label, + fontVariantNumeric: "tabular-nums", + wordBreak: "break-word", }} > {children} @@ -2029,11 +1857,9 @@ function hhmmss(iso: string): string { if (!Number.isFinite(d.getTime())) return iso; return d .toISOString() - .slice(11, 19); // "HH:MM:SS" + .slice(11, 19); } -// ─────── Tiny shared primitives ─────────────────────────────── - function Card({ title, subtitle, @@ -2048,16 +1874,16 @@ function Card({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: R.card, padding: 10, }} >
-
+
{title}
{subtitle && ( -
+
{subtitle}
)} @@ -2079,10 +1905,8 @@ function Section({
@@ -2097,12 +1921,12 @@ function Pill({ color, children }: { color: string; children: React.ReactNode }) return ( - {children} +
); } -function ErrorPanel({ msg }: { msg: string }) { +function TableLoading({ + columns, + rows, + rowHeight, +}: { + columns: (number | string)[]; + rows: number; + rowHeight: number; +}) { + const show = useLoadingDelay(true); + if (!show) return null; return (
- {msg} + +
+ ); +} + +function ErrorPanel({ msg, onRetry }: { msg: string; onRetry?: () => void }) { + return ( +
+
+ Could not load ledger data. +
+
+ The participant did not answer. Check the instance is running, then + retry. +
+ {onRetry && ( + + )} +
+ Details + + {msg} + +
); } @@ -2158,31 +2037,33 @@ function EmptyPanel({ return (
-

+

{title}

-

{body}

-

{remediation}

+

+ {body} +

+

+ {remediation} +

); } function Hint({ children }: { children: React.ReactNode }) { return ( -
+
{children}
); } -// ─────── Helpers ────────────────────────────────────────────── - function shortTemplate(tpl: string): string { const parts = tpl.split(":"); return parts.length >= 3 ? `${parts[1]}:${parts[2]}` : tpl; diff --git a/frontend/src/screens/InstanceDetail.test.tsx b/frontend/src/screens/InstanceDetail.test.tsx index 3da96e13..d81d9c2e 100644 --- a/frontend/src/screens/InstanceDetail.test.tsx +++ b/frontend/src/screens/InstanceDetail.test.tsx @@ -1,13 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { InstanceDetail } from "./InstanceDetail"; - -// InstanceDetail tests — surfaces every field the /api/instances/:name -// endpoint returns beyond the summary. Three states: -// -// 1. ok with full payload → grid populated -// 2. ok with live_probe_failed=true → warning pill in header -// 3. fetch error → red error line +import { ConfirmHost } from "../components/ConfirmDialog"; function mockInstanceFetch( body: object | { status: number; error: string }, @@ -44,14 +38,10 @@ describe("InstanceDetail", () => { render(); - // Wait for the loading state to clear. await waitFor(() => { expect(screen.getByText("0.4.12")).toBeInTheDocument(); }); - // Identity + runtime + paths — pin one from each block to - // catch a future refactor that drops a section. "cdk-demo" - // appears in both compose-project and container-prefix - // fields, so use getAllByText and assert the count. + // "cdk-demo" is both compose-project and container-prefix, hence count 2. expect(screen.getAllByText("cdk-demo")).toHaveLength(2); expect(screen.getByText("2h 14m")).toBeInTheDocument(); expect( @@ -80,9 +70,79 @@ describe("InstanceDetail", () => { }); }); + it("shows the unreachable-UI banner with the Recreate remediation", async () => { + mockInstanceFetch({ + schema_version: 1, + name: "demo", + splice_version: "0.4.12", + status: "running", + created_at: "2026-05-25T10:00:00Z", + compose_project: "cdk-demo", + docker_network: "cdk-demo_default", + container_prefix: "cdk-demo", + project_dir: "/x", + data_dir: "/x/data", + endpoints: [ + { + label: "Wallet · app-user", + url: "http://localhost:4485", + port: 4485, + scheme: "http", + reachability: "unreachable", + reachability_detail: + "connection accepted but no HTTP response (empty reply)", + }, + { + label: "Postgres", + url: "postgresql://localhost:5432", + port: 5432, + scheme: "postgresql", + }, + ], + }); + + render(); + await waitFor(() => { + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("Wallet · app-user"); + expect(alert).toHaveTextContent(/not serving HTTP/i); + expect(alert).toHaveTextContent("Recreate"); + expect(alert).toHaveTextContent("dpm localnet up --name demo"); + }); + }); + + it("renders no reachability banner when probed endpoints are ok", async () => { + mockInstanceFetch({ + schema_version: 1, + name: "demo", + splice_version: "0.4.12", + status: "running", + created_at: "2026-05-25T10:00:00Z", + compose_project: "cdk-demo", + docker_network: "cdk-demo_default", + container_prefix: "cdk-demo", + project_dir: "/x", + data_dir: "/x/data", + endpoints: [ + { + label: "Wallet · app-user", + url: "http://localhost:4485", + port: 4485, + scheme: "http", + reachability: "ok", + }, + ], + }); + + render(); + await waitFor(() => { + expect(screen.getByText("0.4.12")).toBeInTheDocument(); + }); + expect(screen.queryByText(/not serving HTTP/i)).not.toBeInTheDocument(); + }); + it("shows em-dash for missing uptime", async () => { - // Uptime is optional in the type — a freshly-stopped instance - // may not carry it. The grid uses "—" as the muted fallback. + // Uptime is optional; the grid uses "—" as the muted fallback. mockInstanceFetch({ schema_version: 1, name: "demo", @@ -98,10 +158,8 @@ describe("InstanceDetail", () => { }); render(); - // Find the row labelled "uptime" and check its sibling. await waitFor(() => { const uptimeLabel = screen.getByText("uptime"); - // Sibling is the next div under the same grid-row. expect(uptimeLabel.nextElementSibling?.textContent).toBe("—"); }); }); @@ -117,11 +175,48 @@ describe("InstanceDetail", () => { }); }); + it("uses a neutral error banner for non-stop action failures", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.endsWith("/start")) { + return Promise.resolve( + new Response(JSON.stringify({ code: "START_FAILED", error: "boom" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }), + ); + } + return Promise.resolve( + new Response( + JSON.stringify({ + schema_version: 1, + name: "demo", + splice_version: "0.4.12", + status: "stopped", + created_at: "2026-05-25T10:00:00Z", + compose_project: "cdk-demo", + docker_network: "cdk-demo_default", + container_prefix: "cdk-demo", + project_dir: "/x", + data_dir: "/x/data", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + render(); + + const startBtn = await screen.findByRole("button", { name: /start/i }); + fireEvent.click(startBtn); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent("Action failed: boom"); + }); + expect(screen.getByRole("alert")).not.toHaveTextContent("Stop failed"); + }); + it("posts to /recreate and fires onChanged when the Recreate button is clicked", async () => { - // The restart button is offered on running / paused / failed / - // partial. The click invokes recreateInstance which POSTs to the - // backend; on the 202 response the detail card refetches and - // bubbles onChanged so the dashboard's row updates. const fetchMock = vi.fn().mockImplementation((url: string) => { if (typeof url === "string" && url.endsWith("/recreate")) { return Promise.resolve( @@ -154,11 +249,13 @@ describe("InstanceDetail", () => { ); }); vi.stubGlobal("fetch", fetchMock); - vi.stubGlobal("confirm", vi.fn().mockReturnValue(true)); const onChanged = vi.fn(); render( - , + <> + + + , ); // Wait for the Recreate button to appear (the action-button @@ -166,6 +263,10 @@ describe("InstanceDetail", () => { const restartBtn = await screen.findByRole("button", { name: /recreate/i }); fireEvent.click(restartBtn); + // Recreate routes through the confirm dialog; approve it. + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: /recreate/i })); + await waitFor(() => { const calls = fetchMock.mock.calls.map((c) => c[0]); expect( @@ -180,10 +281,155 @@ describe("InstanceDetail", () => { }); }); + it("posts to /stop (not /down) when the Stop button is clicked on a running instance", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.endsWith("/stop")) { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + schema_version: 1, + name: "demo", + splice_version: "0.4.12", + status: "running", + created_at: "2026-05-25T10:00:00Z", + compose_project: "cdk-demo", + docker_network: "cdk-demo_default", + container_prefix: "cdk-demo", + project_dir: "/x", + data_dir: "/x/data", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const onChanged = vi.fn(); + render( + , + ); + + const stopBtn = await screen.findByRole("button", { name: /^Stop$/ }); + fireEvent.click(stopBtn); + + await waitFor(() => { + const calls = fetchMock.mock.calls.map((c) => c[0]); + expect( + calls.some( + (u: string) => + typeof u === "string" && u.endsWith("/api/instances/demo/stop"), + ), + ).toBe(true); + expect( + calls.some( + (u: string) => typeof u === "string" && u.endsWith("/down"), + ), + ).toBe(false); + }); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); + + it("posts to /start when the Start button is clicked on a stopped instance", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.endsWith("/start")) { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + schema_version: 1, + name: "demo", + splice_version: "0.4.12", + status: "stopped", + created_at: "2026-05-25T10:00:00Z", + compose_project: "cdk-demo", + docker_network: "cdk-demo_default", + container_prefix: "cdk-demo", + project_dir: "/x", + data_dir: "/x/data", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const onChanged = vi.fn(); + render( + , + ); + + const startBtn = await screen.findByRole("button", { name: /start/i }); + fireEvent.click(startBtn); + + await waitFor(() => { + const calls = fetchMock.mock.calls.map((c) => c[0]); + expect( + calls.some( + (u: string) => + typeof u === "string" && u.endsWith("/api/instances/demo/start"), + ), + ).toBe(true); + }); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); + + it("posts to /down when the Down button is clicked on a stopped instance", async () => { + const fetchMock = vi.fn().mockImplementation((url: string) => { + if (typeof url === "string" && url.endsWith("/down")) { + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response( + JSON.stringify({ + schema_version: 1, + name: "demo", + splice_version: "0.4.12", + status: "stopped", + created_at: "2026-05-25T10:00:00Z", + compose_project: "cdk-demo", + docker_network: "cdk-demo_default", + container_prefix: "cdk-demo", + project_dir: "/x", + data_dir: "/x/data", + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + }); + vi.stubGlobal("fetch", fetchMock); + + const onChanged = vi.fn(); + render( + <> + + + , + ); + + const downBtn = await screen.findByRole("button", { name: /^Down$/ }); + fireEvent.click(downBtn); + + // Down routes through the confirm dialog; approve it. + const dialog = await screen.findByRole("dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: /^Down$/ })); + + await waitFor(() => { + const calls = fetchMock.mock.calls.map((c) => c[0]); + expect( + calls.some( + (u: string) => + typeof u === "string" && u.endsWith("/api/instances/demo/down"), + ), + ).toBe(true); + }); + await waitFor(() => expect(onChanged).toHaveBeenCalled()); + }); + it("re-fetches when the name prop changes", async () => { - // The Dashboard hands a new name when the user switches - // instances. Without the useEffect dep on `name`, the - // first-fetched detail would stick forever. + // Without the useEffect dep on `name`, the first detail would stick forever. let i = 0; vi.stubGlobal( "fetch", diff --git a/frontend/src/screens/InstanceDetail.tsx b/frontend/src/screens/InstanceDetail.tsx index ab1303b2..8b34edc5 100644 --- a/frontend/src/screens/InstanceDetail.tsx +++ b/frontend/src/screens/InstanceDetail.tsx @@ -1,41 +1,43 @@ import { useEffect, useState } from "react"; import { ApiError, + type Endpoint, type Instance, + downInstance, fetchInstance, pauseInstance, recreateInstance, - resumeInstance, scrubInstance, + startInstance, stopInstance, unpauseInstance, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { + IcEject, + IcPause, + IcPlay, + IcRefresh, + IcStop, + IcX, +} from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; +import { SkeletonBar, useLoadingDelay } from "../components/Skeleton"; +import { confirmDialog } from "../components/ConfirmDialog"; import { BackupRestore } from "./BackupRestore"; -// InstanceDetail — the per-instance detail card the dashboard -// pops above the Developer setup card when a row is selected. -// -// Surfaces the fields that GET /api/instances/:name returns -// beyond the summary row (compose project, docker network, -// data dir, container prefix, uptime, live-probe state). The -// summary table only carries name/status/version/ports/started -// — the rest is hidden behind this fetch. -// -// Pure-frontend slice: this wires the existing detail endpoint -// into a screen without changing the backend. +function unreachableUIs(inst: Instance): Endpoint[] { + return (inst.endpoints ?? []).filter( + (e) => e.reachability === "unreachable", + ); +} + interface Props { name: string; - // statusHint comes from sel.instances (the always-fresh list) - // and gates which action button renders. Falls back to the - // status in the fetched-instance state if omitted — but the - // dashboard should pass it so the button reflects the latest - // list state immediately after onChanged, not the stale copy - // from this component's own mount-time fetch. + // From the dashboard's fresh list; gates which action button renders. + // Falls back to this card's own fetched status when omitted. statusHint?: string; - // Optional: refresh the dashboard's instance list after a Stop - // succeeds so the row's status updates (running → stopped) and - // the DeveloperSetup panel hides. onChanged?: () => void; } @@ -45,27 +47,21 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { | { kind: "ok"; instance: Instance } | { kind: "err"; error: string } >({ kind: "loading" }); - // Bumped to force a refetch after a successful Stop/Remove so - // the cached instance.status doesn't lie about the post-action - // state. + // Bumped after an action so the cached instance.status is refetched. const [refetchTick, setRefetchTick] = useState(0); const [stopping, setStopping] = useState< | { kind: "idle" } | { kind: "running" } | { kind: "err"; message: string } >({ kind: "idle" }); + const showSkeleton = useLoadingDelay(state.kind === "loading"); async function onStop() { - if (!confirm(`Stop instance ${name}? Containers will be brought down via docker compose. Data volumes are preserved.`)) { - return; - } + // docker compose stop keeps containers for a fast Start; no confirm needed. setStopping({ kind: "running" }); try { await stopInstance(name); setStopping({ kind: "idle" }); - // Bump our own refetch tick so this card's status field - // updates from running → stopped, then notify the parent - // so the dashboard's row + ActionButton catch up too. setRefetchTick((n) => n + 1); onChanged?.(); } catch (e) { @@ -76,6 +72,32 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { } } + async function onDown() { + if ( + !(await confirmDialog({ + title: "Tear down instance?", + body: `Removes ${name}'s containers and networks. Data volumes are preserved, so Start recreates it.`, + detail: `dpm localnet down ${name}`, + confirmLabel: "Down", + danger: true, + })) + ) { + return; + } + setStopping({ kind: "running" }); + try { + await downInstance(name); + setStopping({ kind: "idle" }); + setRefetchTick((n) => n + 1); + onChanged?.(); + } catch (e) { + const msg = e instanceof ApiError ? e.message : "failed to tear down"; + setStopping({ kind: "err", message: msg }); + setRefetchTick((n) => n + 1); + onChanged?.(); + } + } + async function onPause() { setStopping({ kind: "running" }); try { @@ -106,20 +128,19 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onRecreate() { if ( - !confirm( - `Recreate ${name}? Containers will be brought down and back up via docker compose. ` + - `The recorded Splice version and profiles are preserved; data volumes are NOT touched.`, - ) + !(await confirmDialog({ + title: "Recreate instance?", + body: `Brings ${name} down then back up. The recorded Splice version and profiles are preserved. Data volumes are not touched.`, + detail: `dpm localnet down ${name} && dpm localnet up ${name}`, + confirmLabel: "Recreate", + })) ) { return; } setStopping({ kind: "running" }); try { await recreateInstance(name); - // 202 — recreate is async (down → up). The dashboard's 15s - // poll will pick up the transitional `creating` status when - // the goroutine reaches the up phase; refresh both surfaces - // eagerly so the user sees movement within the next tick. + // 202 async (down → up); refresh eagerly to show the transitional status. setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); onChanged?.(); @@ -134,11 +155,8 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onStart() { setStopping({ kind: "running" }); try { - await resumeInstance(name); - // 202 — bring-up is in progress. The dashboard's 15s - // poll will pick up the running status when the - // reconciler sees it. Refresh both surfaces eagerly so - // the user sees "creating" within the next tick. + // 204 → fast start done; 202 → full bring-up (containers had been removed). + await startInstance(name); setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); onChanged?.(); @@ -150,10 +168,13 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onRemove() { if ( - !confirm( - `Remove ${name} from the registry?\n\nThis deletes the instance entry + state.json. ` + - `Docker volumes (if any) are NOT touched — for that, use \`dpm localnet clean --name ${name}\` from a terminal.`, - ) + !(await confirmDialog({ + title: "Remove from registry?", + body: `Deletes the ${name} entry and its state.json. Docker volumes (if any) are not touched. To drop those, run dpm localnet remove from a terminal.`, + detail: `dpm localnet remove --name ${name}`, + confirmLabel: "Remove", + danger: true, + })) ) { return; } @@ -162,9 +183,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { await scrubInstance(name); setStopping({ kind: "idle" }); onChanged?.(); - // No setRefetchTick — the entry is gone, the parent's - // refresh will drop this whole card via sel.selected - // changing or the conditional render hiding it. + // No setRefetchTick — the entry is gone; the parent's refresh drops this card. } catch (e) { const msg = e instanceof ApiError ? e.message : "failed to remove"; setStopping({ kind: "err", message: msg }); @@ -174,11 +193,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { useEffect(() => { let cancelled = false; - // Only show the loading placeholder on a true name-change - // mount, not on a refetchTick bump — the latter is a - // background refresh and the cached data is still valid - // until the new fetch resolves. Without this guard, every - // Stop/Remove would briefly blank the detail card. + // Only blank to loading on a name-change mount, not a refetchTick bump. if (refetchTick === 0) { setState({ kind: "loading" }); } @@ -204,41 +219,37 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: R.card, padding: 16, }} >
-
+
Instance detail
- {name} + {name} {state.kind === "ok" && state.instance.live_probe_failed && ( - live probe failed + Live probe failed )} - {/* Status source priority: - 1. statusHint from parent (always-fresh sel.instances row) - 2. state.instance.status (this card's own fetch) - This keeps the action button accurate the instant the - dashboard refreshes after Stop, without waiting for - this card's own refetch to settle. */} {(statusHint || state.kind === "ok") && ( - Stop failed: {stopping.message} + Action failed: {stopping.message}
)} - {state.kind === "loading" && ( -
Loading…
+ {state.kind === "ok" && unreachableUIs(state.instance).length > 0 && ( +
+ {unreachableUIs(state.instance) + .map((e) => e.label) + .join(", ")}{" "} + not serving HTTP. Usually a stale port overlay from an instance + created by an older DevKit. Use Recreate (or re-run{" "} + + dpm localnet up --name {name} + + ) to regenerate its overlays. +
)} + + {state.kind === "loading" && showSkeleton && } {state.kind === "err" && ( -
{state.error}
+
{state.error}
)} {state.kind === "ok" && } - {/* Backup & restore lives inside the detail card so - the instance-name context is implicit. Renders even on - loading/error so the user can still take a snapshot of a - mostly-broken instance for support tickets. */} + {/* Rendered even on loading/error so a broken instance can still be snapshotted. */}

); } function DetailGrid({ instance }: { instance: Instance }) { - // Field order mirrors the mockup's "About this instance" card: - // identity first, then runtime, then on-disk locations. - const rows: Array<[string, React.ReactNode]> = [ - ["splice", instance.splice_version], - ["status", instance.status], - ["created", instance.created_at], - ["uptime", instance.uptime ?? "—"], - ["compose project", instance.compose_project], - ["docker network", instance.docker_network], - ["container prefix", instance.container_prefix], - ["project dir", instance.project_dir], - ["data dir", instance.data_dir], + // `mono` marks machine-string rows so prose values (status/uptime) stay proportional. + const rows: Array<[string, React.ReactNode, boolean]> = [ + ["splice", instance.splice_version, true], + ["status", , false], + ["created", instance.created_at, true], + ["uptime", instance.uptime ?? "—", false], + ["compose project", instance.compose_project, true], + ["docker network", instance.docker_network, true], + ["container prefix", instance.container_prefix, true], + ["project dir", instance.project_dir, true], + ["data dir", instance.data_dir, true], ]; return ( @@ -302,13 +332,20 @@ function DetailGrid({ instance }: { instance: Instance }) { gridTemplateColumns: "160px 1fr", rowGap: 6, columnGap: 16, - fontSize: 12.5, + fontSize: fs.meta, }} > - {rows.map(([k, v]) => ( -
+ {rows.map(([k, v, mono]) => ( +
{k}
-
+
{v}
@@ -317,30 +354,34 @@ function DetailGrid({ instance }: { instance: Instance }) { ); } -// ActionButton dispatches the right verb(s) per instance status. -// Registry status alone isn't enough — docker truth may diverge -// (the ContainerHealth panel shows this). Specifically: -// -// - running → Stop (containers are live by definition) -// - failed/partial → Stop + Remove (containers MAY still be up -// — the orchestrator gave up but docker -// compose down is the right cleanup; if no -// project exists docker no-ops cleanly) -// - stopped → Remove only (containers definitely gone) -// - creating → no button (CreatingPanel owns that surface) -// - other → no button (defensive) -// -// The Stop variant on failed/partial is labeled "Stop containers" -// (distinct from "Stop" on running) so the user knows it's a -// force-cleanup rather than a graceful shutdown of a healthy -// instance. The wording difference also matters because docker -// compose down with --volumes is destructive — surface it -// explicitly when the registry's been lying. +function DetailGridLoading() { + return ( +
+ {Array.from({ length: 6 }).map((_, r) => ( +
+ + +
+ ))} +
+ ); +} + +// Dispatches verbs per status; on failed/partial containers MAY still be +// up (compose down no-ops cleanly if not), so Down is offered there too. function ActionButton({ status, busy, onStart, onStop, + onDown, onPause, onResume, onRemove, @@ -350,151 +391,131 @@ function ActionButton({ busy: boolean; onStart: () => void; onStop: () => void; + onDown: () => void; onPause: () => void; onResume: () => void; onRemove: () => void; onRecreate: () => void; }) { - // Recreate is offered alongside the existing controls on every - // non-transitional status: running, paused, failed, partial. The - // `creating` and `stopping` statuses are in-flight transitions - // where ActionButton renders nothing (the CreatingPanel and the - // disabled-by-busy guard cover those), so the Recreate button is - // implicitly hidden during those phases. - if (status === "running") { + if (status === "running" || status === "paused") { return (
- - + ) : ( + + )} + - -
- ); - } - if (status === "paused") { - return ( -
- - - + {busy ? "…" : "Down"} +
); } if (status === "failed" || status === "partial") { - // Both Stop and Remove — docker may still have live containers - // even though the registry gave up. Recreate is also offered: - // failed/partial often comes from a transient compose hiccup - // that a clean down + up sequence resolves without losing the - // instance metadata. return (
- - - + {busy ? "Removing…" : "Remove entry"} +
); } if (status === "stopped") { - // Start sits next to Remove so a user who came back to a - // stopped instance can resume it without bouncing to the - // terminal. The Start path is the dedicated POST /up - // endpoint — reuses the recorded version + ports, won't - // silently upgrade. return (
- - + + {busy ? "Removing…" : "Remove entry"} +
); } return null; } - -function btnStyle(accent: string, busy: boolean): React.CSSProperties { - return { - background: "transparent", - color: busy ? W.dim : accent, - border: `1px solid ${busy ? W.dim : accent}`, - borderRadius: 6, - padding: "4px 12px", - fontSize: 11.5, - fontWeight: 600, - cursor: busy ? "wait" : "pointer", - }; -} diff --git a/frontend/src/screens/MetricsScreen.test.tsx b/frontend/src/screens/MetricsScreen.test.tsx new file mode 100644 index 00000000..b188f20b --- /dev/null +++ b/frontend/src/screens/MetricsScreen.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { Q } from "./MetricsScreen"; + +describe("Q.cmdLatency", () => { + // Guards the command-latency panel that replaced the (never-populating on + // 0.6.x) submit-to-commit heatmap: it must be a real mean (sum/count) in + // ms per node — NOT a histogram_quantile, which is NaN on 0.6.x's + // +Inf-only histogram. + it("is a per-node sum/count average in ms, not a percentile", () => { + const q = Q.cmdLatency; + const base = "daml_participant_api_commands_submissions_duration_seconds"; + expect(q).toContain(`${base}_sum`); + expect(q).toContain(`${base}_count`); + expect(q).toContain("by (node)"); + expect(q).toContain("/"); // a ratio + expect(q.startsWith("1000")).toBe(true); // seconds -> ms + expect(q).not.toContain("histogram_quantile"); + }); +}); diff --git a/frontend/src/screens/MetricsScreen.tsx b/frontend/src/screens/MetricsScreen.tsx index 49d355a2..40dd33f2 100644 --- a/frontend/src/screens/MetricsScreen.tsx +++ b/frontend/src/screens/MetricsScreen.tsx @@ -8,80 +8,48 @@ import { type PrometheusRangeResponse, } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { IcX } from "../components/icons"; import { MetricCard } from "../components/MetricCard"; import { AreaChart } from "../components/charts/AreaChart"; import { MultiLine } from "../components/charts/MultiLine"; import { BarChart, type Bar } from "../components/charts/BarChart"; -import { Heatmap, type Cell } from "../components/charts/Heatmap"; import { CHART_PALETTE, decodePrometheusRange, type Series, } from "../components/charts/types"; -// MetricsScreen — production layout. -// -// Matches docs/design/mockups/webui-metrics-agent.jsx: -// - 4-up MetricCard strip (Throughput, Command completion p99, -// Active contracts, Errors) with delta vs prior window + -// inline sparkline -// - Six chart cards in a 2-col grid: -// Latency by phase · Per-template throughput -// Active contracts trend · Ledger errors -// Resource usage · Submit-to-commit heatmap -// - "Top error sources" full-width bar chart at the bottom -// -// Every card hits a real PromQL query via /api/instances/:name/ -// metrics/range. Loading / empty / error states are first-class. -// Auto-refresh every 5 s (cheap — the cards run in parallel). -// -// When the observability profile isn't enabled the screen renders -// the same friendly empty-state panel as before. - interface CardState { kind: "loading" | "ok" | "err"; data?: T; error?: string; } -// PromQL queries. Sourced from internal/metricsq for parity with the -// CLI's `localnet metrics` headline. Per-template / phase / heatmap -// queries are extensions specific to this screen. -// -// All metric names are the daml_* / db_client_* family the Splice -// OTel reporter actually emits (verified against a live obs profile). -// The earlier `canton_*` names were aspirational and silently -// returned no data. See queries.go + docs/observability.md for the -// substitute mapping rationale. -// -// A handful of the per-screen extensions below (errors rate, per- -// template throughput) do not have a direct daml_* equivalent on -// Splice 0.6.4 — substitutes are the closest functional analogue, -// marked inline. A focused follow-up (see docs/observability.md -// "Metric-name follow-ups") will revisit when those exposures land. -const Q = { +export const Q = { // Substitute: indexer-update counter, same as HeadlineLedgerTPS. throughputSeries: "sum(rate(daml_participant_api_indexer_updates[1m])) or vector(0)", - p99: 'histogram_quantile(0.99, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))', - // Live Splice does not expose total ACS cardinality as a stock - // Prometheus metric. This is the audited ACS-related signal that - // exists in 0.6.4; keep UI copy honest and call it a lookup buffer. + // 0.6.4 exports this histogram with only the +Inf bucket, so + // histogram_quantile is NaN; use the mean (sum/count), x1000 -> ms. + avgLatency: + "1000 * sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum[5m])) / sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count[5m]))", + // Participant/ledger-API command latency (submission → completion), ms + // per node. A real average (sum/count) — Canton's documented command + // duration signal, computable where the percentile buckets aren't. + cmdLatency: + "1000 * sum by (node) (rate(daml_participant_api_commands_submissions_duration_seconds_sum[5m])) / sum by (node) (rate(daml_participant_api_commands_submissions_duration_seconds_count[5m]))", + // No total-ACS-cardinality metric on 0.6.4 (old proxy gone); the + // active-contracts buffer gauge is the closest present signal. acsLookupBuffer: - "sum(daml_participant_api_index_db_active_contract_lookup_batch_buffer_length)", - // No daml_* command-rejection counter on Splice 0.6.4 — use the - // user-error completion-status counter as a proxy for "things - // the participant refused to commit". Returns 0 if not exposed. + "sum(daml_participant_api_index_active_contracts_buffer_size)", + // No command-rejection counter on 0.6.4; the non-OK gRPC completion + // counter is the substitute for refused commands. 0 if not exposed. errorsRate: 'sum(rate(daml_grpc_server_handled_total{grpc_code!="OK"}[1m])) or vector(0)', - latencyMedian: - 'histogram_quantile(0.50, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))', - latencyP99: - 'histogram_quantile(0.99, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))', - // Splice 0.6.x does not expose template-grain submission counters. - // Use the live gRPC method counter as a command-throughput fallback - // instead of querying a non-existent `daml_commands_*` family. + // No template-grain submission counters on 0.6.x; the gRPC method + // counter is the command-throughput substitute. commandThroughput: "sum by (grpc_method_name) (rate(daml_grpc_server_handled_total[5m]))", errors1m: @@ -92,15 +60,10 @@ const Q = { 'sum by (component) (jvm_memory_used_bytes{jvm_memory_type="heap"})', }; -// scopeQ injects instance="" into every metric selector of a chart -// query when the summary reports a scope — i.e. when this instance is -// served by the shared multi-instance Prometheus, so a chart shows -// one instance, not the sum across all of them. It targets our known -// metric-name prefixes, so it never touches function names (sum, rate, -// histogram_quantile) or `by (...)` label lists, and composes with a -// metric's existing label without an invalid trailing comma. An empty -// scope (the single-instance per-instance Prometheus) returns the query -// unchanged. +// Injects instance="" into each metric selector so a chart shows +// one instance on the shared multi-instance Prometheus. Matches only +// metric-name prefixes, so it skips function names and `by (...)` lists; +// empty scope returns the query unchanged. export function scopeQ(query: string, scope: string): string { if (!scope) return query; const inst = `instance="${scope}"`; @@ -113,10 +76,10 @@ export function scopeQ(query: string, scope: string): string { ); } -const TPS_COLOR = "#7CB5F7"; -const P99_COLOR = "#F5BF55"; -const ACS_COLOR = "#5BD7C5"; -const ERR_COLOR = "#F08FB5"; +const TPS_COLOR = "#8FA3EE"; +const LATENCY_COLOR = "#DDB25E"; +const ACS_COLOR = "#6480E6"; +const ERR_COLOR = "#7BD2C6"; export function MetricsScreen() { const sel = useInstanceSelection(); @@ -128,7 +91,7 @@ export function MetricsScreen() { const [throughputSeries, setThroughputSeries] = useState>({ kind: "loading", }); - const [p99Series, setP99Series] = useState>({ + const [latencySeries, setLatencySeries] = useState>({ kind: "loading", }); const [acsSeries, setAcsSeries] = useState>({ @@ -146,32 +109,22 @@ export function MetricsScreen() { const [cpuSeries, setCpuSeries] = useState>({ kind: "loading", }); - const [heatmap, setHeatmap] = useState>({ kind: "loading" }); + const [cmdLatency, setCmdLatency] = useState>({ + kind: "loading", + }); const [topErrors, setTopErrors] = useState>({ kind: "loading", }); useEffect(() => { if (!name) return; - // AbortController. The previous `cancelled` boolean was passed BY - // VALUE to each loader, so flipping it on unmount didn't reach - // in-flight loaders that had already started — they would resolve - // and setState on a dead component. AbortSignal solves both: - // fetch aborts mid-flight and loaders short-circuit on - // signal.aborted. - // - // We also gate polling on document.visibilityState: no point - // hammering Prometheus when the tab is hidden. let outer: AbortController | null = null; const tick = async () => { - // Abort the prior tick's in-flight requests before starting a - // new one — otherwise a slow Prometheus query from t=0 could - // resolve after the t=5s query and clobber it. + // Abort the prior tick's in-flight requests — a slow query from + // t=0 must not resolve after the t=5s query and clobber it. outer?.abort(); outer = new AbortController(); const signal = outer.signal; - // Instance label to scope the chart queries by — set when the - // summary reports we're reading the shared multi-instance stack. let scope = ""; try { const s = await fetchMetricsSummary(name, signal); @@ -198,15 +151,12 @@ export function MetricsScreen() { } await Promise.all([ loadSeries(name, scopeQ(Q.throughputSeries, scope), "tx/s", setThroughputSeries, signal), - loadSeries(name, scopeQ(Q.p99, scope), "p99", setP99Series, signal), + loadSeries(name, scopeQ(Q.avgLatency, scope), "avg latency", setLatencySeries, signal), loadSeries(name, scopeQ(Q.acsLookupBuffer, scope), "ACS lookup buffer", setAcsSeries, signal), loadSeries(name, scopeQ(Q.errorsRate, scope), "errors", setErrorsSeries, signal), loadMultiSeries( name, - [ - { query: scopeQ(Q.latencyMedian, scope), label: "median", color: CHART_PALETTE[1] }, - { query: scopeQ(Q.latencyP99, scope), label: "p99", color: CHART_PALETTE[3] }, - ], + [{ query: scopeQ(Q.avgLatency, scope), label: "avg", color: CHART_PALETTE[1] }], setLatencyPhase, signal, ), @@ -231,10 +181,11 @@ export function MetricsScreen() { setCpuSeries, signal, ), - loadHeatmap( + loadMultiSeriesGrouped( name, - scopeQ('sum(increase(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[1m])) by (le)', scope), - setHeatmap, + scopeQ(Q.cmdLatency, scope), + (m) => m.node ?? "node", + setCmdLatency, signal, ), ]); @@ -250,22 +201,22 @@ export function MetricsScreen() { }; }, [name]); - // Memoize the 4 delta calls. MUST sit ABOVE every conditional - // return below so hook order is stable across the - // (!name) and (observabilityOff) early-exit paths — rules of - // hooks. Without memo the body of deltaFromSeries (walks the - // series, computes time deltas, runs comparisons) ran four times - // per render and we re-render at least every 5s when polling. + // These memos must sit above every conditional return so hook order + // is stable across the early-exit paths (rules of hooks). const tpsDelta = useMemo(() => deltaFromSeries(throughputSeries.data), [throughputSeries.data]); - const p99Delta = useMemo(() => deltaFromSeries(p99Series.data, 1000), [p99Series.data]); + const latencyDelta = useMemo(() => deltaFromSeries(latencySeries.data), [latencySeries.data]); const acsDelta = useMemo(() => deltaFromSeries(acsSeries.data), [acsSeries.data]); const errDelta = useMemo(() => deltaFromSeries(errorsSeries.data), [errorsSeries.data]); if (!name) { return ( -
-

- No instance selected. Create or pick one from the dashboard first. +

+

+ No instance selected. +

+

+ Pick an instance from the topbar switcher, or create one from + Overview.

); @@ -278,10 +229,6 @@ export function MetricsScreen() { { - // Clearing the empty-state re-runs the effect via the - // `observabilityOff` dependency; the next tick will - // start pulling metrics from the newly-running - // Prometheus. setObservabilityOff(null); }} /> @@ -290,22 +237,20 @@ export function MetricsScreen() { } const m = summary.data?.metrics; - const p99Value = - summary.kind === "ok" && summary.data - ? (summary.data.latency?.p99_ms ?? Number.NaN) - : undefined; + // Backend p99_ms is NaN on 0.6.4 (no finite buckets); use the + // computable average series' latest point, already in ms. + const latencyValue = latencySeries.data?.points.at(-1)?.v; return (
- {/* 4-up top strip */}
({ t: p.t, v: p.v * 1000 }))} - sparklineColor={P99_COLOR} - error={p99Series.kind === "err" ? p99Series.error : undefined} - delta={p99Delta} + value={latencyValue} + sparkline={latencySeries.data?.points} + sparklineColor={LATENCY_COLOR} + error={latencySeries.kind === "err" ? latencySeries.error : undefined} + delta={latencyDelta} deltaPolarity="down-is-good" format={(v) => (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(1))} /> @@ -353,16 +298,15 @@ export function MetricsScreen() { />
- {/* 2-col chart grid */}
- + {latencyPhase.kind === "err" ? ( ) : ( @@ -370,7 +314,7 @@ export function MetricsScreen() { series={latencyPhase.data ?? []} width={420} height={170} - format={(v) => (v >= 1 ? v.toFixed(2) + "s" : (v * 1000).toFixed(0) + "ms")} + format={(v) => (v >= 1000 ? (v / 1000).toFixed(2) + "s" : v.toFixed(0) + "ms")} /> )} @@ -415,7 +359,7 @@ export function MetricsScreen() { ) : null} - + {cpuSeries.kind === "err" ? ( ) : ( @@ -429,34 +373,36 @@ export function MetricsScreen() { - {heatmap.kind === "err" ? ( - + {cmdLatency.kind === "err" ? ( + ) : ( - 2s"]} + (v >= 1000 ? (v / 1000).toFixed(2) + "s" : v.toFixed(0) + "ms")} /> )}
- {/* Latency headline triplet — mirrors `dpm localnet metrics` - text output so CLI and UI agree on the curated quantiles. */} - + {/* Percentiles are NaN on 0.6.4 (+Inf-only histogram); hide the + strip rather than show three dashes. Reappears with finite buckets. */} + {[ + summary.data?.latency?.p50_ms, + summary.data?.latency?.p95_ms, + summary.data?.latency?.p99_ms, + ].some((v) => typeof v === "number" && Number.isFinite(v)) && ( + + )} - {/* Top error sources — full width */} {topErrors.kind === "err" ? ( @@ -470,18 +416,11 @@ export function MetricsScreen() { )} - {/* Dashboards — deep link to the bundled Grafana view. Same - UID the CLI's text output prints; per AGENTS.md CLI ↔ UI - parity rule the two surfaces must point at the same view. */}
); } -// LatencyStrip is the in-page reminder of the three quantiles -// `dpm localnet metrics` also prints. The 4-up MetricCard row shows -// p99 specifically; surfacing p50/p95 next to it makes the SLA -// shape visible at a glance. function LatencyStrip(props: { p50?: number; p95?: number; @@ -492,9 +431,10 @@ function LatencyStrip(props: { padding: "10px 14px", background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 6, + borderRadius: R.control, fontFamily: wMono, - fontSize: 13, + fontSize: fs.data, + fontVariantNumeric: "tabular-nums", color: W.text, }; const label: CSSProperties = { @@ -507,7 +447,7 @@ function LatencyStrip(props: { display: "grid", gridTemplateColumns: "repeat(3, max-content)", gap: 12, - marginBottom: 14, + marginBottom: 16, }} >
@@ -526,19 +466,15 @@ function LatencyStrip(props: { ); } -// DashboardsBlock surfaces the Grafana deep link returned by the -// summary handler. When the observability profile is off the URL -// is empty — we render the same hint as the CLI rather than hiding -// the section, so users learn the profile exists. function DashboardsBlock(props: { url?: string }) { const wrap: CSSProperties = { - marginTop: 14, + marginTop: 16, padding: "10px 14px", background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 6, + borderRadius: R.control, fontFamily: wMono, - fontSize: 13, + fontSize: fs.data, color: W.text, }; if (!props.url) { @@ -555,7 +491,7 @@ function DashboardsBlock(props: { url?: string }) { ); @@ -563,12 +499,12 @@ function DashboardsBlock(props: { url?: string }) { function Header({ name }: { name: string }) { return ( -
-

+
+

Metrics —{" "} {name}

-

+

Live Canton + Splice metrics scraped from Prometheus. Auto-refresh 5 s.

@@ -589,19 +525,19 @@ function ChartCard({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: 14, display: "flex", flexDirection: "column", minWidth: 0, }} > -
-
+
+
{title}
{subtitle && ( -
+
{subtitle}
)} @@ -613,8 +549,23 @@ function ChartCard({ function ErrLine({ msg }: { msg: string }) { return ( -
- {msg} +
+
Query failed. Retrying every 5 s.
+
+ + Server message + +
+ {msg} +
+
); } @@ -633,12 +584,7 @@ function ObservabilityOffPanel({ setBusy(true); setErr(null); try { - // Per-component fields are the canonical shape on the server; - // we send BOTH because the Metrics screen needs Prometheus - // (for data) AND Grafana (for the embedded dashboards). Routed - // through the typed setObservability helper so the apiFetch - // chokepoint's envelope decoding + ApiError mapping apply, - // instead of a hand-rolled fetch that re-implemented them. + // Prometheus for data, Grafana for the dashboards link. await setObservability(name, { prometheus: true, grafana: true }); onEnabled(); } catch (e) { @@ -650,16 +596,16 @@ function ObservabilityOffPanel({ return (
-

+

Observability profile not enabled

-

+

Instance{" "} {name}{" "} was started without the observability profile. Prometheus and Grafana @@ -667,36 +613,26 @@ function ObservabilityOffPanel({

- - + + Brings up Prometheus + Grafana on this instance without restarting Canton.
{err && ( -
- ✗ {err} +
+ + {err} +
)} -

+

Or from the CLI (same hot toggle, no restart):{" "} {`dpm localnet observability enable --name ${name}`} @@ -714,10 +650,6 @@ function ObservabilityOffPanel({ ); } -// ── Loaders ────────────────────────────────────────────────────── - -// isAborted treats an AbortError thrown by fetch the same as the -// signal being already aborted at the moment we check it. function isAborted(signal: AbortSignal, e: unknown): boolean { if (signal.aborted) return true; return e instanceof DOMException && e.name === "AbortError"; @@ -814,7 +746,6 @@ async function loadBars( r as unknown as PrometheusRangeResponse, labelFn, ); - // For a "right now" bar chart we just want the latest value per series. const bars: Bar[] = decoded .map((s, i) => ({ label: s.label, @@ -834,62 +765,10 @@ async function loadBars( } } -async function loadHeatmap( - name: string, - query: string, - set: (s: CardState) => void, - signal: AbortSignal, -) { - try { - const r = await fetchMetricsRange(name, query, "1h", "1m", signal); - if (signal.aborted) return; - const decoded = decodePrometheusRange( - r as unknown as PrometheusRangeResponse, - (m) => m.le ?? "+Inf", - ); - // Map le buckets to row indices (6 rows: <5ms, <25ms, <100ms, - // <500ms, <2s, >2s). Skip series we don't have a row for. - const rowFor = (le: string): number | null => { - const n = Number(le); - if (!Number.isFinite(n)) return 5; // +Inf - if (n <= 0.005) return 0; - if (n <= 0.025) return 1; - if (n <= 0.1) return 2; - if (n <= 0.5) return 3; - if (n <= 2) return 4; - return 5; - }; - // Determine global max for normalisation. - let max = 0; - for (const s of decoded) { - for (const p of s.points) { - if (p.v > max) max = p.v; - } - } - if (max === 0) max = 1; - const cells: Cell[] = []; - for (const s of decoded) { - const r = rowFor(s.label); - if (r === null) continue; - s.points.forEach((p, c) => { - cells.push({ r, c, i: p.v / max }); - }); - } - set({ kind: "ok", data: cells }); - } catch (e) { - if (isAborted(signal, e)) return; - set({ - kind: "err", - error: e instanceof ApiError ? e.message : "failed", - }); - } -} - -// deltaFromSeries: latest minus the value 5 minutes back. +// Latest value minus the point nearest 5 minutes back. function deltaFromSeries(s: Series | undefined, scale = 1): number | undefined { if (!s || s.points.length < 2) return undefined; const last = s.points[s.points.length - 1].v * scale; - // 5 minutes back in points: assume step is consistent; find nearest. const targetT = s.points[s.points.length - 1].t - 5 * 60 * 1000; let nearest = s.points[0]; let nd = Math.abs(s.points[0].t - targetT); diff --git a/frontend/src/screens/Placeholder.tsx b/frontend/src/screens/Placeholder.tsx index 3a38ff74..bba29fae 100644 --- a/frontend/src/screens/Placeholder.tsx +++ b/frontend/src/screens/Placeholder.tsx @@ -1,25 +1,24 @@ -import { W } from "../tokens"; +import { W, R, fs } from "../tokens"; -// Placeholder — the route stub for screens whose backend hasn't -// landed yet. Swap the route in App.tsx to the real screen component -// as each one becomes available. +// 404 page for the `path="*"` catch-all — the only place this renders. export function Placeholder({ name }: { name: string }) { return (

-

{name}

-

- Not implemented yet in this build. +

+ {name} +

+

+ That route doesn’t exist. Pick another screen from the sidebar or + press ⌘K.

); diff --git a/frontend/src/screens/Screens.smoke.test.tsx b/frontend/src/screens/Screens.smoke.test.tsx index 8810990a..5125c345 100644 --- a/frontend/src/screens/Screens.smoke.test.tsx +++ b/frontend/src/screens/Screens.smoke.test.tsx @@ -1,24 +1,16 @@ -// Screen-level smoke tests (yellow Y16). +// Screen-level smoke tests. // // Each screen is mounted with a stubbed `fetch` and the // InstanceSelectionProvider primed via an /api/instances seed. The // asserts are deliberately narrow — "did the screen render without -// throwing and surface the expected heading/empty-state" — because -// the heavy lifting (chart shapes, filter logic) lives in -// component-level tests already. The smoke tests exist as a tripwire -// against the kind of regression that yellow B1 was: a stale -// reference that compiles fine but throws at runtime when its code -// path is hit (TimelineView's `hovered` → `focused` rename). A render -// smoke test of TimelineView would have caught that on the first -// run. -// -// We do NOT test the screens' interactive behaviour here — they -// already get a lot of coverage via the component tests -// (charts.test.tsx, BackupRestore.test.tsx, etc.). This is the -// "won't blow up on first paint" guard. +// throwing and surface the expected heading/empty-state" — a tripwire +// for stale references that compile fine but throw at runtime when +// their code path is hit. Interactive behaviour is covered by the +// component-level tests; this is the "won't blow up on first paint" +// guard. import { afterEach, describe, expect, it, vi } from "vitest"; -import { render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import type { ReactNode } from "react"; import { InstanceSelectionProvider } from "../shell/useInstanceSelection"; @@ -42,7 +34,15 @@ import { DoctorScreen } from "./DoctorScreen"; interface InstanceShape { name: string; status?: string; - endpoints?: Array<{ label: string; url: string; port: number; scheme: string }>; + endpoints?: Array<{ + key: string; + label: string; + url: string; + port: number; + scheme: string; + reachability?: "ok" | "unreachable"; + reachability_detail?: string; + }>; } // stubFetch wires the minimum set of endpoints each screen probes. @@ -220,6 +220,7 @@ describe("WalletScreen smoke", () => { name: "demo", endpoints: [ { + key: "app_user_ui", label: "Wallet · app-user", url: "http://wallet.localhost:60470", port: 60470, @@ -237,6 +238,66 @@ describe("WalletScreen smoke", () => { expect(screen.getByText(/Login:/i)).toBeTruthy(); }); }); + + it("replaces the iframe with remediation when the wallet UI is unreachable", async () => { + stubFetch({ + name: "demo", + endpoints: [ + { + key: "app_user_ui", + label: "Wallet · app-user", + url: "http://localhost:4485", + port: 4485, + scheme: "http", + reachability: "unreachable", + reachability_detail: + "connection accepted but no HTTP response (empty reply)", + }, + ], + }); + render( + + + , + ); + await waitFor(() => { + const alert = screen.getByRole("alert"); + expect(alert.textContent).toMatch(/not serving HTTP/i); + expect(alert.textContent).toContain("Recreate"); + expect(alert.textContent).toContain("dpm localnet up --name demo"); + }); + expect(document.querySelector("iframe")).toBeNull(); + }); + + // The fixture label is deliberately not "Wallet · " — wallet + // resolution must match on the stable key, not the display label. + it("resolves the sv wallet by endpoint key, not label", async () => { + stubFetch({ + name: "demo", + endpoints: [ + { + key: "sv_ui", + label: "Some Future Label · sv", + url: "http://wallet.localhost:60472", + port: 60472, + scheme: "http", + }, + ], + }); + const { container } = render( + + + , + ); + await waitFor(() => { + expect(screen.getByRole("button", { name: /sv$/ })).toBeTruthy(); + }); + fireEvent.click(screen.getByRole("button", { name: /sv$/ })); + await waitFor(() => { + const iframe = container.querySelector("iframe"); + expect(iframe?.getAttribute("src")).toBe("http://wallet.localhost:60472"); + }); + }); }); describe("DoctorScreen smoke", () => { diff --git a/frontend/src/screens/TokensScreen.test.tsx b/frontend/src/screens/TokensScreen.test.tsx index 98bcb572..0cba3f5d 100644 --- a/frontend/src/screens/TokensScreen.test.tsx +++ b/frontend/src/screens/TokensScreen.test.tsx @@ -6,18 +6,14 @@ import { InstanceSelectionProvider } from "../shell/useInstanceSelection"; import { TokensScreen, createErrorText } from "./TokensScreen"; import { ApiError } from "../api"; -// TokensScreen tests — minimal smoke that the screen mounts under the -// shared providers App.tsx uses, and that the list/empty paths render. -// The interactive modal flows are exercised by the live UI on a -// running LocalNet, not by unit tests; replicating the InstanceSelection -// + apiFetch + react-router timing here is brittle and low-value. +// TokensScreen tests — smoke that the screen mounts under the shared +// providers and the list/empty/modal paths render. afterEach(() => vi.unstubAllGlobals()); -// holdingsResponse lets a test drive the source banner: pass -// { source: "registry", rows: [...] } to exercise the pseudo-balance -// disclaimer. Defaults to a live empty ACS (no banner) so existing -// callers are unaffected. +// holdingsResponse drives the source banner: pass { source: "registry", +// rows } to exercise the pseudo-balance disclaimer. Defaults to a live +// empty ACS (no banner). function stubFetch( tokens: Array<{ symbol: string; name: string }>, holdingsResponse?: { @@ -44,6 +40,14 @@ function stubFetch( instances: [{ name: "demo", status: "running" }], }); } + if (url.startsWith("/api/tokens/identity")) { + return json({ + schema_version: 1, + instance: "demo", + available_roles: ["app-user", "app-provider", "sv"], + current_role: "app-user", + }); + } if (url.startsWith("/api/tokens/matrix")) { return json({ schema_version: 1, @@ -51,9 +55,16 @@ function stubFetch( parties: ["alice::abc", "bob::def"], instruments: [ { admin: "alice::abc", instrument_id: "RTK", symbol: "RTK", standard: "CIP-0112 v2", on_ledger: true }, + { admin: "alice::abc", instrument_id: "GOV", symbol: "GOV", standard: "CIP-0112 v2", on_ledger: true }, + ], + cells: [ + { party: "bob::def", instrument_id: "RTK", amount: "1275.0" }, + { party: "alice::abc", instrument_id: "GOV", amount: "42.0" }, + ], + totals: [ + { party: "", instrument_id: "RTK", amount: "1275.0" }, + { party: "", instrument_id: "GOV", amount: "42.0" }, ], - cells: [{ party: "bob::def", instrument_id: "RTK", amount: "1275.0" }], - totals: [{ party: "", instrument_id: "RTK", amount: "1275.0" }], }, }); } @@ -77,22 +88,23 @@ function stubFetch( }); } if (url.startsWith("/api/tokens/") && url.includes("/activity")) { - return json({ - schema_version: 1, - events: [ - { - offset: 1106, update_id: "u1106", record_time: "2026-05-30T16:50:45Z", - instrument_id: "RTK", kind: "mint", amount: "1000", - receivers: [{ party: "bob::def", amount: "1000" }], - }, - { - offset: 1200, update_id: "u1200", record_time: "2026-05-30T17:00:00Z", - instrument_id: "RTK", kind: "transfer", amount: "100", - senders: [{ party: "bob::def", amount: "100" }], - receivers: [{ party: "alice::abc", amount: "100" }], - }, - ], - }); + // Honour ?limit so pagination tests can drive a full-page → "Load + // more" state: synthesize `limit` newest-first rows. + const limit = Number(new URL(url, "http://x").searchParams.get("limit") ?? "50"); + const pool = 120; // more rows than one page → "Load more" appears + const n = Math.min(limit, pool); + const events = Array.from({ length: n }, (_, i) => ({ + offset: 2000 - i, // descending → newest first + update_id: `u${2000 - i}`, + record_time: "2026-05-30T17:00:00Z", + instrument_id: "RTK", + kind: i === 0 ? "mint" : "transfer", + source: "event_log", + amount: "100", + senders: i === 0 ? undefined : [{ party: "bob::def", amount: "100" }], + receivers: [{ party: "alice::abc", amount: "100" }], + })); + return json({ schema_version: 1, events, truncated: false }); } if (url.startsWith("/api/tokens/") && url.includes("/summary")) { return json({ @@ -220,7 +232,7 @@ describe("TokensScreen", () => { const user = userEvent.setup(); stubFetch([{ symbol: "RTK", name: "Retail Token" }]); renderTokens(); - await user.click(await screen.findByRole("button", { name: "→ Transfer" }, { timeout: 4000 })); + await user.click(await screen.findByRole("button", { name: "Transfer" }, { timeout: 4000 })); await waitFor( () => expect(screen.queryByText(/Auto-accept/i)).toBeInTheDocument(), { timeout: 4000 }, @@ -231,7 +243,7 @@ describe("TokensScreen", () => { const user = userEvent.setup(); stubFetch([{ symbol: "RTK", name: "Retail Token" }]); renderTokens(); - await user.click(await screen.findByRole("button", { name: "→ Transfer" }, { timeout: 4000 })); + await user.click(await screen.findByRole("button", { name: "Transfer" }, { timeout: 4000 })); // From is now a PartyPicker dropdown; pick a registered party, then // fill Amount → the dry-run plan fires (debounced). await user.selectOptions( @@ -304,6 +316,52 @@ describe("TokensScreen", () => { expect(screen.queryAllByText(/transfer/i).length).toBeGreaterThan(0); }); + // Newest-first + paginated: a "Showing N … newest first" affordance + // and a "Load more" that grows the requested limit. + it("paginates the Activity feed with a Load more button", async () => { + const user = userEvent.setup(); + stubFetch([{ symbol: "RTK", name: "Retail Token" }]); + renderTokens(); + await user.click(await screen.findByRole("button", { name: /^activity$/i }, { timeout: 4000 })); + // First page: the "showing N, newest first" count is present. + await waitFor( + () => expect(screen.queryByText(/newest first/i)).toBeInTheDocument(), + { timeout: 4000 }, + ); + expect(screen.queryByText(/Showing 50 movements/i)).toBeInTheDocument(); + // A full page → the feed offers "Load more"; clicking it grows the + // page and re-requests a larger limit (stub caps at 120). + const loadMore = await screen.findByRole("button", { name: /Load more/i }); + await user.click(loadMore); + await waitFor( + () => expect(screen.queryByText(/Showing 100 movements/i)).toBeInTheDocument(), + { timeout: 4000 }, + ); + }); + + it("offers a copy-party-id control per row in the party manager", async () => { + const user = userEvent.setup(); + stubFetch([{ symbol: "RTK", name: "Retail Token" }]); + renderTokens(); + await user.click(await screen.findByRole("button", { name: /Parties/i }, { timeout: 4000 })); + // Each registered party row exposes an accessible "Copy party id …" + // button that copies the FULL alias::fingerprint id. + const copyBtn = await screen.findByRole( + "button", + { name: /copy party id for treasury/i }, + { timeout: 4000 }, + ); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { ...navigator, clipboard: { writeText } }); + await user.click(copyBtn); + expect(writeText).toHaveBeenCalledWith("bob::def"); + // Feedback flips the label to "Copied!". + await waitFor( + () => expect(screen.queryAllByText(/Copied!/i).length).toBeGreaterThan(0), + { timeout: 2000 }, + ); + }); + it("switches to the Holdings matrix lens and renders the pivot", async () => { const user = userEvent.setup(); stubFetch([{ symbol: "RTK", name: "Retail Token" }]); @@ -329,6 +387,36 @@ describe("TokensScreen", () => { ); }); + it("filters the matrix to one token's column via the chips", async () => { + const user = userEvent.setup(); + stubFetch([ + { symbol: "RTK", name: "Retail Token" }, + { symbol: "GOV", name: "Gov Token" }, + ]); + renderTokens(); + await user.click(await screen.findByRole("button", { name: /Holdings matrix/i }, { timeout: 4000 })); + // the All-tokens + per-token chips render + await waitFor( + () => expect(screen.getByRole("button", { name: "All tokens" })).toBeInTheDocument(), + { timeout: 4000 }, + ); + // filter to GOV → its cell shows, RTK's column drops out + await user.click(screen.getByRole("button", { name: "GOV" })); + await waitFor( + () => expect(screen.queryAllByText("42.0").length).toBeGreaterThan(0), + { timeout: 4000 }, + ); + expect(screen.queryByText("1275.0")).not.toBeInTheDocument(); + // All tokens → the full grid returns (RTK's 1275.0 shows in both its + // cell and the Σ total row, hence queryAllByText) + await user.click(screen.getByRole("button", { name: "All tokens" })); + await waitFor( + () => expect(screen.queryAllByText("1275.0").length).toBeGreaterThan(0), + { timeout: 4000 }, + ); + expect(screen.queryAllByText("42.0").length).toBeGreaterThan(0); + }); + it("opens the party manager and lists registered aliases", async () => { const user = userEvent.setup(); stubFetch([{ symbol: "RTK", name: "Retail Token" }]); @@ -360,6 +448,48 @@ describe("TokensScreen", () => { expect(screen.queryByText(/on-ledger holdings/i)).toBeInTheDocument(); }); + // Selecting "app-provider" must re-plumb the role: the screen refetches + // with role=app-provider; before the switch, calls omit role (app-user default). + it("threads the selected identity through token API calls", async () => { + const user = userEvent.setup(); + stubFetch([{ symbol: "RTK", name: "Retail Token" }]); + renderTokens(); + + const fetchMock = global.fetch as unknown as ReturnType; + const calledWith = (needle: string) => + fetchMock.mock.calls.some(([u]) => String(u).includes(needle)); + + // Instruments load under the default identity first. + await waitFor( + () => expect(screen.queryAllByText(/Retail Token/).length).toBeGreaterThan(0), + { timeout: 4000 }, + ); + // Default calls carry no explicit role (app-user is the omitted default). + expect(calledWith("role=app-provider")).toBe(false); + + // Switch identity via the header segmented control. + await user.click( + await screen.findByRole("button", { name: /app-provider/i }, { timeout: 4000 }), + ); + + // The switch triggers a refetch of the token list under the new role. + await waitFor( + () => expect(calledWith("/api/tokens?instance=demo&role=app-provider")).toBe(true), + { timeout: 4000 }, + ); + }); + + it("populates the switcher from GET /api/tokens/identity", async () => { + stubFetch([{ symbol: "RTK", name: "Retail Token" }]); + renderTokens(); + // available_roles from the identity endpoint render as switcher buttons. + await waitFor( + () => expect(screen.getByRole("button", { name: /app-provider/i })).toBeInTheDocument(), + { timeout: 4000 }, + ); + expect(screen.getByRole("button", { name: /^sv$/i })).toBeInTheDocument(); + }); + it("does NOT show the disclaimer when holdings are live on-ledger", async () => { stubFetch( [{ symbol: "RTK", name: "Retail Token" }], @@ -378,7 +508,7 @@ describe("TokensScreen", () => { // createErrorText: the on-ledger create 412 (TEST_TOKEN_DAR_UNAVAILABLE) // must surface the actionable token-standard-v2 remedy in the create // modal, not the raw backend message — matching the NEEDS_V2_LOCALNET -// remediation pattern (#169 UI-parity fix). +// remediation pattern. describe("createErrorText", () => { it("maps the on-ledger DAR 412 to the token-standard-v2 remedy", () => { const e = new ApiError(412, { diff --git a/frontend/src/screens/TokensScreen.tsx b/frontend/src/screens/TokensScreen.tsx index d7de1555..b72fb729 100644 --- a/frontend/src/screens/TokensScreen.tsx +++ b/frontend/src/screens/TokensScreen.tsx @@ -3,11 +3,15 @@ import { ApiError, acceptTransfer, aliasMapFrom, + allocateToken, burnToken, + cancelAllocation, createParty, createToken, faucetToken, + fetchAllocations, launchDemoToken, + fetchTokenIdentity, fetchActivity, fetchHoldingContracts, fetchParties, @@ -19,63 +23,77 @@ import { mintToken, planTransfer, transferToken, + withdrawAllocation, type ActivityEvent, type AliasMap, + type AllocationSummary, type BalanceMatrix, type HoldingSource, type PartyRef, type HoldingContract, type InstrumentRef, type InstrumentSummary, + type Role, type TokenHolding, type TokenRef, } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { W, wMono } from "../tokens"; +import { useIdentityRole } from "../shell/useIdentityRole"; +import { W, wMono, tableCaps, wideCaps, tint, R, FAST, fs, ROLE_COLOR } from "../tokens"; +import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; +import { CopyPartyId } from "../components/CopyPartyId"; +import { + Dot, + IcArrowRight, + IcArrowUp, + IcBolt, + IcCheck, + IcChevronDown, + IcChevronRight, + IcDroplet, + IcFlame, + IcPlus, + IcX, +} from "../components/icons"; + +// Fallback identity list; the real set comes from GET +// /api/tokens/identity. Seeds the switcher for the first render and +// covers a fetch failure. Default (app-user) first. +const IDENTITY_ROLES: Role[] = ["app-user", "app-provider", "sv"]; + +// Activity feed page size; "Load more" grows the limit by this step. +const ACTIVITY_PAGE = 50; -// shortParty trims a fingerprinted id to its readable prefix for display. function shortParty(p: string): string { const i = p.indexOf("::"); return i > 0 ? p.slice(0, i) : p; } -// partyLabel prefers a registered alias over the raw prefix: -// `app_user_v2-localparty-1::1220…` → `app-user` when aliased, else the -// `::`-prefix fallback. +// Prefers a registered alias over the `::`-prefix fallback. function partyLabel(aliases: AliasMap, p: string): string { return aliases[p] ?? shortParty(p); } -// Asset capability guards — gated on the machine generation tag, never -// the human display label: -// mint : only a native Token Standard V2 (CIP-0112) instrument we -// created on-ledger. V1 tokens (Amulet) have no user-mint. -// burn : no deployable token supports a standalone burn yet (needs -// AllocationV2/DvP). +// Capability guard keyed on the machine generation tag, not the display +// label: mint requires a native V2 (CIP-0112) instrument created on-ledger. export function mintDisabledReason(t: InstrumentRef): string | null { if (t.generation !== "v2") - return `${t.symbol} (${t.standard}) has no standard mint — use the asset's wallet UI`; + return `${t.symbol} (${t.standard}) has no standard mint. Use the asset's wallet UI.`; if (!t.on_ledger) - return `${t.symbol} is recorded only — create it on-ledger first`; + return `${t.symbol} is recorded only. Create it on-ledger first.`; return null; } const BURN_DISABLED_REASON = "Burn is only available on a native CIP-0112 v2 token created on this " + - "instance — Amulet has no burn surface."; + "instance. Amulet has no burn surface."; -// TOKEN_DAR_UNAVAILABLE_HINT is the friendly remediation for the on-ledger -// create 412 (TEST_TOKEN_DAR_UNAVAILABLE): the test-token DAR isn't -// published for this instance's Splice version. Shared by the create modal -// (where on-ledger create surfaces it) and the action-modal banner. +// Remediation for the on-ledger create 412 (TEST_TOKEN_DAR_UNAVAILABLE). const TOKEN_DAR_UNAVAILABLE_HINT = "The test-token DAR isn't published for this instance's Splice version, so on-ledger " + "V2 tokens can't be created here. Bring up a token-standard-v2 instance " + "(localnet up --version token-standard-v2 --profile tokens-v2) and re-run."; -// createErrorText maps a token-create failure to the message shown in the -// create modal: the actionable DAR remedy for the on-ledger 412 -// (TEST_TOKEN_DAR_UNAVAILABLE), otherwise the raw server message (or a -// generic fallback for a non-API error). Exported for unit testing. export function createErrorText(e: unknown): string { if (e instanceof ApiError && e.code === "TEST_TOKEN_DAR_UNAVAILABLE") { return TOKEN_DAR_UNAVAILABLE_HINT; @@ -83,23 +101,15 @@ export function createErrorText(e: unknown): string { return e instanceof ApiError ? e.message : "create failed"; } -// TokensScreen — . -// -// V2 Token Standard surface: lists every instrument recorded on the -// selected instance, exposes Mint / Transfer / Burn / Accept actions -// on each, and the Create wizard for a brand-new instrument. The -// holdings table for the selected instrument refreshes whenever the -// user picks a row or completes a mutation. -// -// Errors: -// • 409 SYMBOL_IN_USE → surfaced in the create modal as a focused -// "pick a different symbol" hint. -// • 412 NEEDS_V2_LOCALNET → big yellow remediation banner with the -// command to bring up the V2 LocalNet. -// • everything else → red alert with the server message. export function TokensScreen() { const sel = useInstanceSelection(); const instance = sel.selected; + // Top-level act-as identity, threaded through every token API call so + // the whole screen reads/writes as this role; persisted per instance. + const [role, setRole] = useIdentityRole(instance ?? ""); + // Selectable identities from GET /api/tokens/identity; seeded with the + // static default so the switcher renders before the fetch lands. + const [availableRoles, setAvailableRoles] = useState(IDENTITY_ROLES); const [list, setList] = useState([]); const [listErr, setListErr] = useState(null); @@ -109,28 +119,28 @@ export function TokensScreen() { const [holdings, setHoldings] = useState([]); const [holdingsErr, setHoldingsErr] = useState(null); - // holdingsSource: "ledger" = real on-ledger balances; "registry" = - // the pseudo-balance fallback shown when no live participant is - // reachable. Drives the disclaimer banner so a user never mistakes a - // fabricated row for a real holding. + // "registry" (vs "ledger") drives the pseudo-balance disclaimer banner. const [holdingsSource, setHoldingsSource] = useState("ledger"); - const [expanded, setExpanded] = useState(null); // party whose UTXOs are shown + const [expanded, setExpanded] = useState(null); const [contracts, setContracts] = useState([]); - // expandSeqRef is the monotonic counter behind toggleExpand's - // latest-click guard: if the user clicks a new party while a fetch - // is still in flight, the stale resolution bails before clobbering - // the newer state. Same pattern as the `cancelled` flags in the - // useEffect fetches above. const expandSeqRef = useRef(0); const [matrix, setMatrix] = useState(null); const [matrixErr, setMatrixErr] = useState(null); + // Which token the Holdings matrix is filtered to (null = full grid). + const [matrixSymbol, setMatrixSymbol] = useState(null); const [summary, setSummary] = useState(null); const [parties, setParties] = useState([]); const [showParties, setShowParties] = useState(false); const aliases: AliasMap = useMemo(() => aliasMapFrom(parties), [parties]); - const [detailTab, setDetailTab] = useState<"overview" | "activity">("overview"); + const [detailTab, setDetailTab] = useState<"overview" | "activity" | "allocations">("overview"); const [activity, setActivity] = useState(null); const [activityErr, setActivityErr] = useState(null); + // Requested page size; "Load more" grows it. activityTruncated is set + // when the ledger scan itself was capped (distinct from a full page). + const [activityLimit, setActivityLimit] = useState(ACTIVITY_PAGE); + const [activityTruncated, setActivityTruncated] = useState(false); + const [allocations, setAllocations] = useState(null); + const [allocationsErr, setAllocationsErr] = useState(null); const [showCreate, setShowCreate] = useState(false); const [modal, setModal] = useState< @@ -139,6 +149,7 @@ export function TokensScreen() { | { kind: "burn"; symbol: string } | { kind: "faucet"; symbol: string } | { kind: "accept"; id?: string; party?: string } + | { kind: "allocate"; symbol: string } | null >(null); const [topNotice, setTopNotice] = useState<{ tone: "ok" | "warn" | "err"; text: string } | null>(null); @@ -151,9 +162,7 @@ export function TokensScreen() { return; } let cancelled = false; - // ACS-derived instrument discovery: Amulet + any minted - // token appear without a state.Tokens seed. - fetchInstruments(instance) + fetchInstruments(instance, role) .then((items) => { if (cancelled) return; setList(items); @@ -172,13 +181,31 @@ export function TokensScreen() { }; // activeSymbol intentionally NOT in deps — selecting a row shouldn't refetch the list. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [instance, refreshTick]); + }, [instance, role, refreshTick]); + + // Available identities from the backend. Best-effort: on failure the + // switcher keeps the static default. Only re-runs per instance. + useEffect(() => { + if (!instance) return; + let cancelled = false; + fetchTokenIdentity(instance) + .then((id) => { + if (!cancelled && id.available_roles?.length) { + setAvailableRoles(id.available_roles); + } + }) + .catch(() => { + if (!cancelled) setAvailableRoles(IDENTITY_ROLES); + }); + return () => { + cancelled = true; + }; + }, [instance]); - // Matrix lens — one ACS scan, party × instrument. useEffect(() => { if (!instance || view !== "matrix") return; let cancelled = false; - fetchMatrix(instance) + fetchMatrix(instance, role) .then((m) => { if (!cancelled) { setMatrix(m); @@ -191,7 +218,14 @@ export function TokensScreen() { return () => { cancelled = true; }; - }, [instance, view, refreshTick]); + }, [instance, view, role, refreshTick]); + + // Opening the matrix focuses the instrument selected on the Instruments + // tab, so "pick a token → matrix follows" holds across the two views. + // The All chip clears it back to the full grid. + useEffect(() => { + if (view === "matrix") setMatrixSymbol(activeSymbol); + }, [view, activeSymbol]); useEffect(() => { if (!instance || !activeSymbol) { @@ -201,7 +235,7 @@ export function TokensScreen() { let cancelled = false; setExpanded(null); setContracts([]); - fetchHoldings(instance, activeSymbol) + fetchHoldings(instance, activeSymbol, undefined, role) .then((r) => { if (!cancelled) { setHoldings(r.holdings); @@ -217,17 +251,15 @@ export function TokensScreen() { return () => { cancelled = true; }; - }, [instance, activeSymbol, refreshTick]); + }, [instance, activeSymbol, role, refreshTick]); - // Party alias registry: one fetch per instance powers the - // alias labels across every lens and the party manager. useEffect(() => { if (!instance) { setParties([]); return; } let cancelled = false; - fetchParties(instance) + fetchParties(instance, role) .then((p) => { if (!cancelled) setParties(p); }) @@ -237,18 +269,16 @@ export function TokensScreen() { return () => { cancelled = true; }; - }, [instance, refreshTick]); + }, [instance, role, refreshTick]); - // Instrument-first KPI summary: supply, holder + - // contract counts, holder distribution. One ACS scan; best-effort — - // a failure just hides the KPI strip, the holdings table still loads. + // Best-effort: a failure just hides the KPI strip; holdings still load. useEffect(() => { if (!instance || !activeSymbol) { setSummary(null); return; } let cancelled = false; - fetchInstrumentSummary(instance, activeSymbol) + fetchInstrumentSummary(instance, activeSymbol, role) .then((s) => { if (!cancelled) setSummary(s); }) @@ -258,20 +288,21 @@ export function TokensScreen() { return () => { cancelled = true; }; - }, [instance, activeSymbol, refreshTick]); + }, [instance, activeSymbol, role, refreshTick]); - // Activity feed: transfer/mint/burn history - // reconstructed from the ledger transaction stream. Fetched lazily — - // only when the Activity tab is open — since it's a full historical - // scan, heavier than the ACS snapshots the other lenses use. + // Lazy: only when the Activity tab is open (a full historical scan, + // heavier than the ACS-snapshot lenses). Re-runs when activityLimit grows. useEffect(() => { if (!instance || !activeSymbol || detailTab !== "activity") return; let cancelled = false; setActivity(null); setActivityErr(null); - fetchActivity(instance, activeSymbol) - .then((ev) => { - if (!cancelled) setActivity(ev); + fetchActivity(instance, activeSymbol, role, activityLimit) + .then((page) => { + if (!cancelled) { + setActivity(page.events); + setActivityTruncated(page.truncated); + } }) .catch((e: unknown) => { if (!cancelled) setActivityErr(e instanceof ApiError ? e.message : "failed to load activity"); @@ -279,7 +310,33 @@ export function TokensScreen() { return () => { cancelled = true; }; - }, [instance, activeSymbol, detailTab, refreshTick]); + }, [instance, activeSymbol, detailTab, role, refreshTick, activityLimit]); + + // Reset the activity page size whenever the instrument or role changes, + // so a deep "Load more" on one instrument doesn't carry into the next. + useEffect(() => { + setActivityLimit(ACTIVITY_PAGE); + setActivityTruncated(false); + }, [activeSymbol, role, instance]); + + // Lazy: only when the Allocations tab is open. Not instrument-scoped — + // shows every visible allocation regardless of the active symbol. + useEffect(() => { + if (!instance || detailTab !== "allocations") return; + let cancelled = false; + setAllocations(null); + setAllocationsErr(null); + fetchAllocations(instance, undefined, role) + .then((r) => { + if (!cancelled) setAllocations(r.allocations); + }) + .catch((e: unknown) => { + if (!cancelled) setAllocationsErr(e instanceof ApiError ? e.message : "failed to load allocations"); + }); + return () => { + cancelled = true; + }; + }, [instance, detailTab, role, refreshTick]); // Selecting a different instrument resets the detail tab to Overview. useEffect(() => { @@ -291,12 +348,8 @@ export function TokensScreen() { [list, activeSymbol], ); - // Expand a party's balance into its individual Holding contracts (UTXOs). - // The latest-click guard mirrors the `cancelled` pattern used by the - // useEffect fetches above: if the user clicks a different party while - // a fetch is still in flight, the stale resolution must not overwrite - // the newer state. expandSeq increments on every click; the in-flight - // closure captures its own seq and bails when it no longer matches. + // Each click bumps expandSeq; the in-flight closure bails when its seq + // no longer matches, so a stale fetch can't overwrite a newer click. function toggleExpand(party: string) { if (expanded === party) { setExpanded(null); @@ -308,7 +361,7 @@ export function TokensScreen() { expandSeqRef.current = seq; setExpanded(party); setContracts([]); - fetchHoldingContracts(instance, activeSymbol, party) + fetchHoldingContracts(instance, activeSymbol, party, role) .then((cs) => { if (expandSeqRef.current === seq) setContracts(cs); }) @@ -336,23 +389,33 @@ export function TokensScreen() { return { tone: "err", text: e instanceof ApiError ? e.message : fallback }; } - // launchDemo provisions a live, transferable demo token in one click — - // the server composes issuer-party → create → mint → faucet-a-holder. - // A 412 (no live V2) surfaces via renderActionError's NEEDS_V2_LOCALNET - // branch ("bring up a V2 LocalNet first") rather than a generic error. + // Runs a per-allocation action promise (settle/withdraw/cancel), surfaces + // a notice on success/failure, and refreshes the allocations list. + async function runAllocationAction(p: Promise, verb: string) { + try { + await p; + setTopNotice({ tone: "ok", text: `Allocation ${verb} submitted.` }); + } catch (e) { + setTopNotice(renderActionError(e, `${verb} failed`)); + } finally { + bump(); + } + } + + // Server composes issuer-party → create → mint → faucet-a-holder. async function launchDemo() { if (!instance) return; setDemoBusy(true); setTopNotice(null); try { - const res = await launchDemoToken(instance); + const res = await launchDemoToken(instance, undefined, role); setActiveSymbol(res.token.symbol); bump(); setTopNotice({ tone: "ok", text: res.seeded - ? `Launched ${res.token.symbol} — supply minted to ${res.issuer.alias}, ${res.holder?.alias ?? "a holder"} funded. Try a transfer.` - : `Launched ${res.token.symbol} — supply minted to ${res.issuer.alias}.`, + ? `Launched ${res.token.symbol}. Supply minted to ${res.issuer.alias}, ${res.holder?.alias ?? "a holder"} funded. Try a transfer.` + : `Launched ${res.token.symbol}. Supply minted to ${res.issuer.alias}.`, }); } catch (e) { setTopNotice(renderActionError(e, "demo launch failed")); @@ -372,7 +435,7 @@ export function TokensScreen() { if (listErr) { return (
-
+
} />

{listErr}

); @@ -381,30 +444,29 @@ export function TokensScreen() { return (
- - + - + {demoBusy ? "Launching…" : "Launch demo"} + } /> @@ -412,17 +474,16 @@ export function TokensScreen() {
{topNotice.text}
)} - {/* Lens switcher */} -
+
{(["instruments", "matrix"] as const).map((v) => (
{view === "matrix" ? ( - + ) : list.length === 0 ? ( -
-
+
+
No tokens on {instance} yet
-
- Go from empty to a live, transferable token in one click — no party ids to paste. +
+ Go from empty to a live, transferable token in one click. No party ids to paste.
- - + +
-
+
One click provisions an issuer party, a DEMO instrument with supply, and a funded holder. Or run dpm localnet token demo --instance {instance}.
) : (
- {/* Left rail: instrument list (ACS-discovered) */} -
+
{list.map((t) => { const sym = t.symbol ?? t.instrument_id; const isActive = sym === activeSymbol; @@ -466,14 +526,15 @@ export function TokensScreen() { onClick={() => setActiveSymbol(sym)} style={{ display: "block", width: "100%", textAlign: "left", padding: "10px 14px", - background: isActive ? W.surface2 : "transparent", border: "none", - borderLeft: `2px solid ${isActive ? W.brand : "transparent"}`, cursor: "pointer", + background: isActive ? tint(W.brand, 12) : "transparent", border: "none", + cursor: "pointer", + transition: `background-color ${FAST}`, }} > -
+
{sym} {t.name && · {t.name}}
-
+
{t.standard}{t.on_ledger ? " · on-ledger" : " · recorded"}
@@ -481,8 +542,7 @@ export function TokensScreen() { })}
- {/* Right pane: detail + holdings + actions */} -
+
{active && (() => { const sym = active.symbol ?? active.instrument_id; const mintReason = mintDisabledReason(active); @@ -490,34 +550,40 @@ export function TokensScreen() { <>

{active.name ?? sym}

- + {sym} · {active.standard} - - - - + + + - + >Burn + +
-
- admin {partyLabel(aliases, active.admin)} · id {active.instrument_id} +
+ admin {partyLabel(aliases, active.admin)} + · id +
- {/* Overview / Activity tab switcher */}
- {(["overview", "activity"] as const).map((tab) => ( + {(["overview", "activity", "allocations"] as const).map((tab) => (
); } -// HolderDistribution — per-holder stake table: balance, -// share of supply (with an inline bar), and how many Holding contracts -// back each holder. Sorted biggest-first by the backend. +// Group thousands, cap at two decimals; non-numeric strings pass through. +function statAmount(raw: string): string { + const n = Number(raw); + if (!Number.isFinite(n)) return raw; + return n.toLocaleString("en-US", { maximumFractionDigits: 2 }); +} + +// Per-holder stake table (balance, share, UTXO count); backend sorts biggest-first. function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: AliasMap }) { return ( <>

Holder distribution{" "} - · share of total supply + · share of total supply

- +
- + - + @@ -907,25 +1192,25 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali return ( - + - + ); })} @@ -935,31 +1220,65 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali ); } -// ActivityFeed — the instrument's transfer/mint/burn history ( -// Activity tab), reconstructed from the ledger transaction stream. Each -// row is one netted transaction: kind, amount, and who sent → received. -function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null; err: string | null; aliases: AliasMap }) { - if (err) return
{err}
; - if (events === null) return
Scanning ledger history…
; +// Transfer/mint/burn history from the ledger stream, one netted +// transaction per row, newest-first. `onLoadMore` grows `limit`; +// `truncated` means the backend's scan was capped (partial history). +function ActivityFeed({ + events, + err, + aliases, + limit, + truncated, + onLoadMore, +}: { + events: ActivityEvent[] | null; + err: string | null; + aliases: AliasMap; + limit: number; + truncated: boolean; + onLoadMore: () => void; +}) { + if (err) return
{err}
; + if (events === null) return
Scanning ledger history…
; if (events.length === 0) - return
No activity for this instrument yet.
; + return
No activity for this instrument yet.
; + + // A full page may have older movements clipped off — offer to grow it. + const maybeMore = events.length >= limit; const tone: Record = { mint: W.brand, burn: W.err, transfer: W.warn, }; + const kindLabel: Record = { + mint: "Mint", + burn: "Burn", + transfer: "Transfer", + }; const fmtParties = (ps?: { party: string; amount: string }[]) => !ps || ps.length === 0 ? "·" : ps.map((p) => `${partyLabel(aliases, p.party)} ${p.amount}`).join(", "); + // Provenance: "event_log" = admin's authoritative events; "transaction" + // = netted from HoldingV2 create/archive deltas. + const sourceLabel = (s: ActivityEvent["source"]) => + s === "event_log" ? "EventLog" : "derived"; return ( -
HOLDERBALANCEBALANCE SHAREUTXOSUTXOS
{partyLabel(aliases, h.party)}{h.balance}{h.balance}
-
+
- + {h.pct_of_supply}%
{h.contract_count}{h.contract_count}
+ <> +
+ + Showing {events.length} {events.length === 1 ? "movement" : "movements"}, newest first + {truncated && · ledger scan capped, older movements omitted} + +
+
- + + @@ -967,39 +1286,72 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null {events.map((e) => ( - - + + ))}
TIME KINDAMOUNTSOURCEAMOUNT FROM TO
+ {e.record_time ? e.record_time.replace("T", " ").slice(0, 19) : `@${e.offset}`} - {e.kind} + + {kindLabel[e.kind]} {e.amount} + {sourceLabel(e.source)} + {e.amount} {fmtParties(e.senders)} {fmtParties(e.receivers)}
+ {maybeMore && ( +
+ +
+ )} + ); } -// MatrixLens — the god-mode party × instrument balance table (/ -// #2). One ACS scan; rows = parties, columns = instruments, -// plus a totals row. Only the parties the role's JWT can read appear. -function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; err: string | null; aliases: AliasMap }) { - if (err) return
{err}
; - if (!matrix) return
Loading matrix…
; +// Party × instrument balance table from one ACS scan; only parties the +// role's JWT can read appear. Filterable to one token's column. +function MatrixLens({ + matrix, + err, + aliases, + filter, + onFilter, +}: { + matrix: BalanceMatrix | null; + err: string | null; + aliases: AliasMap; + filter: string | null; + onFilter: (s: string | null) => void; +}) { + if (err) return
{err}
; + if (!matrix) return
Scanning ACS…
; const syms = matrix.instruments.map((i) => i.symbol ?? i.instrument_id); const symByInst: Record = {}; @@ -1012,38 +1364,66 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er matrix.totals.forEach((t) => { totals[symByInst[t.instrument_id]] = t.amount; }); const parties = [...matrix.parties].sort(); + // Filter to one token's column when selected; the All chip restores the + // full every-party × every-token grid. + const active = filter && syms.includes(filter) ? filter : null; + const shownSyms = active ? [active] : syms; + + const chip = (label: string, value: string | null, on: boolean) => ( + + ); + return ( -
-
- {parties.length} {parties.length === 1 ? "party" : "parties"} × {syms.length} {syms.length === 1 ? "instrument" : "instruments"} — - every readable party's balance of every instrument, in one ACS scan. +
+
+ {chip("All tokens", null, active === null)} + {syms.map((s) => chip(s, s, active === s))} +
+
+ {active ? ( + <>{parties.length} {parties.length === 1 ? "party" : "parties"} · balances of {active} only. + ) : ( + <>{parties.length} {parties.length === 1 ? "party" : "parties"} × {syms.length} {syms.length === 1 ? "instrument" : "instruments"}. Every readable party's balance of every instrument, in one ACS scan. + )}
- +
- {syms.map((s) => )} + {shownSyms.map((s) => )} {parties.map((p) => ( - {syms.map((s) => ( - ))} ))} - - {syms.map((s) => ( - + + {shownSyms.map((s) => ( + ))} {parties.length === 0 && ( - + )}
PARTY ╲ TOKEN{s}{s}
{partyLabel(aliases, p)} + {shownSyms.map((s) => ( + {amt[p]?.[s] ?? "·"}
Σ total{totals[s] ?? ""}Σ total{totals[s] ?? ""}
No holdings visible to this role.
No holdings visible to this role.
@@ -1054,25 +1434,104 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er function Header({ right }: { right?: React.ReactNode }) { return (
-

Tokens

- Token Standard instruments + actions +

Tokens

+ Token Standard instruments + actions {right}
); } -// PartyManagerModal — the god-mode party registry: list the -// instance's aliased parties, allocate a new one by name, or forget an -// alias. New parties immediately become visible in the matrix / activity -// (the scan grants read-as for every registered party). +// Top-level act-as identity picker: selecting a role re-plumbs it +// through every token API call (read + write) so the whole screen speaks +// the ledger as that identity. +function RoleSwitcher({ + role, + roles, + onChange, +}: { + role: Role; + roles: Role[]; + onChange: (r: Role) => void; +}) { + return ( +
+ {roles.map((id) => { + const active = id === role; + return ( + + ); + })} +
+ ); +} + +function RoleDot({ role }: { role: Role }) { + return ( + + {role[0].toUpperCase()} + + ); +} + +// List/allocate/forget aliased parties. New parties show immediately in +// the matrix/activity (the scan grants read-as for every registered party). function PartyManagerModal({ instance, + role, parties, onClose, onChanged, onError, }: { instance: string; + role: Role; parties: PartyRef[]; onClose: () => void; onChanged: () => void; @@ -1086,7 +1545,7 @@ function PartyManagerModal({ if (!alias.trim()) return; setBusy(true); try { - await createParty(instance, alias.trim()); + await createParty(instance, alias.trim(), role); setAlias(""); onChanged(); } catch (err) { @@ -1110,14 +1569,15 @@ function PartyManagerModal({ return ( -

+

On LocalNet you own every party. Name one here and use the alias anywhere a party is accepted — it appears in the matrix and activity automatically.

- +
+ @@ -1126,22 +1586,32 @@ function PartyManagerModal({ {parties.map((p) => ( + ))} {parties.length === 0 && ( - + )}
ALIASPARTY ID ROLE
{p.alias} + {p.party_id ? ( + + + + + ) : ( + · + )} + {p.role} - +
No parties registered yet.
No parties registered yet.
@@ -1150,11 +1620,11 @@ function PartyManagerModal({ value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="new alias (e.g. bob)" - style={{ flex: 1, background: W.bg, border: `1px solid ${W.border}`, borderRadius: 6, padding: "8px 10px", color: W.text, fontSize: 13 }} + style={{ flex: 1, background: W.bg, border: `1px solid ${W.border}`, borderRadius: 2, padding: "8px 10px", color: W.text, fontSize: fs.data }} /> - +
); @@ -1162,12 +1632,14 @@ function PartyManagerModal({ function CreateTokenModal({ instance, + role, parties, onPartiesChanged, onClose, onCreated, }: { instance: string; + role: Role; parties: PartyRef[]; onPartiesChanged?: () => void; onClose: () => void; @@ -1191,7 +1663,7 @@ function CreateTokenModal({ try { const ref = await createToken(instance, { name, symbol, decimals, initial_supply: initialSupply, issuer, - }); + }, role); onCreated(ref); } catch (e) { setErr(createErrorText(e)); @@ -1211,6 +1683,7 @@ function CreateTokenModal({ - {err &&
{err}
} + {err &&
{err}
}
- - + +
@@ -1232,6 +1705,7 @@ function ActionModal({ title, fields, instance, + role, parties, onPartiesChanged, initial, @@ -1243,6 +1717,7 @@ function ActionModal({ title: string; fields: { label: string; key: string; optional?: boolean; party?: boolean }[]; instance: string; + role: Role; parties: PartyRef[]; onPartiesChanged?: () => void; initial?: Record; @@ -1275,6 +1750,7 @@ function ActionModal({ {f.party ? ( setValues((vv) => ({ ...vv, [f.key]: v }))} @@ -1291,23 +1767,18 @@ function ActionModal({ )} ))} - {err &&
{err}
} + {err &&
{err}
}
- - + +
); } -// PartyPicker — alias-aware party selector. Replaces the raw -// fingerprinted-party-id text inputs across the token modals: pick a -// registered alias, create one inline (POST /api/parties), or fall back -// to typing a raw id. Always emits the resolved party_id, which the -// backend's ResolveAlias passes through unchanged — so it's correct for -// create (no alias resolution) and the mint/transfer/burn/faucet paths -// (which do resolve) alike. +// Alias-aware party selector: pick a registered alias, create one inline, +// or type a raw id. Always emits the resolved party_id. function PartyPicker({ instance, parties, @@ -1329,8 +1800,7 @@ function PartyPicker({ const [newAlias, setNewAlias] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); - // Locally-created parties show in the list instantly, before the - // parent's onPartiesChanged refetch lands. + // Locally-created parties show instantly, before the parent's refetch lands. const [extra, setExtra] = useState([]); const all = useMemo(() => { @@ -1339,11 +1809,6 @@ function PartyPicker({ }, [parties, extra]); const known = all.some((p) => p.party_id === value); - const linkBtn: React.CSSProperties = { - background: "transparent", border: "none", color: W.dim, - fontSize: 11, cursor: "pointer", justifySelf: "start", padding: 0, - }; - async function createNew() { const a = newAlias.trim(); if (!a) return; @@ -1380,19 +1845,24 @@ function PartyPicker({ placeholder="new alias (e.g. bob)" style={{ ...input, flex: 1 }} /> - +
- {err && {err}} - + {err && {err}} +
); } @@ -1404,37 +1874,47 @@ function PartyPicker({ value={value} onChange={(e) => onChange(e.target.value)} placeholder="party id (alias::fingerprint)" - style={{ ...input, fontFamily: wMono, fontSize: 12 }} + style={{ ...input, fontFamily: wMono, fontSize: fs.meta }} /> {all.length > 0 && ( - + )}
); } return ( - +
+ + {/* Copy the full selected party id — the value a user pastes into + another target field. Only shown once a real party is chosen. */} + {value !== "" && known && } +
); } @@ -1446,14 +1926,18 @@ function ModalShell({ title, onClose, children }: { title: string; onClose: () = }}>
-

{title}

- +

{title}

+
{children}
@@ -1464,7 +1948,7 @@ function ModalShell({ title, onClose, children }: { title: string; onClose: () = function Field({ label, children }: { label: string; children: React.ReactNode }) { return ( ); @@ -1472,39 +1956,17 @@ function Field({ label, children }: { label: string; children: React.ReactNode } const input: React.CSSProperties = { background: W.surface2, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 6, padding: "6px 8px", fontSize: 13, + borderRadius: 2, padding: "6px 8px", fontSize: fs.data, }; -const th: React.CSSProperties = { padding: "6px 8px", borderBottom: `1px solid ${W.border}`, fontSize: 11 }; -const td: React.CSSProperties = { padding: "6px 8px", borderBottom: `1px solid ${W.border}`, color: W.text }; +const th: React.CSSProperties = { ...tableCaps, padding: "6px 10px", borderBottom: `1px solid ${W.border}`, fontSize: fs.label }; +const td: React.CSSProperties = { padding: "6px 10px", borderBottom: `1px solid ${W.border}`, color: W.text }; +const thNum: React.CSSProperties = { ...th, textAlign: "right" }; +const tdNum: React.CSSProperties = { ...td, textAlign: "right", fontFamily: wMono, fontVariantNumeric: "tabular-nums" }; function notice(tone: "ok" | "warn" | "err"): React.CSSProperties { const c = tone === "ok" ? W.ok : tone === "warn" ? W.warn : W.err; return { - background: `${c}10`, color: c, border: `1px solid ${c}`, - borderRadius: 8, padding: "8px 12px", fontSize: 12.5, + background: tint(c, 10), color: c, border: `1px solid ${tint(c, 40)}`, + borderRadius: R.control, padding: "8px 12px", fontSize: fs.meta, }; } - -function btnStyle(accent: string, busy: boolean, filled = false, disabled = false): React.CSSProperties { - if (disabled) { - return { - background: "transparent", color: W.dim, border: `1px solid ${W.border}`, - borderRadius: 6, padding: filled ? "5px 12px" : "4px 10px", - fontSize: filled ? 12 : 11.5, fontWeight: 600, cursor: "not-allowed", opacity: 0.6, - }; - } - return filled - ? { - background: busy ? W.surface2 : accent, - color: busy ? W.dim : "#0B0E13", - border: "none", borderRadius: 6, padding: "5px 12px", - fontSize: 12, fontWeight: 600, cursor: busy ? "wait" : "pointer", - } - : { - background: "transparent", - color: busy ? W.dim : accent, - border: `1px solid ${busy ? W.dim : accent}`, - borderRadius: 6, padding: "4px 10px", fontSize: 11.5, - fontWeight: 600, cursor: busy ? "wait" : "pointer", - }; -} diff --git a/frontend/src/screens/TxReplayDrawer.tsx b/frontend/src/screens/TxReplayDrawer.tsx index 248961f8..e64fd1d0 100644 --- a/frontend/src/screens/TxReplayDrawer.tsx +++ b/frontend/src/screens/TxReplayDrawer.tsx @@ -6,22 +6,20 @@ import { type TxReplayEvent, type TxReplayResponse, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, R, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; +import { IcX } from "../components/icons"; -// TxReplayDrawer — the per-party visibility projection. -// -// The Web UI counterpart of `dpm localnet tx replay --id `. It -// fetches one transaction with the LEDGER_EFFECTS shape (exercised -// choices, not just the ACS delta) projected through a party set, and -// renders the event tree. The "Project as" party selector lets a user -// ask "what did party P see in this transaction?" — querying the same -// id as different parties returns different event sets, which is the -// whole point of the projection. +// Replays one transaction with the LEDGER_EFFECTS shape (exercised +// choices, not just the ACS delta) projected through a party set. The +// party selector answers "what did party P see?" — the same id returns +// different event sets per party. const EVENT_COLOR: Record = { - created: "#62E2A0", - archived: "#F08FB5", - exercised: "#7CB5F7", + created: "#7CC89A", + archived: "#7BD2C6", + exercised: "#8FA3EE", }; export function TxReplayDrawer({ @@ -38,8 +36,7 @@ export function TxReplayDrawer({ partyOptions: string[]; onClose: () => void; }) { - // "" = project through the JWT's own parties (the default the - // backend uses when no ?party is passed). + // "" = project through the JWT's own parties (the backend default). const [party, setParty] = useState(""); const [state, setState] = useState< | { kind: "loading" } @@ -89,11 +86,19 @@ export function TxReplayDrawer({ return (
-
+
Replay · per-party projection
- - {updateId} - +
- + title="Close (esc)" + onClick={onClose} + />
- visible to + visible to -// fallback when the API call returned empty. These tests pin the -// "always a dropdown, never a +// free-text " invariant for the curated Splice catalogue +// picker (an earlier build regressed to an fallback when the +// version list came back empty). // // Two contracts under test: // 1. With versions: native HTML , -// disabled, with a single placeholder option. We MUST NOT render -// an in this state — that's the regression we're guarding. +// disabled, with a single placeholder option — never an . const FIXTURES: SpliceVersionEntry[] = [ { tag: "0.6.4", status: "latest", major: "0.6", commit: "abc1234567890" }, @@ -33,10 +31,9 @@ describe("VersionPicker — curated catalogue dropdown", () => { />, ); - // Accessible-name lookup is the right shape: a future refactor - // that swaps the element type (e.g. to a custom combobox) would - // need to preserve role="combobox" / aria-label for screen-reader - // parity. The "must be a " assertion enforces native semantics. const select = screen.getByLabelText(/splice version/i); expect(select.tagName).toBe("SELECT"); expect(select).not.toBeDisabled(); @@ -50,7 +47,6 @@ describe("VersionPicker — curated catalogue dropdown", () => { onSelect={() => {}} />, ); - // The earlier implementation had a fallback `` branch. // Whatever the picker renders, it must NOT be a text input. expect(container.querySelector("input")).toBeNull(); }); @@ -120,10 +116,8 @@ describe("VersionPicker — curated catalogue dropdown", () => { const select = screen.getByLabelText(/splice version/i); expect(select.tagName).toBe("SELECT"); expect(select).toBeDisabled(); - // The regression: empty-state must not fall back to a free-text - // input. Pinning this is the entire point of this test file — - // users reported the textbox felt like a regression from the - // previous dropdown UX. + // The empty state must not fall back to a free-text input — + // pinning this is the entire point of this test file. expect(container.querySelector("input")).toBeNull(); }); diff --git a/frontend/src/screens/WalletScreen.tsx b/frontend/src/screens/WalletScreen.tsx index cfbc0e91..060afda9 100644 --- a/frontend/src/screens/WalletScreen.tsx +++ b/frontend/src/screens/WalletScreen.tsx @@ -1,43 +1,35 @@ import { useEffect, useState } from "react"; import { ApiError, fetchInstance, type Instance, type Role } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { ROLE_COLOR, W, wMono } from "../tokens"; +import { ROLE_COLOR, W, wMono, tint, R, FAST, fs } from "../tokens"; +import { Button } from "../components/Button"; +import { Dot, IcAlert, IcRefresh } from "../components/icons"; -// WalletScreen — (new in design drop 2026-05-26). -// -// Embeds Splice's per-role Wallet UI inside the DevKit shell so -// users don't juggle three browser tabs (one per party). The -// iframe target is the existing `_ui` host port from -// state.json — Splice exposes its wallet on those ports already; -// we just frame them with a switcher. -// -// X-Frame-Options check: confirmed empty on Splice 0.6.4's -// wallet UI (served by nginx with no frame-options header), so -// the iframe loads without CORS gymnastics. If a future Splice -// release ships SAMEORIGIN headers, the "Open in new tab" -// fallback covers that case. +// Embeds Splice's per-role Wallet UI (the `_ui` host port from +// state.json) in an iframe. Splice 0.6.4 sends no X-Frame-Options, so it +// loads directly; the "Open in new tab" fallback covers a future SAMEORIGIN. const ROLES: Role[] = ["app-user", "app-provider", "sv"]; -// LocalNet wallet login — Splice's LocalNet ships a self-signed -// auth flow (NOT MetaMask, NOT a real OAuth provider). On the -// wallet landing page click "Log in" and enter the role's -// validator user name when prompted. These are the hardcoded -// names from `env/-auth-on.env`: -// AUTH__WALLET_ADMIN_USER_NAME -// (`app-user`, `app-provider`, `sv`). Password is ignored — -// auth is dev-only HS-256 with the literal secret "unsafe". +// Hardcoded AUTH__WALLET_ADMIN_USER_NAME values from env/-auth-on.env. +// Password is ignored: LocalNet auth is dev-only HS-256 with the secret "unsafe". const LOGIN_USER_FOR: Record = { "app-user": "app-user", "app-provider": "app-provider", sv: "sv", }; -// roleLabel matches the backend's Endpoint.Label format: -// "Wallet · ". Resolves a role to the matching endpoint URL. -function walletURLFor(role: Role, endpoints: Instance["endpoints"]): string | null { +// Logical port names from state.json; endpoints match by key, labels are display-only. +const WALLET_ENDPOINT_KEY: Record = { + "app-user": "app_user_ui", + "app-provider": "app_provider_ui", + sv: "sv_ui", +}; + +// Returns the whole endpoint so callers get both URL and reachability verdict. +function walletEndpointFor(role: Role, endpoints: Instance["endpoints"]) { if (!endpoints) return null; - const want = `Wallet · ${role}`; - return endpoints.find((e) => e.label === want)?.url ?? null; + const want = WALLET_ENDPOINT_KEY[role]; + return endpoints.find((e) => e.key === want) ?? null; } export function WalletScreen() { @@ -49,6 +41,8 @@ export function WalletScreen() { | { kind: "ok"; instance: Instance } | { kind: "err"; error: string } >({ kind: "loading" }); + // Bumped by Retry to re-fetch and re-run the backend reachability probe. + const [refetchNonce, setRefetchNonce] = useState(0); useEffect(() => { if (!name) return; @@ -68,7 +62,7 @@ export function WalletScreen() { return () => { cancelled = true; }; - }, [name]); + }, [name, refetchNonce]); if (!name) { return ( @@ -83,7 +77,7 @@ export function WalletScreen() { if (state.kind === "loading") { return (
-

Loading wallet…

+

Loading wallet…

); } @@ -91,15 +85,16 @@ export function WalletScreen() { if (state.kind === "err") { return (
-

{state.error}

+

{state.error}

); } - // Resolve the per-role wallet URL from the endpoints projection - //. Falls back to - // null if the instance doesn't yet have endpoints surfaced. - const walletURL = walletURLFor(role, state.instance.endpoints); + const walletEndpoint = walletEndpointFor(role, state.instance.endpoints); + const walletURL = walletEndpoint?.url ?? null; + // Own the failure state rather than let an iframe render the browser's + // gray error page on a dead port. + const walletUnreachable = walletEndpoint?.reachability === "unreachable"; return (
- {/* Header */}
-

Wallet

+

Wallet

provided by Splice
-
+
Embedded Splice Wallet · DevKit handles auth + party selection so you don't juggle browser tabs.
@@ -141,23 +135,20 @@ export function WalletScreen() {
- {/* Login help — Splice's LocalNet wallet uses a self-signed - dev-mode auth flow (NOT MetaMask). Surface the credentials - inline so users don't have to dig through env files. */} + {/* Login help — dev-mode credentials inline, no env-file digging. */}
- 🔑
Login: on the wallet landing page, click Log in and enter user name{" "} @@ -167,23 +158,22 @@ export function WalletScreen() { color: W.text, background: W.border, padding: "1px 6px", - borderRadius: 4, + borderRadius: 2, }} > {LOGIN_USER_FOR[role]} - . Password is unused — LocalNet auth is dev-mode HS-256 with the + . Password is unused. LocalNet auth is dev-mode HS-256 with the shared secret "unsafe". No MetaMask required.
- {/* Active wallet info strip */}
- + {role} - @{name} + @{name}
- ↗ Open in new tab + Open in new tab )}
- {/* Embedded wallet iframe */}
- {/* Fake browser chrome so devs know they're looking at the - real Splice UI inside our shell, not a re-implementation. */} + {/* Fake browser chrome: signals this is the real Splice UI, not a reimplementation. */}
- + {walletURL ?? "—"} - + signed in as {role} via DevKit JWT
- {walletURL ? ( + {walletUnreachable ? ( +
+
+ Wallet UI is not serving HTTP +
+

+ The wallet UI for{" "} + {role}{" "} + accepts connections but returns no HTTP response + {walletEndpoint?.reachability_detail + ? ` (${walletEndpoint.reachability_detail})` + : ""} + . This usually means the instance was created by an older DevKit + whose generated port overlay is stale. +

+

+ Use Recreate on the dashboard, or re-run{" "} + + dpm localnet up --name {name} + {" "} + to regenerate the instance's overlays. +

+ +
+ ) : walletURL ? (