diff --git a/.gitignore b/.gitignore index 5cde9d7a..4bc936a4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,11 @@ CLAUDE.md # 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/ diff --git a/assets/dashboard_test.go b/assets/dashboard_test.go index 7834222d..069efa7b 100644 --- a/assets/dashboard_test.go +++ b/assets/dashboard_test.go @@ -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/docs/dashboard-customization.md b/docs/dashboard-customization.md index b612f4f5..91282956 100644 --- a/docs/dashboard-customization.md +++ b/docs/dashboard-customization.md @@ -42,13 +42,13 @@ non-existent `canton_*` names. |---|---|---|---| | 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. | -| Sequencer Submission 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. | +| 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 Sequencing Latency | 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. | +| 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 diff --git a/frontend/index.html b/frontend/index.html index e117ff55..aea43437 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - + canton-devkit diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 04a9bcd6..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,9 +16,8 @@ import { AgentSkillsScreen } from "./screens/AgentSkillsScreen"; import { TokensScreen } from "./screens/TokensScreen"; import { W } from "./tokens"; -// App boots with a schema-version handshake against the backend and -// renders the shell only on a match — a UI bundle must never silently -// mis-decode responses from a backend with a different schema. +// 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", @@ -49,13 +49,14 @@ export function App() { + {/* One confirm-dialog host; confirmDialog() from anywhere resolves against it. */} + ); } -// RoutedSurface wraps each route element in its own ErrorBoundary, -// keyed by pathname, so a crash on one screen neither follows the -// user to the next route nor takes down the shell around 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 ( diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx index 9a8ed232..e26c0c63 100644 --- a/frontend/src/components/Button.tsx +++ b/frontend/src/components/Button.tsx @@ -1,16 +1,10 @@ -// The one button system for the Web UI (visuals in index.css under -// .bd-btn). Four variants with a strict usage contract: -// -// primary — THE one dominant action of a view or dialog (cobalt -// fill, ink text). At most one visible per context. -// secondary — the default: bordered, quiet (Refresh, Pause, Mint…). -// ghost — low-emphasis inline actions (Edit, close ×, chips). -// danger — destructive and irreversible only (Down, Burn, -// Scrub, force-restore). Filled red; use sparingly — -// recoverable actions like Stop/Pause stay secondary. -// -// Sizes: sm 28px (row/table actions — the console default) and -// md 36px (forms, dialog footers). +// 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, @@ -24,7 +18,7 @@ export type ButtonSize = "sm" | "md"; interface ButtonProps { variant?: ButtonVariant; size?: ButtonSize; - /** Icon slot — pass an icons.tsx glyph; it inherits text color. */ + /** Icon slot — pass an icons.tsx glyph. */ icon?: ReactNode; disabled?: boolean; fullWidth?: boolean; 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 500f2ce3..91485ff6 100644 --- a/frontend/src/components/MetricCard.tsx +++ b/frontend/src/components/MetricCard.tsx @@ -1,27 +1,18 @@ import type { Point } from "./charts/types"; import { Sparkline } from "./charts/Sparkline"; import { IcArrowUp } from "./icons"; -import { W, wMono, wideCaps } from "../tokens"; +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. */ @@ -56,14 +47,13 @@ export function MetricCard({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 14, display: "flex", flexDirection: "column", minWidth: 0, }} > - {/* Stat label row — label left, delta chip right (>=8px apart). */}
— @@ -143,6 +135,7 @@ export function MetricCard({ fontSize: 26, fontWeight: 600, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", lineHeight: 1, }} > @@ -181,7 +174,7 @@ function Skeleton({ width, height, background: W.border, - borderRadius: 2, + 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 238faa9a..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 ? ( <> - + inside a Button icon slot, or standalone with -// size/style overrides. All icons are aria-hidden decoration; the -// accessible name belongs to the surrounding control. +// 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"; @@ -148,7 +142,92 @@ export const IcDroplet = (p: IconProps) => ( ); -/** Status dot — the only full-radius element in the system. */ +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, diff --git a/frontend/src/index.css b/frontend/src/index.css index 72cca064..82f2eff2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,12 +1,8 @@ -/* Base styles. Components own their styling via tokens.ts; this file - holds only what CSS must own: font-face, body defaults, focus - rings, hover states, and keyframes. tokens.ts stays the single - source of truth for the palette. */ - -/* Canton Infrastructure Design System typefaces, self-hosted so the - UI renders identically offline (licenses in src/fonts/). Archivo - variable: body at 100% width, wide structural caps at 118%. - JetBrains Mono for code and all data values. */ +/* 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"); @@ -41,15 +37,67 @@ font-display: swap; } +/* Raw ramps (theme-independent). */ :root { - color-scheme: dark; - --bg: #0b0f1a; - --text: #e9ecf4; - /* Motion — quick, damped, no bounce. */ + --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-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); +} + * { box-sizing: border-box; } @@ -60,13 +108,14 @@ body, margin: 0; padding: 0; height: 100%; - background: var(--bg); - color: var(--text); + 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 { @@ -79,55 +128,82 @@ 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. - * - * 2px cobalt outline (blue-500 — identical in light and dark per - * the design system), 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 #3d5bdc; + outline: 2px solid var(--blue-500); outline-offset: 2px; border-radius: 2px; } -/* Sidebar nav items (shell/Shell.tsx::Sidebar). Hover/active tints - * live here because inline style objects can't express :hover. - * Active = accent-subtle fill + accent text at 2px radius — the - * design system's "current item" signature. */ +/* Sidebar nav items — hover/active tints live here since inline styles + * can't express :hover. */ .side-nav-link { - display: block; + display: flex; + align-items: center; + gap: 10px; padding: 7px 10px; margin: 1px 0; - border-radius: 2px; + border-radius: 4px; font-size: 13px; - color: #a9b2c6; + color: var(--text-secondary); transition: background var(--duration-fast) var(--ease-out), color var(--duration-fast) var(--ease-out); } .side-nav-link:hover { - background: #171e2c; - color: #e9ecf4; + background: var(--hover-tint); + color: var(--text-primary); } .side-nav-link.active, .side-nav-link.active:hover { - background: #141c36; - color: #93a7f0; + background: var(--accent-subtle); + color: var(--accent-text); font-weight: 500; } -/* Button system (components/Button.tsx). Hover/active tints live - * here because inline style objects can't express :hover. Values - * are the dark-console tokens from tokens.ts. */ +.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; @@ -167,50 +243,47 @@ a { } .bd-btn--primary { - background: #6480e6; - color: #0b0f1a; + background: var(--accent-solid); + color: var(--on-accent-solid); } .bd-btn--primary:hover:not(:disabled) { - background: #7b93ec; + background: var(--accent-solid-hover); } .bd-btn--primary:active:not(:disabled) { - background: #93a7f0; + background: var(--accent-active); } .bd-btn--secondary { - background: #10151f; - border-color: #232b3d; - color: #e9ecf4; + background: var(--bg-surface); + border-color: var(--border-default); + color: var(--text-primary); } .bd-btn--secondary:hover:not(:disabled) { - background: #171e2c; - border-color: #313b52; + background: var(--hover-tint); + border-color: var(--border-strong); } .bd-btn--secondary:active:not(:disabled) { - background: #1e2637; + background: var(--active-tint); } .bd-btn--ghost { background: transparent; - color: #a9b2c6; + color: var(--text-secondary); } .bd-btn--ghost:hover:not(:disabled) { - background: #171e2c; - color: #e9ecf4; + background: var(--hover-tint); + color: var(--text-primary); } .bd-btn--ghost:active:not(:disabled) { - background: #1e2637; + background: var(--active-tint); } .bd-btn--danger { - background: #d2604b; + background: var(--danger); color: #fff; } .bd-btn--danger:hover:not(:disabled) { - background: #e08d7d; -} -.bd-btn--danger:active:not(:disabled) { - background: #ba3a29; + background: var(--danger-hover); } .bd-btn__icon { @@ -218,16 +291,14 @@ a { flex: none; } -/* 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. */ +/* 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: #6480e6; - color: #0b0f1a; + background: var(--accent-solid); + color: var(--on-accent-solid); padding: 8px 14px; font-weight: 600; border-radius: 2px; @@ -241,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 586b952f..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. 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 */}
- {/* Two-pane: list | preview */}
{s.name}
@@ -278,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.
); diff --git a/frontend/src/screens/BackupRestore.tsx b/frontend/src/screens/BackupRestore.tsx index 4b1332f0..e93d34df 100644 --- a/frontend/src/screens/BackupRestore.tsx +++ b/frontend/src/screens/BackupRestore.tsx @@ -5,24 +5,11 @@ import { restoreSnapshot, type RestoreResponse, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R, FAST } from "../tokens"; import { Button } from "../components/Button"; import { IcCheck, IcDownload } from "../components/icons"; -// Backup & restore card. Two actions: -// 1. Download snapshot — POST /api/instances/:name/snapshot; the -// browser saves the tar via Content-Disposition. -// 2. Restore from snapshot — drag-drop or file picker, with an -// optional target-name override and a `--force` checkbox for -// cross-version restores. -// -// CLI ↔ UI parity (CONTRIBUTING.md): mirrors `localnet snapshot --name -// X --to ` and `localnet restore --name X --from -// [--force]` — same server-side validation, same error taxonomy. - interface Props { - // Snapshot downloads always use this name; restore defaults to it - // but lets the user override. instanceName: string; } @@ -41,9 +28,7 @@ export function BackupRestore({ instanceName }: Props) { const [dragOver, setDragOver] = useState(false); const fileInputRef = useRef(null); - // useState only honors its initial value on first mount, so switching - // instances must resync targetName explicitly — and reset the result - // banner/options so state doesn't bleed across instances. + // Resync on instance switch: useState keeps its first-mount value. useEffect(() => { setTargetName(instanceName); setRestore({ kind: "idle" }); @@ -52,18 +37,11 @@ export function BackupRestore({ instanceName }: Props) { }, [instanceName]); async function onDownload() { - // The snapshot is application-consistent: 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 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; without surfacing it the button - // would just flash and the user would assume success. setDownloadError( e instanceof ApiError ? e.message : "snapshot download failed", ); @@ -74,9 +52,8 @@ export function BackupRestore({ instanceName }: Props) { async function onFileChosen(file: File | null) { if (!file) return; - // 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({ @@ -107,10 +84,8 @@ export function BackupRestore({ instanceName }: Props) {
@@ -130,7 +105,6 @@ export function BackupRestore({ instanceName }: Props) { - {/* Download row */}
- {/* Download error banner */} {downloadError && (
)} - {/* Restore row */}
{restore.kind === "uploading" ? ( @@ -234,7 +206,6 @@ export function BackupRestore({ instanceName }: Props) { onChange={(e) => void onFileChosen(e.target.files?.[0] ?? null)} /> - {/* Options row */}
- {/* Result banner */} {restore.kind === "success" && (
diff --git a/frontend/src/screens/ContainerHealth.tsx b/frontend/src/screens/ContainerHealth.tsx index dcb53085..9d854cc5 100644 --- a/frontend/src/screens/ContainerHealth.tsx +++ b/frontend/src/screens/ContainerHealth.tsx @@ -5,18 +5,14 @@ import { fetchContainers, restartContainer, } from "../api"; -import { W, wMono, tableCaps } 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 ("is canton in a restart loop, or did -// postgres crash?") instead of the coarse registry status enum. -// -// Renders nothing when the instance has no docker project (backend -// returns 503) — the InstanceDetail card stays usable. +// Live per-container status, polled every POLL_MS. Renders nothing +// when the instance has no docker project (backend returns 503). const POLL_MS = 3000; @@ -27,22 +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); - // Containers with a restart in flight; a Set so rapid clicks on - // different rows 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); - // The poll loop picks up the new status; no manual refresh needed. } catch (e) { setRestartErr( `Restart ${container} failed: ` + @@ -98,7 +98,7 @@ export function ContainerHealth({ name }: { name: string }) { marginTop: 16, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 14, }} > @@ -131,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: 2, + borderRadius: R.control, padding: "6px 10px", fontSize: 12, }} @@ -147,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: 2, + borderRadius: R.control, padding: "6px 10px", fontSize: 12, marginBottom: 8, @@ -178,10 +178,6 @@ export function ContainerHealth({ name }: { name: string }) { ); } -// Dense-panel micro-labels: sentence case, muted — wide caps are -// reserved for real table/card headers. -// Table column headers use the quiet caps cut — match every other -// table in the app. const colHeader: React.CSSProperties = { ...tableCaps, color: W.dim, @@ -206,7 +202,6 @@ function ContainersTable({
); } - // Failure-mode rows sort to the top. const sorted = [...containers].sort((a, b) => severity(a) - severity(b)); return (
{ const color = signalFor(c); const onLogs = (e: React.MouseEvent) => { - // Don't let the opening click double as a backdrop click on - // the modal overlay (which would close it immediately). e.stopPropagation(); onPickLogs(c.name); }; @@ -237,9 +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; the restart cell stops propagation - // so 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", @@ -307,12 +298,13 @@ function SummaryPills({ counts }: { counts: ContainersResponse }) { key={label} style={{ padding: "2px 8px", - borderRadius: 2, - 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} @@ -322,19 +314,16 @@ function SummaryPills({ counts }: { counts: ContainersResponse }) { ); } -// severity orders rows so failure-mode containers come first -// (lower 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; } -// signalFor maps a container's docker state/health to its status-dot -// color (the state word next to it carries the same color). 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; @@ -342,7 +331,6 @@ function signalFor(c: { state: string; health?: string }): string { if (c.health === "unhealthy") return W.err; if (c.health === "starting") return W.brand; if (c.health === "healthy") return W.ok; - // running with no healthcheck 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 f0fcfea5..d6318094 100644 --- a/frontend/src/screens/ContainerLogsModal.tsx +++ b/frontend/src/screens/ContainerLogsModal.tsx @@ -1,14 +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 and renders them in a terminal-styled
. Tail size
-// and since duration are toolbar-tunable; auto-scroll-to-bottom is on
-// by default but disabled once the user scrolls up.
+// 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;
 
@@ -27,12 +25,10 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
   const [loading, setLoading] = useState(false);
   const preRef = useRef(null);
   const autoScrollRef = useRef(true);
-  // Only close when both mousedown AND click originated on the overlay;
-  // otherwise the click that opened the modal (mousedown on a row cell,
-  // mouseup after the modal mounted) would immediately close it.
+  // 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.
   useEffect(() => {
     if (!open) return;
     function onKey(e: KeyboardEvent) {
@@ -73,8 +69,6 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
     };
   }, [open, instance, container, tail, since]);
 
-  // Auto-scroll to bottom on new content unless the user scrolled up;
-  // tracked via a ref to avoid a state update per scroll event.
   useEffect(() => {
     if (!preRef.current || !autoScrollRef.current) return;
     preRef.current.scrollTop = preRef.current.scrollHeight;
@@ -134,7 +128,7 @@ export function ContainerLogsModal({ open, instance, container, onClose }: Props
             role="alert"
             style={{
               color: W.err,
-              background: `${W.err}10`,
+              background: `${tint(W.err, 6)}`,
               borderBottom: `1px solid ${W.err}`,
               padding: "8px 16px",
               fontSize: 12,
@@ -230,9 +224,8 @@ const modalStyle: React.CSSProperties = {
   width: "min(900px, 95vw)",
   height: "min(700px, 88vh)",
   background: W.surface,
-  border: `1px solid ${W.border}`,
-  borderRadius: 4,
-  boxShadow: "0 24px 64px rgba(0,0,0,0.6)",
+  border: `1px solid ${W.borderHi}`,
+  borderRadius: R.card,
   display: "flex",
   flexDirection: "column",
   overflow: "hidden",
@@ -250,7 +243,7 @@ const selectStyle: React.CSSProperties = {
   background: W.bg,
   color: W.text,
   border: `1px solid ${W.border}`,
-  borderRadius: 2,
+  borderRadius: R.control,
   padding: "2px 6px",
   fontSize: 11,
   fontFamily: wMono,
diff --git a/frontend/src/screens/ContractDetailDrawer.tsx b/frontend/src/screens/ContractDetailDrawer.tsx
index 56c20e85..95b07fc3 100644
--- a/frontend/src/screens/ContractDetailDrawer.tsx
+++ b/frontend/src/screens/ContractDetailDrawer.tsx
@@ -6,33 +6,21 @@ import {
   type ContractRow,
   type Role,
 } from "../api";
-import { W, wMono, wideCaps } from "../tokens";
+import { W, wMono, wideCaps, tint, R } from "../tokens";
 import { Button } from "../components/Button";
+import { MonoId } from "../components/MonoId";
 import { IcX } from "../components/icons";
 
-// ContractDetailDrawer is a true right-side overlay: position-fixed
-// below the topbar so the ACS table keeps its full width. It opens
-// when a row is clicked. Fetches the deep view from
-// /api/instances/{name}/contracts/{cid}
-// (EventQueryService.GetEventsByContractId) for the create event's full
-// payload, signatories, observers, and archive metadata. While that
-// loads it shows the row-level ACS fields so the user always has
-// something to read.
-//
-// Keyboard: Esc closes, J/K move to the next/previous row. The parent
-// owns row navigation because it owns the filtered table state; the
-// drawer only owns the deep-view fetch lifecycle.
-
+// Right-side overlay showing a contract's deep view (payload, parties,
+// archive metadata), falling back to row-level ACS fields while it loads.
+// The parent owns J/K row navigation since it holds the filtered table state.
 export interface ContractDetailDrawerProps {
   instance: string;
   role: Role;
   /** Row data we already have from the ACS snapshot. */
   row: ContractRow;
-  /** Close the drawer. */
   onClose: () => void;
-  /** Move selection to the previous row (K / ArrowUp). */
   onPrev?: () => void;
-  /** Move selection to the next row (J / ArrowDown). */
   onNext?: () => void;
 }
 
@@ -49,7 +37,6 @@ export function ContractDetailDrawer({
     | { kind: "ok"; detail: ContractDetail }
     | { kind: "err"; message: string }
   >({ kind: "loading" });
-  const [copied, setCopied] = useState(false);
 
   useEffect(() => {
     let cancelled = false;
@@ -72,9 +59,8 @@ export function ContractDetailDrawer({
     };
   }, [instance, role, row.contract_id]);
 
-  // Esc / J / K, listened on window so keystrokes work from anywhere
-  // on the page. INPUT/TEXTAREA/contenteditable are ignored so typing
-  // in the search box doesn't trigger navigation.
+  // Esc / J / K on window; skip when an editable element is focused so
+  // typing in the search box doesn't trigger navigation.
   useEffect(() => {
     const onKey = (e: KeyboardEvent) => {
       const active = document.activeElement as HTMLElement | null;
@@ -116,16 +102,6 @@ export function ContractDetailDrawer({
           archived: false,
         };
 
-  const copyCid = async () => {
-    try {
-      await navigator.clipboard.writeText(detail.contract_id);
-      setCopied(true);
-      setTimeout(() => setCopied(false), 1100);
-    } catch {
-      // clipboard may be unavailable (http on non-localhost); silent
-    }
-  };
-
   return (
     
@@ -291,23 +260,32 @@ export function ContractDetailDrawer({
)} {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)} )} @@ -330,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, @@ -372,11 +354,11 @@ function Pill({ return ( - - {party} - +
); } -// PayloadNode — recursive JSON-like view for the contract payload: -// objects as label:value pairs, arrays as indexed lists, primitives in -// place. Each level indents 12px — enough to see structure without -// burning horizontal space in the overlay drawer. +// Recursive JSON-like view of the contract payload; each level indents 12px. function PayloadNode({ value, depth, @@ -495,7 +465,8 @@ function primStyle(kind: "text" | "num" | "dim"): React.CSSProperties { return { fontFamily: wMono, fontSize: 11, - color: kind === "dim" ? W.dim : kind === "num" ? "#DDB25E" : 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 0687ef45..59a5e574 100644 --- a/frontend/src/screens/CreateLocalNetModal.tsx +++ b/frontend/src/screens/CreateLocalNetModal.tsx @@ -13,7 +13,7 @@ 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"; @@ -23,26 +23,14 @@ import { useCreateProgress, } from "./useCreateProgress"; -// CreateLocalNetModal — the "Create LocalNet" flow. Three top-level -// stages: -// -// 1. form — name + version + advanced options -// 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. -// -// The name regex matches internal/registry's RFC 1123 DNS-label rule. -// Client-side validation is for snappy feedback only; 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 can refresh its instance list - // and select the new instance. onCreated?: (name: string) => void; } @@ -56,28 +44,15 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { const [name, setName] = useState(""); const [version, setVersion] = useState(""); const [allowUncurated, setAllowUncurated] = useState(false); - // Prometheus and Grafana are independent toggles (metrics-only - // setups, or Grafana pointed at an external scrape source). Both - // default OFF — the overlay pulls extra images and adds memory - // pressure. Grafana-without-Prometheus shows a warning, not a block, - // so the combination stays 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 probe. "blocked" (any FAIL) - // disables Create; a WARN-only report still allows submit and renders - // inline as a heads-up. + // "blocked" (any FAIL) disables Create; WARN-only still allows submit. const [preflight, setPreflight] = useState< | { kind: "idle" } | { kind: "loading" } @@ -91,7 +66,7 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { stage.kind === "progress" ? stage.accepted.events_url : null, ); - // Reset on every open — each open is a fresh form. + // Each open is a fresh form. useEffect(() => { if (open) { setName(""); @@ -103,22 +78,17 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { setPortBase(""); setStage({ kind: "form" }); requestAnimationFrame(() => inputRef.current?.focus()); - // Refresh the version catalogue on open. Server-cached (embedded - // versions.json), so this is fast. setVersionsLoading(true); setVersionsError(null); fetchSpliceVersions() .then((r) => { setVersions(r.versions); - // Pre-select the "latest" entry 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" so a 5xx doesn't - // leave the picker on "Loading…" forever. setVersions([]); setVersionsError( e instanceof ApiError ? e.message : "Couldn't load the version catalogue", @@ -126,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 — but not mid-submit or while a bring-up is running. - // Accidentally cancelling a 90-second up with 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(); } @@ -147,11 +114,8 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { return () => window.removeEventListener("keydown", onKey); }, [open, stage, progress.banner.kind, onClose]); - // Fire onCreated when the up succeeds — at most once per accepted - // instance. Parents typically pass a fresh arrow function as - // `onCreated`, which changes the effect's identity every render and - // would re-invoke the callback; firedRef guarantees one call - // regardless of how the parent typed it. + // 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 ( @@ -163,16 +127,13 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { onCreated?.(stage.accepted.instance); } }, [progress.banner.kind, stage, onCreated]); - // Reset the guard on close so a new create can fire onCreated again. useEffect(() => { if (!open) firedRef.current = null; }, [open]); - // Probe system requirements when the picked version changes (form - // stage only). Skipped for uncurated tags — the server doesn't - // enforce a per-version floor for tags outside the catalogue. The - // cancelled flag keeps only the latest result when the user flips - // versions quickly, not whichever 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) { @@ -208,8 +169,6 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { if (!open) return null; const nameValid = NAME_RE.test(name); - // "loading"/"err" preflight still allows submit — it's 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; @@ -236,9 +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. Drop - // back to the form 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; @@ -256,11 +213,9 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { if (stage.kind !== "progress") return; try { await cancelInstanceUp(stage.accepted.instance); - // The SSE stream delivers kind=cancelled and the reducer flips - // the banner; no local state mutation needed. + // SSE delivers kind=cancelled; the reducer flips the banner. } catch { - // Cancel-after-finish 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. } } @@ -325,8 +280,6 @@ export function CreateLocalNetModal({ open, onClose, onCreated }: Props) { ); } -// ── header / footer ─────────────────────────────────────────────── - function ModalHeader({ stage, progress, @@ -441,8 +394,6 @@ function ModalFooter({ ); } -// ── stage bodies ────────────────────────────────────────────────── - type PreflightState = | { kind: "idle" } | { kind: "loading" } @@ -558,7 +509,7 @@ function FormBody({ fontSize: 10.5, padding: "1px 6px", borderRadius: 2, - background: `${W.brand}1A`, + background: `${tint(W.brand, 10)}`, color: W.brand, fontFamily: wMono, }} @@ -603,7 +554,7 @@ function FormBody({ fontSize: 10.5, padding: "1px 6px", borderRadius: 2, - background: `${W.brand}1A`, + background: `${tint(W.brand, 10)}`, color: W.brand, fontFamily: wMono, }} @@ -623,7 +574,7 @@ function FormBody({ style={{ marginTop: 8, padding: "6px 8px", - background: `${W.warn}15`, + background: `${tint(W.warn, 8)}`, border: `1px solid ${W.warn}`, borderRadius: 2, fontSize: 11.5, @@ -666,7 +617,7 @@ function FormBody({ fontSize: 10.5, padding: "1px 6px", borderRadius: 2, - background: `${W.brand}1A`, + background: `${tint(W.brand, 10)}`, color: W.brand, fontFamily: wMono, }} @@ -677,7 +628,7 @@ function FormBody({ Injects the alpha-protocol Canton config for CIP-0112 token flows. Requires a V2-capable Splice version (e.g.{" "} token-standard-v2). 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{" "} @@ -698,7 +649,7 @@ function FormBody({ padding: "6px 0", }} > - Advanced — uncurated versions + Advanced · uncurated versions @@ -745,12 +696,13 @@ function FormBody({ style={{ width: 88, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", fontSize: 12, padding: "4px 6px", background: W.surface, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, }} />
@@ -823,8 +775,8 @@ function ProgressBody({
@@ -927,17 +879,15 @@ function ErrorBody({ ); } -// ── pieces ──────────────────────────────────────────────────────── - function BannerStripe({ banner }: { banner: ProgressState["banner"] }) { if (banner.kind === "running") { return (
{remediation.title} @@ -1014,12 +963,11 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
); } - // cancelled return (
- cancelled{banner.reason ? ` — ${banner.reason}` : ""} + Cancelled{banner.reason ? `. ${banner.reason}` : ""}
); @@ -1060,7 +1008,7 @@ 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, }} > @@ -1115,17 +1063,9 @@ function StepRow({ label, state }: { label: string; state: StepState }) { ); } -// VersionPicker renders the curated Splice catalogue as a native HTML -// , 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 string -// collation sorts the rc AFTER the release — inverting semver -// precedence. Non-semver tags ("token-standard-v2") have no precedence -// to reason about and 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); @@ -1199,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; @@ -1212,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("."); @@ -1238,32 +1169,24 @@ function comparePrerelease(a: string, b: string): number { return 0; } -// selectStyle is inlined rather than spread from `inputStyle`, which is -// declared further down — referencing it here would hit the ES-module -// TDZ ("used before declaration") under Vite/SWC. Visual parity with -// inputStyle is intentional. `appearance: "auto"` keeps the native OS -// dropdown caret, which some browsers drop when a custom -// borderRadius/background is applied — leaving the field looking like a -// disabled text input. +// 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: 2, + 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: nothing when idle, a pill while loading, a neutral non-blocking -// note on probe error (the server-side gate still runs on submit), a -// compact green pill on pass, an amber box for warnings (submit still -// allowed), and a red box with per-check remediation when blocked. function PreflightPanel({ state }: { state: PreflightState }) { if (state.kind === "idle") return null; if (state.kind === "loading") { @@ -1323,17 +1246,16 @@ function PreflightPanel({ state }: { state: PreflightState }) { const heading = blocked ? "Host doesn't meet this version's requirements" : warns.length > 0 - ? "Host meets minimums — but raise resources for headroom" + ? "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 (
@@ -1472,7 +1394,6 @@ function Field({ } function Elapsed({ startedAt }: { startedAt: number }) { - // Tick once per second to keep the counter live; cleared on unmount. const [, force] = useState(0); useEffect(() => { const t = setInterval(() => force((n) => n + 1), 1000); @@ -1484,8 +1405,6 @@ function Elapsed({ startedAt }: { startedAt: number }) { return <>{m}:{String(s).padStart(2, "0")} elapsed; } -// ── styles ──────────────────────────────────────────────────────── - const overlayStyle: React.CSSProperties = { position: "fixed", inset: 0, @@ -1502,8 +1421,8 @@ const modalStyle: React.CSSProperties = { width: "min(680px, 92vw)", background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 8, - boxShadow: "0 24px 64px rgba(0,0,0,0.6)", + borderRadius: R.dialog, + boxShadow: "0 10px 32px rgba(0,0,0,0.24)", overflow: "hidden", }; @@ -1512,9 +1431,10 @@ const inputStyle: React.CSSProperties = { background: W.bg, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "7px 10px", fontSize: 13, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", outline: "none", }; diff --git a/frontend/src/screens/CreatingPanel.tsx b/frontend/src/screens/CreatingPanel.tsx index 1c5ad801..53e5fdbf 100644 --- a/frontend/src/screens/CreatingPanel.tsx +++ b/frontend/src/screens/CreatingPanel.tsx @@ -6,34 +6,24 @@ import { scrubInstance, type StepName, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { Dot, IcAlert, IcCheck, IcRefresh, IcX } from "../components/icons"; +import { StatusBadge } from "../components/StatusBadge"; import { type ProgressState, type StepState, useCreateProgress, } from "./useCreateProgress"; -// CreatingPanel — shown above the InstanceDetail/DeveloperSetup cards -// when the selected instance is status="creating". Subscribes to -// /api/instances/{name}/events and renders the same step rows as the -// create modal (both consume the shared useCreateProgress state). -// -// Two scenarios: -// 1. Live bring-up: the SSE stream replays buffered events + live -// ones — real-time progress just like the modal. -// 2. Zombie creating: the registry says creating but no goroutine is -// publishing (e.g. a server restart killed it mid-flight). No -// events arrive; after a grace period the panel surfaces a "looks -// stalled" hint with a cleanup CTA. +// Shown when the selected instance is status="creating". Renders live +// SSE bring-up progress, or — if no event arrives within ZOMBIE_GRACE_MS +// (e.g. a server restart orphaned the entry) — a stalled hint + cleanup. -const ZOMBIE_GRACE_MS = 3000; // wait this long before showing "stalled" hint +const ZOMBIE_GRACE_MS = 3000; interface Props { name: string; - // Called after a cancel or stalled-state cleanup so the Dashboard - // re-fetches and the row's status updates. onRefresh: () => void; } @@ -41,13 +31,9 @@ export function CreatingPanel({ name, onRefresh }: Props) { const eventsUrl = `/api/instances/${encodeURIComponent(name)}/events`; const progress = useCreateProgress(eventsUrl); - // Zombie detection: no event by ZOMBIE_GRACE_MS surfaces the - // "stalled" affordance. Derived freshly on every render rather than - // via setTimeout — a timeout closure would capture progress.startedAt - // at setup time and never re-check it, so events arriving late (slow - // network, slow first publish) would leave the panel permanently - // "stalled". mountedAtRef pegs the start time per name; the 1s ticker - // below keeps the derived check current. + // Derived per render, not via setTimeout: a timeout closure would + // capture startedAt once and never re-check, wedging late events as + // "stalled". mountedAtRef pegs the start; the 1s ticker below refreshes. const mountedAtRef = useRef(Date.now()); useEffect(() => { mountedAtRef.current = Date.now(); @@ -61,9 +47,8 @@ export function CreatingPanel({ name, onRefresh }: Props) { progress.startedAt === null && Date.now() - mountedAtRef.current > ZOMBIE_GRACE_MS; - // Live path: ask the goroutine to stop. The backend publishes - // kind=cancelled, then the goroutine sees ctx.Done() and writes - // status=failed via its existing path. + // Live path: ask the goroutine to stop; it publishes kind=cancelled + // then writes status=failed. async function onCancelLive() { try { await cancelInstanceUp(name); @@ -74,14 +59,12 @@ export function CreatingPanel({ name, onRefresh }: Props) { } // Zombie path: no live goroutine, so /up cancel would 404 — scrub the - // registry entry instead so the row disappears from the list. + // registry entry instead. async function onScrub() { try { await scrubInstance(name); onRefresh(); } catch { - // Even if scrub fails (e.g. 409 because the entry is now - // running), refresh so the user sees current state. onRefresh(); } } @@ -92,7 +75,7 @@ export function CreatingPanel({ name, onRefresh }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 16, }} > @@ -125,10 +108,10 @@ export function CreatingPanel({ name, onRefresh }: Props) {
@@ -277,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", }} > @@ -304,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, @@ -350,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 diff --git a/frontend/src/screens/DARDiff.tsx b/frontend/src/screens/DARDiff.tsx index 47cbfb20..685ef2da 100644 --- a/frontend/src/screens/DARDiff.tsx +++ b/frontend/src/screens/DARDiff.tsx @@ -1,16 +1,13 @@ -// DAR structural diff viewer. Renders /api/instances/:name/dar/diff -// between two DARs as expandable sections: modules / templates / -// interfaces added/removed/changed. No third-party diff library — the -// JSON shape is small enough that a hand-rolled list-with-colour reads -// cleanly. Embedded as a 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, @@ -201,7 +198,7 @@ export function DARDiff({ instance, a, b, role }: Props) { const paneStyle: React.CSSProperties = { background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 12, fontSize: 12, maxHeight: "60vh", @@ -229,14 +226,14 @@ function Side({ ); } return ( - - {label}: + + {label}: {side.name}@{side.version} - - {side.main.slice(0, 8)}… - + ); } @@ -246,13 +243,13 @@ type Tone = "add" | "rm" | "chg" | "info"; function toneColour(t: Tone): { bg: string; fg: string } { switch (t) { case "add": - return { bg: "#7CC89A22", fg: "#7CC89A" }; + 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 }; } } @@ -281,10 +278,9 @@ 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, @@ -337,7 +333,7 @@ function ChipGroup({ key={l} style={{ padding: "0 5px", - borderRadius: 2, + 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 122ed609..1b0d48fb 100644 --- a/frontend/src/screens/DARPackageTree.tsx +++ b/frontend/src/screens/DARPackageTree.tsx @@ -1,8 +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), with choices and methods as inline chips. -// Self-contained — fetches its own data and owns its expand/collapse -// state. 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, @@ -11,9 +8,17 @@ 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; mainID: string; @@ -38,8 +43,6 @@ export function DARPackageTree({ instance, mainID, role }: Props) { .then((data) => { if (cancelled) return; setState({ kind: "ok", data }); - // Auto-expand the main package so the most useful tree is - // visible on first render. const main = data.packages.find((p) => p.is_main); if (main) setExpandedPkgs(new Set([main.package_id])); }) @@ -85,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) => ( )} - - {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 && @@ -228,7 +259,7 @@ function ModuleNode({
    {(mod.templates ?? []).map((t) => (
    - template{" "} + template{" "} {t.name} {t.choices && t.choices.length > 0 && ( @@ -241,7 +272,7 @@ function ModuleNode({ ))} {(mod.interfaces ?? []).map((i) => (
    - interface{" "} + interface{" "} {i.name} {i.choices && i.choices.length > 0 && ( @@ -261,7 +292,7 @@ function ModuleNode({ ))} {(mod.data_types ?? []).map((dt) => (
    - data{" "} + data{" "} {dt}
    ))} @@ -301,7 +332,9 @@ function Chip({ kind: "choice" | "method"; }) { const tone = - kind === "choice" ? { bg: `${W.brand}1A`, fg: W.brand } : { bg: "#8FA3EE22", fg: "#8FA3EE" }; + kind === "choice" + ? { bg: tint(W.brand, 10), fg: W.brand } + : { bg: tint(W.mag, 13), fg: W.mag }; return ( ("app-user"); - // Which participants an upload fans out to (the backend dials each in - // parallel). Default ON for all three so "vet everywhere" is one - // drag-and-drop. Orthogonal to `role`, which drives the package LIST: - // 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, @@ -74,17 +63,13 @@ export function DARScreen() { | { kind: "err"; error: string } >({ kind: "loading" }); const [selectedHash, setSelectedHash] = useState(null); - // Diff mode: a picked "compare with" target flips the right drawer - // from the inspect tree to DARDiff. Kept separate from selectedHash - // so the user can toggle the comparison off without losing their + // 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 - // Per-participant vetting per listed DAR, keyed by main package id; - // populated lazily by the batch-fetch effect below. const [vetting, setVetting] = useState>({}); const fileInputRef = useRef(null); @@ -139,9 +124,7 @@ export function DARScreen() { }); return; } - // Mirrors the backend's multipart cap (darUploadMax = 64 MiB in - // internal/ui/handlers/dar.go); reject client-side so an oversized - // DAR doesn't upload just to fail server-side. + // 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,7 +164,6 @@ export function DARScreen() { if (state.kind !== "ok") return [] as DARRow[]; let list = state.data.dars; if (filter === "app") { - // Hide the canton/splice/daml system packages. list = list.filter( (d) => !d.name.startsWith("canton-builtin-") && @@ -192,17 +174,12 @@ export function DARScreen() { return list; }, [state, filter]); - // Reset the vetting cache when the instance changes or the list is - // refetched. Keyed by main id, so a role switch — same DARs, - // different participant's list — reuses already-fetched verdicts. useEffect(() => { setVetting({}); }, [name, tick]); - // Lazily fetch real per-participant vetting for each visible row (the - // endpoint fans out to all three participants server-side) so the - // list column reflects ledger state. Rows are marked "loading" in one - // batch before dispatch so re-renders never double-fetch. + // 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; @@ -231,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]); @@ -279,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" ? ( @@ -359,7 +332,7 @@ export function DARScreen() { Drop DAR here
    - or click to browse · multi-file ok + or click to browse · multiple .dar accepted
    )} @@ -448,7 +421,6 @@ export function DARScreen() {
    - {/* MIDDLE — package list */}
    Packages on {role} participant
    @@ -492,7 +464,6 @@ export function DARScreen() {
    - {/* Column header */}
    - {/* RIGHT — inspect drawer / diff viewer */} (null); const [active, setActive] = useState(false); - // Re-render every 10s so the "ago" label stays fresh. const [, setNow] = useState(Date.now()); useEffect(() => { @@ -598,14 +565,18 @@ function WatchModeCard({ instance }: { instance: string }) {
    + {active ? "Watching" : "Idle"} {last && ( @@ -639,8 +610,6 @@ function WatchModeCard({ instance }: { instance: string }) { ); } -// formatAgo renders a "X ago" label for a unix-second delta; bands -// finer than 5s read as noise on this card. function formatAgo(deltaSec: number): string { if (deltaSec < 5) return "just now"; if (deltaSec < 60) return `${Math.floor(deltaSec)}s ago`; @@ -649,8 +618,7 @@ function formatAgo(deltaSec: number): string { return `${Math.floor(deltaSec / 86400)}d ago`; } -// VetState is the per-row vetting cell state; undefined means not yet -// requested. +// undefined means not yet requested. type VetState = | { kind: "loading" } | { kind: "ok"; rows: DARVettingRow[] } @@ -676,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 per-participant vetting for one DAR as a compact -// "U P S" trio of dots — green vetted, grey unvetted, amber "?" when -// that participant couldn't be probed. Matches the CLI `dar list -// --vetting` column and the inspect-drawer toggles. +// 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 ? "#7CC89A" : 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"}`; @@ -794,14 +754,16 @@ function InspectDrawer({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, - 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.
    ); } @@ -816,7 +778,7 @@ function InspectDrawer({ >
    - + {row.name} @@ -830,7 +792,18 @@ function InspectDrawer({ )}
    - +
    + pkg-id + +
    {row.description && ( @@ -873,9 +846,7 @@ function InspectDrawer({ ); } -// 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, @@ -919,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, @@ -1014,14 +983,14 @@ function VettingPanel({ border: "none", padding: 0, cursor: pending === r.role ? "wait" : "pointer", - color: r.vetted ? "#7CC89A" : W.dim, + color: r.vetted ? W.ok : W.dim, }} > 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: 2, + 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} @@ -1267,7 +1234,7 @@ function VetToggle({ style={{ width: 24, height: 14, - background: on ? W.brand : "#313B52", + background: on ? W.brand : W.borderHi, borderRadius: 999, position: "relative", flexShrink: 0, @@ -1309,7 +1276,7 @@ function FilterBtn({ fontSize: 11.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, @@ -1321,8 +1288,6 @@ function FilterBtn({ ); } -// ─── Tiny shared primitives ───────────────────────────────── - function Card({ title, subtitle, @@ -1383,8 +1348,8 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
    {children} @@ -1418,7 +1383,8 @@ function KV({ color: color ?? W.text2, fontSize: mono ? 11 : 12, fontFamily: mono ? wMono : undefined, - wordBreak: "break-all", + fontVariantNumeric: mono ? "tabular-nums" : undefined, + wordBreak: "break-word", }} > {value} @@ -1451,19 +1417,38 @@ function Row({ ); } -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 ? ( + + ) : ( +
    + )}
    ); } @@ -1495,7 +1480,7 @@ function EmptyPanel({ return (
    | "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 b8317603..ae21898e 100644 --- a/frontend/src/screens/Dashboard.tsx +++ b/frontend/src/screens/Dashboard.tsx @@ -6,9 +6,12 @@ import { type TransactionEvent, type TransactionRow, } from "../api"; -import { W, wMono, tableCaps } from "../tokens"; +import { W, wMono, tableCaps, tint, R } from "../tokens"; import { Button } from "../components/Button"; -import { Dot, IcPlus, IcRefresh } from "../components/icons"; +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"; @@ -16,16 +19,12 @@ import { CreatingPanel } from "./CreatingPanel"; import { DeveloperSetup } from "./DeveloperSetup"; import { InstanceDetail } from "./InstanceDetail"; -// Dashboard — the Overview screen. Renders the registered-instance -// table 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. +// 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 (
    @@ -41,8 +40,7 @@ export function Dashboard() { LocalNet instances
    - - - - + + + + - {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", @@ -221,31 +231,11 @@ const td: React.CSSProperties = { verticalAlign: "middle", }; -function StatusBadge({ status }: { status: string }) { - const color = (() => { - switch (status) { - case "running": - return W.ok; - case "creating": - case "stopping": - case "partial": - return W.warn; - case "failed": - return W.err; - case "stopped": - default: - return W.dim; - } - })(); - return ( - - - {status} - - ); -} +const numCell: React.CSSProperties = { + textAlign: "right", + fontFamily: wMono, + fontVariantNumeric: "tabular-nums", +}; function EmptyState({ onCreate }: { onCreate: () => void }) { return ( @@ -253,10 +243,9 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, - padding: 32, + borderRadius: R.card, + padding: 16, color: W.dim, - textAlign: "center", }} >

    @@ -273,7 +262,9 @@ function EmptyState({ onCreate }: { onCreate: () => void }) {

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

    @@ -283,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 @@ -391,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.
    )} @@ -421,13 +408,14 @@ function RecentActivity({ name }: { name: string }) { {events.map((e) => ( - {e.time} + {e.time} {e.event} - - {e.cid.slice(0, 10)}… + + ))} @@ -453,9 +441,7 @@ 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(":"); diff --git a/frontend/src/screens/DeveloperSetup.tsx b/frontend/src/screens/DeveloperSetup.tsx index 4c58290b..ca0e3aab 100644 --- a/frontend/src/screens/DeveloperSetup.tsx +++ b/frontend/src/screens/DeveloperSetup.tsx @@ -9,27 +9,13 @@ import { } from "../api"; import { W, wMono } from "../tokens"; import { Button } from "../components/Button"; +import { MonoId } from "../components/MonoId"; -// DeveloperSetup — the "Developer setup" card. Two sub-panels: -// -// 1. JWT generator: role/audience picker + a usable token preview + -// copy button. LocalNet is loopback-only with dev-secret tokens -// (the dev-secret warning renders below), so the raw token is -// surfaced directly — no redaction toggle. -// -// 2. App config exporter: format tabs (env / json / yaml) + monospace -// preview + copy button, all backed by -// /api/instances/{name}/app-config?format=. -// -// The Dashboard owns 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]; -// The backend redacts JWTs by default; this LocalNet-only UI opts -// into the raw token (?include_jwt=true) so the generated token is -// usable as-is. The dev-secret warning makes the trade-off explicit. export function DeveloperSetup({ name }: { name: string }) { return (
    (null); const [busy, setBusy] = useState(false); - // Issue a usable JWT on mount + whenever role/audience/name changes. - // include_jwt=true so the raw token is returned — LocalNet only. + // include_jwt=true returns the raw token, usable as-is (LocalNet only). useEffect(() => { let cancelled = false; setBusy(true); @@ -103,9 +88,11 @@ function JwtPanel({ name }: { name: string }) { /> - - {jwt?.party ?? "—"} - + {jwt?.party ? ( + + ) : ( + + )}
    @@ -220,11 +207,6 @@ function AppConfigPanel({ name }: { name: string }) { ); } -// ──────────────────────── shared primitives ───────────────────────── -// -// Kept inline while this screen is the only consumer; promote to a -// shared module when a second screen needs them. - interface CardProps { title: string; subtitle?: string; @@ -299,7 +281,7 @@ function ChipRow({ options, value, onChange }: ChipRowProps) { onClick={() => onChange(opt)} style={{ background: opt === value ? W.brand : W.surface2, - color: opt === value ? "#0B0F1A" : W.text2, + color: opt === value ? W.onAccent : W.text2, border: `1px solid ${opt === value ? W.brand : W.border}`, borderRadius: 2, padding: "4px 10px", @@ -316,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. Placeholders ("—", "…") aren't 3-part tokens, so they - // render as plain text. + // 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 ( diff --git a/frontend/src/screens/DoctorScreen.tsx b/frontend/src/screens/DoctorScreen.tsx index 69470114..fc2a56aa 100644 --- a/frontend/src/screens/DoctorScreen.tsx +++ b/frontend/src/screens/DoctorScreen.tsx @@ -7,44 +7,30 @@ import { fetchDoctor, fetchSpliceVersions, } from "../api"; -import { W, wMono, wideCaps } from "../tokens"; +import { W, wMono, wideCaps, tint, R } from "../tokens"; import { Button } from "../components/Button"; +import { SkeletonTable, useLoadingDelay } from "../components/Skeleton"; import { Dot, IcAlert, IcCheck, IcRefresh, IcX } from "../components/icons"; -// DoctorScreen — the Web UI surface for `dpm localnet doctor`. -// -// GET /api/doctor runs the same shared localnet.CollectDoctor collector -// as the CLI verb: the resource/Docker gate /api/preflight exposes, -// plus two advisory checks (platform-support matrix + host-port -// availability). The report shape is types.PreflightReport — identical -// to the create-modal preflight panel — so the two surfaces can't -// drift. -// -// Not instance-scoped: doctor diagnoses the HOST, so it sits in the nav -// alongside Overview rather than under an instance selector. - +// Web UI surface for `dpm localnet doctor`: GET /api/doctor runs the +// same shared CollectDoctor collector as the CLI. Host-scoped, not per-instance. export function DoctorScreen() { const [report, setReport] = useState(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; }; @@ -70,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 ( @@ -86,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} -
    - )} - - {loading && !report && ( -
    - Running host checks… + {message}
    - )} +
    +
    + ); +} - {report?.sections.map((sec) => ( -
    - ))} +function DoctorLoading() { + const shown = useLoadingDelay(true); + if (!shown) return null; + return ( +
    +
    ); } @@ -169,10 +192,11 @@ function Header({ background: W.surface, color: W.text, border: `1px solid ${W.border}`, - borderRadius: 2, + borderRadius: R.control, padding: "5px 8px", fontSize: 12, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", }} > @@ -197,9 +221,7 @@ 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"), @@ -218,9 +240,9 @@ function SummaryBanner({ report }: { report: PreflightReport }) { style={{ marginTop: 16, padding: "12px 14px", - background: `${accent}14`, + background: tint(accent, 8), border: `1px solid ${accent}`, - borderRadius: 4, + borderRadius: R.control, color: accent, fontSize: 13, fontWeight: 600, @@ -263,7 +285,7 @@ function Section({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, overflow: "hidden", }} > @@ -318,6 +340,7 @@ function CheckRow({ check, last }: { check: PreflightCheck; last: boolean }) { color: W.dim, fontSize: 11.5, fontFamily: wMono, + fontVariantNumeric: "tabular-nums", marginTop: 2, }} > diff --git a/frontend/src/screens/ExplorerScreen.tsx b/frontend/src/screens/ExplorerScreen.tsx index 294d7b26..e3e787f0 100644 --- a/frontend/src/screens/ExplorerScreen.tsx +++ b/frontend/src/screens/ExplorerScreen.tsx @@ -16,24 +16,16 @@ import { import { useInstanceSelection } from "../shell/useInstanceSelection"; import { Button } from "../components/Button"; import { Dot, IcRefresh } from "../components/icons"; -import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps } from "../tokens"; +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 — live Active Contract Set, transaction history, and -// per-party visibility for the selected instance. -// -// 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"]; -// Hash palette for template/party dots — the dataviz ramp ordered so -// neighbouring indices never share a hue family, and no danger red -// (red stays reserved for errors). +// Template/party dot palette, ordered so neighbouring indices differ in +// hue; no red (reserved for errors). const PALETTE = [ "#6480E6", "#7BD2C6", "#DDB25E", "#7CC89A", "#93A7F0", "#C8971F", "#189E8C", "#9BA3B5", @@ -41,6 +33,12 @@ const PALETTE = [ 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; @@ -57,19 +55,13 @@ export function ExplorerScreen() { const [activeParties, setActiveParties] = useState>(new Set()); const [search, setSearch] = useState(""); const [selectedCid, setSelectedCid] = useState(null); - // Live-stream status: "live" after the first frame, "reconnecting" - // while the browser retries a dropped connection, "truncated" when - // the backend hit its event cap. const [streamStatus, setStreamStatus] = useState< "idle" | "live" | "reconnecting" | "truncated" >("idle"); const searchRef = useRef(null); - // refreshSnapshot fills the table from the snapshot endpoint. - // Background callers (the 30s reconciliation timer, SSE recovery) - // pass quiet=true so the table repopulates in place without - // flashing the loading panel; 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) { @@ -114,8 +106,7 @@ export function ExplorerScreen() { error: e instanceof ApiError ? e.message : "failed to load ACS", }); } - // Quiet background failures are swallowed — the user keeps - // the last-known good state and the next tick retries. + // Quiet background failures are swallowed; next tick retries. } }, [], @@ -127,19 +118,14 @@ export function ExplorerScreen() { 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. - // EventSource auto-reconnects on transient failures; the `error` - // listener triggers a snapshot refetch to recover missed events. - // - // Deltas are applied via a Map, which dedupes - // create-then-archive races: an archive arriving before its create - // removes nothing, so either ordering converges to the same 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 no events are - // 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) => { @@ -153,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; } @@ -190,9 +174,8 @@ export function ExplorerScreen() { }; es.addEventListener("contracts", onMessage as EventListener); es.onerror = () => { - // EventSource auto-reconnects unless closed. Show the - // reconnecting state and reconcile via snapshot — 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); @@ -203,13 +186,12 @@ export function ExplorerScreen() { es.close(); setStreamStatus("idle"); }; - // Depend on state.kind (not state) so the subscription is set up - // once per snapshot transition, not 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]); - // Every 30s, quietly re-pull the snapshot to correct any drift the - // SSE deltas missed (network hiccups, browser suspend, restarts). + // Every 30s, re-pull the snapshot to correct drift the SSE deltas missed. useEffect(() => { if (!name) return; if (state.kind !== "ok") return; @@ -220,8 +202,7 @@ export function ExplorerScreen() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [name, role, state.kind, refreshSnapshot]); - // Keyboard: "/" focuses search (unless typing in an editable - // element); Esc clears the selection. + // "/" focuses search (unless already in an editable); Esc clears selection. useEffect(() => { const onKey = (e: KeyboardEvent) => { const active = document.activeElement as HTMLElement | null; @@ -241,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(); @@ -257,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(); @@ -293,9 +274,7 @@ export function ExplorerScreen() { [state, selectedCid], ); - // j/k navigation over the *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); @@ -339,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 */}
    Stream - - {streamStatus} - +
    {state.data.ledger_end ?? "—"} @@ -447,7 +435,6 @@ export function ExplorerScreen() {
    - {/* CENTER — ACS table */}
    - {/* 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 ?? "—"} @@ -577,7 +614,6 @@ export function ExplorerScreen() {
    )} - {/* Detail drawer — fixed right-side overlay, outside the grid */} {state.kind === "ok" && view === "contracts" && selected && ( {v} ))}
    - {pillLabel} +
    ); } @@ -767,27 +786,28 @@ function FilterChip({ return ( + + ) : ( + <> + No updates in the current ledger window. + + dpm localnet tx ls + + + )}
    )} @@ -1125,8 +1179,6 @@ function TransactionsView({ name, role }: { name: string; role: Role }) { active={!!hasFilters} /> {body} - {/* Replay drawer — fixed right-side overlay; the table keeps - its full width underneath. */} {replayId && ( s @@ -1272,7 +1321,7 @@ function TxRowComponent({ gap: 14, padding: "9px 14px", alignItems: "center", - background: open ? `${W.brand}10` : "transparent", + background: open ? `${tint(W.brand, 6)}` : "transparent", borderBottom: `1px solid ${W.border}`, cursor: "pointer", }} @@ -1287,22 +1336,23 @@ function TxRowComponent({ > {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 ?? "—"} @@ -1353,7 +1404,7 @@ function TxRowComponent({ {open && tx.events && tx.events.length > 0 && (
    - - {ev.contract_id.slice(0, 16)}… - +
    ); } -// TimelineView — time-axis strip showing every update as a coloured -// glyph. Clicking a glyph highlights it and shows quick metadata in -// a side card — useful for "what happened in the last minute". function TimelineView({ name, role }: { name: string; role: Role }) { const [state, setState] = useState< | { kind: "loading" } @@ -1436,10 +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 when nothing is - // selected. Click again or Esc 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) => { @@ -1488,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 (
    - {/* Activity strip */}
    {buckets.map((b, i) => { @@ -1563,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, }} /> ); @@ -1594,7 +1638,6 @@ function TimelineView({ name, role }: { name: string; role: Role }) { )}
    - {/* Event glyph row */}
    ); @@ -1672,14 +1711,12 @@ 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."}
    - {/* Detail overlay — hovered/pinned update, fixed to the right - edge so the timeline strip keeps its full width. */} {focused && (
    {focused.kind} - + offset {focused.offset.toLocaleString()}
    @@ -1777,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} @@ -1817,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, @@ -1836,7 +1874,7 @@ function Card({ style={{ background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 10, }} > @@ -1883,11 +1921,11 @@ function Pill({ color, children }: { color: string; children: React.ReactNode }) return ( - {children} +
    ); } -function ErrorPanel({ msg }: { msg: string }) { +function TableLoading({ + columns, + rows, + rowHeight, +}: { + columns: (number | string)[]; + rows: number; + rowHeight: number; +}) { + const show = useLoadingDelay(true); + if (!show) return null; return (
    + +
    + ); +} + +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} + +
    ); } @@ -1944,17 +2037,21 @@ function EmptyPanel({ return (

    {title}

    -

    {body}

    -

    {remediation}

    +

    + {body} +

    +

    + {remediation} +

    ); } @@ -1967,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 db98bcc6..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( @@ -152,8 +142,7 @@ describe("InstanceDetail", () => { }); 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", @@ -169,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("—"); }); }); @@ -230,10 +217,6 @@ describe("InstanceDetail", () => { }); 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( @@ -266,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 @@ -278,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( @@ -293,8 +282,6 @@ describe("InstanceDetail", () => { }); it("posts to /stop (not /down) when the Stop button is clicked on a running instance", async () => { - // Gentle Stop = docker compose stop, containers kept. Distinct - // from the Down button (docker compose down, removes containers). const fetchMock = vi.fn().mockImplementation((url: string) => { if (typeof url === "string" && url.endsWith("/stop")) { return Promise.resolve(new Response(null, { status: 204 })); @@ -335,7 +322,6 @@ describe("InstanceDetail", () => { typeof u === "string" && u.endsWith("/api/instances/demo/stop"), ), ).toBe(true); - // Must NOT have hit /down. expect( calls.some( (u: string) => typeof u === "string" && u.endsWith("/down"), @@ -348,7 +334,6 @@ describe("InstanceDetail", () => { 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")) { - // 204 fast-start path. return Promise.resolve(new Response(null, { status: 204 })); } return Promise.resolve( @@ -415,16 +400,22 @@ describe("InstanceDetail", () => { ); }); vi.stubGlobal("fetch", fetchMock); - vi.stubGlobal("confirm", vi.fn().mockReturnValue(true)); 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( @@ -438,9 +429,7 @@ describe("InstanceDetail", () => { }); 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 fbbf6a4b..405d0647 100644 --- a/frontend/src/screens/InstanceDetail.tsx +++ b/frontend/src/screens/InstanceDetail.tsx @@ -12,7 +12,7 @@ import { stopInstance, unpauseInstance, } from "../api"; -import { W, wMono } from "../tokens"; +import { W, wMono, tint, R } from "../tokens"; import { Button } from "../components/Button"; import { IcEject, @@ -22,29 +22,22 @@ import { 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"; -// UI endpoints the backend probed and found not serving HTTP. function unreachableUIs(inst: Instance): Endpoint[] { return (inst.endpoints ?? []).filter( (e) => e.reachability === "unreachable", ); } -// InstanceDetail — the per-instance detail card the dashboard shows -// when a row is selected. Surfaces the fields GET /api/instances/:name -// returns beyond the summary row (compose project, docker network, -// data dir, container prefix, uptime, live-probe state). interface Props { name: string; - // statusHint comes from the dashboard's always-fresh instance list - // and gates which action button renders. Falls back to this card's - // own fetched status 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 card's 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; - // Refresh the dashboard's instance list after an action succeeds so - // the row's status updates. onChanged?: () => void; } @@ -54,18 +47,17 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { | { kind: "ok"; instance: Instance } | { kind: "err"; error: string } >({ kind: "loading" }); - // Bumped after an action 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() { - // Gentle stop: `docker compose stop` keeps containers around for a - // fast Start. No destructive confirm needed — nothing is removed. + // docker compose stop keeps containers for a fast Start; no confirm needed. setStopping({ kind: "running" }); try { await stopInstance(name); @@ -81,15 +73,21 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { } async function onDown() { - if (!confirm(`Tear down instance ${name}? Containers will be removed via docker compose down. Data volumes are preserved.`)) { + 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" }); - // Refetch our own status, then notify the parent so the - // dashboard's row + ActionButton catch up too. setRefetchTick((n) => n + 1); onChanged?.(); } catch (e) { @@ -130,19 +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). Refresh both surfaces - // eagerly so the user sees the transitional status before the - // dashboard's next poll. + // 202 async (down → up); refresh eagerly to show the transitional status. setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); onChanged?.(); @@ -157,10 +155,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { async function onStart() { setStopping({ kind: "running" }); try { - // 204 → fast `docker compose start` done; 202 → full bring-up in - // progress (containers had been removed). Either way, refresh - // both surfaces so the user sees the transitional status before - // the dashboard's next poll. + // 204 → fast start done; 202 → full bring-up (containers had been removed). await startInstance(name); setStopping({ kind: "idle" }); setRefetchTick((n) => n + 1); @@ -173,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 remove --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; } @@ -185,8 +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 - // drops this whole card. + // 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 }); @@ -196,9 +193,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { useEffect(() => { let cancelled = false; - // Show the loading placeholder only on a true name-change mount, - // not on a refetchTick bump — without this guard, every action - // 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" }); } @@ -224,7 +219,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { marginTop: 24, background: W.surface, border: `1px solid ${W.border}`, - borderRadius: 4, + borderRadius: R.card, padding: 16, }} > @@ -238,18 +233,16 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) { style={{ color: W.warn, fontSize: 11, - border: `1px solid ${W.warn}`, - borderRadius: 2, + border: `1px solid ${tint(W.warn, 34)}`, + background: tint(W.warn, 13), + borderRadius: R.control, padding: "2px 8px", }} > - live probe failed + Live probe failed )} - {/* Prefer statusHint (parent's fresh list) over this card's - own fetch so the action button updates the instant the - dashboard refreshes. */} {(statusHint || state.kind === "ok") && ( e.label) .join(", ")}{" "} - not serving HTTP — usually a stale port overlay from an instance + 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} @@ -307,32 +300,29 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) {
    )} - {state.kind === "loading" && ( -
    Loading…
    - )} + {state.kind === "loading" && showSkeleton && } {state.kind === "err" && ( -
    {state.error}
    +
    {state.error}
    )} {state.kind === "ok" && } - {/* Rendered 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 }) { - // 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 ( @@ -345,10 +335,17 @@ function DetailGrid({ instance }: { instance: Instance }) { fontSize: 12.5, }} > - {rows.map(([k, v]) => ( -
    + {rows.map(([k, v, mono]) => ( +
    {k}
    -
    +
    {v}
    @@ -357,20 +354,28 @@ function DetailGrid({ instance }: { instance: Instance }) { ); } -// ActionButton dispatches the right verb(s) per instance status. -// Registry status alone isn't enough — docker truth may diverge: -// -// - running/paused → Pause/Resume + Recreate + Stop + Down -// - failed/partial → Recreate + Down + Remove (containers MAY still -// be up even though the orchestrator gave up; -// compose down no-ops cleanly if not) -// - stopped → Start + Down + Remove -// - creating/other → no button (CreatingPanel owns that surface) -// -// Stop (docker compose stop) is the gentle halt — containers are kept -// so Start is fast. Down (docker compose down) removes containers; a -// following Start recreates them via up. On failed/partial, Down is -// labeled "Down containers" to signal a force-cleanup. +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, @@ -447,9 +452,6 @@ function ActionButton({ ); } if (status === "failed" || status === "partial") { - // Recreate is offered because failed/partial often comes from a - // transient compose hiccup that a clean down + up resolves - // without losing the instance metadata. return (
    - Go from empty to a live, transferable token in one click — no party ids to paste. + Go from empty to a live, transferable token in one click. No party ids to paste.
    ) : (
    - {/* Left rail: instrument list (ACS-discovered) */}
    {list.map((t) => { const sym = t.symbol ?? t.instrument_id; @@ -460,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}`, }} >
    @@ -475,7 +442,6 @@ export function TokensScreen() { })}
    - {/* Right pane: detail + holdings + actions */}
    {active && (() => { const sym = active.symbol ?? active.instrument_id; @@ -509,11 +475,12 @@ export function TokensScreen() {
    -
    - 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) => (