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..020bc789 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,106 @@ +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: description + attributes: + label: Description + description: What went wrong? + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: Numbered steps to trigger the issue. + placeholder: | + 1. Run `canton-devkit localnet up demo` + 2. Run `canton-devkit localnet status --name demo` + 3. See error + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + 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: input + id: splice_version + attributes: + label: Splice / LocalNet version + description: If applicable. + placeholder: e.g. 0.6.4, token-standard-v2 + + - type: dropdown + id: surface + attributes: + label: Surface + options: + - CLI + - Web UI + - Both + - Unsure + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: OS / platform + options: + - macOS + - Linux + - Windows + - Other + validations: + required: true + + - type: input + id: instance + attributes: + label: Instance name + description: If applicable. + placeholder: e.g. demo + + - type: textarea + id: doctor + attributes: + label: Diagnostic output + description: Paste `canton-devkit localnet doctor` output (redact secrets). + render: shell + + - type: textarea + id: logs + attributes: + label: Logs / command output + description: stderr, docker compose output, or other relevant output. + render: shell diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..c4c6910b --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + + + +## Changes + + + +## Test plan + +- [ ] `make test` +- [ ] `make lint` +- [ ] `scripts/e2e-milestone1.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/ci.yml b/.github/workflows/ci.yml index 266ba94f..4665daba 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" @@ -40,8 +42,44 @@ permissions: contents: read jobs: - test: - name: Build and test + changes: + name: Detect changed paths + runs-on: [self-hosted, Linux] + outputs: + go: ${{ steps.filter.outputs.go }} + install_sh: ${{ steps.filter.outputs.install_sh }} + frontend: ${{ steps.filter.outputs.frontend }} + mockups: ${{ steps.filter.outputs.mockups }} + steps: + - name: Check out repository + # actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd + - name: Filter changed paths + # dorny/paths-filter@v4.0.2 + id: filter + uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d + with: + filters: | + go: + - '**/*.go' + - 'go.mod' + - 'go.sum' + - 'Makefile' + - 'internal/skills/docs/**' + - 'packaging/**' + - '.github/workflows/ci.yml' + install_sh: + - 'install.sh' + frontend: + - 'frontend/**' + - 'Makefile' + mockups: + - 'docs/design/mockups/**.jsx' + + go: + name: Build, lint, and test + needs: changes + if: needs.changes.outputs.go == 'true' || needs.changes.outputs.install_sh == 'true' runs-on: [self-hosted, Linux] steps: @@ -56,10 +94,23 @@ jobs: go-version-file: go.mod cache: false + - name: Validate install.sh syntax + if: needs.changes.outputs.install_sh == 'true' + run: sh -n install.sh + + - name: Run golangci-lint + if: needs.changes.outputs.go == 'true' + # golangci/golangci-lint-action@v9.2.0 + uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 + with: + version: v2.12.2 + - name: Build + if: needs.changes.outputs.go == 'true' run: go build ./... - name: Test + if: needs.changes.outputs.go == 'true' run: go test ./... mockup-syntax: @@ -69,6 +120,8 @@ jobs: # browser. esbuild's `--loader=jsx` is the cheapest JSX-aware parser # available; no node_modules install needed. name: Mockup JSX syntax + needs: changes + if: needs.changes.outputs.mockups == 'true' runs-on: [self-hosted, Linux] steps: - name: Check out repository @@ -90,32 +143,10 @@ jobs: || { echo "::error file=$f::JSX syntax error"; exit 1; } done - lint: - name: Lint - runs-on: [self-hosted, Linux] - - 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: Run golangci-lint - # golangci/golangci-lint-action@v9.2.0 - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 - with: - version: v2.12.2 - frontend: - # Frontend lint + typecheck + Vitest suite. Gated on - # frontend/** + Makefile path filters above, so a Go-only - # change doesn't pay the npm-install cost. + # Frontend typecheck + Vitest suite. Job-level path filter (via the + # `changes` job) skips this when only Go/mockup/install.sh files + # change, so a Go-only PR doesn't pay the npm-install cost. # # Deliberately does NOT run `npm run build` — the production # bundle goes through `make frontend` at release time @@ -123,6 +154,8 @@ jobs: # //go:embed picks it up. This job is the per-PR fast loop: # tsc + vitest, ~30s on a warm runner. name: Frontend test + needs: changes + if: needs.changes.outputs.frontend == 'true' runs-on: [self-hosted, Linux] steps: 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.yml b/.github/workflows/e2e-test-devkit-functions.yml similarity index 72% rename from .github/workflows/e2e.yml rename to .github/workflows/e2e-test-devkit-functions.yml index 06c17794..1e3f03c4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e-test-devkit-functions.yml @@ -1,4 +1,4 @@ -name: E2E +name: "E2E: canton-devkit Functions" # Shell-based end-to-end tests. Each milestone adds a job to this # workflow. Currently: Milestone 1 (LocalNet CLI lifecycle). @@ -56,6 +56,14 @@ jobs: 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 + # `compose down --volumes` only removes volumes Compose itself + # created; volumes left by a prior run get re-adopted as + # external and survive. On this persistent self-hosted runner + # that strands canton-_postgres / _domain-upgrade-dump and + # trips M1-CLN-001's "volumes remain after clean" check. Remove + # them explicitly by project-name prefix to guarantee a clean slate. + 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 @@ -74,5 +82,9 @@ jobs: 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 + # Also drop adopted/external volumes Compose won't remove, so the + # next run on this persistent runner starts from a clean slate. + 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 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" < | 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/.gitignore b/.gitignore index ada4c161..4bc936a4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,13 +12,26 @@ 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 + +tmp/ 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 59% rename from AGENTS.md rename to CONTRIBUTING.md index b88415a3..14f9f67c 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,55 @@ 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) +``` + +### Web UI dev loop + +To iterate on the `frontend/` UI, run the backend API and the Vite dev +server side by side. + +**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 +``` + +Open **http://localhost:5173** (not 7777). Vite proxies `/api` and +`/events` to the backend on `:7777` — see `frontend/vite.config.ts`. + +Notes: + +- You do **not** need `make frontend` for this loop; that target only + builds the production bundle embedded into the Go binary. The + placeholder-bundle warning printed at `localnet ui` startup is + expected here since the browser loads the Vite dev server. +- Live API data requires a running LocalNet (`dpm localnet up`); pure + UI/theming work renders without one. + +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 +75,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 +113,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 +127,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 +150,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..db4a89b1 100644 --- a/Makefile +++ b/Makefile @@ -5,29 +5,21 @@ LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) .PHONY: build clean docker-build lint test frontend frontend-install frontend-test ui -# 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. +# 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 +27,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: diff --git a/README.md b/README.md index 2c17c5fc..dcd760ad 100644 --- a/README.md +++ b/README.md @@ -1,493 +1,208 @@ -
- - - - - 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/). - ---- - -## 🚀 Quickstart - -> 📖 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) +[![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) +[![License](https://img.shields.io/badge/license-Apache_2.0-blue.svg)](LICENSE) -### 1 · Install +Homebrew installation: [![Homebrew Downloads](https://img.shields.io/github/downloads/bitdynamics-ab/homebrew-canton-devkit/total.svg?label=downloads)](https://github.com/bitdynamics-ab/homebrew-canton-devkit/releases) -
-Pre-built binary — pick your OS (recommended) +Other installation methods: [![Other Downloads](https://img.shields.io/github/downloads/bitdynamics-ab/canton-devkit/total.svg?label=downloads)](https://github.com/bitdynamics-ab/canton-devkit/releases) -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. +canton-devkit helps you to run a complete local [Canton](https://canton.network/) +network on your machine. You get two participant/validator nodes and +a super-validator nodes, each with their own party +(app-user, app-provider, super-validator) and JWT token. All done through +a single command line interface with zero knowledge required for +infrastructure, DevOps or Docker. -**macOS (Apple Silicon)** +The only prerequisite is Docker and at least 8 GB of available memory (12 GB +recommended) and 10 GB of free disk — see the +[installation guide](docs/getting-started.md). -```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)** +## Install -```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 -``` +Through the dpm ([Daml Package Manager](https://docs.canton.network/sdks-tools/cli-tools/dpm)), in a project's `daml.yaml`. Ensure you remove the sdk-version field from the file. For example, if your current daml.yaml file is: -**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 +```yaml +sdk-version: 3.5.2 +name: daml-test-1 +source: daml +init-script: Main:setup +version: 0.0.1 +dependencies: + - daml-prim + - daml-stdlib + - daml-script ``` -> **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. - -
+You should use the following daml.yaml file: -
-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) +```yaml +#sdk-version: 3.5.2 +name: daml-test-1 +source: daml +init-script: Main:setup +version: 0.0.1 +dependencies: + - daml-prim + - daml-stdlib + - daml-script +components: + - damlc:3.5.2 + - daml-script:3.5.2 + - oci://ghcr.io/bitdynamics-ab/canton-devkit:latest ``` -
+then `dpm install package` and use it as `dpm localnet `. -
-As a DPM component +For a quick standalone install on macOS or Linux: ```sh -dpm install package canton-devkit -dpm localnet up demo +curl -fsSL https://raw.githubusercontent.com/bitdynamics-ab/canton-devkit/main/install.sh | sh ``` -`dpm localnet …` and `canton-devkit localnet …` are the same binary; pick whichever your team uses. +As a standalone binary, you can also download the archive for your +platform (macOS arm64, Linux amd64, Windows amd64) from the +[releases page](https://github.com/bitdynamics-ab/canton-devkit/releases) +and verify it against the `SHA256SUMS` file published with each release. +Homebrew (`brew install bitdynamics-ab/canton-devkit/canton-devkit`), an APT +repository for Debian/Ubuntu, and `go install` are also supported — the +[installation guide](docs/getting-started.md) covers each path +step by step. -
+Both paths ship the same binary; `dpm localnet ` and +`canton-devkit localnet ` are interchangeable everywhere below. -### 2 · Run +## Usage -```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 -``` - -> [!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: - -```sh -ssh -L 7777:127.0.0.1:7777 dev-host +canton-devkit localnet status demo +eval "$(canton-devkit localnet env demo)" +canton-devkit localnet down demo ``` -
- ---- - -## 📚 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 -``` - -**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. - ---- - -## ❓ FAQ - -
-Is this an official Canton or Digital Asset project? - -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) -``` - -Open an [issue](https://github.com/bitdynamics-ab/canton-devkit/issues) first for anything non-trivial. PRs against `main` welcomed. - ---- - -## 📦 Releasing - -Tagged builds (`v*`) publish: - -- Linux + macOS + Windows binaries to [GitHub Releases](https://github.com/bitdynamics-ab/canton-devkit/releases) -- Docker images to `ghcr.io/bitdynamics-ab/canton-devkit:` - -Manual cut: `git tag v0.1.0 && git push origin v0.1.0`. The [release workflow](.github/workflows/release.yml) handles the rest. - ---- - -## 💛 Acknowledgements - -`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. - -
- - -Built with care by Bit Dynamics AB · Licensed Apache 2.0 · ⭐ Star us - - -
+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 | + + +Token commands support both Canton token-standard generations, routed +per instrument: [CIP-0056](https://github.com/global-synchronizer-foundation/cips/blob/main/cip-0056/cip-0056.md) +(Final — what existing assets such as Canton Coin implement) for reads +and transfers, and Token Standard V2 +([CIP-0112](https://github.com/global-synchronizer-foundation/cips/blob/main/cip-0112/cip-0112.md), +approved but not yet final) for creating new instruments, which requires +an alpha Splice build (`--version token-standard-v2 --profile tokens-v2`) +— see the [tokens guide](docs/tokens.md). + +Commands that produce output take `--format json`, and exit codes are +stable (`0` ok, `1` usage, `2` preflight, `3` timeout or interrupt, `4` +runtime), so the CLI drops into CI without wrapper scripts. A ready-made +GitHub Actions workflow lives in +[`examples/ci/`](examples/ci/github-actions.yml). + +Multiple named instances run side by side — each gets its own compose +project, network, and ports. Ports are auto-allocated by default; +`--port-base` pins a deterministic layout when you need one. + +## Web UI + +`canton-devkit localnet ui` serves a local dashboard (loopback only). +CLI and Web UI expose the same operations: instance lifecycle, live +container health and logs, a contract explorer with per-party visibility, +DAR upload and inspection, metrics, and the token workspace. + +## Observability + +`up --profile observability` adds Prometheus and Grafana with a bundled +Canton dashboard: transaction rates, mediator latency, per-component +health. `localnet metrics` prints the headline numbers in the terminal. +See the [observability guide](docs/observability.md) and +[dashboard customization](docs/dashboard-customization.md). + +## How it works + +The devkit keeps a per-instance registry (compose project, allocated +ports, party credentials, Splice version) under your user config +directory. `up` resolves a pinned Splice version from the catalogue, +materialises compose +overlays for instance isolation, allocates loopback ports, signs dev +JWTs, and waits for health checks. `snapshot` captures a logical +PostgreSQL dump (`pg_dumpall`) of the instance's database plus its +registry state into a single archive that `restore` can replay on any +machine. + +Telemetry is anonymous, aggregate-only, and opt-out — no paths, no +party IDs, no per-invocation rows. Disable it with +`canton-devkit telemetry off`; [docs/telemetry.md](docs/telemetry.md) +documents every counter collected and the collector you can self-host. + +## Documentation + +Full documentation is published at + and lives in +[`docs/`](docs/). + +**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) + +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, the +[Canton forum](https://forum.canton.network/) is the better venue. + +## Download statistics + +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). + +Total downloads per release over time + +All-time downloads per platform + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the build, test, and lint +setup and the conventions the project holds itself to: a regression test +for every fix, CLI/Web-UI feature parity, SHA-pinned CI actions. + +canton-devkit builds on the work of the +[Splice](https://github.com/canton-network/splice) and +[Canton](https://github.com/digital-asset/canton) teams. + +## License + +[Apache 2.0](LICENSE) diff --git a/assets/assets.go b/assets/assets.go index d8b88832..59631e49 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): // 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/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..cb93c59b --- /dev/null +++ b/docs/changes-from-proposal.md @@ -0,0 +1,369 @@ +# 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) + - [`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 — JWTs omitted for safety), `env` (shell-exportable `AUTH__TOKEN=...` lines), `json` (full credential objects including JWTs), `raw` (single JWT, requires `--role`). + +**Why:** `env` covers Ledger API endpoints and wallet URLs; `creds` is the dedicated surface for auth tokens. Separating them avoids combining sensitive credential material with non-sensitive endpoint strings in one command, and makes it easier to handle each category differently (e.g. redact tokens in logs while freely printing URLs). + +--- + +## `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 | + +**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. + +--- + +### `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/explorer.md b/docs/explorer.md index d7d33d42..8804e37c 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. --- @@ -163,8 +164,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 +184,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 +196,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 @@ -238,7 +240,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 +272,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 +292,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..81081c04 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,8 +40,6 @@ 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 @@ -66,57 +63,71 @@ 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. - ---- +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 +151,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 +178,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 +233,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 +255,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 +282,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..1250a7c9 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,22 +67,29 @@ 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/`latest`, 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 @@ -120,30 +104,26 @@ 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. + +- **Each observability-enabled instance still *also* runs a + per-instance Prometheus + Grafana overlay** alongside the shared + stack, so while running it has **two** Prometheus and **two** Grafana + containers — roughly **~600 MiB** of duplicated overhead per extra + environment. +- **Why it's kept (for now).** The per-instance overlay is a deliberate + 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. +- **Removal pending validation.** The per-instance overlay stays enabled + until the shared-only path is validated end-to-end on a native Linux + Docker host. When that validation completes, the overlay can be gated + off without changing the CLI or Web UI observability commands. 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..1c538d7f 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,8 +101,7 @@ 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. @@ -117,14 +114,15 @@ 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.) ## 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 +130,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 @@ -149,9 +147,8 @@ 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 +platform regardless of the Linux `host-gateway` mapping. The per-instance +overlay remains enabled until the shared-only path is validated end-to-end +on a native Linux Docker host — see [Known limitations](limitations.md#observability-transitional-dual-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. 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..bf2da65d 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -31,29 +31,43 @@ 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`, so declare the component there and then install: + +```yaml +# daml.yaml +components: + - 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. +`` follows semver (no `v` prefix); tag `latest` points at the +most recently published final (non-pre-release) release. ### Manifest @@ -62,30 +76,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 +126,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 +155,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 +181,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 +197,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..a9682f26 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 | @@ -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,7 +852,7 @@ 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. | @@ -806,6 +860,7 @@ The test plan assumes command syntax that differs from the actual CLI implementa | 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-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-milestone1.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..50e9c79f 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 @@ -37,8 +39,13 @@ 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 @@ -52,10 +59,12 @@ ready until the Splice app fully boots. **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 @@ -68,23 +77,28 @@ 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. +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 --name [--service ]` — tail container logs + (repeat `--service` to filter to specific services). +- `localnet doctor` — host readiness diagnostics (docker, resources, + network); use `localnet status --name ` 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/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/examples/ci/github-actions.yml b/examples/ci/github-actions.yml index dcdb0f91..81c43bdc 100644 --- a/examples/ci/github-actions.yml +++ b/examples/ci/github-actions.yml @@ -98,7 +98,7 @@ jobs: - name: Tear down LocalNet if: always() run: | - # always() ensures cleanup even if tests failed. `clean` + # always() ensures cleanup even if tests failed. `remove` # removes containers, volumes, and registry state for the # instance in one shot. - canton-devkit localnet clean --name "${INSTANCE}" --force || true + canton-devkit localnet remove --name "${INSTANCE}" --force || true diff --git a/examples/ci/gitlab-ci.yml b/examples/ci/gitlab-ci.yml index e41bbdef..12382216 100644 --- a/examples/ci/gitlab-ci.yml +++ b/examples/ci/gitlab-ci.yml @@ -52,4 +52,4 @@ localnet-tests: - echo "Run your integration tests here against the live LocalNet." after_script: # Runs even on failure — guaranteed teardown. - - canton-devkit localnet clean --name "${INSTANCE}" --force || true + - canton-devkit localnet remove --name "${INSTANCE}" --force || true diff --git a/frontend/index.html b/frontend/index.html index e43daf81..aea43437 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - + canton-devkit diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 436108b2..4e427a07 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2007,9 +2007,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/src/App.tsx b/frontend/src/App.tsx index a76b3edb..2fbe896b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,6 +4,7 @@ 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"; @@ -15,18 +16,8 @@ import { AgentSkillsScreen } from "./screens/AgentSkillsScreen"; import { TokensScreen } from "./screens/TokensScreen"; import { W } 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 +49,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 ( @@ -108,7 +94,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, diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d1d6050b..39e9edd8 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -57,14 +57,11 @@ export async function apiFetch(path: string, init?: RequestInit): Promise }, }); 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 +99,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 { @@ -275,12 +281,17 @@ 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. +// +// include_jwt=true: the app config is meant to be copy-pasted into a +// dApp's environment, so a redacted token makes it unusable. LocalNet +// is loopback-only with dev-secret tokens (the UI shows the dev-secret +// warning), so the raw token is surfaced here on purpose. export async function fetchAppConfigText( name: string, format: "env" | "yaml", ): Promise { const resp = await fetch( - `/api/instances/${encodeURIComponent(name)}/app-config?format=${format}`, + `/api/instances/${encodeURIComponent(name)}/app-config?format=${format}&include_jwt=true`, ); if (!resp.ok) { const body = await resp.text(); @@ -303,7 +314,7 @@ export interface AppConfigPayload { export const fetchAppConfigJSON = (name: string) => apiFetch( - `/api/instances/${encodeURIComponent(name)}/app-config?format=json`, + `/api/instances/${encodeURIComponent(name)}/app-config?format=json&include_jwt=true`, ); // ── create-instance flow ────────────────────────────────── @@ -469,43 +480,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 +505,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 +519,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 +587,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 +716,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 +798,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 +1001,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 +1236,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 +1309,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 +1330,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 +1540,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. 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..1030007f --- /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 } 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/MetricCard.tsx b/frontend/src/components/MetricCard.tsx index 60814616..91485ff6 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 } 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} @@ -87,11 +77,21 @@ export function MetricCard({ style={{ fontFamily: wMono, fontSize: 11, + fontVariantNumeric: "tabular-nums", color: deltaColor, fontWeight: 600, + display: "inline-flex", + alignItems: "center", + gap: 4, }} > - {deltaSign > 0 ? "▲" : deltaSign < 0 ? "▼" : "—"}{" "} + {deltaSign > 0 ? ( + + ) : deltaSign < 0 ? ( + + ) : ( + "—" + )} {format(Math.abs(delta))} {unit && {" " + unit}} @@ -99,7 +99,7 @@ export function MetricCard({ {error ? ( -
+
{error}
) : ( @@ -119,9 +119,10 @@ export function MetricCard({ style={{ color: W.text, fontSize: 26, - fontWeight: 700, + fontWeight: 600, lineHeight: 1.1, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", }} > — @@ -134,6 +135,7 @@ export function MetricCard({ fontSize: 26, fontWeight: 600, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", lineHeight: 1, }} > @@ -145,7 +147,7 @@ export function MetricCard({ )}
-
+
{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..8e1139fe --- /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 } 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; + color?: string; + style?: CSSProperties; +} + +export function MonoId({ + value, + head = 8, + tail = 6, + full = false, + size = 12, + 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..bb8c4047 --- /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 } 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..e4f4b1a2 100644 --- a/frontend/src/components/charts/AreaChart.tsx +++ b/frontend/src/components/charts/AreaChart.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useId, useMemo, useState } from "react"; import { W, wMono } 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" }} > - + @@ -164,7 +168,7 @@ export function AreaChart({ {hasData ? ( <> - + (Math.abs(v) >= 1000 ? v.toFixed(0) : v.toFixed(1)), }: Props) { // Default height grows with bar count so dense lists don't squish. diff --git a/frontend/src/components/charts/Heatmap.tsx b/frontend/src/components/charts/Heatmap.tsx index ef1c72dd..db0909e3 100644 --- a/frontend/src/components/charts/Heatmap.tsx +++ b/frontend/src/components/charts/Heatmap.tsx @@ -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); 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..b24e0927 --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,257 @@ +// 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 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) => ( + + + + +); + +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..82f2eff2 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,14 @@ body, margin: 0; padding: 0; height: 100%; - background: var(--bg); - color: var(--text); - font-family: "IBM Plex Sans", "Inter Tight", system-ui, sans-serif; + background: var(--bg-page); + color: var(--text-primary); + font-family: "Archivo", -apple-system, "Segoe UI", "Helvetica Neue", + Arial, sans-serif; font-size: 14px; line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } button { @@ -37,40 +128,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: 13px; + 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: 12px; +} + +.bd-btn--md { + height: 36px; + padding: 0 14px; + font-size: 13px; +} + +.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 +312,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..12332e2a 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 } 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" } @@ -103,7 +101,6 @@ export function AgentSkillsScreen() { >
- {/* Install bar */}
doInstall("codex")} /> {install.kind === "done" && ( - - ✓ {install.count} installed → {install.dir} + + {install.count} installed → {install.dir} )} {install.kind === "done" && install.skipped.length > 0 && ( @@ -146,35 +152,32 @@ export function AgentSkillsScreen() { fontFamily: wMono, }} > - ⚠ {install.skipped.length} preserved (locally modified):{" "} - {install.skipped.join(", ")} - + )} {install.kind === "err" && ( - - ✗ {install.message} + + {install.message} )}
- {/* Two-pane: list | preview */}
@@ -203,11 +206,11 @@ 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}
@@ -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", }} @@ -261,7 +264,7 @@ function Header() { color: W.dim, border: `1px solid ${W.border}`, padding: "1px 7px", - borderRadius: 4, + borderRadius: 2, fontSize: 10.5, fontFamily: wMono, }} @@ -271,8 +274,8 @@ function Header() {
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/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..e93d34df 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 } 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) {
@@ -145,19 +101,19 @@ export function BackupRestore({ instanceName }: Props) { Backup & restore
- tar archive of docker volumes + registry state + logical database dump + registry state - {/* Download row */}
- + {downloading ? "Preparing…" : "Download snapshot"} + mirrors{" "} @@ -167,15 +123,14 @@ export function BackupRestore({ instanceName }: Props) {
- {/* Download error banner */} {downloadError && (
)} - {/* Restore row */}
{restore.kind === "uploading" ? ( @@ -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,9 +289,9 @@ 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, 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..9d854cc5 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 } 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, }} > @@ -140,9 +131,9 @@ 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, }} @@ -156,9 +147,9 @@ 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, marginBottom: 8, @@ -187,6 +178,12 @@ export function ContainerHealth({ name }: { name: string }) { ); } +const colHeader: React.CSSProperties = { + ...tableCaps, + color: W.dim, + fontSize: 11, +}; + function ContainersTable({ containers, onPickLogs, @@ -205,11 +202,7 @@ function ContainersTable({
); } - // 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}
@@ -275,25 +262,17 @@ function ContainersTable({ )}
{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, 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..d6318094 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 } 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;
@@ -126,9 +114,13 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
             since={since}
             setSince={setSince}
           />
-          
+          
+            title="Close (esc)"
+            onClick={onClose}
+          />
         
- {detail.contract_id} - +
{state.kind === "loading" && (
- loading full detail… + Loading full detail…
)} {state.kind === "err" && (
)} {detail.archived_offset !== undefined && ( -
+
offset {detail.archived_offset.toLocaleString()}
)} {detail.archived_update_id && ( - tx · {detail.archived_update_id.slice(0, 16)}… + tx · {truncMid(detail.archived_update_id)} )} @@ -348,14 +308,18 @@ export function ContractDetailDrawer({ ); } -// ── helpers ────────────────────────────────────────────────────── - function shortTemplateLabel(tpl: string | undefined): string { if (!tpl) return "—"; const parts = tpl.split(":"); return parts.length >= 3 ? `${parts[1]}:${parts[2]}` : tpl; } +// Middle-truncate an id, keeping both ends (the suffix is discriminating). +function truncMid(s: string, head = 8, tail = 6): string { + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} + function Section({ label, children, @@ -369,9 +333,7 @@ function Section({ style={{ color: W.dim, fontSize: 10.5, - letterSpacing: 1.4, - textTransform: "uppercase", - fontWeight: 600, + ...wideCaps, marginBottom: 6, }} > @@ -392,11 +354,11 @@ function Pill({ return ( - - {party} - +
); } -// PayloadRenderer — recursive JSON-like view for the contract's -// payload. Renders objects as label : value pairs, arrays as -// bracketed lists, primitives in place. Each level indents by 12 px -// — enough to see structure without burning horizontal space in -// the 380 px drawer. -function PayloadRenderer({ value }: { value: unknown }): JSX.Element { - return ; -} - +// Recursive JSON-like view of the contract payload; each level indents 12px. function PayloadNode({ value, depth, @@ -520,7 +465,8 @@ function primStyle(kind: "text" | "num" | "dim"): React.CSSProperties { return { fontFamily: wMono, fontSize: 11, - color: kind === "dim" ? W.dim : kind === "num" ? "#F5BF55" : W.text2, - wordBreak: "break-all", + color: kind === "dim" ? W.dim : kind === "num" ? W.warn : W.text2, + fontVariantNumeric: kind === "num" ? "tabular-nums" : undefined, + wordBreak: "break-word", }; } diff --git a/frontend/src/screens/CreateLocalNetModal.tsx b/frontend/src/screens/CreateLocalNetModal.tsx index 665fff72..59a5e574 100644 --- a/frontend/src/screens/CreateLocalNetModal.tsx +++ b/frontend/src/screens/CreateLocalNetModal.tsx @@ -13,7 +13,9 @@ import { type PreflightReport, type SpliceVersionEntry, } from "../api"; -import { W, wMono, wSans } from "../tokens"; +import { W, wMono, wSans, tint, R } from "../tokens"; +import { Button } from "../components/Button"; +import { Dot, IcAlert, IcCheck, IcStop, IcX } from "../components/icons"; import { remediationForCode } from "./remediation"; import { type ProgressState, @@ -21,28 +23,14 @@ import { useCreateProgress, } from "./useCreateProgress"; -// CreateLocalNetModal — the "Create LocalNet" flow from -// webui-create.jsx. Drives three top-level stages internally: -// -// 1. form — empty/validating; name + version + advanced -// 2. submitting — POST in flight; brief (<1s) -// 3. progress — 202 received; EventSource open; render steps -// -// "Done" / "failed" / "cancelled" are sub-states of progress — -// the modal stays open until the user closes it. -// -// Validation: name regex matches internal/registry's RFC 1123 -// DNS-label rule. We pre-validate client-side for snappy feedback; -// the server validates too, so a stale regex here is a UX bug -// not a security one. +// Stages: form → submitting → progress (with done/failed/cancelled +// sub-states). RFC 1123 DNS-label rule from internal/registry; the +// server re-validates, so this is advisory only. const NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; interface Props { open: boolean; onClose: () => void; - // Called on success so the dashboard refreshes its instance - // list and selects the new instance. Optional — most callers - // pass () => sel.refresh(). onCreated?: (name: string) => void; } @@ -56,32 +44,15 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { const [name, setName] = useState(""); const [version, setVersion] = useState(""); const [allowUncurated, setAllowUncurated] = useState(false); - // observability: split into two per-component toggles so users can - // enable Prometheus and Grafana independently (e.g. metrics-only - // setups, or Grafana pointed at an external scrape source). - // Default OFF for both — the overlay pulls extra container images - // and adds memory pressure, opt-in is friendlier for "just spin - // one up". When grafana is on but prometheus is off the form - // shows a warning (not a block) so the combination is reachable - // but the empty-dashboard surprise is signposted. const [prometheus, setPrometheus] = useState(false); const [grafana, setGrafana] = useState(false); - // tokensV2: when on, bring-up adds the Token Standard V2 alpha-protocol - // Canton overlay (`--profile tokens-v2`). Needs a V2-capable Splice - // version; default OFF. const [tokensV2, setTokensV2] = useState(false); - // portBase: when non-empty, pins deterministic host ports from this - // base (`--port-base`) instead of auto-allocating. Empty = auto. const [portBase, setPortBase] = useState(""); const [versions, setVersions] = useState([]); const [versionsLoading, setVersionsLoading] = useState(false); const [versionsError, setVersionsError] = useState(null); const [stage, setStage] = useState({ kind: "form" }); - // Per-version system-requirements check. "idle" = no version - // picked yet; "loading" = probe in flight; "ok" = host meets - // floor; "blocked" = at least one FAIL — Create button disabled. - // Warnings (WARN-only report) still allow submit; the user sees - // them inline as a heads-up. + // "blocked" (any FAIL) disables Create; WARN-only still allows submit. const [preflight, setPreflight] = useState< | { kind: "idle" } | { kind: "loading" } @@ -95,8 +66,7 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { stage.kind === "progress" ? stage.accepted.events_url : null, ); - // Reset every time the modal opens. Stale form values from a - // previous launch are confusing — each open is a fresh form. + // Each open is a fresh form. useEffect(() => { if (open) { setName(""); @@ -108,23 +78,17 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { setPortBase(""); setStage({ kind: "form" }); requestAnimationFrame(() => inputRef.current?.focus()); - // Refresh the version catalogue on open. Cached on the - // server (versions.json is embedded), so this is fast. setVersionsLoading(true); setVersionsError(null); fetchSpliceVersions() .then((r) => { setVersions(r.versions); - // Pre-select the "latest" entry so the user doesn't - // have to click anything for the common case. if (!version) { const latest = r.versions.find((v) => v.status === "latest"); if (latest) setVersion(latest.tag); } }) .catch((e) => { - // Distinguish failure from "still loading": a collapsed empty - // state left the picker showing "Loading…" forever on a 5xx. setVersions([]); setVersionsError( e instanceof ApiError ? e.message : "Couldn't load the version catalogue", @@ -132,20 +96,17 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { }) .finally(() => setVersionsLoading(false)); } - // versions captured intentionally — only re-run on open. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - // Escape closes the modal — but only from the form/done/failed - // stages, not mid-submit. Accidentally cancelling a 90-second - // up because of a stray Esc is much worse than the extra click. + // Escape closes, but not mid-submit or while a bring-up is running. useEffect(() => { if (!open) return; function onKey(e: KeyboardEvent) { if (e.key !== "Escape") return; if (stage.kind === "submitting") return; if (stage.kind === "progress" && progress.banner.kind === "running") { - return; // running — require explicit Cancel button click + return; // require explicit Cancel while running } onClose(); } @@ -153,17 +114,8 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { return () => window.removeEventListener("keydown", onKey); }, [open, stage, progress.banner.kind, onClose]); - // When the up succeeds, fire onCreated so the dashboard can - // refresh its list and pick up the new instance. The modal - // stays open until the user closes it explicitly — the - // "is ready" banner is the celebratory beat. - // - // Fires AT MOST ONCE per (stage instance) — without this the - // callback gets re-invoked on every render because parents - // typically pass a fresh arrow function as `onCreated`, which - // changes the effect's identity each render and re-triggers - // the body. With `firedRef` we guarantee one call per accepted - // instance, regardless of how the parent typed the callback. + // Fire onCreated at most once per accepted instance; a fresh + // callback identity each render would otherwise re-invoke it. const firedRef = useRef(null); useEffect(() => { if ( @@ -175,18 +127,13 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { onCreated?.(stage.accepted.instance); } }, [progress.banner.kind, stage, onCreated]); - // Reset the fired guard whenever the modal closes so a NEW - // open with a NEW create can fire onCreated again. useEffect(() => { if (!open) firedRef.current = null; }, [open]); - // Probe system requirements whenever the picked version changes - // (while still on the form stage). Skipped for the uncurated-tag - // bypass since the server doesn't enforce a per-version floor - // for tags not in the catalogue. Race-safe via a cancelled flag — - // a fast-clicker who flips between versions only sees the latest - // result, not whichever subprocess probe finishes last. + // Probe requirements on version change (form stage only). Skipped for + // uncurated tags; the cancelled flag keeps the latest result when the + // user flips versions quickly. useEffect(() => { if (!open || stage.kind !== "form") return; if (!version || allowUncurated) { @@ -222,9 +169,6 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { if (!open) return null; const nameValid = NAME_RE.test(name); - // Gating: name validity AND preflight not blocked. "loading" or - // "err" still allows submit — preflight is advisory in those - // states; the server's own gate is the source of truth. const preflightBlocks = preflight.kind === "blocked"; const canSubmit = nameValid && stage.kind === "form" && !preflightBlocks; @@ -251,10 +195,7 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { setStage({ kind: "progress", accepted }); } catch (e) { if (e instanceof PreflightFailedError) { - // Server-side gate caught what the inline probe missed - // (race with the user, or first time the version was - // chosen). Drop back to form stage with the report - // populated so the inline panel renders the findings. + // Server gate caught what the inline probe missed; show findings. setPreflight({ kind: "blocked", report: e.report }); setStage({ kind: "form" }); return; @@ -272,13 +213,9 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { if (stage.kind !== "progress") return; try { await cancelInstanceUp(stage.accepted.instance); - // The SSE stream will deliver the kind=cancelled event; - // the reducer flips banner.kind to "cancelled". No local - // state mutation needed. + // SSE delivers kind=cancelled; the reducer flips the banner. } catch { - // Cancel-already-finished is a 404 swallowed by the API - // client; other errors are rare enough that an inline - // toast is overkill. + // Cancel-after-finish 404s; not worth surfacing. } } @@ -343,8 +280,6 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { ); } -// ── header / footer ─────────────────────────────────────────────── - function ModalHeader({ stage, progress, @@ -432,44 +367,33 @@ function ModalFooter({ )} {isRunning ? ( - <> - - + ) : stage.kind === "form" ? ( <> - - + ) : ( - + )} ); } -// ── stage bodies ────────────────────────────────────────────────── - type PreflightState = | { kind: "idle" } | { kind: "loading" } @@ -527,7 +451,7 @@ function FormBody({ return (
0 @@ -546,7 +470,7 @@ function FormBody({ /> - + - {/* Observability sidecars — opt-in, per-component. Splitting - Prometheus and Grafana lets users run metrics-scrape-only - (no Grafana RAM cost) or point Grafana at an external - scrape source. */} - {/* Token Standard V2 alpha overlay — opt-in. Mirrors the CLI's - --profile tokens-v2; needs a V2-capable Splice version. */} ). The - instance will settle at status partial — the V2 + instance settles at status partial. The V2 splice healthcheck never reports healthy, but token flows work. Equivalent to{" "} @@ -731,7 +649,7 @@ function FormBody({ padding: "6px 0", }} > - Advanced — uncurated versions + Advanced · uncurated versions
@@ -766,7 +684,7 @@ function FormBody({ marginTop: 8, padding: "8px 12px", background: W.surface2, - borderRadius: 8, + borderRadius: 4, }} >
@@ -804,7 +723,7 @@ function FormBody({ marginTop: 4, padding: "8px 10px", background: W.surface2, - borderRadius: 7, + borderRadius: 2, fontFamily: wMono, }} > @@ -856,16 +775,18 @@ function ProgressBody({
- ⚠ {m} + + {m} +
))}
@@ -887,7 +808,7 @@ function ProgressBody({ margin: "8px 0 0", background: W.bg, border: `1px solid ${W.border}`, - borderRadius: 7, + borderRadius: 2, padding: "10px 12px", fontFamily: wMono, fontSize: 10.5, @@ -934,7 +855,7 @@ function ErrorBody({ role="alert" style={{ padding: "20px 22px", - background: `${W.err}10`, + background: `${tint(W.err, 6)}`, color: W.text, }} > @@ -958,23 +879,23 @@ function ErrorBody({ ); } -// ── pieces ──────────────────────────────────────────────────────── - function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { if (banner.kind === "running") { return (
- ● streaming step events + + streaming step events +
); } @@ -983,15 +904,17 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
- ✓ {banner.detail || "ready"} + + {banner.detail || "ready"} +
); } @@ -1001,14 +924,18 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
- ✗ {banner.summary ?? "failed"} + + {banner.summary ?? "failed"} + {banner.cause && (
{banner.cause} @@ -1019,11 +946,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}`, }} > {remediation.title} @@ -1037,19 +963,20 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
); } - // cancelled return (
- ⏹ cancelled{banner.reason ? ` — ${banner.reason}` : ""} + + Cancelled{banner.reason ? `. ${banner.reason}` : ""} +
); } @@ -1058,13 +985,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,11 +1008,22 @@ function StepRow({ label, state }: { label: string; state: StepState }) { display: "flex", gap: 10, padding: "6px 4px", - borderBottom: `1px dashed ${W.border}`, + borderBottom: `1px solid ${W.border}`, fontSize: 12.5, }} > - {icon} + + {icon} +
{label}
{(state.detail || state.summary) && ( @@ -1125,22 +1063,9 @@ function StepRow({ label, state }: { label: string; state: StepState }) { ); } -// VersionPicker renders the curated Splice catalogue as a native HTML -// 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 +1132,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 +1145,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 +1169,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, 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 +1197,15 @@ function PreflightPanel({ state }: { state: PreflightState }) { background: W.surface2, color: W.dim, border: `1px solid ${W.border}`, - borderRadius: 7, + borderRadius: 2, fontSize: 11.5, fontFamily: wMono, }} > - ⠋ checking system requirements (docker memory · disk · daemon)… + + checking system requirements (docker + memory · disk · daemon)… +
); } @@ -1319,7 +1217,7 @@ function PreflightPanel({ state }: { state: PreflightState }) { background: W.surface2, color: W.dim, border: `1px solid ${W.border}`, - borderRadius: 7, + borderRadius: 2, fontSize: 11.5, }} > @@ -1338,26 +1236,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,14 +1272,23 @@ 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, + borderRadius: R.control, fontSize: 12, }} > -
- {heading} +
+ {headingIcon} {heading}
{state.report.summary && (
@@ -1388,18 +1303,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}
@@ -1451,11 +1374,10 @@ function Field({
)} {progress.terminal.length > 0 && ( @@ -181,7 +153,7 @@ export function CreatingPanel({ name, onRefresh }: Props) { margin: "8px 0 0", background: W.bg, border: `1px solid ${W.border}`, - borderRadius: 7, + borderRadius: R.control, padding: "10px 12px", fontFamily: wMono, fontSize: 10.5, @@ -228,13 +200,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 = @@ -251,11 +223,23 @@ function StepRow({ label, state }: { label: string; state: StepState }) { display: "flex", gap: 10, padding: "6px 4px", - borderBottom: `1px dashed ${W.border}`, + borderBottom: `1px solid ${W.border}`, fontSize: 12.5, }} > - {icon} + + {icon} +
{label}
{(state.detail || state.summary) && ( @@ -276,7 +260,7 @@ function StepRow({ label, state }: { label: string; state: StepState }) { marginTop: 4, height: 4, background: W.surface2, - borderRadius: 2, + borderRadius: R.control, overflow: "hidden", }} > @@ -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,10 +312,11 @@ function ZombieHint({ }) { return (
    -
  • 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..685ef2da 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 } from "../tokens"; +import { MonoId } from "../components/MonoId"; +import { + IcArrowRight, + IcChevronDown, + IcChevronRight, +} from "../components/icons"; interface Props { instance: string; @@ -75,7 +73,16 @@ export function DARDiff({ instance, a, b, role }: Props) {
- + + +
@@ -191,7 +198,7 @@ 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, maxHeight: "60vh", @@ -219,14 +226,14 @@ function Side({ ); } 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 }; } } @@ -271,13 +278,18 @@ function Section({ border: "none", color: c.fg, fontSize: 11.5, - fontWeight: 600, 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 && (
@@ -321,7 +333,7 @@ function ChipGroup({ key={l} style={{ padding: "0 5px", - borderRadius: 3, + borderRadius: R.control, background: c.bg, color: c.fg, fontSize: 10.5, diff --git a/frontend/src/screens/DARPackageTree.tsx b/frontend/src/screens/DARPackageTree.tsx index 08cede8d..1b0d48fb 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 } 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,8 +234,21 @@ function ModuleNode({ aria-expanded={expanded} style={treeRowStyle(false)} > - - {total === 0 ? "·" : expanded ? "▾" : "▸"} + + {total === 0 ? ( + "·" + ) : expanded ? ( + + ) : ( + + )} {mod.name} @@ -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}
))} @@ -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 ( { }); 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..0b0e6e15 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 } 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]); @@ -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" ? ( ) : ( <> -
+
+ +
- or click to browse · multi-file ok + or click to browse · multiple .dar accepted
)} @@ -397,7 +373,7 @@ export function DARScreen() { marginTop: 12, padding: "8px 10px", background: W.border, - borderRadius: 6, + borderRadius: 2, fontSize: 11.5, color: selectedRoles.length === 0 ? W.warn : W.text2, lineHeight: 1.5, @@ -405,13 +381,21 @@ export function DARScreen() { > {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,7 +440,7 @@ export function DARScreen() { >
Packages on {role} participant
@@ -481,7 +464,6 @@ export function DARScreen() {
- {/* Column header */}
@@ -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(() => { @@ -592,16 +565,18 @@ function WatchModeCard({ instance }: { instance: string }) {
+ {active ? "Watching" : "Idle"} {last && ( @@ -618,7 +593,7 @@ function WatchModeCard({ instance }: { instance: string }) { background: W.border, padding: "6px 8px", marginTop: 4, - borderRadius: 4, + borderRadius: 2, fontFamily: wMono, fontSize: 11, color: W.text2, @@ -635,10 +610,7 @@ function WatchModeCard({ instance }: { instance: string }) { ); } -// formatAgo renders a "X ago" label for a unix-second delta. Tuned -// for human-perceptible bands; finer than 5s is noise for this card. function formatAgo(deltaSec: number): string { - if (deltaSec < 0) return "just now"; if (deltaSec < 5) return "just now"; if (deltaSec < 60) return `${Math.floor(deltaSec)}s ago`; if (deltaSec < 3600) return `${Math.floor(deltaSec / 60)}m ago`; @@ -646,10 +618,7 @@ function formatAgo(deltaSec: number): string { return `${Math.floor(deltaSec / 86400)}d ago`; } -// VetState is the per-row vetting cell state: "loading" while the -// vetting probe is in flight, "ok" with the resolved per-participant -// rows, or "err" when the probe failed. Undefined means not yet -// requested. +// undefined means not yet requested. type VetState = | { kind: "loading" } | { kind: "ok"; rows: DARVettingRow[] } @@ -675,11 +644,10 @@ function PkgRow({ gap: 14, padding: "10px 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 120ms", }} > {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 ( @@ -751,7 +711,7 @@ function VettingCell({ vet }: { vet: VetState | undefined }) { {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, + lineHeight: 1.5, }} > - Select a package to inspect. + Select a package to inspect its tree, per-participant vetting, and + structural diff.
); } @@ -818,13 +772,13 @@ function InspectDrawer({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, overflow: "hidden", }} >
- + {row.name} @@ -838,7 +792,18 @@ function InspectDrawer({ )}
- +
+ 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, @@ -917,7 +870,7 @@ function CompareSelector({ background: W.border, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: 2, padding: "3px 6px", fontSize: 11.5, fontFamily: wMono, @@ -937,10 +890,8 @@ function CompareSelector({ ); } -// VettingPanel renders the per-participant vetting state for one -// DAR and lets the user toggle each. Loads on mount, refetches after -// every successful toggle so the UI never shows a stale "vetted=true" -// after an UnvetDar succeeded. +// Per-participant vetting toggles; refetches after each successful +// toggle so state never goes stale. function VettingPanel({ instance, mainID, @@ -1032,15 +983,15 @@ function VettingPanel({ border: "none", padding: 0, cursor: pending === r.role ? "wait" : "pointer", - color: r.vetted ? "#62E2A0" : W.dim, + color: r.vetted ? W.ok : W.dim, }} > @@ -1109,12 +1060,8 @@ function UploadProgress({ ); } -// UploadResultBanner renders the per-participant outcome of a -// multi-target upload. "success" = every role succeeded; -// "partial" = at least one role failed but others succeeded -// (the backend still returns 200 — partial failures land here, -// not in the error banner — so the user sees what landed and -// what didn't). +// Per-participant outcome of a multi-target upload. Partial failures +// still return 200, so they land here, not the error banner. function UploadResultBanner({ kind, total, @@ -1128,22 +1075,32 @@ function UploadResultBanner({ const accent = kind === "success" ? W.brand : W.warn; const heading = kind === "success" - ? `✓ Uploaded ${total} package${total === 1 ? "" : "s"} to ${okCount} participant${okCount === 1 ? "" : "s"}. Refreshing list…` - : `⚠ Partial upload — ${okCount}/${results.length} participant${results.length === 1 ? "" : "s"} succeeded`; + ? `Uploaded ${total} package${total === 1 ? "" : "s"} to ${okCount} participant${okCount === 1 ? "" : "s"}. Refreshing list…` + : `Partial upload. ${okCount}/${results.length} participant${results.length === 1 ? "" : "s"} succeeded.`; return (
-
0 ? 6 : 0 }}> - {heading} +
0 ? 6 : 0, + display: "flex", + alignItems: "center", + gap: 6, + }} + > + {kind === "success" ? : } + {heading}
{results.map((r) => (
- - {r.ok ? "✓" : "✗"} + + {r.ok ? : } {r.role} @@ -1178,9 +1142,9 @@ function ErrorBanner({ msg }: { msg: string }) {
{ROLES.map((r) => { @@ -1215,16 +1179,16 @@ function RoleSwitcher({ key={r} onClick={() => onChange(r)} style={{ - background: active ? W.surface : "transparent", - color: active ? W.text : W.dim, + background: active ? tint(W.brand, 16) : "transparent", + color: active ? W.brand : W.dim, border: "none", - borderRadius: 6, + borderRadius: R.control, padding: "5px 12px", fontSize: 12, fontFamily: wMono, fontWeight: active ? 600 : 500, cursor: active ? "default" : "pointer", - boxShadow: active ? `0 0 0 1px ${W.brand}` : "none", + transition: "background-color 120ms", }} > {r} @@ -1258,7 +1222,7 @@ function VetToggle({ padding: "5px 8px", background: W.border, border: "none", - borderRadius: 6, + borderRadius: 2, fontSize: 12, cursor: "pointer", width: "100%", @@ -1270,8 +1234,8 @@ function VetToggle({ style={{ width: 24, height: 14, - background: on ? W.brand : "#3A4248", - borderRadius: 7, + background: on ? W.brand : W.borderHi, + borderRadius: 999, position: "relative", flexShrink: 0, transition: "background 120ms", @@ -1310,9 +1274,9 @@ function FilterBtn({ style={{ padding: "4px 10px", fontSize: 11.5, - borderRadius: 5, + borderRadius: 2, border: `1px solid ${active ? W.brand : W.border}`, - background: active ? `${W.brand}1A` : "transparent", + background: active ? `${tint(W.brand, 10)}` : "transparent", color: active ? W.brand : W.dim, cursor: "pointer", fontFamily: wMono, @@ -1324,8 +1288,6 @@ function FilterBtn({ ); } -// ─── Tiny shared primitives ───────────────────────────────── - function Card({ title, subtitle, @@ -1340,7 +1302,7 @@ function Card({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, overflow: "hidden", }} > @@ -1387,9 +1349,7 @@ function SectionLabel({ children }: { children: React.ReactNode }) { style={{ color: W.dim, fontSize: 10.5, - letterSpacing: 1.4, - textTransform: "uppercase", - fontWeight: 600, + ...tableCaps, }} > {children} @@ -1410,15 +1370,21 @@ function KV({ }) { return (
- {label} + {label} {value} @@ -1437,26 +1403,52 @@ function Row({ vColor?: string; }) { return ( -
- {k} +
+ {k} {v}
); } -function Status({ children }: { children: React.ReactNode }) { +// Package-list skeleton, gated so a fast local fetch never flashes it. +function DARListLoading() { + const show = useLoadingDelay(true); return (
- {children} +
+ Loading package list +
+ {show ? ( + + ) : ( +
+ )}
); } @@ -1467,7 +1459,7 @@ function ErrorPanel({ msg }: { msg: string }) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: 16, color: W.err, fontSize: 13, @@ -1488,9 +1480,9 @@ function EmptyPanel({ return (
diff --git a/frontend/src/screens/Dashboard.test.tsx b/frontend/src/screens/Dashboard.test.tsx index 5066d2d0..2e1d3791 100644 --- a/frontend/src/screens/Dashboard.test.tsx +++ b/frontend/src/screens/Dashboard.test.tsx @@ -5,16 +5,6 @@ import { MemoryRouter } from "react-router-dom"; import { Dashboard } from "./Dashboard"; import { InstanceSelectionProvider } from "../shell/useInstanceSelection"; -// Dashboard tests — the user-facing states for the Overview -// screen. Pin the table-rendering + click-to-select wiring + -// the empty/error fallbacks; the InstanceTable's status badge -// is implementation detail not worth testing in isolation. -// -// Three classes of state the user sees: -// 1. ok with instances → table + InstanceDetail + DeveloperSetup -// 2. ok with empty list → EmptyState ("run dpm localnet up") -// 3. error → ErrorPanel with the message - function mockListResponse( instances: Array<{ name: string; status: string }> | "error", warning?: string, @@ -23,7 +13,6 @@ function mockListResponse( vi.stubGlobal( "fetch", vi.fn().mockImplementation((url: string) => { - // /api/instances/:name detail — for InstanceDetail card. if (url.match(/\/api\/instances\/[^/?]+(?:\?|$)/)) { return Promise.resolve( new Response( @@ -43,9 +32,7 @@ function mockListResponse( ), ); } - // /api/instances/{name}/containers — ContainerHealth's - // 3s poll. Return empty list so the panel renders the - // "no containers" placeholder rather than the error path. + // Empty list so ContainerHealth renders its placeholder, not the error path. if (url.match(/\/api\/instances\/[^/?]+\/containers/)) { return Promise.resolve( new Response( @@ -63,8 +50,7 @@ function mockListResponse( ), ); } - // /api/instances/{name}/transactions — the RecentActivity - // panel's ledger-event scan, fired only for a running instance. + // RecentActivity's ledger-event scan, fired only for a running instance. if (url.includes("/transactions")) { if (txOverride) { return Promise.resolve( @@ -99,7 +85,6 @@ function mockListResponse( ), ); } - // /api/instances list — primary fetch. if (url.includes("/api/instances")) { if (instances === "error") { return Promise.resolve( @@ -130,9 +115,7 @@ function mockListResponse( ), ); } - // JWT + app-config — DeveloperSetup fires these once the - // instance is selected. Return minimal payloads to keep - // the components happy. + // DeveloperSetup fires these once an instance is selected. if (url.includes("/jwt")) { return Promise.resolve( new Response( @@ -177,19 +160,15 @@ describe("Dashboard", () => { ]); renderDashboard(); - // "demo" appears in the table AND in the InstanceDetail - // header (auto-selected); "hubble" only in the table. - // Scope to so we're asserting the row, not the - // detail card's echo. + // Scope to
so we assert the row, not the detail card's echo of "demo". await waitFor(() => { const table = screen.getByRole("table"); expect(within(table).getByText("demo")).toBeInTheDocument(); expect(within(table).getByText("hubble")).toBeInTheDocument(); }); - // STATE badges within the table. const table = screen.getByRole("table"); - expect(within(table).getByText("running")).toBeInTheDocument(); - expect(within(table).getByText("stopped")).toBeInTheDocument(); + expect(within(table).getByText("Running")).toBeInTheDocument(); + expect(within(table).getByText("Stopped")).toBeInTheDocument(); }); it("renders the EmptyState when no instances are registered", async () => { @@ -198,8 +177,6 @@ describe("Dashboard", () => { await waitFor(() => { expect(screen.getByText(/no localnet instances/i)).toBeInTheDocument(); }); - // The remediation hint must include the dpm command — this - // is the user's first interaction with an empty UI. expect(screen.getByText(/dpm localnet up/i)).toBeInTheDocument(); }); @@ -212,9 +189,6 @@ describe("Dashboard", () => { }); it("renders the warning strip when ListResponse.warning is set", async () => { - // Same warning the CLI's `dpm localnet list` surfaces (e.g. - // registry parse drift). Should show as an amber strip above - // the table. mockListResponse( [{ name: "demo", status: "running" }], "registry has 1 unreadable entry; ignoring", @@ -234,20 +208,12 @@ describe("Dashboard", () => { ]); renderDashboard(); - // The auto-pick rule picks demo (first running). Click on - // hubble's row to override. + // Auto-pick selects demo (first running); click hubble to override. const hubbleCell = await screen.findByText("hubble"); await userEvent.click(hubbleCell); - // After selection, the InstanceDetail card pops with the - // detail-fetched data. We fetch a static "demo" detail in - // the mock, but the card header echoes the URL-selected - // name (hubble), so look for that as the source-of-truth. + // InstanceDetail only renders once selection is non-null. await waitFor(() => { - // The hubble cell should now show in the brand colour - // class — but we can't easily check colour. Instead pin - // that the InstanceDetail section appeared, which only - // happens once selection is non-null. expect(screen.getByText(/instance detail/i)).toBeInTheDocument(); }); }); @@ -259,9 +225,7 @@ describe("Dashboard", () => { ]); renderDashboard(); - // InstanceDetail appears because the auto-pick selected demo. - // Without the auto-pick rule there'd be no selected - // instance and the detail card wouldn't render. + // InstanceDetail renders only because auto-pick selected demo. await waitFor(() => { expect(screen.getByText(/instance detail/i)).toBeInTheDocument(); }); @@ -270,8 +234,6 @@ describe("Dashboard", () => { it("shows the recent-activity panel with ledger events for a running instance", async () => { mockListResponse([{ name: "demo", status: "running" }]); renderDashboard(); - // The panel mounts for the auto-selected running instance and - // flattens transactions → one row per ledger event. await waitFor(() => expect(screen.getByText(/recent activity/i)).toBeInTheDocument(), ); @@ -292,8 +254,7 @@ describe("Dashboard", () => { }); it("recent-activity shows the restart-to-capture hint for the no-JWT-recorded 500", async () => { - // The real e2e-metrics-demo case: instances predating JWT capture - // return a generic 500, distinguished by message, not a code. + // Instances predating JWT capture return a generic 500 distinguished by message, not code. mockListResponse([{ name: "demo", status: "running" }], undefined, { status: 500, body: { code: "INTERNAL", error: "no JWT recorded for role app-provider" }, diff --git a/frontend/src/screens/Dashboard.tsx b/frontend/src/screens/Dashboard.tsx index 9063cc9b..ae21898e 100644 --- a/frontend/src/screens/Dashboard.tsx +++ b/frontend/src/screens/Dashboard.tsx @@ -6,7 +6,12 @@ import { type TransactionEvent, type TransactionRow, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tableCaps, tint, R } from "../tokens"; +import { Button } from "../components/Button"; +import { IcPlus, IcRefresh } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; +import { MonoId } from "../components/MonoId"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; import { useInstanceSelection } from "../shell/useInstanceSelection"; import { ContainerHealth } from "./ContainerHealth"; import { CreateLocalNetModal } from "./CreateLocalNetModal"; @@ -14,22 +19,12 @@ import { CreatingPanel } from "./CreatingPanel"; import { DeveloperSetup } from "./DeveloperSetup"; import { InstanceDetail } from "./InstanceDetail"; -// Dashboard — Overview screen. Mirrors the LocalNet table at the -// top of docs/design/mockups/webui-dashboard.jsx. Renders the -// list of registered instances pulled from GET /api/instances. -// -// Selection state lives in the URL (?instance=) via -// useInstanceSelection so the topbar switcher and Dashboard -// agree on a single source of truth — and so shared links -// preserve the user's pick. Pre-lift this lived in local -// useState; the topbar couldn't see it. -// -// SSE wiring for live updates is deferred to a follow-on slice -// (publishes "instances" topic events when an instance's -// status flips — needs a producer in internal/localnet first). +// Selection state lives in the URL (?instance=) so the topbar +// switcher and Dashboard share one source of truth and links survive. export function Dashboard() { const sel = useInstanceSelection(); const [createOpen, setCreateOpen] = useState(false); + const showSkeleton = useLoadingDelay(sel.loading); return (
@@ -44,21 +39,14 @@ export function Dashboard() {

LocalNet instances

- + New instance + setCreateOpen(false), [])} onCreated={useCallback( (name: string) => { - // After a successful create, refresh the list and - // promote the new instance to the URL-driven selection - // so the detail card pops the moment the modal closes. - // - // useCallback'd so the modal's done-effect doesn't - // see this as a new identity each render and refire. - // Deps cover both sel.refresh + sel.select since - // they come from the context value. + // useCallback'd so the modal's done-effect keeps a stable + // identity and doesn't refire each render. sel.refresh(); sel.select(name); }, @@ -81,7 +63,7 @@ export function Dashboard() { )} /> - {sel.loading &&

Loading…

} + {sel.loading && showSkeleton && } {sel.error && } @@ -91,25 +73,25 @@ export function Dashboard() {
- Couldn’t refresh — showing last known state. + Couldn’t refresh. Showing last known state.
)} {sel.warning && (
{ - // When the selected instance is mid-bring-up, surface - // the live progress panel above the static detail. The - // JWT generator is hidden while creating — no point - // signing tokens for an instance that's not running yet. + // While creating, show the live progress panel and hide the JWT + // generator — no point signing tokens before it's running. const selectedRow = sel.instances.find((i) => i.name === sel.selected); const isCreating = selectedRow?.status === "creating"; return ( @@ -171,7 +151,7 @@ function InstanceTable({ instances, selected, onSelect }: InstanceTableProps) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 8, + borderRadius: R.card, overflow: "hidden", }} > @@ -184,52 +164,66 @@ function InstanceTable({ instances, selected, onSelect }: InstanceTableProps) { >
- - - - + + + + - {instances.map((i) => ( - onSelect(i.name)} - style={{ - borderTop: `1px solid ${W.border}`, - background: i.name === selected ? W.surface2 : undefined, - cursor: "pointer", - }} - > - - - - - - ))} + {instances.map((i) => { + const isSel = i.name === selected; + return ( + onSelect(i.name)} + style={{ + borderTop: `1px solid ${W.border}`, + // Flat fill, no padding swap, so the row never shifts on select. + background: isSel ? W.selRow : undefined, + cursor: "pointer", + }} + > + + + + + + ); + })}
NAMESTATESPLICEPORTSNameStateSplicePorts
- - {i.name} - - - - {i.splice_version} - {i.ports} -
+ + {i.name} + + + + + {i.splice_version} + {i.ports}
); } +function InstanceTableLoading() { + return ( +
+ +
+ ); +} + const th: React.CSSProperties = { + ...tableCaps, padding: "8px 12px", - fontWeight: 500, fontSize: 11, - letterSpacing: 0.6, }; const td: React.CSSProperties = { @@ -237,30 +231,11 @@ const td: React.CSSProperties = { verticalAlign: "middle", }; -function StatusBadge({ status }: { status: string }) { - const tone = (() => { - switch (status) { - case "running": - return { color: W.ok, glyph: "●" }; - case "creating": - case "stopping": - case "partial": - return { color: W.warn, glyph: "◐" }; - case "failed": - return { color: W.err, glyph: "⊗" }; - case "stopped": - return { color: W.dim, glyph: "○" }; - default: - return { color: W.dim, glyph: "·" }; - } - })(); - return ( - - {tone.glyph} - {status} - - ); -} +const numCell: React.CSSProperties = { + textAlign: "right", + fontFamily: wMono, + fontVariantNumeric: "tabular-nums", +}; function EmptyState({ onCreate }: { onCreate: () => void }) { return ( @@ -268,34 +243,28 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 8, - padding: 32, + borderRadius: R.card, + padding: 16, color: W.dim, - textAlign: "center", }} >

No LocalNet instances yet.

- + Create your first instance +

Or run{" "} - dpm localnet up --name demo{" "} + + dpm localnet up --name demo + {" "} in your terminal.

@@ -305,10 +274,11 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { function ErrorPanel({ error }: { error: string }) { return (
-

Recent activity

+

Recent activity

ledger events · as seen by the app-provider participant - + Refresh +
{state.kind === "loading" && ( @@ -422,15 +378,15 @@ function RecentActivity({ name }: { name: string }) { )} {state.kind === "needs-jwt" && (
- Ledger activity needs a party-rights JWT — Splice LocalNet signs user-id tokens by + Ledger activity needs a party-rights JWT. Splice LocalNet signs user-id tokens by default. Open the Explorer to project through a specific party.
)} {state.kind === "err" && (
{/no jwt recorded/i.test(state.error) - ? "Ledger activity needs recorded role JWTs — restart the instance to capture them (older instances predate JWT capture)." - : `Ledger activity unavailable — ${state.error}.`}{" "} + ? "Ledger activity needs recorded role JWTs. Restart the instance to capture them (older instances predate JWT capture)." + : `Ledger activity unavailable. ${state.error}.`}{" "} Open the Explorer for the full ledger view.
)} @@ -452,24 +408,24 @@ function RecentActivity({ name }: { name: string }) { {events.map((e) => ( - {e.time} + {e.time} {e.kind} {e.event} - - {e.cid.slice(0, 10)}… + + ))} @@ -485,14 +441,12 @@ function RecentActivity({ name }: { name: string }) { ); } -// shortTemplate drops the package-id prefix from a fully-qualified -// template id (`:Module:Entity` → `Module:Entity`) for a compact, -// readable EVENT column. +// `:Module:Entity` → `Module:Entity` for a compact EVENT column. function shortTemplate(t?: string): string { if (!t) return "—"; const parts = t.split(":"); return parts.length >= 3 ? `${parts[parts.length - 2]}:${parts[parts.length - 1]}` : t; } -const actTh: React.CSSProperties = { padding: "6px 10px 6px 0", fontWeight: 500, fontSize: 11, letterSpacing: 0.4 }; +const actTh: React.CSSProperties = { ...tableCaps, padding: "6px 10px 6px 0", fontSize: 11 }; const actTd: React.CSSProperties = { padding: "8px 10px 8px 0", verticalAlign: "middle" }; diff --git a/frontend/src/screens/DeveloperSetup.test.tsx b/frontend/src/screens/DeveloperSetup.test.tsx index 00bd9e6e..dc751f73 100644 --- a/frontend/src/screens/DeveloperSetup.test.tsx +++ b/frontend/src/screens/DeveloperSetup.test.tsx @@ -3,17 +3,17 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DeveloperSetup } from "./DeveloperSetup"; -// DeveloperSetup tests — the JWT redacted-by-default + reveal -// contract is security-relevant; pin it in tests so a refactor -// that breaks it can't ship silently. +// DeveloperSetup tests — LocalNet surfaces usable (raw) tokens by +// default. Pin that contract so a refactor that re-redacts the UI +// (making copy-pasted config unusable) can't ship silently. // // What matters: -// 1. mount fetches WITHOUT include_jwt=true (redacted-default) -// 2. "Show token" re-fetches WITH include_jwt=true -// 3. Copy on the revealed token hits navigator.clipboard.writeText -// 4. Hide re-redacts (state goes back to the redacted view) -// 5. AppConfigPanel switches transport based on format -// (env/yaml → text endpoint, json → apiFetch JSON path) +// 1. JWT panel fetches WITH include_jwt=true on mount and renders +// the real token split into header.payload.signature +// 2. Copy writes the full token to navigator.clipboard.writeText +// 3. AppConfigPanel fetches WITH include_jwt=true and switches +// transport based on format (env/yaml → text endpoint, json → +// apiFetch JSON path) // // Other surface (chip-row interactions, audience input) is // trivial and covered by the build/typecheck step; testing it @@ -63,10 +63,10 @@ describe("DeveloperSetup — JwtPanel", () => { }); afterEach(() => vi.unstubAllGlobals()); - it("fetches redacted JWT on mount (no include_jwt query)", async () => { + it("fetches a usable JWT on mount with include_jwt=true and renders it", async () => { const { calls } = recordingFetch(({ url }) => { if (url.includes("/jwt")) { - return jwtResponse({ redacted: true, token: "" }); + return jwtResponse({ redacted: false, token: "header.payload.signature" }); } // app-config text fetch from AppConfigPanel return new Response("KEY=value\n", { status: 200 }); @@ -79,34 +79,11 @@ describe("DeveloperSetup — JwtPanel", () => { expect(jwtCall).toBeDefined(); }); const jwtCall = calls.find((c) => c.url.includes("/jwt"))!; - // The mount fetch MUST NOT include the reveal query — the - // redacted-by-default contract lives here. - expect(jwtCall.url).toBe("/api/instances/demo/jwt"); - expect(jwtCall.url).not.toContain("include_jwt"); - }); + // LocalNet surfaces a usable token — the mount fetch opts into + // the raw token so the generated JWT is copy-pasteable. + expect(jwtCall.url).toContain("include_jwt=true"); - it("Show token re-fetches with include_jwt=true and displays the real token", async () => { - let callIdx = 0; - recordingFetch(({ url }) => { - if (url.includes("/jwt")) { - callIdx++; - // Second JWT call is the reveal — return the real token. - return callIdx === 1 - ? jwtResponse({ redacted: true, token: "" }) - : jwtResponse({ - redacted: false, - token: "header.payload.signature", - }); - } - return new Response("KEY=value\n", { status: 200 }); - }); - - render(); - - const showBtn = await screen.findByRole("button", { name: /show token/i }); - await userEvent.click(showBtn); - - // The revealed token appears in the TokenBox split into parts. + // The real token renders in the TokenBox split into parts. await waitFor(() => { expect(screen.getByText("header")).toBeInTheDocument(); expect(screen.getByText("payload")).toBeInTheDocument(); @@ -114,24 +91,18 @@ describe("DeveloperSetup — JwtPanel", () => { }); }); - it("Copy on a revealed token writes the full token to clipboard", async () => { - let callIdx = 0; + it("Copy writes the full token to clipboard", async () => { recordingFetch(({ url }) => { if (url.includes("/jwt")) { - callIdx++; - return callIdx === 1 - ? jwtResponse({ redacted: true, token: "" }) - : jwtResponse({ - redacted: false, - token: "header.payload.signature", - }); + return jwtResponse({ redacted: false, token: "header.payload.signature" }); } return new Response("KEY=value\n", { status: 200 }); }); render(); - await userEvent.click(await screen.findByRole("button", { name: /show token/i })); + // Wait for the token to render before copying. + await screen.findByText("signature"); // Two Copy buttons exist (JwtPanel + AppConfigPanel). Scope // to the JWT card so we click the right one. const jwtCard = screen.getByText("JWT generator").closest("section")!; @@ -141,32 +112,6 @@ describe("DeveloperSetup — JwtPanel", () => { "header.payload.signature", ); }); - - it("Hide re-redacts the token view (back to the redacted UI)", async () => { - let callIdx = 0; - recordingFetch(({ url }) => { - if (url.includes("/jwt")) { - callIdx++; - return callIdx === 1 - ? jwtResponse({ redacted: true, token: "" }) - : jwtResponse({ - redacted: false, - token: "header.payload.signature", - }); - } - return new Response("KEY=value\n", { status: 200 }); - }); - - render(); - - await userEvent.click(await screen.findByRole("button", { name: /show token/i })); - await userEvent.click(await screen.findByRole("button", { name: /hide/i })); - - // After Hide, the Show token button is back; the split-out - // header/payload/signature spans should be gone. - expect(await screen.findByRole("button", { name: /show token/i })).toBeInTheDocument(); - expect(screen.queryByText("signature")).not.toBeInTheDocument(); - }); }); describe("DeveloperSetup — AppConfigPanel", () => { @@ -175,7 +120,7 @@ describe("DeveloperSetup — AppConfigPanel", () => { it("uses ?format=env on mount and switches to ?format=json on tab click", async () => { const { calls } = recordingFetch(({ url }) => { if (url.includes("/jwt")) { - return jwtResponse({ redacted: true, token: "" }); + return jwtResponse({ redacted: false, token: "header.payload.signature" }); } if (url.includes("format=json")) { return new Response( @@ -197,7 +142,11 @@ describe("DeveloperSetup — AppConfigPanel", () => { await waitFor(() => { expect( - calls.find((c) => c.url.includes("app-config?format=env")), + calls.find( + (c) => + c.url.includes("app-config?format=env") && + c.url.includes("include_jwt=true"), + ), ).toBeDefined(); }); @@ -210,7 +159,11 @@ describe("DeveloperSetup — AppConfigPanel", () => { await waitFor(() => { expect( - calls.find((c) => c.url.includes("app-config?format=json")), + calls.find( + (c) => + c.url.includes("app-config?format=json") && + c.url.includes("include_jwt=true"), + ), ).toBeDefined(); }); diff --git a/frontend/src/screens/DeveloperSetup.tsx b/frontend/src/screens/DeveloperSetup.tsx index 1b2470d8..ca0e3aab 100644 --- a/frontend/src/screens/DeveloperSetup.tsx +++ b/frontend/src/screens/DeveloperSetup.tsx @@ -8,31 +8,14 @@ import { issueJwt, } from "../api"; import { W, wMono } from "../tokens"; +import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; -// DeveloperSetup — the "Developer setup" card from the 2026-05-25 -// webui-dashboard.jsx refresh. Two sub-panels: -// -// 1. JWT generator: role/audience picker + redacted-by-default -// token preview + "show token" toggle that re-issues with -// ?include_jwt=true. Mirrors the mockup's chip-row controls -// and the colored token-segment display. -// -// 2. App config exporter: format tabs (env / json / yaml) + -// monospace preview + copy button. Each format hits the -// same /api/instances/{name}/app-config endpoint with the -// ?format= query. -// -// Both panels operate on the currently-selected instance. The -// Dashboard owns the instance selection; this component just -// receives `name` as a prop. +// Two panels: a JWT generator and an app-config exporter (env/json/yaml). const ROLES = ["app-provider", "app-user", "sv"] as const; type Role = (typeof ROLES)[number]; -// Default-redact is enforced server-side; this UI surfaces it -// explicitly. "Show token" triggers a one-shot re-fetch with -// ?include_jwt=true rather than persisting the raw value in -// component state for long — every render re-checks `revealed`. export function DeveloperSetup({ name }: { name: string }) { return (
("app-provider"); const [audience, setAudience] = useState("https://canton.network.global"); - const [redacted, setRedacted] = useState(null); - const [revealed, setRevealed] = useState(null); + const [jwt, setJwt] = useState(null); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); - // Fetch a redacted JWT on mount + whenever role/audience/name - // changes. The redacted form gives us the party + warning - // metadata without ever surfacing the raw token by default. + // include_jwt=true returns the raw token, usable as-is (LocalNet only). useEffect(() => { let cancelled = false; setBusy(true); - setRevealed(null); // reveal is one-shot per (role, audience) - issueJwt(name, { role, audience }, false) + issueJwt(name, { role, audience }, true) .then((r) => { - if (!cancelled) setRedacted(r); + if (!cancelled) setJwt(r); setErr(null); }) .catch((e) => { @@ -81,17 +60,7 @@ function JwtPanel({ name }: { name: string }) { }; }, [name, role, audience]); - const reveal = async () => { - setBusy(true); - try { - const r = await issueJwt(name, { role, audience }, true); - setRevealed(r.token); - } catch (e) { - setErr(e instanceof ApiError ? e.message : "failed to reveal token"); - } finally { - setBusy(false); - } - }; + const token = jwt?.token ?? null; return ( @@ -111,7 +80,7 @@ function JwtPanel({ name }: { name: string }) { background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 6, + borderRadius: 2, padding: "6px 10px", fontSize: 13, fontFamily: wMono, @@ -119,12 +88,14 @@ function JwtPanel({ name }: { name: string }) { /> - - {redacted?.party ?? "—"} - + {jwt?.party ? ( + + ) : ( + + )}
- +
- {!revealed && ( - - )} - {revealed && ( - - )} - {revealed && ( - - )} +
- {redacted?.warning_dev_secret && ( + {jwt?.warning_dev_secret && (

- {redacted.warning_dev_secret} + {jwt.warning_dev_secret}

)} {err && } @@ -227,7 +180,7 @@ function AppConfigPanel({ name }: { name: string }) { margin: "12px 0 0", background: W.bg, border: `1px solid ${W.border}`, - borderRadius: 7, + borderRadius: 2, padding: "10px 12px", fontFamily: wMono, fontSize: 11.5, @@ -241,26 +194,19 @@ function AppConfigPanel({ name }: { name: string }) { {busy ? "…" : body || "—"}
- +
{err && }
); } -// ──────────────────────── shared primitives ───────────────────────── -// -// Kept inline here while the frontend has one consumer; promote to -// frontend/src/shell/primitives.tsx when the second screen needs -// them. Premature shared component lib is the classic over-abstraction -// trap — wait for the second use. - interface CardProps { title: string; subtitle?: string; @@ -273,7 +219,7 @@ function Card({ title, subtitle, children }: CardProps) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: 16, }} > @@ -335,9 +281,9 @@ function ChipRow({ options, value, onChange }: ChipRowProps) { onClick={() => onChange(opt)} style={{ background: opt === value ? W.brand : W.surface2, - color: opt === value ? "#082018" : W.text2, + color: opt === value ? W.onAccent : W.text2, border: `1px solid ${opt === value ? W.brand : W.border}`, - borderRadius: 6, + borderRadius: 2, padding: "4px 10px", fontSize: 11.5, fontWeight: opt === value ? 600 : 400, @@ -352,9 +298,8 @@ function ChipRow({ options, value, onChange }: ChipRowProps) { } function TokenBox({ token, revealed }: { token: string; revealed: boolean }) { - // Split the JWT into header.payload.signature for the colored - // preview from the mockup. If the token is the redacted - // placeholder, render it without splitting. + // header.payload.signature for the colored preview; placeholders + // ("—", "…") aren't 3-part tokens and render as plain text. const parts = token.split("."); const isJwt = parts.length === 3 && revealed; return ( @@ -362,7 +307,7 @@ function TokenBox({ token, revealed }: { token: string; revealed: boolean }) { style={{ background: W.bg, border: `1px solid ${W.border}`, - borderRadius: 7, + borderRadius: 2, padding: "10px 12px", fontFamily: wMono, fontSize: 11, @@ -386,19 +331,6 @@ function TokenBox({ token, revealed }: { token: string; revealed: boolean }) { ); } -function btnStyle(accent: string): React.CSSProperties { - return { - background: accent === W.brand ? W.brand : "transparent", - color: accent === W.brand ? "#082018" : accent, - border: `1px solid ${accent}`, - borderRadius: 6, - padding: "4px 10px", - fontSize: 11.5, - fontWeight: 600, - cursor: "pointer", - }; -} - function ErrorLine({ msg }: { msg: string }) { return (
(null); const [versions, setVersions] = useState([]); - // "" → server's "latest" alias. The picker lets an operator grade - // the memory checks against a heavier Splice version's floor before - // they commit to creating an instance on that version. + // "" → server's "latest" alias; the picker grades memory checks + // against a chosen Splice version's floor before committing to it. const [version, setVersion] = useState(""); const [loading, setLoading] = useState(true); const [err, setErr] = useState(null); - // Load the curated version list once so the picker can offer the - // same tags the create modal does. A failure here is non-fatal: the - // doctor still runs against "latest", we just hide the picker. + // Non-fatal: on failure the picker hides and doctor runs against "latest". useEffect(() => { let cancelled = false; fetchSpliceVersions() .then((r) => { if (!cancelled) setVersions(r.versions); }) - .catch(() => { - /* picker stays hidden; doctor still works against latest */ - }); + .catch(() => {}); return () => { cancelled = true; }; @@ -71,8 +56,6 @@ export function DoctorScreen() { }; }, []); - // Re-run whenever the selected version changes (including the first - // mount with the default "latest"). useEffect(() => run(version), [run, version]); return ( @@ -87,31 +70,70 @@ export function DoctorScreen() { {report && } - {err && ( + {err && run(version)} />} + + {loading && !report && !err && } + + {report?.sections.map((sec) => ( +
+ ))} +
+ ); +} + +function DoctorError({ + message, + onRetry, +}: { + message: string; + onRetry: () => void; +}) { + return ( +
+ Couldn't run host checks.{" "} + The doctor endpoint didn't respond. Confirm the devkit server is up, + then retry. +
+ +
+
+ + Server message +
- {err} + {message}
- )} - - {loading && !report && ( -
- Running host checks… -
- )} +
+
+ ); +} - {report?.sections.map((sec) => ( -
- ))} +function DoctorLoading() { + const shown = useLoadingDelay(true); + if (!shown) return null; + return ( +
+
); } @@ -170,10 +192,11 @@ function Header({ background: W.surface, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 6, + borderRadius: R.control, padding: "5px 8px", fontSize: 12, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", }} > @@ -185,45 +208,41 @@ function Header({ )} - + ); } -// 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,7 +310,13 @@ function CheckRow({ check, last }: { check: PreflightCheck; last: boolean }) { > @@ -312,6 +340,7 @@ function CheckRow({ check, last }: { check: PreflightCheck; last: boolean }) { color: W.dim, fontSize: 11.5, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", marginTop: 2, }} > @@ -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..e3e787f0 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 } 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); @@ -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 */}
@@ -522,7 +481,7 @@ export function ExplorerScreen() { color: W.text, fontSize: 12, padding: "5px 32px 5px 10px", - borderRadius: 6, + borderRadius: 2, width: 240, }} aria-label="Filter contracts" @@ -538,7 +497,7 @@ export function ExplorerScreen() { 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 @@ -702,7 +686,7 @@ function ProjectionBar({ fontFamily: wMono, fontSize: 11.5, padding: "5px 10px", - borderRadius: 6, + borderRadius: 2, }} > participant{" "} @@ -720,7 +704,7 @@ function ProjectionBar({ fontFamily: wMono, fontSize: 11.5, padding: "5px 10px", - borderRadius: 6, + borderRadius: 2, cursor: "pointer", }} > @@ -755,7 +739,7 @@ function ProjectionBar({ style={{ display: "flex", background: W.border, - borderRadius: 8, + borderRadius: 4, padding: 3, border: `1px solid ${W.border}`, }} @@ -767,20 +751,21 @@ function ProjectionBar({ style={{ padding: "5px 12px", fontSize: 12, - borderRadius: 5, + 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,27 +786,28 @@ function FilterChip({ return ( + + ) : ( + <> + No updates in the current ledger window. + + dpm localnet tx ls + + + )}
)} @@ -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 @@ -1382,7 +1256,7 @@ function TxFilterBar({ fontSize: 12, 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.event_count ?? "—"} - + {onReplay ? ( - + ) : ( )} @@ -1557,7 +1404,7 @@ function TxRowComponent({ {open && tx.events && tx.events.length > 0 && (
= { - create: "#62E2A0", - archive: "#F08FB5", - exercise: "#7CB5F7", + create: "#7CC89A", + archive: "#7BD2C6", + exercise: "#8FA3EE", }; return (
- - {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 ( + <>
@@ -1758,7 +1591,6 @@ function TimelineView({ name, role }: { name: string; role: Role }) {
- {/* 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, }} /> ); @@ -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 ( ); @@ -1881,95 +1705,98 @@ function TimelineView({ name, role }: { name: string; role: Role }) { }} > - - - + + + {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) => ( + + ))} +
+ )} +
+ )} + ); } @@ -1989,7 +1816,8 @@ function Mono({ children }: { children: React.ReactNode }) { fontFamily: wMono, color: W.text2, fontSize: 11, - wordBreak: "break-all", + 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,7 +1874,7 @@ function Card({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: R.card, padding: 10, }} > @@ -2080,9 +1906,7 @@ function Section({ style={{ color: W.dim, fontSize: 10.5, - letterSpacing: 1.4, - textTransform: "uppercase", - fontWeight: 600, + ...wideCaps, marginBottom: 6, }} > @@ -2097,11 +1921,11 @@ function Pill({ color, children }: { color: string; children: React.ReactNode }) return ( - {children} + +
+ ); +} + +function TableLoading({ + columns, + rows, + rowHeight, +}: { + columns: (number | string)[]; + rows: number; + rowHeight: number; +}) { + const show = useLoadingDelay(true); + if (!show) return null; + return ( +
+
); } -function ErrorPanel({ msg }: { msg: string }) { +function ErrorPanel({ msg, onRetry }: { msg: string; onRetry?: () => void }) { return (
- {msg} +
+ Could not load ledger data. +
+
+ The participant did not answer. Check the instance is running, then + retry. +
+ {onRetry && ( + + )} +
+ Details + + {msg} + +
); } @@ -2158,17 +2037,21 @@ function EmptyPanel({ return (

{title}

-

{body}

-

{remediation}

+

+ {body} +

+

+ {remediation} +

); } @@ -2181,8 +2064,6 @@ function Hint({ children }: { children: React.ReactNode }) { ); } -// ─────── 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..405d0647 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 } 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,7 +219,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: R.card, padding: 16, }} > @@ -218,27 +233,23 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { style={{ color: W.warn, fontSize: 11, - border: `1px solid ${W.warn}`, - borderRadius: 6, + border: `1px solid ${tint(W.warn, 34)}`, + background: tint(W.warn, 13), + borderRadius: R.control, padding: "2px 8px", }} > - 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 ( @@ -305,10 +335,17 @@ function DetailGrid({ instance }: { instance: Instance }) { fontSize: 12.5, }} > - {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.tsx b/frontend/src/screens/MetricsScreen.tsx index 49d355a2..673d0aff 100644 --- a/frontend/src/screens/MetricsScreen.tsx +++ b/frontend/src/screens/MetricsScreen.tsx @@ -8,7 +8,9 @@ import { type PrometheusRangeResponse, } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R } 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"; @@ -20,68 +22,30 @@ import { 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 = { // 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]))", + // 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 +56,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 +72,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 +87,7 @@ export function MetricsScreen() { const [throughputSeries, setThroughputSeries] = useState>({ kind: "loading", }); - const [p99Series, setP99Series] = useState>({ + const [latencySeries, setLatencySeries] = useState>({ kind: "loading", }); const [acsSeries, setAcsSeries] = useState>({ @@ -153,25 +112,13 @@ export function MetricsScreen() { 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 +145,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, ), @@ -250,22 +194,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 +222,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 +230,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 +291,15 @@ export function MetricsScreen() { />
- {/* 2-col chart grid */}
- + {latencyPhase.kind === "err" ? ( ) : ( @@ -370,7 +307,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 +352,7 @@ export function MetricsScreen() { ) : null} - + {cpuSeries.kind === "err" ? ( ) : ( @@ -448,15 +385,20 @@ export function MetricsScreen() {
- {/* 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 +412,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 +427,10 @@ function LatencyStrip(props: { padding: "10px 14px", background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 6, + borderRadius: R.control, fontFamily: wMono, fontSize: 13, + fontVariantNumeric: "tabular-nums", color: W.text, }; const label: CSSProperties = { @@ -507,7 +443,7 @@ function LatencyStrip(props: { display: "grid", gridTemplateColumns: "repeat(3, max-content)", gap: 12, - marginBottom: 14, + marginBottom: 16, }} >
@@ -526,17 +462,13 @@ 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, color: W.text, @@ -555,7 +487,7 @@ function DashboardsBlock(props: { url?: string }) { ); @@ -563,7 +495,7 @@ function DashboardsBlock(props: { url?: string }) { function Header({ name }: { name: string }) { return ( -
+

Metrics —{" "} {name} @@ -589,14 +521,14 @@ function ChartCard({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 10, + borderRadius: 4, padding: 14, display: "flex", flexDirection: "column", minWidth: 0, }} > -
+
{title}
@@ -613,8 +545,23 @@ function ChartCard({ function ErrLine({ msg }: { msg: string }) { return ( -
- {msg} +
+
Query failed. Retrying every 5 s.
+
+ + Server message + +
+ {msg} +
+
); } @@ -633,12 +580,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,9 +592,9 @@ function ObservabilityOffPanel({ return (
@@ -667,23 +609,9 @@ function ObservabilityOffPanel({

- + Brings up Prometheus + Grafana on this instance without restarting Canton. @@ -692,7 +620,11 @@ function ObservabilityOffPanel({ {err && (
- ✗ {err} + + {err} +
)} @@ -704,7 +636,7 @@ function ObservabilityOffPanel({ color: W.text, background: W.border, padding: "1px 6px", - borderRadius: 4, + borderRadius: 2, }} > {`dpm localnet observability enable --name ${name}`} @@ -714,10 +646,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 +742,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, @@ -847,8 +774,6 @@ async function loadHeatmap( 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 @@ -859,7 +784,6 @@ async function loadHeatmap( 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) { @@ -885,11 +809,10 @@ async function loadHeatmap( } } -// 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..e0f94f4e 100644 --- a/frontend/src/screens/Placeholder.tsx +++ b/frontend/src/screens/Placeholder.tsx @@ -1,25 +1,24 @@ -import { W } from "../tokens"; +import { W, R } 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..8293b5dd 100644 --- a/frontend/src/screens/TokensScreen.test.tsx +++ b/frontend/src/screens/TokensScreen.test.tsx @@ -220,7 +220,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 +231,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( @@ -378,7 +378,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..598aac81 100644 --- a/frontend/src/screens/TokensScreen.tsx +++ b/frontend/src/screens/TokensScreen.tsx @@ -31,51 +31,52 @@ import { type TokenRef, } from "../api"; import { useInstanceSelection } from "../shell/useInstanceSelection"; -import { W, wMono } from "../tokens"; +import { W, wMono, tableCaps, wideCaps, tint, R, FAST } from "../tokens"; +import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; +import { + Dot, + IcArrowRight, + IcArrowUp, + IcBolt, + IcCheck, + IcChevronDown, + IcChevronRight, + IcDroplet, + IcFlame, + IcPlus, + IcX, +} from "../components/icons"; -// 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 guards 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,20 +84,6 @@ 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; @@ -109,18 +96,11 @@ 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. + // "ledger" = real on-ledger balances; "registry" = pseudo-balance fallback + // when no live participant is reachable. Drives the 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); @@ -151,8 +131,6 @@ export function TokensScreen() { return; } let cancelled = false; - // ACS-derived instrument discovery: Amulet + any minted - // token appear without a state.Tokens seed. fetchInstruments(instance) .then((items) => { if (cancelled) return; @@ -174,7 +152,6 @@ export function TokensScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [instance, refreshTick]); - // Matrix lens — one ACS scan, party × instrument. useEffect(() => { if (!instance || view !== "matrix") return; let cancelled = false; @@ -219,8 +196,6 @@ export function TokensScreen() { }; }, [instance, activeSymbol, refreshTick]); - // Party alias registry: one fetch per instance powers the - // alias labels across every lens and the party manager. useEffect(() => { if (!instance) { setParties([]); @@ -239,9 +214,7 @@ export function TokensScreen() { }; }, [instance, 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); @@ -260,10 +233,8 @@ export function TokensScreen() { }; }, [instance, activeSymbol, 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 fetched when the Activity tab is open, since it's a full + // historical scan, heavier than the other lenses' ACS snapshots. useEffect(() => { if (!instance || !activeSymbol || detailTab !== "activity") return; let cancelled = false; @@ -291,12 +262,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); @@ -336,10 +303,7 @@ 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. + // Server composes issuer-party → create → mint → faucet-a-holder. async function launchDemo() { if (!instance) return; setDemoBusy(true); @@ -351,8 +315,8 @@ export function TokensScreen() { 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")); @@ -382,29 +346,27 @@ export function TokensScreen() {
- - + - + {demoBusy ? "Launching…" : "Launch demo"} + } /> @@ -412,17 +374,16 @@ export function TokensScreen() {
{topNotice.text}
)} - {/* Lens switcher */} -
+
{(["instruments", "matrix"] as const).map((v) => ( - + +
One click provisions an issuer party, a DEMO instrument with supply, and a funded holder. @@ -455,8 +416,7 @@ export function TokensScreen() {
) : (
- {/* Left rail: instrument list (ACS-discovered) */} -
+
{list.map((t) => { const sym = t.symbol ?? t.instrument_id; const isActive = sym === activeSymbol; @@ -466,8 +426,9 @@ 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}`, }} >
@@ -481,8 +442,7 @@ export function TokensScreen() { })}
- {/* Right pane: detail + holdings + actions */} -
+
{active && (() => { const sym = active.symbol ?? active.instrument_id; const mintReason = mintDisabledReason(active); @@ -494,28 +454,33 @@ export function TokensScreen() { {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) => ( - + +
); } -// KpiRow — the instrument-first KPI strip. Supply, -// circulating (= supply on a UTXO ledger), holder count, and the number -// of Holding contracts backing it. All derived from one ACS scan. +// KPI strip from one ACS scan. Circulating == total supply on a UTXO ledger. function KpiRow({ s }: { s: InstrumentSummary }) { - const cards: Array<{ label: string; value: string; hint?: string }> = [ - { label: "Total supply", value: s.total_supply }, - { label: "In circulation", value: s.total_supply, hint: "sum of all holdings" }, + const cards: Array<{ label: string; value: string; full?: string; hint?: string }> = [ + { label: "Total supply", value: statAmount(s.total_supply), full: s.total_supply }, + { label: "In circulation", value: statAmount(s.total_supply), full: s.total_supply, hint: "sum of all holdings" }, { label: "Holders", value: String(s.holder_count) }, { label: "Holding contracts", value: String(s.contract_count), hint: "UTXOs" }, ]; @@ -857,7 +824,7 @@ function KpiRow({ s }: { s: InstrumentSummary }) { style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(120px, 1fr))", - gap: 10, + gap: 12, margin: "16px 0 4px", }} > @@ -867,14 +834,28 @@ function KpiRow({ s }: { s: InstrumentSummary }) { style={{ background: W.bg, border: `1px solid ${W.border}`, - borderRadius: 8, + borderRadius: 4, padding: "10px 12px", }} > -
+
{c.label}
-
{c.value}
+
+ {c.value} +
{c.hint &&
{c.hint}
}
))} @@ -882,9 +863,14 @@ function KpiRow({ s }: { s: InstrumentSummary }) { ); } -// 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 ( <> @@ -896,9 +882,9 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali HOLDER - BALANCE + BALANCE SHARE - UTXOS + UTXOS @@ -907,25 +893,25 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali return ( {partyLabel(aliases, h.party)} - {h.balance} + {h.balance}
-
+
- + {h.pct_of_supply}%
- {h.contract_count} + {h.contract_count} ); })} @@ -935,9 +921,7 @@ 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. +// Transfer/mint/burn history from the ledger stream; one netted transaction per row. function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null; err: string | null; aliases: AliasMap }) { if (err) return
{err}
; if (events === null) return
Scanning ledger history…
; @@ -949,6 +933,11 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null 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 ? "·" @@ -959,7 +948,7 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null TIME KIND - AMOUNT + AMOUNT FROM TO @@ -967,24 +956,28 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null {events.map((e) => ( - + {e.record_time ? e.record_time.replace("T", " ").slice(0, 19) : `@${e.offset}`} - {e.kind} + + {kindLabel[e.kind]} - {e.amount} + {e.amount} {fmtParties(e.senders)} {fmtParties(e.receivers)} @@ -994,12 +987,11 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null ); } -// 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. +// Party × instrument balance table from one ACS scan; only 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…
; + if (!matrix) return
Scanning ACS…
; const syms = matrix.instruments.map((i) => i.symbol ?? i.instrument_id); const symByInst: Record = {}; @@ -1013,10 +1005,10 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er const parties = [...matrix.parties].sort(); 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. + {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.
@@ -1030,16 +1022,16 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er {syms.map((s) => ( - ))} ))} - + {syms.map((s) => ( - + ))} {parties.length === 0 && ( @@ -1061,10 +1053,8 @@ function Header({ right }: { right?: React.ReactNode }) { ); } -// 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). +// List/allocate/forget aliased parties. New parties are immediately visible +// in the matrix/activity (the scan grants read-as for every registered party). function PartyManagerModal({ instance, parties, @@ -1128,15 +1118,15 @@ function PartyManagerModal({ ))} @@ -1150,11 +1140,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: 13 }} /> - + ); @@ -1220,8 +1210,8 @@ function CreateTokenModal({ {err &&
{err}
}
- - + +
@@ -1293,21 +1283,17 @@ function ActionModal({ ))} {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 (ResolveAlias passes +// it through unchanged, so it's correct on both create and action paths). function PartyPicker({ instance, parties, @@ -1329,8 +1315,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 +1324,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 +1360,24 @@ function PartyPicker({ placeholder="new alias (e.g. bob)" style={{ ...input, flex: 1 }} /> - + {err && {err}} - + ); } @@ -1407,9 +1392,14 @@ function PartyPicker({ style={{ ...input, fontFamily: wMono, fontSize: 12 }} /> {all.length > 0 && ( - + )} ); @@ -1446,14 +1436,18 @@ function ModalShell({ title, onClose, children }: { title: string; onClose: () = }}>

{title}

- +
{children}
@@ -1472,39 +1466,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: 13, }; -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: 11 }; +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: 12.5, }; } - -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..655741fc 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 } 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} + />
)} {state.kind === "err" && ( -
+
{state.error}
)} @@ -204,14 +191,20 @@ export function TxReplayDrawer({ }} > offset{" "} - + {state.data.offset.toLocaleString()} {" "} · {state.data.event_count}{" "} {state.data.event_count === 1 ? "event" : "events"} visible {state.data.workflow_id ? ` · ${state.data.workflow_id}` : ""}
-
+
{state.data.events.length === 0 ? (
No events in this transaction are visible to the selected @@ -277,17 +270,14 @@ function ReplayNode({ ev, last }: { ev: TxReplayEvent; last: boolean }) { {detail && ( {detail} )} - - {ev.contract_id.slice(0, 16)}… - +
); } diff --git a/frontend/src/screens/VersionPicker.test.tsx b/frontend/src/screens/VersionPicker.test.tsx index 97e01dfc..58ae3419 100644 --- a/frontend/src/screens/VersionPicker.test.tsx +++ b/frontend/src/screens/VersionPicker.test.tsx @@ -4,18 +4,16 @@ import userEvent from "@testing-library/user-event"; import type { SpliceVersionEntry } from "../api"; import { VersionPicker, compareSpliceTags } from "./CreateLocalNetModal"; -// VersionPicker — the curated Splice catalogue picker that used to be -// a custom button-list and briefly regressed to a free-text -// 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..f35726f5 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 } 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 ( @@ -96,10 +90,11 @@ export function WalletScreen() { ); } - // 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 */}
- {/* 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 */}
- ↗ 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 ? (
{partyLabel(aliases, p)} + {amt[p]?.[s] ?? "·"}
Σ totalΣ total{totals[s] ?? ""}{totals[s] ?? ""}
{p.alias} {p.role} - +