@@ -357,6 +383,28 @@ function DetailGrid({ instance }: { instance: Instance }) {
);
}
+// DetailGridLoading — same 160px / 1fr rhythm as the real grid so the
+// values slot in without a jump.
+function DetailGridLoading() {
+ return (
+
+ );
+}
+
// ActionButton dispatches the right verb(s) per instance status.
// Registry status alone isn't enough — docker truth may diverge:
//
diff --git a/frontend/src/screens/MetricsScreen.tsx b/frontend/src/screens/MetricsScreen.tsx
index f64e2a24..71d40462 100644
--- a/frontend/src/screens/MetricsScreen.tsx
+++ b/frontend/src/screens/MetricsScreen.tsx
@@ -8,7 +8,7 @@ import {
type PrometheusRangeResponse,
} from "../api";
import { useInstanceSelection } from "../shell/useInstanceSelection";
-import { W, wMono, tint } from "../tokens";
+import { W, wMono, tint, R } from "../tokens";
import { Button } from "../components/Button";
import { IcX } from "../components/icons";
import { MetricCard } from "../components/MetricCard";
@@ -239,9 +239,13 @@ export function MetricsScreen() {
if (!name) {
return (
-
-
- No instance selected. Create or pick one from the dashboard first.
+
+
+ No instance selected.
+
+
+ Pick an instance from the topbar switcher, or create one from
+ Overview.
);
@@ -336,7 +340,7 @@ export function MetricsScreen() {
marginBottom: 16,
}}
>
-
+
{latencyPhase.kind === "err" ? (
) : (
@@ -389,7 +393,7 @@ export function MetricsScreen() {
) : null}
-
+
{cpuSeries.kind === "err" ? (
) : (
@@ -464,9 +468,10 @@ function LatencyStrip(props: {
padding: "10px 14px",
background: W.surface,
border: `1px solid ${W.border}`,
- borderRadius: 2,
+ borderRadius: R.control,
fontFamily: wMono,
fontSize: 13,
+ fontVariantNumeric: "tabular-nums",
color: W.text,
};
const label: CSSProperties = {
@@ -507,7 +512,7 @@ function DashboardsBlock(props: { url?: string }) {
padding: "10px 14px",
background: W.surface,
border: `1px solid ${W.border}`,
- borderRadius: 2,
+ borderRadius: R.control,
fontFamily: wMono,
fontSize: 13,
color: W.text,
@@ -582,10 +587,29 @@ function ChartCard({
);
}
+// ErrLine — a chart card's query failed. The 5 s poll re-issues the
+// query on the next tick, so this states the cause and that a retry is
+// already in flight, with the raw server message tucked behind a
+// disclosure rather than shouting a stack-shaped string.
function ErrLine({ msg }: { msg: string }) {
return (
-
- {msg}
+
+
Query failed. Retrying every 5 s.
+
+
+ Server message
+
+
+ {msg}
+
+
);
}
@@ -619,7 +643,7 @@ function ObservabilityOffPanel({
style={{
background: `${tint(W.warn, 6)}`,
border: `1px solid ${W.warn}`,
- borderRadius: 4,
+ borderRadius: R.card,
padding: 20,
}}
>
diff --git a/frontend/src/screens/Placeholder.tsx b/frontend/src/screens/Placeholder.tsx
index f13a6d25..34b59967 100644
--- a/frontend/src/screens/Placeholder.tsx
+++ b/frontend/src/screens/Placeholder.tsx
@@ -1,4 +1,4 @@
-import { W } from "../tokens";
+import { W, R } from "../tokens";
// Placeholder — the route stub for screens whose backend hasn't
// landed yet. Swap the route in App.tsx to the real screen component
@@ -8,18 +8,19 @@ export function Placeholder({ name }: { name: string }) {
-
{name}
-
- Not implemented yet in this build.
+
+ {name}
+
+
+ Not implemented yet in this build. Pick another screen from the
+ sidebar or press ⌘K.
);
diff --git a/frontend/src/screens/TokensScreen.tsx b/frontend/src/screens/TokensScreen.tsx
index c223d6e7..34d56587 100644
--- a/frontend/src/screens/TokensScreen.tsx
+++ b/frontend/src/screens/TokensScreen.tsx
@@ -31,9 +31,11 @@ import {
type TokenRef,
} from "../api";
import { useInstanceSelection } from "../shell/useInstanceSelection";
-import { W, wMono, tableCaps, wideCaps } from "../tokens";
+import { W, wMono, tableCaps, wideCaps, tint, R, FAST } from "../tokens";
import { Button } from "../components/Button";
+import { MonoId } from "../components/MonoId";
import {
+ Dot,
IcArrowRight,
IcArrowUp,
IcBolt,
@@ -67,14 +69,14 @@ function partyLabel(aliases: AliasMap, p: string): string {
// AllocationV2/DvP).
export function mintDisabledReason(t: InstrumentRef): string | null {
if (t.generation !== "v2")
- return `${t.symbol} (${t.standard}) has no standard mint — use the asset's wallet UI`;
+ return `${t.symbol} (${t.standard}) has no standard mint. Use the asset's wallet UI.`;
if (!t.on_ledger)
- return `${t.symbol} is recorded only — create it on-ledger first`;
+ return `${t.symbol} is recorded only. Create it on-ledger first.`;
return null;
}
const BURN_DISABLED_REASON =
"Burn is only available on a native CIP-0112 v2 token created on this " +
- "instance — Amulet has no burn surface.";
+ "instance. Amulet has no burn surface.";
// TOKEN_DAR_UNAVAILABLE_HINT is the friendly remediation for the on-ledger
// create 412 (TEST_TOKEN_DAR_UNAVAILABLE): the test-token DAR isn't
@@ -347,8 +349,8 @@ export function TokensScreen() {
setTopNotice({
tone: "ok",
text: res.seeded
- ? `Launched ${res.token.symbol} — supply minted to ${res.issuer.alias}, ${res.holder?.alias ?? "a holder"} funded. Try a transfer.`
- : `Launched ${res.token.symbol} — supply minted to ${res.issuer.alias}.`,
+ ? `Launched ${res.token.symbol}. Supply minted to ${res.issuer.alias}, ${res.holder?.alias ?? "a holder"} funded. Try a transfer.`
+ : `Launched ${res.token.symbol}. Supply minted to ${res.issuer.alias}.`,
});
} catch (e) {
setTopNotice(renderActionError(e, "demo launch failed"));
@@ -432,7 +434,7 @@ export function TokensScreen() {
No tokens on
{instance} yet
- Go from empty to a live, transferable token in one click — no party ids to paste.
+ Go from empty to a live, transferable token in one click. No party ids to paste.
} disabled={demoBusy} onClick={() => void launchDemo()}>
@@ -460,8 +462,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}`,
}}
>
@@ -509,8 +512,10 @@ export function TokensScreen() {
} onClick={() => setModal({ kind: "accept" })}>Accept transfer
-
- admin {partyLabel(aliases, active.admin)} · id {active.instrument_id}
+
+ admin {partyLabel(aliases, active.admin)}
+ · id
+
{/* Overview / Activity tab switcher */}
@@ -542,12 +547,12 @@ export function TokensScreen() {
{summary && summary.holders.length > 0 &&
}
- Holdings · a balance is the sum of its Holding contracts — click a row to expand
+ Holdings · a balance sums its Holding contracts. Click a row to expand.
{holdingsSource === "registry" && (
No live ledger reachable for {instance}. These are
- registry pseudo-balances — local bookkeeping that shows the issuer
+ registry pseudo-balances . Local bookkeeping that shows the issuer
holding the full supply and everyone else zero, not on-ledger holdings.
Start the instance to see real balances.
@@ -557,7 +562,7 @@ export function TokensScreen() {
PARTY
- AMOUNT
+ AMOUNT
@@ -566,7 +571,7 @@ export function TokensScreen() {
toggleExpand(h.party)}
- style={{ cursor: "pointer", background: expanded === h.party ? W.surface2 : "transparent" }}
+ style={{ cursor: "pointer", background: expanded === h.party ? tint(W.brand, 12) : "transparent", transition: `background-color ${FAST}` }}
>
@@ -574,19 +579,26 @@ export function TokensScreen() {
{partyLabel(aliases, h.party)}
- {h.amount}
+ {h.amount}
{expanded === h.party && contracts.map((c) => (
-
- └ {c.contract_id.slice(0, 16)}…
- {c.locked && locked }
+
+
+ └
+
+ {c.locked && (
+
+ Locked
+
+ )}
+
- {c.amount}
+ {c.amount}
))}
{expanded === h.party && contracts.length === 0 && (
- loading contracts…
+ Loading contracts…
)}
>
))}
@@ -656,7 +668,7 @@ export function TokensScreen() {
if (offered) {
// Offer transfer: hand the id straight to a prefilled Accept
// modal so the receiver can settle it without copy-pasting.
- setTopNotice({ tone: "ok", text: `Transfer offered — accept instruction ${offered.instructionId.slice(0, 12)}… to settle it` });
+ setTopNotice({ tone: "ok", text: `Transfer offered. Accept instruction ${offered.instructionId.slice(0, 12)}… to settle it.` });
setModal({ kind: "accept", id: offered.instructionId, party: offered.receiver });
} else {
setModal(null);
@@ -684,7 +696,7 @@ export function TokensScreen() {
fields={[
{ label: "To party", key: "to", party: true },
{ label: "Amount", key: "amount" },
- { label: "Source (optional — defaults to funded party)", key: "source", optional: true, party: true },
+ { label: "Source (optional, defaults to funded party)", key: "source", optional: true, party: true },
]}
instance={instance}
parties={parties}
@@ -786,7 +798,7 @@ function TransferModal({
setReason(e.target.value)} style={input} />
setAutoAccept(e.target.checked)} />
- Auto-accept (settle in one step — you own the receiver on LocalNet)
+ Auto-accept (settle in one step. You own the receiver on LocalNet.)
{plan && (
@@ -797,19 +809,21 @@ function TransferModal({
{plan.sufficient ? (
{plan.inputs.map((i) => (
-
-
{i.contract_id.slice(0, 14)}… consume
-
−{i.amount}
+
+
+ consume
+
+ −{i.amount}
))}
→ {shortParty(to || from)} receive
- +{amount}
+ +{amount}
{Number(plan.change) > 0 && (
→ {shortParty(from)} change
- +{plan.change}
+ +{plan.change}
)}
@@ -920,9 +934,9 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali
HOLDER
- BALANCE
+ BALANCE
SHARE
- UTXOS
+ UTXOS
@@ -931,7 +945,7 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali
return (
{partyLabel(aliases, h.party)}
- {h.balance}
+ {h.balance}
@@ -944,12 +958,12 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali
}}
/>
-
+
{h.pct_of_supply}%
- {h.contract_count}
+ {h.contract_count}
);
})}
@@ -973,6 +987,11 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null
burn: W.err,
transfer: W.warn,
};
+ const kindLabel: Record = {
+ mint: "Mint",
+ burn: "Burn",
+ transfer: "Transfer",
+ };
const fmtParties = (ps?: { party: string; amount: string }[]) =>
!ps || ps.length === 0
? "·"
@@ -983,7 +1002,7 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null
TIME
KIND
- AMOUNT
+ AMOUNT
FROM
TO
@@ -991,23 +1010,28 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null
{events.map((e) => (
-
+
{e.record_time ? e.record_time.replace("T", " ").slice(0, 19) : `@${e.offset}`}
- {e.kind}
+
+ {kindLabel[e.kind]}
- {e.amount}
+ {e.amount}
{fmtParties(e.senders)}
{fmtParties(e.receivers)}
@@ -1022,7 +1046,7 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null
// parties the role's JWT can read appear.
function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; err: string | null; aliases: AliasMap }) {
if (err) return {err}
;
- if (!matrix) return Loading matrix…
;
+ if (!matrix) return Scanning ACS…
;
const syms = matrix.instruments.map((i) => i.symbol ?? i.instrument_id);
const symByInst: Record = {};
@@ -1038,8 +1062,8 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er
return (
- {parties.length} {parties.length === 1 ? "party" : "parties"} × {syms.length} {syms.length === 1 ? "instrument" : "instruments"} —
- every readable party's balance of every instrument, in one ACS scan.
+ {parties.length} {parties.length === 1 ? "party" : "parties"} × {syms.length} {syms.length === 1 ? "instrument" : "instruments"}.
+ Every readable party's balance of every instrument, in one ACS scan.
@@ -1053,7 +1077,7 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er
{partyLabel(aliases, p)}
{syms.map((s) => (
-
+
{amt[p]?.[s] ?? "·"}
))}
@@ -1062,7 +1086,7 @@ function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; er
Σ total
{syms.map((s) => (
- {totals[s] ?? ""}
+ {totals[s] ?? ""}
))}
{parties.length === 0 && (
@@ -1509,11 +1533,15 @@ const input: React.CSSProperties = {
// side-padding keeps >=12px of air between adjacent columns.
const th: React.CSSProperties = { ...tableCaps, padding: "6px 10px", borderBottom: `1px solid ${W.border}`, fontSize: 11 };
const td: React.CSSProperties = { padding: "6px 10px", borderBottom: `1px solid ${W.border}`, color: W.text };
+// Numeric columns (amounts, balances, counts) right-align with tabular
+// figures so digits line up column-wise.
+const thNum: React.CSSProperties = { ...th, textAlign: "right" };
+const tdNum: React.CSSProperties = { ...td, textAlign: "right", fontFamily: wMono, fontVariantNumeric: "tabular-nums" };
function notice(tone: "ok" | "warn" | "err"): React.CSSProperties {
const c = tone === "ok" ? W.ok : tone === "warn" ? W.warn : W.err;
return {
- background: `${c}10`, color: c, border: `1px solid ${c}`,
- borderRadius: 4, padding: "8px 12px", fontSize: 12.5,
+ background: tint(c, 10), color: c, border: `1px solid ${tint(c, 40)}`,
+ borderRadius: R.control, padding: "8px 12px", fontSize: 12.5,
};
}
diff --git a/frontend/src/screens/TxReplayDrawer.tsx b/frontend/src/screens/TxReplayDrawer.tsx
index e47939c0..5eed589f 100644
--- a/frontend/src/screens/TxReplayDrawer.tsx
+++ b/frontend/src/screens/TxReplayDrawer.tsx
@@ -6,8 +6,9 @@ import {
type TxReplayEvent,
type TxReplayResponse,
} from "../api";
-import { W, wMono } from "../tokens";
+import { W, wMono, R } from "../tokens";
import { Button } from "../components/Button";
+import { MonoId } from "../components/MonoId";
import { IcX } from "../components/icons";
// TxReplayDrawer — the Web UI counterpart of `dpm localnet tx replay
@@ -98,11 +99,10 @@ export function TxReplayDrawer({
bottom: 0,
width: "min(480px, 92vw)",
// Raised surface — a fixed overlay sits above the page, and
- // surface-on-page was reading dark-on-dark.
+ // surface-on-page was reading dark-on-dark. One depth technique
+ // for a dense-console drawer: hairline border, no shadow.
background: W.surface2,
borderLeft: `1px solid ${W.borderHi}`,
- boxShadow:
- "0 0 0 1px rgba(0,0,0,0.2), -16px 0 40px -12px rgba(0,0,0,0.5)",
// Below the CommandPalette (zIndex 100) but above page content.
zIndex: 40,
overscrollBehavior: "contain",
@@ -122,17 +122,7 @@ export function TxReplayDrawer({
Replay · per-party projection
-
- {updateId}
-
+
)}
{state.kind === "err" && (
-
+
{state.error}
)}
@@ -209,7 +199,13 @@ export function TxReplayDrawer({
}}
>
offset{" "}
-
+
{state.data.offset.toLocaleString()}
{" "}
· {state.data.event_count}{" "}
@@ -282,17 +278,14 @@ function ReplayNode({ ev, last }: { ev: TxReplayEvent; last: boolean }) {
{detail && (
{detail}
)}
-
- {ev.contract_id.slice(0, 16)}…
-
+
);
}
diff --git a/frontend/src/screens/WalletScreen.tsx b/frontend/src/screens/WalletScreen.tsx
index 5a2446fe..011e203e 100644
--- a/frontend/src/screens/WalletScreen.tsx
+++ b/frontend/src/screens/WalletScreen.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { ApiError, fetchInstance, type Instance, type Role } from "../api";
import { useInstanceSelection } from "../shell/useInstanceSelection";
-import { ROLE_COLOR, W, wMono, tint } from "../tokens";
+import { ROLE_COLOR, W, wMono, tint, R, FAST } from "../tokens";
import { Button } from "../components/Button";
import { Dot, IcAlert, IcRefresh } from "../components/icons";
@@ -180,7 +180,7 @@ export function WalletScreen() {
>
{LOGIN_USER_FOR[role]}
- . Password is unused — LocalNet auth is dev-mode HS-256 with the
+ . Password is unused. LocalNet auth is dev-mode HS-256 with the
shared secret "unsafe".
No MetaMask required.
@@ -337,7 +337,7 @@ export function WalletScreen() {
No wallet endpoint recorded for{" "}
{role}{" "}
on this instance. Splice publishes a per-role wallet UI on a host
- port — re-run{" "}
+ port. Re-run{" "}
dpm localnet up --name {name}
{" "}
@@ -378,15 +378,15 @@ function RoleSwitcher({
alignItems: "center",
gap: 8,
padding: "6px 11px",
- borderRadius: 2,
+ borderRadius: R.control,
border: "none",
- background: active ? W.surface : "transparent",
+ background: active ? tint(W.brand, 16) : "transparent",
cursor: active ? "default" : "pointer",
fontSize: 12.5,
fontFamily: wMono,
fontWeight: active ? 600 : 500,
color: active ? W.text : W.dim,
- boxShadow: active ? `0 0 0 1px ${W.brand}` : "none",
+ transition: `background-color ${FAST}`,
}}
>
@@ -406,7 +406,7 @@ function RoleAvatar({ role }: { role: Role }) {
width: 36,
height: 36,
borderRadius: "50%",
- background: `linear-gradient(135deg, ${color}, ${W.brand})`,
+ background: color,
color: W.onAccent,
display: "flex",
alignItems: "center",
@@ -428,7 +428,7 @@ function RoleAvatarMini({ role }: { role: Role }) {
width: 16,
height: 16,
borderRadius: "50%",
- background: `linear-gradient(135deg, ${color}, ${W.brand})`,
+ background: color,
color: W.onAccent,
display: "flex",
alignItems: "center",
diff --git a/frontend/src/shell/CommandPalette.tsx b/frontend/src/shell/CommandPalette.tsx
index 0b60b3b0..06fd205f 100644
--- a/frontend/src/shell/CommandPalette.tsx
+++ b/frontend/src/shell/CommandPalette.tsx
@@ -7,7 +7,7 @@ import {
type KeyboardEvent,
} from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
-import { W, wMono, wSans, tint } from "../tokens";
+import { W, wMono, wSans, tint, R, wideCaps } from "../tokens";
import { useInstanceSelection } from "./useInstanceSelection";
import { NAV, isInstanceScoped, linkTo } from "./routes";
@@ -107,7 +107,7 @@ export function CommandPalette() {
id: `inst-${i.name}`,
group: "Switch instance",
label: i.name,
- hint: `${i.status} · ${i.splice_version}`,
+ hint: `${titleCase(i.status)} · ${i.splice_version}`,
perform: () => sel.select(i.name),
}));
return [...nav, ...instances];
@@ -168,9 +168,12 @@ export function CommandPalette() {
style={{
width: "min(560px, 92vw)",
background: W.surface,
+ // Floating overlay: one depth technique, matched to the
+ // instance switcher — hairline border plus a subtle shadow,
+ // not a hard border AND a heavy shadow.
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",
}}
>
@@ -250,11 +253,10 @@ function renderGroups(
key={`group-${a.group}`}
aria-hidden
style={{
+ ...wideCaps,
padding: "10px 12px 4px",
color: W.dim,
fontSize: 10.5,
- textTransform: "uppercase", fontStretch: "118%",
- letterSpacing: 1.1,
}}
>
{a.group}
@@ -287,7 +289,14 @@ function renderGroups(
>
{a.label}
{a.hint && (
-
+
{a.hint}
)}
@@ -319,6 +328,13 @@ function Hotkey({ label, hint }: { label: string; hint: string }) {
);
}
+// titleCase renders a status enum ("running") as a Title-Case label
+// ("Running") for the instance hint line, matching StatusBadge's
+// vocabulary so the palette and the switcher read the same.
+function titleCase(s: string): string {
+ return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
+}
+
// filter — case-insensitive substring match across label + hint.
// Deliberately not a fuzzy matcher: the palette has ~10–20 items and
// exact substring is easier to reason about. Results keep input order
diff --git a/frontend/src/shell/ErrorBoundary.tsx b/frontend/src/shell/ErrorBoundary.tsx
index ebfbf25e..d0cf998b 100644
--- a/frontend/src/shell/ErrorBoundary.tsx
+++ b/frontend/src/shell/ErrorBoundary.tsx
@@ -1,5 +1,5 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
-import { W, wMono, tint } from "../tokens";
+import { W, wMono, tint, R } from "../tokens";
import { Button } from "../components/Button";
// ErrorBoundary — catches render-time exceptions from descendants and
@@ -81,8 +81,8 @@ function Fallback({ error, onRetry }: FallbackProps) {
style={{
background: `${tint(W.err, 6)}`,
border: `1px solid ${W.err}`,
- borderRadius: 4,
- padding: 20,
+ borderRadius: R.control,
+ padding: 16,
margin: "8px 0",
color: W.text,
}}
@@ -96,33 +96,39 @@ function Fallback({ error, onRetry }: FallbackProps) {
- The rest of the UI is still usable — switch screens via the
- sidebar or ⌘K. If the error keeps coming back, capture this
- block in a screenshot and the full stack from your browser's
- dev-tools console.
+ The rest of the console still works. Switch screens from the
+ sidebar or ⌘K. Retry re-mounts this screen. If it throws again,
+ open the browser dev-tools console for the full stack.
-
- {error.message || "(no message)"}
-
-
+
Retry
+
+
+ Error details
+
+
+ {error.message || "(no message)"}
+
+
);
}
diff --git a/frontend/src/shell/Shell.tsx b/frontend/src/shell/Shell.tsx
index 3cb8705d..5803379a 100644
--- a/frontend/src/shell/Shell.tsx
+++ b/frontend/src/shell/Shell.tsx
@@ -1,6 +1,7 @@
import { NavLink, useLocation, useSearchParams } from "react-router-dom";
import { useState } from "react";
-import { W, wMono, wSans, wideCaps, tint } from "../tokens";
+import { W, wMono, wSans, wideCaps, tint, R } from "../tokens";
+import { StatusBadge } from "../components/StatusBadge";
import {
Dot,
IcOverview,
@@ -215,10 +216,10 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
// rather than a dropdown. The Dashboard owns the "go run dpm
// localnet up" empty-state messaging; the topbar just shrugs.
if (sel.loading) {
- return loading instances… ;
+ return Loading instances… ;
}
if (sel.error || sel.instances.length === 0) {
- return no instances ;
+ return No instances ;
}
const selected = sel.instances.find((i) => i.name === sel.selected);
return (
@@ -255,7 +256,14 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
>
instance
-
+
{sel.selected ?? "—"}
@@ -271,11 +279,13 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
padding: 4,
listStyle: "none",
background: W.surface,
+ // Floating overlay: one depth technique. Hairline border
+ // plus a subtle shadow, matched to the command palette.
border: `1px solid ${W.border}`,
- borderRadius: 4,
+ borderRadius: R.card,
minWidth: 240,
zIndex: 10,
- boxShadow: "0 8px 28px rgba(0,0,0,0.28)",
+ boxShadow: "0 6px 20px rgba(0,0,0,0.16)",
}}
>
{sel.instances.map((i) => (
@@ -294,13 +304,15 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
style={{
display: "flex",
alignItems: "center",
- gap: 8,
+ gap: 10,
width: "100%",
padding: "7px 10px",
+ // Flat active fill, constant padding — no accent
+ // side-bar, no content shift on selection.
background:
i.name === sel.selected ? W.brandSoft : "transparent",
border: "none",
- borderRadius: 2,
+ borderRadius: R.control,
color: i.name === sel.selected ? W.brandText : W.text,
fontFamily: wMono,
fontSize: 12,
@@ -308,9 +320,15 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
cursor: "pointer",
}}
>
-
{i.name}
-
+
+
{i.splice_version}
@@ -412,7 +430,7 @@ function HealthPill({ conn }: { conn: ConnectionState }) {
case "offline":
return {
color: W.err,
- label: "offline",
+ label: "Offline",
tooltip:
conn.serverVersion != null
? `Lost connection · last seen schema v${conn.serverVersion}`
diff --git a/internal/ui/dist/index.html b/internal/ui/dist/index.html
index 023be8f1..efab4b69 100644
--- a/internal/ui/dist/index.html
+++ b/internal/ui/dist/index.html
@@ -6,8 +6,8 @@
canton-devkit
-
-
+
+
From b36156ea41a1b28a12a55c5fa75b8c68aafa1561 Mon Sep 17 00:00:00 2001
From: srikanth-bitdynamics
<259878899+srikanth-bitdynamics@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:34:46 +0530
Subject: [PATCH 04/14] fix(ui): correct the Metrics screen queries for Splice
0.6.4
Two panels queried metrics Splice 0.6.4 does not provide, so they never
populated:
- ACS lookup buffer used
daml_participant_api_index_db_active_contract_lookup_batch_buffer_length,
which is no longer emitted. Point it at the live
daml_participant_api_index_active_contracts_buffer_size gauge.
- The latency panels used histogram_quantile on the sequencing-duration
histogram, which 0.6.4 exports with only the +Inf bucket, so quantiles
are NaN regardless of load. Show the computable average (sum/count)
instead, labelled as an average, and hide the p50/p95/p99 strip when
the backend can't compute those (it returns on versions whose
histograms carry finite buckets).
---
frontend/src/screens/MetricsScreen.tsx | 84 +++++++++++++++-----------
internal/ui/dist/index.html | 2 +-
2 files changed, 50 insertions(+), 36 deletions(-)
diff --git a/frontend/src/screens/MetricsScreen.tsx b/frontend/src/screens/MetricsScreen.tsx
index 71d40462..9d4a27cf 100644
--- a/frontend/src/screens/MetricsScreen.tsx
+++ b/frontend/src/screens/MetricsScreen.tsx
@@ -48,21 +48,27 @@ const Q = {
// Substitute: indexer-update counter, same as HeadlineLedgerTPS.
throughputSeries:
"sum(rate(daml_participant_api_indexer_updates[1m])) or vector(0)",
- p99: 'histogram_quantile(0.99, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))',
+ // Splice 0.6.4 exports the sequencing-duration histogram with only the
+ // +Inf bucket (no finite `le` boundaries), so histogram_quantile()
+ // returns NaN regardless of load — percentiles are not computable here.
+ // The average IS (sum/count), so the latency surfaces show that instead,
+ // labelled honestly as an average. In milliseconds.
+ avgLatency:
+ "1000 * sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum[5m])) / sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count[5m]))",
// Live Splice does not expose total ACS cardinality as a stock
- // Prometheus metric. This is the audited ACS-related signal that
- // exists in 0.6.4; keep UI copy honest and call it a lookup buffer.
+ // Prometheus metric. The former proxy
+ // (daml_participant_api_index_db_active_contract_lookup_batch_buffer_length)
+ // is no longer emitted by Splice 0.6.4 — verified absent from a live
+ // instance's Prometheus. The active-contracts in-memory buffer gauge
+ // is the audited ACS-related signal that exists in 0.6.4; keep UI copy
+ // honest and call it a lookup buffer.
acsLookupBuffer:
- "sum(daml_participant_api_index_db_active_contract_lookup_batch_buffer_length)",
+ "sum(daml_participant_api_index_active_contracts_buffer_size)",
// No daml_* command-rejection counter on Splice 0.6.4 — use the
// user-error completion-status counter as a proxy for "things
// the participant refused to commit". Returns 0 if not exposed.
errorsRate:
'sum(rate(daml_grpc_server_handled_total{grpc_code!="OK"}[1m])) or vector(0)',
- latencyMedian:
- 'histogram_quantile(0.50, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))',
- latencyP99:
- 'histogram_quantile(0.99, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))',
// Splice 0.6.x does not expose template-grain submission counters.
// Use the live gRPC method counter as a command-throughput fallback
// instead of querying a non-existent `daml_commands_*` family.
@@ -98,7 +104,7 @@ export function scopeQ(query: string, scope: string): string {
}
const TPS_COLOR = "#8FA3EE";
-const P99_COLOR = "#DDB25E";
+const LATENCY_COLOR = "#DDB25E";
const ACS_COLOR = "#6480E6";
const ERR_COLOR = "#7BD2C6";
@@ -112,7 +118,7 @@ export function MetricsScreen() {
const [throughputSeries, setThroughputSeries] = useState>({
kind: "loading",
});
- const [p99Series, setP99Series] = useState>({
+ const [latencySeries, setLatencySeries] = useState>({
kind: "loading",
});
const [acsSeries, setAcsSeries] = useState>({
@@ -177,15 +183,12 @@ export function MetricsScreen() {
}
await Promise.all([
loadSeries(name, scopeQ(Q.throughputSeries, scope), "tx/s", setThroughputSeries, signal),
- loadSeries(name, scopeQ(Q.p99, scope), "p99", setP99Series, signal),
+ loadSeries(name, scopeQ(Q.avgLatency, scope), "avg latency", setLatencySeries, signal),
loadSeries(name, scopeQ(Q.acsLookupBuffer, scope), "ACS lookup buffer", setAcsSeries, signal),
loadSeries(name, scopeQ(Q.errorsRate, scope), "errors", setErrorsSeries, signal),
loadMultiSeries(
name,
- [
- { query: scopeQ(Q.latencyMedian, scope), label: "median", color: CHART_PALETTE[1] },
- { query: scopeQ(Q.latencyP99, scope), label: "p99", color: CHART_PALETTE[3] },
- ],
+ [{ query: scopeQ(Q.avgLatency, scope), label: "avg", color: CHART_PALETTE[1] }],
setLatencyPhase,
signal,
),
@@ -233,7 +236,8 @@ export function MetricsScreen() {
// order is stable across the (!name) and (observabilityOff)
// early-exit paths — rules of hooks.
const tpsDelta = useMemo(() => deltaFromSeries(throughputSeries.data), [throughputSeries.data]);
- const p99Delta = useMemo(() => deltaFromSeries(p99Series.data, 1000), [p99Series.data]);
+ // avgLatency is already in ms — no unit scaling for the delta.
+ const latencyDelta = useMemo(() => deltaFromSeries(latencySeries.data), [latencySeries.data]);
const acsDelta = useMemo(() => deltaFromSeries(acsSeries.data), [acsSeries.data]);
const errDelta = useMemo(() => deltaFromSeries(errorsSeries.data), [errorsSeries.data]);
@@ -268,10 +272,10 @@ export function MetricsScreen() {
}
const m = summary.data?.metrics;
- const p99Value =
- summary.kind === "ok" && summary.data
- ? (summary.data.latency?.p99_ms ?? Number.NaN)
- : undefined;
+ // The backend latency.p99_ms is histogram_quantile-derived and NaN on
+ // Splice 0.6.4 (no finite buckets); use the computable average from the
+ // frontend series instead — its latest point, already in ms.
+ const latencyValue = latencySeries.data?.points.at(-1)?.v;
return (
@@ -297,13 +301,13 @@ export function MetricsScreen() {
deltaPolarity="up-is-good"
/>
({ t: p.t, v: p.v * 1000 }))}
- sparklineColor={P99_COLOR}
- error={p99Series.kind === "err" ? p99Series.error : undefined}
- delta={p99Delta}
+ value={latencyValue}
+ sparkline={latencySeries.data?.points}
+ sparklineColor={LATENCY_COLOR}
+ error={latencySeries.kind === "err" ? latencySeries.error : undefined}
+ delta={latencyDelta}
deltaPolarity="down-is-good"
format={(v) => (Math.abs(v) >= 100 ? v.toFixed(0) : v.toFixed(1))}
/>
@@ -340,7 +344,7 @@ export function MetricsScreen() {
marginBottom: 16,
}}
>
-
+
{latencyPhase.kind === "err" ? (
) : (
@@ -348,7 +352,7 @@ export function MetricsScreen() {
series={latencyPhase.data ?? []}
width={420}
height={170}
- format={(v) => (v >= 1 ? v.toFixed(2) + "s" : (v * 1000).toFixed(0) + "ms")}
+ format={(v) => (v >= 1000 ? (v / 1000).toFixed(2) + "s" : v.toFixed(0) + "ms")}
/>
)}
@@ -426,13 +430,23 @@ export function MetricsScreen() {
- {/* Latency headline triplet — mirrors `dpm localnet metrics`
- text output so CLI and UI agree on the curated quantiles. */}
-
+ {/* Latency headline triplet — mirrors `dpm localnet metrics` text
+ output so CLI and UI agree on the curated quantiles. Splice 0.6.4
+ exports the histogram with only the +Inf bucket, so these
+ percentiles are NaN there; hide the strip rather than show three
+ dashes. It reappears on any version whose histogram carries finite
+ buckets. */}
+ {[
+ summary.data?.latency?.p50_ms,
+ summary.data?.latency?.p95_ms,
+ summary.data?.latency?.p99_ms,
+ ].some((v) => typeof v === "number" && Number.isFinite(v)) && (
+
+ )}
{/* Top error sources — full width */}
diff --git a/internal/ui/dist/index.html b/internal/ui/dist/index.html
index efab4b69..4f3697e9 100644
--- a/internal/ui/dist/index.html
+++ b/internal/ui/dist/index.html
@@ -6,7 +6,7 @@
canton-devkit
-
+
From 5bdb8a2218fe026f9ffbce6d2730a876dfe46f5a Mon Sep 17 00:00:00 2001
From: srikanth-bitdynamics
<259878899+srikanth-bitdynamics@users.noreply.github.com>
Date: Fri, 10 Jul 2026 21:49:50 +0530
Subject: [PATCH 05/14] ui: trim comment noise and correct stale palette naming
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Comment-only pass over the console. The component and shell files had
grown header blocks that narrated redesign history and editorialised
about the audience ("the discipline an auditor relies on", "reads as
unfinished", "one depth technique") rather than documenting the code.
Trim those to the load-bearing "why" and drop the asides; the metric,
race-condition, and accessibility comments are left intact.
Also fix palette naming left over from before the Carbon Slate swap:
the accent is no longer cobalt, so the design-token, CSS, and Button
docs that still called it that were inaccurate. The chart ramp stays
labelled cobalt — those hexes really are cobalt-blue.
One small non-comment change: MonoId's clipboard copy collapses the
redundant Promise temporary into a single optional-chained call.
---
frontend/src/components/Button.tsx | 2 +-
frontend/src/components/ConfirmDialog.tsx | 10 ++--
frontend/src/components/MonoId.tsx | 21 +++-----
frontend/src/components/Skeleton.tsx | 15 +++---
frontend/src/components/StatusBadge.tsx | 14 ++----
frontend/src/index.css | 7 ++-
frontend/src/screens/ContractDetailDrawer.tsx | 5 +-
frontend/src/screens/Dashboard.tsx | 4 +-
frontend/src/screens/ExplorerScreen.tsx | 3 +-
frontend/src/shell/CommandPalette.tsx | 49 ++++++-------------
frontend/src/shell/Shell.tsx | 7 +--
frontend/src/theme.ts | 19 +++----
frontend/src/tokens.ts | 36 ++++++--------
internal/ui/dist/index.html | 2 +-
14 files changed, 69 insertions(+), 125 deletions(-)
diff --git a/frontend/src/components/Button.tsx b/frontend/src/components/Button.tsx
index 9a8ed232..dc40ea69 100644
--- a/frontend/src/components/Button.tsx
+++ b/frontend/src/components/Button.tsx
@@ -1,7 +1,7 @@
// 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
+// primary — THE one dominant action of a view or dialog (accent
// 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).
diff --git a/frontend/src/components/ConfirmDialog.tsx b/frontend/src/components/ConfirmDialog.tsx
index 02986118..79a198c4 100644
--- a/frontend/src/components/ConfirmDialog.tsx
+++ b/frontend/src/components/ConfirmDialog.tsx
@@ -1,12 +1,9 @@
-// In-app confirm dialog — replaces the browser-native confirm(), which
-// can't match the console's typography, can't show container/port
-// detail inline, and reads as unfinished. Promise-based so call sites
-// stay a one-liner:
+// In-app confirm dialog. Promise-based so call sites stay a one-liner:
//
// if (!(await confirmDialog({ title, body, confirmLabel, danger }))) return;
//
-// A single ConfirmHost is mounted once (see App); confirmDialog()
-// dispatches an event it listens for, keeping open state in the host.
+// A single ConfirmHost (mounted in App) listens for the event
+// confirmDialog() dispatches and owns the open state.
import { useEffect, useState } from "react";
import { W, wMono, wSans, R, EASE, FAST } from "../tokens";
@@ -87,7 +84,6 @@ export function ConfirmHost() {
onClick={(e) => e.stopPropagation()}
style={{
width: "min(440px, 92vw)",
- // One depth technique: hairline border, no competing shadow.
background: W.surface,
border: `1px solid ${W.borderHi}`,
borderRadius: R.dialog,
diff --git a/frontend/src/components/MonoId.tsx b/frontend/src/components/MonoId.tsx
index 9176d50c..e0365ff7 100644
--- a/frontend/src/components/MonoId.tsx
+++ b/frontend/src/components/MonoId.tsx
@@ -1,11 +1,6 @@
-// MonoId — the one way to render a ledger identifier (contract id,
-// party id, package id, hash, offset) in this console.
-//
-// Ledger ids are long and their *suffix* is the discriminating part,
-// so tail-only truncation ("00ce960f…") hides exactly what tells two
-// ids apart. MonoId middle-truncates (head…tail), keeps the full value
-// in the title for hover, and copies it on click — the discipline an
-// auditor comparing ids relies on.
+// MonoId — renders a ledger identifier (contract / party / package id,
+// hash, offset). Middle-truncates (head…tail) so the discriminating suffix
+// stays visible, shows the full value on hover, and copies it on click.
import { useState, type CSSProperties } from "react";
import { W, wMono } from "../tokens";
@@ -40,16 +35,14 @@ export function MonoId({
const [copied, setCopied] = useState(false);
const shown = full ? value : truncateMid(value, head, tail);
const copy = () => {
- // clipboard may be unavailable (http on non-localhost) or denied;
- // swallow both the throw and the promise rejection so a failed
- // copy is a silent no-op, not an unhandled rejection.
+ // clipboard can be unavailable (non-localhost http) or denied; ignore
+ // both the throw and the rejection so a failed copy is a no-op.
try {
- const p = navigator.clipboard?.writeText(value);
- if (p) p.catch(() => {});
+ navigator.clipboard?.writeText(value).catch(() => {});
setCopied(true);
window.setTimeout(() => setCopied(false), 1100);
} catch {
- // no clipboard API at all
+ /* no clipboard API */
}
};
return (
diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx
index 6c729098..8b2b9e33 100644
--- a/frontend/src/components/Skeleton.tsx
+++ b/frontend/src/components/Skeleton.tsx
@@ -1,14 +1,11 @@
-// Skeleton — layout-matched loading placeholders. A dense console
-// knows its table shapes ahead of time, so a bare centered "Loading…"
-// that pops into a full table causes a jarring layout shift. Skeletons
-// mirror the real row height and column rhythm so content arrives in
-// place, and a short show-delay avoids a flicker on fast local fetches.
+// Skeleton — loading placeholders shaped like the real table so content
+// arrives in place without a layout shift. A short delay avoids a
+// flicker on fast local fetches.
import { useEffect, useState, type CSSProperties } from "react";
import { W, R } from "../tokens";
-// useDelayedFlag returns true only after `ms`, so a fetch that resolves
-// in <150ms never flashes a skeleton.
+// Returns true only after `ms`, so a fast fetch never flashes a skeleton.
export function useLoadingDelay(active: boolean, ms = 160): boolean {
const [shown, setShown] = useState(false);
useEffect(() => {
@@ -49,8 +46,8 @@ export function SkeletonBar({
);
}
-// SkeletonTable mirrors a column-based table: pass the same relative
-// column widths the real table uses so the skeleton lines up with it.
+// Pass the same relative column widths the real table uses so the
+// skeleton lines up with it.
export function SkeletonTable({
columns,
rows = 4,
diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx
index 9cc733fb..b95220a7 100644
--- a/frontend/src/components/StatusBadge.tsx
+++ b/frontend/src/components/StatusBadge.tsx
@@ -1,10 +1,5 @@
-// StatusBadge — the one renderer for instance / container / connection
-// status across the console. Before this, the same status datum showed
-// up four different ways (a lowercase dot+enum in the table, plain mono
-// text in the detail grid, a Title-Case pill in the topbar, a bare
-// color-only dot in the ACS). One renderer fixes the inconsistency and
-// guarantees color is never the ONLY cue — the label carries the
-// meaning for the colorblind / auditor audience.
+// StatusBadge — single renderer for instance / container / stream status.
+// Always 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";
@@ -12,8 +7,7 @@ import { Dot } from "./icons";
type Tone = "ok" | "warn" | "danger" | "muted";
-// Canonical status vocabulary. Terse Title-Case labels; unknown values
-// fall through to a muted, capitalized rendering rather than breaking.
+// Known statuses map to a label + tone; unknown values render muted.
const MAP: Record = {
running: { label: "Running", tone: "ok" },
healthy: { label: "Healthy", tone: "ok" },
@@ -30,7 +24,7 @@ const MAP: Record = {
failed: { label: "Failed", tone: "danger" },
error: { label: "Error", tone: "danger" },
dead: { label: "Dead", tone: "danger" },
- // Explorer stream states — the ACS/tx snapshot-vs-live stream.
+ // Explorer stream states.
live: { label: "Live", tone: "ok" },
reconnecting: { label: "Reconnecting", tone: "warn" },
truncated: { label: "Truncated", tone: "warn" },
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 943b1f21..f0805025 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -169,9 +169,8 @@ a {
* 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. */
+ * 2px accent outline (blue-500 — identical in light and dark), offset
+ * 2px so it doesn't merge into the element's own border. */
:focus {
outline: none;
}
@@ -225,7 +224,7 @@ a {
/* Button system (components/Button.tsx). Hover/active tints live
* here because inline style objects can't express :hover.
- * primary = the solid cobalt CTA (white text, both themes);
+ * primary = the solid accent CTA (white text, both themes);
* secondary = bordered surface; ghost = quiet; danger = filled red. */
.bd-btn {
appearance: none;
diff --git a/frontend/src/screens/ContractDetailDrawer.tsx b/frontend/src/screens/ContractDetailDrawer.tsx
index fecafe85..8429021d 100644
--- a/frontend/src/screens/ContractDetailDrawer.tsx
+++ b/frontend/src/screens/ContractDetailDrawer.tsx
@@ -125,9 +125,8 @@ export function ContractDetailDrawer({
right: 0,
bottom: 0,
width: "min(480px, 92vw)",
- // Raised surface — a fixed overlay sits above the page, and
- // surface-on-page was reading dark-on-dark. One depth technique
- // for a dense-console drawer: hairline border, no shadow.
+ // Raised surface — a fixed overlay sits above the page, so
+ // surface-on-page would read too flat against it.
background: W.surface2,
borderLeft: `1px solid ${W.borderHi}`,
// Below the CommandPalette (zIndex 100) but above page content.
diff --git a/frontend/src/screens/Dashboard.tsx b/frontend/src/screens/Dashboard.tsx
index 74f259ab..db17fe03 100644
--- a/frontend/src/screens/Dashboard.tsx
+++ b/frontend/src/screens/Dashboard.tsx
@@ -46,8 +46,8 @@ export function Dashboard() {
LocalNet instances
}
onClick={() => setCreateOpen(true)}
diff --git a/frontend/src/screens/ExplorerScreen.tsx b/frontend/src/screens/ExplorerScreen.tsx
index d2329a18..e799f3a6 100644
--- a/frontend/src/screens/ExplorerScreen.tsx
+++ b/frontend/src/screens/ExplorerScreen.tsx
@@ -1782,8 +1782,7 @@ function TimelineView({ name, role }: { name: string; role: Role }) {
right: 0,
bottom: 0,
width: "min(480px, 92vw)",
- // Raised surface — matches ContractDetailDrawer/TxReplayDrawer.
- // One depth technique: hairline border, no shadow ring.
+ // Raised surface, matching the other drawers.
background: W.surface2,
borderLeft: `1px solid ${W.borderHi}`,
// A hover preview must not steal hit-testing from the strip
diff --git a/frontend/src/shell/CommandPalette.tsx b/frontend/src/shell/CommandPalette.tsx
index 06fd205f..d56e45ed 100644
--- a/frontend/src/shell/CommandPalette.tsx
+++ b/frontend/src/shell/CommandPalette.tsx
@@ -11,26 +11,21 @@ import { W, wMono, wSans, tint, R, wideCaps } from "../tokens";
import { useInstanceSelection } from "./useInstanceSelection";
import { NAV, isInstanceScoped, linkTo } from "./routes";
-// CommandPalette — ⌘K (Ctrl+K on non-Mac) launches a centred search
-// modal with two action groups: route navigation and instance
-// switching. Keyboard model is intentionally simple: ↑/↓ to move,
-// Enter to activate, Esc to dismiss — every press has one obvious
-// meaning.
+// CommandPalette — ⌘K (Ctrl+K elsewhere) opens a search modal with two
+// groups: route navigation and instance switching. ↑/↓ move, Enter
+// activates, Esc dismisses.
interface Action {
id: string;
- // Group label rendered as a subtle section header in the list.
group: "Navigate" | "Switch instance";
label: string;
// Secondary line shown beneath the label (path, instance status).
hint?: string;
- // perform is the side-effect — navigate, mutate, etc.
perform: () => void;
}
-// Derive the palette's nav rows from the shared NAV table so the
-// sidebar and the palette can never drift on routes / labels /
-// instance-scoping. Adding a tab to ./routes wires both surfaces.
+// Derive nav rows from the shared NAV table so the sidebar and palette
+// can't drift on routes / labels / instance-scoping.
const NAV_ACTIONS: Array & { path: string }> = NAV.map(
(n) => ({
id: `nav-${n.to === "/" ? "overview" : n.to.replace(/^\//, "")}`,
@@ -41,9 +36,8 @@ const NAV_ACTIONS: Array & { path: string }> = NAV.map(
}),
);
-// openPalette lets non-keyboard callers (the topbar "Commands" button)
-// open the palette. It dispatches an event the mounted CommandPalette
-// listens for, so the open state stays owned by the component.
+// Lets non-keyboard callers (the topbar "Commands" button) open the
+// palette without owning its open state.
const OPEN_EVENT = "cdk-open-palette";
export function openPalette(): void {
window.dispatchEvent(new CustomEvent(OPEN_EVENT));
@@ -62,10 +56,8 @@ export function CommandPalette() {
const [searchParams] = useSearchParams();
const instance = searchParams.get("instance");
- // Global hotkey. ⌘K on Mac, Ctrl+K elsewhere — same as VS Code,
- // Slack, GitHub. We use the e.metaKey || e.ctrlKey gate rather
- // than UA-sniffing because either is acceptable on any platform
- // (some keyboard layouts swap them).
+ // Global hotkey: ⌘K on Mac, Ctrl+K elsewhere. Accept either modifier
+ // rather than UA-sniffing.
useEffect(() => {
function onKey(e: globalThis.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
@@ -86,14 +78,12 @@ export function CommandPalette() {
};
}, [open]);
- // Auto-focus the input when the palette opens; reset query +
- // cursor so each open is a fresh search.
+ // Focus the input on open; reset so each open is a fresh search.
useEffect(() => {
if (open) {
setQuery("");
setCursor(0);
- // Defer to next frame — the input isn't in the DOM until
- // the modal renders, and React batches the state update.
+ // Defer a frame: the input isn't in the DOM until the modal renders.
requestAnimationFrame(() => inputRef.current?.focus());
}
}, [open]);
@@ -168,9 +158,6 @@ export function CommandPalette() {
style={{
width: "min(560px, 92vw)",
background: W.surface,
- // Floating overlay: one depth technique, matched to the
- // instance switcher — hairline border plus a subtle shadow,
- // not a hard border AND a heavy shadow.
border: `1px solid ${W.border}`,
borderRadius: R.dialog,
boxShadow: "0 10px 32px rgba(0,0,0,0.24)",
@@ -236,9 +223,8 @@ export function CommandPalette() {
);
}
-// renderGroups groups consecutive actions sharing a `group` label
-// and inserts a subtle section header before each block. Keeps the
-// list scannable: nav actions cluster, instance actions cluster.
+// Groups consecutive actions sharing a `group` label under a section
+// header.
function renderGroups(
filtered: Action[],
cursor: number,
@@ -328,17 +314,12 @@ function Hotkey({ label, hint }: { label: string; hint: string }) {
);
}
-// titleCase renders a status enum ("running") as a Title-Case label
-// ("Running") for the instance hint line, matching StatusBadge's
-// vocabulary so the palette and the switcher read the same.
function titleCase(s: string): string {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
-// filter — case-insensitive substring match across label + hint.
-// Deliberately not a fuzzy matcher: the palette has ~10–20 items and
-// exact substring is easier to reason about. Results keep input order
-// (NAV first, instances after) so the visual grouping stays stable.
+// Case-insensitive substring match across label + hint; keeps input
+// order so the grouping stays stable.
export function filter(actions: Action[], query: string): Action[] {
const q = query.trim().toLowerCase();
if (!q) return actions;
diff --git a/frontend/src/shell/Shell.tsx b/frontend/src/shell/Shell.tsx
index 5803379a..35de4370 100644
--- a/frontend/src/shell/Shell.tsx
+++ b/frontend/src/shell/Shell.tsx
@@ -212,9 +212,8 @@ function ThemeToggle() {
function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
const [open, setOpen] = useState(false);
- // Empty / loading / error states all degrade to a muted label
- // rather than a dropdown. The Dashboard owns the "go run dpm
- // localnet up" empty-state messaging; the topbar just shrugs.
+ // Empty / loading / error states degrade to a muted label rather than
+ // a dropdown; the Dashboard owns the empty-state messaging.
if (sel.loading) {
return Loading instances… ;
}
@@ -279,8 +278,6 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
padding: 4,
listStyle: "none",
background: W.surface,
- // Floating overlay: one depth technique. Hairline border
- // plus a subtle shadow, matched to the command palette.
border: `1px solid ${W.border}`,
borderRadius: R.card,
minWidth: 240,
diff --git a/frontend/src/theme.ts b/frontend/src/theme.ts
index c912ecfa..bce48e8a 100644
--- a/frontend/src/theme.ts
+++ b/frontend/src/theme.ts
@@ -1,8 +1,6 @@
-// Theme state for the Web UI. The design system ships both a dark
-// (default) and light palette; the active one is written to
-// `data-theme` on , which flips every CSS variable in index.css
-// and therefore every W.* token app-wide. The choice persists in
-// localStorage so a reload keeps it.
+// Theme state. Dark (default) and light palettes live in index.css as
+// CSS variables keyed off `data-theme` on ; setting it re-themes
+// every W.* token. Persisted in localStorage.
import { useSyncExternalStore } from "react";
@@ -22,9 +20,8 @@ function read(): Theme {
return "dark";
}
-// applyTheme sets the attribute that drives the CSS variables. Called
-// once at startup (before render, see main.tsx) so there is no
-// light-on-dark flash, and again on every change.
+// Sets the attribute that drives the CSS variables. Called before the
+// first render (main.tsx) to avoid a flash, and on every change.
export function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
}
@@ -47,14 +44,12 @@ export function toggleTheme(): void {
setTheme(getTheme() === "dark" ? "light" : "dark");
}
-// initTheme applies the persisted (or default) theme. Call before the
-// first render.
+// Apply the persisted (or default) theme before the first render.
export function initTheme(): void {
applyTheme(read());
}
-// useTheme subscribes a component to theme changes so a toggle
-// re-renders with the current value.
+// Subscribe a component to theme changes so a toggle re-renders it.
export function useTheme(): Theme {
return useSyncExternalStore(
(cb) => {
diff --git a/frontend/src/tokens.ts b/frontend/src/tokens.ts
index f5d13c0b..d002de64 100644
--- a/frontend/src/tokens.ts
+++ b/frontend/src/tokens.ts
@@ -2,11 +2,10 @@
//
// Every semantic color resolves through a CSS variable defined in
// index.css under :root (dark) and :root[data-theme="light"], so the
-// same W.* reference renders correctly in both themes with no
-// per-screen change. Structure comes from 1px hairlines, not shadows;
-// one interactive accent (cobalt); teal/amber are DATA accents only
-// (series, parties, throughput) and stay fixed mid-tones legible on
-// either background.
+// same W.* reference renders correctly in both themes. Structure comes
+// from 1px hairlines, not shadows; one interactive accent; teal/amber
+// are data-only accents (series, parties, throughput) held at fixed
+// mid-tones legible on either background.
export const W = {
bg: "var(--bg-page)",
surface: "var(--bg-surface)", // cards, sidebars, inputs
@@ -17,14 +16,14 @@ export const W = {
text2: "var(--text-secondary)",
dim: "var(--text-muted)",
faint: "var(--text-faint)",
- brand: "var(--accent)", // cobalt — buttons, tabs, active nav
+ brand: "var(--accent)", // buttons, tabs, active nav
brandSoft: "var(--accent-subtle)", // active-nav fill, selection
brandText: "var(--accent-text)",
ok: "var(--ok-text)",
warn: "var(--warn-text)",
err: "var(--danger-text)",
info: "var(--info-text)", // status/info + links
- mag: "#93A7F0", // series accent (cobalt-light — data)
+ mag: "#93A7F0", // series accent (data)
rose: "#7BD2C6", // series accent (teal — data only)
amber: "#C8971F", // series accent (deep amber — data)
card: "var(--bg-surface)",
@@ -56,10 +55,9 @@ export const W = {
focus: "var(--blue-500)", // 2px focus outline — identical in both themes
} as const;
-// Translucent tint of a themed color. Replaces the old `${W.x}NN`
-// hex-alpha concatenation, which is invalid once W.x is a CSS var
-// (`var(--accent)1A` is not a color). color-mix over transparent is
-// the faithful equivalent of a hex alpha over the surface behind it.
+// Translucent tint of a themed color. W.x is a CSS var, so a `${W.x}1A`
+// hex-alpha concat is invalid; color-mix over transparent is the
+// equivalent.
export function tint(color: string, pct: number): string {
return `color-mix(in srgb, ${color} ${pct}%, transparent)`;
}
@@ -77,9 +75,8 @@ export const R = { control: 2, card: 4, dialog: 8 } as const;
export const EASE = "cubic-bezier(0.2, 0.6, 0.2, 1)";
export const FAST = "120ms";
-// Wide structural caps — the brand's label voice for STRUCTURE:
-// the wordmark, section headers, stat-card labels. Spread where a
-// style object is built.
+// Wide structural caps for the wordmark, section headers, and stat-card
+// labels.
export const wideCaps = {
fontWeight: 600,
fontStretch: "118%",
@@ -87,19 +84,16 @@ export const wideCaps = {
textTransform: "uppercase",
} as const;
-// Quiet caps for data-table column headers. Uppercase is the table
-// convention, but headers repeat on every table — at 118% width and
-// heavy tracking they read as brand moments instead of chrome, so
-// tables get the toned-down cut.
+// Quieter caps for data-table column headers — the wide structural cut
+// repeats on every table and reads as chrome, so tables tone it down.
export const tableCaps = {
fontWeight: 500,
letterSpacing: "0.05em",
textTransform: "uppercase",
} as const;
-// Role-to-color map shared by every screen so a role addition or
-// palette swap is a one-line change. Parties are data → cobalt /
-// teal / amber triad from the dataviz ramp.
+// Role-to-color map shared by every screen. Parties are data: the
+// accent / teal / amber triad from the dataviz ramp.
export const ROLE_COLOR: Record<"app-user" | "app-provider" | "sv", string> = {
"app-user": W.brand,
"app-provider": W.teal,
diff --git a/internal/ui/dist/index.html b/internal/ui/dist/index.html
index 4f3697e9..d31c3e8e 100644
--- a/internal/ui/dist/index.html
+++ b/internal/ui/dist/index.html
@@ -6,7 +6,7 @@
canton-devkit
-
+
From a043207c8e1fb48d8519461fd8c5399d15a906cf Mon Sep 17 00:00:00 2001
From: srikanth-bitdynamics
<259878899+srikanth-bitdynamics@users.noreply.github.com>
Date: Fri, 10 Jul 2026 22:25:37 +0530
Subject: [PATCH 06/14] =?UTF-8?q?metrics:=20show=20sequencing-latency=20av?=
=?UTF-8?q?erage=20for=20CLI=E2=86=94UI=20parity?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Web UI Metrics screen shows the mean sequencing latency because
Splice 0.6.4 exports the submission-duration histogram with only the
+Inf bucket, so histogram_quantile (p50/p95/p99) is NaN there while the
mean (sum/count) is exact. The CLI `dpm localnet metrics` and the
/metrics/summary handler still led with those NaN percentiles.
Add a MediatorAvg query to the shared metricsq map so both surfaces pick
it up automatically. The CLI text now leads with `avg` and prints
p50/p95/p99 only when the histogram carries finite buckets (rather than a
row of dashes); the JSON and the handler expose `avg_ms` alongside the
percentiles. Percentiles remain for Splice versions whose histograms are
bucketed, so no data is lost where it exists.
---
internal/cli/localnet/metrics.go | 130 +++++--------
internal/cli/localnet/metrics_test.go | 87 +++++----
internal/metricsq/queries.go | 112 ++++--------
internal/metricsq/queries_instance_test.go | 19 +-
internal/metricsq/queries_test.go | 33 ++--
internal/ui/handlers/metrics.go | 201 ++++++---------------
6 files changed, 203 insertions(+), 379 deletions(-)
diff --git a/internal/cli/localnet/metrics.go b/internal/cli/localnet/metrics.go
index f2e5fd08..bc8d53c7 100644
--- a/internal/cli/localnet/metrics.go
+++ b/internal/cli/localnet/metrics.go
@@ -22,13 +22,9 @@ import (
var metricsContainersList = containers.List
-// buildMetrics wires `dpm localnet metrics`. Scrapes the instance's
-// Prometheus (started by the `--profile observability` compose
-// overlay) and prints a small, curated set of headline values. The
-// richer Grafana view lives at http://127.0.0.1:/ but
-// the CLI shape is for CI assertions (`--format json | jq`) and
-// SSH-only operators who can't open a browser. The Web UI's Metrics
-// screen shares the same scrape config (assets/compose/prometheus.yml).
+// buildMetrics wires `dpm localnet metrics`: scrapes the instance's
+// Prometheus and prints a curated set of headline values. The JSON shape is
+// for CI assertions; the Web UI Metrics screen shares the same scrape config.
func buildMetrics() *cobra.Command {
var (
instance string
@@ -39,7 +35,7 @@ func buildMetrics() *cobra.Command {
cmd := &cobra.Command{
Use: "metrics",
Short: "Scrape live metrics from an instance's Prometheus",
- Long: "Reads headline numbers (TPS, submission p95, JVM heap, DB conns) from the per-instance Prometheus. Requires the instance to have been started with --profile observability. JSON output is stable for CI assertions. The wire JSON keys (ledger_tps_5m, mediator_p95_seconds, jvm_heap_used_bytes, postgres_conn_count) are kept stable; what they MEASURE is documented in internal/metricsq/queries.go.",
+ Long: "Reads headline numbers (TPS, sequencing latency, JVM heap, DB conns) from the per-instance Prometheus. Requires the instance to have been started with --profile observability. JSON output is stable for CI assertions. The wire JSON keys (ledger_tps_5m, mediator_p95_seconds, jvm_heap_used_bytes, postgres_conn_count) are kept stable; what they MEASURE is documented in internal/metricsq/queries.go.",
Args: cobra.NoArgs,
SilenceUsage: true,
SilenceErrors: true,
@@ -85,9 +81,8 @@ func buildMetrics() *cobra.Command {
return cmd
}
-// resolveMetricsInstance mirrors resolveLogsInstance — picks the
-// single registered instance when --name is empty, otherwise
-// requires an explicit choice.
+// resolveMetricsInstance picks the single registered instance when --name is
+// empty, otherwise requires an explicit choice.
func resolveMetricsInstance(name string) (*registry.State, error) {
if name != "" {
return resolveInstance(name)
@@ -111,22 +106,15 @@ func resolveMetricsInstance(name string) (*registry.State, error) {
}
}
-// resolvePrometheusEndpoint locates the Prometheus to scrape and reports
-// which instance to scope queries to. It prefers the SHARED host-level
-// stack when this instance is registered with it — returning the
-// instance name so scrapeMetrics filters by instance= across the
-// multi-instance Prometheus. Otherwise it falls back to a per-instance
-// Prometheus (state.Ports["prometheus_ui"], persisted by `up`, or an
-// explicit --prometheus-port), where queries stay unscoped ("") because
-// that Prometheus only holds one instance. We only shell out to docker
-// when no port is recorded, to distinguish "observability is off" from
-// "state is stale".
+// resolvePrometheusEndpoint locates the Prometheus to scrape and the scope to
+// filter by. It prefers the SHARED host-level stack (returning the instance
+// name so queries filter by instance=), else falls back to the
+// per-instance Prometheus (unscoped, since it holds one instance). Docker is
+// probed only when no port is recorded, to tell "obs off" from "stale state".
func resolvePrometheusEndpoint(ctx context.Context, state *registry.State, host string, explicitPort int) (rhost string, rport int, scope string, rerr error) {
if explicitPort > 0 {
return host, explicitPort, "", nil
}
- // Shared stack first: only when this instance is registered with it
- // (its file_sd target exists) AND the stack is actually running.
if localnet.InstanceObservabilityEnabled(state.Name) {
if h, p, err := localnet.SharedPrometheusEndpoint(ctx); err == nil {
return h, p, state.Name, nil
@@ -153,11 +141,8 @@ func resolvePrometheusEndpoint(ctx context.Context, state *registry.State, host
state.Name, state.Name)
}
-// MetricsReport is the stable JSON shape `--format json` emits.
-// Each field is a query against the shared scrape config; missing
-// data (no samples yet) is represented as null in JSON / "—" in
-// text rather than 0 — distinguishes "value is zero" from "we
-// couldn't ask Prometheus."
+// MetricsReport is the stable JSON shape `--format json` emits. Missing data
+// is null (not 0) so "value is zero" stays distinct from "no sample".
type MetricsReport struct {
SchemaVersion int `json:"schema_version"`
LedgerTPS *float64 `json:"ledger_tps_5m,omitempty"`
@@ -168,48 +153,33 @@ type MetricsReport struct {
Dashboards DashboardsBlock `json:"dashboards"`
}
-// LatencyReport groups the mediator-approval latency quantiles in
-// milliseconds. Each field is a pointer so a missing scrape
-// (Prometheus has no samples yet) is distinguishable from a true
-// zero. The JSON keys match what the Web UI's MetricsSummary
-// renders so the two surfaces stay in lock-step.
+// LatencyReport groups the sequencing-latency figures in milliseconds. AvgMs
+// is the headline mean; the percentiles come back nil on Splice 0.6.4 whose
+// histogram exports only the +Inf bucket. JSON keys match the Web UI's.
type LatencyReport struct {
+ AvgMs *float64 `json:"avg_ms,omitempty"`
P50Ms *float64 `json:"p50_ms,omitempty"`
P95Ms *float64 `json:"p95_ms,omitempty"`
P99Ms *float64 `json:"p99_ms,omitempty"`
}
-// DashboardsBlock is the discoverability hand-off — the CLI surface
-// can't render charts, so we point at where they live (Grafana).
-// GrafanaURL is empty when the instance was started without the
-// observability profile; the text renderer turns that into a hint.
+// DashboardsBlock points at Grafana since the CLI can't render charts.
+// GrafanaURL is empty when the observability profile is off.
type DashboardsBlock struct {
GrafanaURL string `json:"grafana_url,omitempty"`
}
-// grafanaDashboardUID pins the UID of the bundled Canton LocalNet
-// dashboard provisioned under assets/grafana/dashboards/. The UID is
-// baked into the JSON so a deep link is stable across restarts —
-// hardcoding here keeps `dpm localnet metrics` from having to read
-// the asset at runtime.
+// grafanaDashboardUID is the bundled dashboard's UID (assets/grafana/
+// dashboards/), hardcoded so the deep link is stable without reading the asset.
const grafanaDashboardUID = "canton-localnet-v1"
-// scrapeMetrics runs the curated Prometheus queries in parallel and
-// assembles them into a MetricsReport. Queries live in
-// internal/metricsq so CLI + handler share one canonical map.
-// instance scopes the queries to a single LocalNet when non-empty
-// (the shared host-level Prometheus serves many); "" sums across
-// whatever Prometheus holds.
+// scrapeMetrics runs the curated queries in parallel into a MetricsReport.
+// instance scopes them to one LocalNet when non-empty.
//
-// Error model: "Prometheus answered but the metric has no samples
-// yet" (promQuery returns (nil, nil)) must NOT fail the call — a
-// fresh instance legitimately has empty headlines. A TRANSPORT
-// failure (connection refused, non-200, malformed body) means we
-// could not ask Prometheus at all; if EVERY query fails that way we
-// return an error so the CLI exits non-zero instead of printing
-// all-dashes and exiting 0. Requiring ALL queries to fail keeps a
-// single flaky query from sinking a report against a healthy
-// Prometheus — a real outage takes every query down together.
+// Error model: "no samples yet" (promQuery returns nil,nil) is a valid empty
+// headline, not a failure. Only when EVERY query fails at the transport layer
+// do we return an error — a single flaky query must not sink a report against
+// a healthy Prometheus, while a real outage takes them all down together.
func scrapeMetrics(ctx context.Context, host string, port int, instance string) (*MetricsReport, error) {
base := fmt.Sprintf("http://%s:%d", host, port)
queries := metricsq.SummaryQueriesFor(instance)
@@ -231,20 +201,14 @@ func scrapeMetrics(ctx context.Context, host string, port int, instance string)
for range queries {
r := <-ch
if r.err != nil {
- // Transport-level failure (could not reach Prometheus).
- // Count it; remember the first for the surfaced message.
transportFailures++
if firstErr == nil {
firstErr = r.err
}
continue
}
- // Prometheus answered; r.val may be nil ("no samples yet"),
- // which is a genuine empty result, not a failure.
results[r.key] = r.val
}
- // All queries failed at the transport layer → Prometheus is
- // unreachable. Surface it rather than masquerading as empty data.
if transportFailures == len(queries) && firstErr != nil {
return nil, firstErr
}
@@ -255,6 +219,7 @@ func scrapeMetrics(ctx context.Context, host string, port int, instance string)
HeapBytes: results[metricsq.HeadlineHeapUsed],
PostgresConn: results[metricsq.HeadlinePostgresConn],
Latency: LatencyReport{
+ AvgMs: scaleSeconds(results[metricsq.HeadlineMediatorAvg]),
P50Ms: scaleSeconds(results[metricsq.HeadlineMediatorP50]),
P95Ms: scaleSeconds(results[metricsq.HeadlineMediatorP95]),
P99Ms: scaleSeconds(results[metricsq.HeadlineMediatorP99]),
@@ -262,10 +227,8 @@ func scrapeMetrics(ctx context.Context, host string, port int, instance string)
}, nil
}
-// grafanaURLFor returns the deep link to the bundled Canton LocalNet
-// dashboard when the instance was started with the observability
-// profile (signal: grafana_ui port is registered). Returns "" when
-// observability is off so the caller can render a hint instead.
+// grafanaURLFor deep-links to the bundled dashboard when the grafana_ui port
+// is registered (obs on), else "" so the caller can render a hint.
func grafanaURLFor(state *registry.State) string {
if state == nil {
return ""
@@ -277,10 +240,8 @@ func grafanaURLFor(state *registry.State) string {
return fmt.Sprintf("http://localhost:%d/d/%s", port, grafanaDashboardUID)
}
-// promQuery executes one PromQL query and returns the scalar
-// result (or nil if no samples). Tiny client — pulling
-// prometheus/client_golang for one query would balloon the
-// binary; the JSON shape is stable and trivially decoded.
+// promQuery executes one PromQL query and returns the scalar result (or nil
+// if no samples).
func promQuery(ctx context.Context, base, query string) (*float64, error) {
u := base + "/api/v1/query?" + url.Values{"query": {query}}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
@@ -310,9 +271,6 @@ func promQuery(ctx context.Context, base, query string) (*float64, error) {
if body.Status != "success" || len(body.Data.Result) == 0 {
return nil, nil
}
- // Prometheus vector result: each entry is
- // {"metric": {...}, "value": [timestamp, "value"]}. Take
- // the first sample's value.
entry := body.Data.Result[0].Value
if len(entry) < 2 {
return nil, nil
@@ -331,9 +289,7 @@ func promQuery(ctx context.Context, base, query string) (*float64, error) {
return &v, nil
}
-// renderMetricsText prints the report in a compact human-readable
-// form. Uses the term primitives so the visual style matches the
-// rest of the CLI surface (Section header + KV rows).
+// renderMetricsText prints the report in a compact human-readable form.
func renderMetricsText(out io.Writer, instance, host string, port int, r *MetricsReport) {
kv := func(k string, v *float64, unit string) string {
val := "—"
@@ -348,12 +304,18 @@ func renderMetricsText(out io.Writer, instance, host string, port int, r *Metric
kv("DB conns (used)", r.PostgresConn, ""),
"",
"Latency:",
- kv(" p50", r.Latency.P50Ms, "ms"),
- kv(" p95", r.Latency.P95Ms, "ms"),
- kv(" p99", r.Latency.P99Ms, "ms"),
- "",
- "Dashboards:",
+ kv(" avg", r.Latency.AvgMs, "ms"),
+ }
+ // Splice 0.6.4's +Inf-only histogram yields no percentiles; print them
+ // only when present rather than a row of dashes (the avg is always exact).
+ if r.Latency.P50Ms != nil || r.Latency.P95Ms != nil || r.Latency.P99Ms != nil {
+ rows = append(rows,
+ kv(" p50", r.Latency.P50Ms, "ms"),
+ kv(" p95", r.Latency.P95Ms, "ms"),
+ kv(" p99", r.Latency.P99Ms, "ms"),
+ )
}
+ rows = append(rows, "", "Dashboards:")
if r.Dashboards.GrafanaURL != "" {
rows = append(rows, term.KV(" Grafana", r.Dashboards.GrafanaURL, 22))
} else {
@@ -365,8 +327,7 @@ func renderMetricsText(out io.Writer, instance, host string, port int, r *Metric
_, _ = fmt.Fprintln(out, term.Section("metrics · "+instance, right, body, 0))
}
-// scaleSeconds converts a seconds-valued metric to milliseconds
-// for friendlier human display (canton's p95 is typically 10-500ms).
+// scaleSeconds converts a seconds-valued metric to milliseconds.
func scaleSeconds(v *float64) *float64 {
if v == nil {
return nil
@@ -375,7 +336,6 @@ func scaleSeconds(v *float64) *float64 {
return &ms
}
-// scaleBytes converts bytes to MiB.
func scaleBytes(v *float64) *float64 {
if v == nil {
return nil
diff --git a/internal/cli/localnet/metrics_test.go b/internal/cli/localnet/metrics_test.go
index c6925209..180639ea 100644
--- a/internal/cli/localnet/metrics_test.go
+++ b/internal/cli/localnet/metrics_test.go
@@ -17,10 +17,8 @@ import (
"github.com/bitdynamics-ab/canton-devkit/internal/registry"
)
-// fakePromHandler is a minimal Prometheus stand-in for the CLI's
-// metrics scraper. Returns hard-coded values keyed off the headline
-// query so the test can assert on rendered text without depending
-// on a real container.
+// fakePromHandler is a minimal Prometheus stand-in returning hard-coded values
+// keyed off the headline query.
func fakePromHandler(t *testing.T, vals map[metricsq.Headline]float64) http.Handler {
t.Helper()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -50,12 +48,10 @@ func fakePromHandler(t *testing.T, vals map[metricsq.Headline]float64) http.Hand
})
}
-// TestScrapeMetrics_PopulatesLatencyBlock pins the p50/p95/p99 fan-out:
-// scrapeMetrics should run every SummaryQuery and project the three
-// quantiles into the milliseconds-scaled LatencyReport block.
func TestScrapeMetrics_PopulatesLatencyBlock(t *testing.T) {
srv := httptest.NewServer(fakePromHandler(t, map[metricsq.Headline]float64{
metricsq.HeadlineLedgerTPS: 12.5,
+ metricsq.HeadlineMediatorAvg: 0.100, // 100 ms
metricsq.HeadlineMediatorP50: 0.012, // 12 ms
metricsq.HeadlineMediatorP95: 0.045, // 45 ms
metricsq.HeadlineMediatorP99: 0.120, // 120 ms
@@ -76,6 +72,9 @@ func TestScrapeMetrics_PopulatesLatencyBlock(t *testing.T) {
if err != nil {
t.Fatalf("scrapeMetrics: %v", err)
}
+ if report.Latency.AvgMs == nil || *report.Latency.AvgMs < 99.9 || *report.Latency.AvgMs > 100.1 {
+ t.Errorf("avg = %v, want ~100 ms", report.Latency.AvgMs)
+ }
if report.Latency.P50Ms == nil || *report.Latency.P50Ms < 11.9 || *report.Latency.P50Ms > 12.1 {
t.Errorf("p50 = %v, want ~12 ms", report.Latency.P50Ms)
}
@@ -87,17 +86,10 @@ func TestScrapeMetrics_PopulatesLatencyBlock(t *testing.T) {
}
}
-// TestScrapeMetrics_TransportFailureSurfacesError: when
-// Prometheus is unreachable (here: a closed listener) EVERY query
-// fails at the transport layer, so scrapeMetrics must return a
-// non-nil error rather than an all-nil report. This is the contract
-// the RunE failure branch ("prometheus query failed", exit 4)
-// depends on — without it the CLI prints all-dashes and exits 0
-// whether observability is off, the port is wrong, or the instance
-// is healthy.
+// When every query fails at the transport layer, scrapeMetrics must return an
+// error, not an all-nil report — the contract the exit-4 RunE branch depends on.
func TestScrapeMetrics_TransportFailureSurfacesError(t *testing.T) {
- // Bind then immediately close so the port is almost certainly
- // refused (no listener) for the duration of the test.
+ // Bind then immediately close so the port is refused for the test.
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
u, err := url.Parse(srv.URL)
if err != nil {
@@ -105,7 +97,7 @@ func TestScrapeMetrics_TransportFailureSurfacesError(t *testing.T) {
}
host := u.Hostname()
port, _ := strconv.Atoi(u.Port())
- srv.Close() // now connections to host:port are refused
+ srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -118,14 +110,9 @@ func TestScrapeMetrics_TransportFailureSurfacesError(t *testing.T) {
}
}
-// TestScrapeMetrics_EmptyButReachableIsNotError: a healthy
-// Prometheus that simply has no samples yet (fresh instance) returns
-// (nil,nil) per query. scrapeMetrics must treat that as a valid empty
-// report — NOT a failure — so a just-started instance doesn't trip the
-// exit-4 branch. The headline fields are nil; the call succeeds.
+// A reachable Prometheus with no samples yet returns (nil,nil) per query;
+// scrapeMetrics must treat that as a valid empty report, not an error.
func TestScrapeMetrics_EmptyButReachableIsNotError(t *testing.T) {
- // fakePromHandler with an empty value map answers every query with
- // an empty Prometheus result vector (status success, no samples).
srv := httptest.NewServer(fakePromHandler(t, map[metricsq.Headline]float64{}))
defer srv.Close()
u, err := url.Parse(srv.URL)
@@ -181,11 +168,8 @@ func TestPromQuery_TreatsNaNAsMissingSample(t *testing.T) {
}
}
-// TestRenderMetricsText_IncludesLatencyAndGrafana confirms the text
-// renderer prints the Latency block (p50/p95/p99) and a Grafana URL
-// when one is populated. The CLI's UX contract is checked here so a
-// silent regression to the previous "p95 only" layout fails fast.
func TestRenderMetricsText_IncludesLatencyAndGrafana(t *testing.T) {
+ avg := 100.0
p50 := 12.0
p95 := 45.0
p99 := 120.0
@@ -193,13 +177,13 @@ func TestRenderMetricsText_IncludesLatencyAndGrafana(t *testing.T) {
report := &MetricsReport{
SchemaVersion: metricsq.SchemaVersion,
LedgerTPS: &tps,
- Latency: LatencyReport{P50Ms: &p50, P95Ms: &p95, P99Ms: &p99},
+ Latency: LatencyReport{AvgMs: &avg, P50Ms: &p50, P95Ms: &p95, P99Ms: &p99},
Dashboards: DashboardsBlock{GrafanaURL: "http://localhost:3001/d/canton-localnet-v1"},
}
var buf bytes.Buffer
renderMetricsText(&buf, "demo", "127.0.0.1", 9090, report)
out := buf.String()
- for _, want := range []string{"Latency:", "p50", "p95", "p99",
+ for _, want := range []string{"Latency:", "avg", "p50", "p95", "p99",
"Dashboards:", "Grafana", "http://localhost:3001/d/canton-localnet-v1"} {
if !strings.Contains(out, want) {
t.Errorf("text output missing %q\n%s", want, out)
@@ -207,10 +191,29 @@ func TestRenderMetricsText_IncludesLatencyAndGrafana(t *testing.T) {
}
}
-// TestRenderMetricsText_GrafanaHintWhenObsOff: with no Grafana URL
-// the renderer should still print a Dashboards block, but with the
-// "enable observability" hint so the operator knows the profile is
-// off.
+// Splice 0.6.4 case: percentiles nil (only +Inf bucket), avg exact. The
+// renderer prints the avg row but no p50/p95/p99 rows of dashes.
+func TestRenderMetricsText_OmitsPercentilesWhenAbsent(t *testing.T) {
+ avg := 113.0
+ report := &MetricsReport{
+ SchemaVersion: metricsq.SchemaVersion,
+ Latency: LatencyReport{AvgMs: &avg},
+ }
+ var buf bytes.Buffer
+ renderMetricsText(&buf, "demo", "127.0.0.1", 9090, report)
+ out := buf.String()
+ if !strings.Contains(out, "avg") || !strings.Contains(out, "113.00 ms") {
+ t.Errorf("expected the avg row; got\n%s", out)
+ }
+ for _, unwanted := range []string{"p50", "p95", "p99"} {
+ if strings.Contains(out, unwanted) {
+ t.Errorf("expected no %q row when percentiles are absent; got\n%s", unwanted, out)
+ }
+ }
+}
+
+// With no Grafana URL the renderer still prints a Dashboards block, with the
+// "enable observability" hint.
func TestRenderMetricsText_GrafanaHintWhenObsOff(t *testing.T) {
report := &MetricsReport{SchemaVersion: metricsq.SchemaVersion}
var buf bytes.Buffer
@@ -224,14 +227,12 @@ func TestRenderMetricsText_GrafanaHintWhenObsOff(t *testing.T) {
}
}
-// TestMetricsReport_JSONShape pins the JSON envelope so CI assertions
-// (`jq .latency.p99_ms`) won't break silently. We marshal a populated
-// report and unmarshal into a generic map to inspect the keys.
+// Pins the JSON envelope so CI assertions (`jq .latency.p99_ms`) don't break.
func TestMetricsReport_JSONShape(t *testing.T) {
- p50, p95, p99 := 1.0, 2.0, 3.0
+ avg, p50, p95, p99 := 0.5, 1.0, 2.0, 3.0
report := &MetricsReport{
SchemaVersion: metricsq.SchemaVersion,
- Latency: LatencyReport{P50Ms: &p50, P95Ms: &p95, P99Ms: &p99},
+ Latency: LatencyReport{AvgMs: &avg, P50Ms: &p50, P95Ms: &p95, P99Ms: &p99},
Dashboards: DashboardsBlock{GrafanaURL: "http://localhost:3001/d/canton-localnet-v1"},
}
b, err := json.Marshal(report)
@@ -246,7 +247,7 @@ func TestMetricsReport_JSONShape(t *testing.T) {
if !ok {
t.Fatalf("missing latency block; got %s", b)
}
- for _, k := range []string{"p50_ms", "p95_ms", "p99_ms"} {
+ for _, k := range []string{"avg_ms", "p50_ms", "p95_ms", "p99_ms"} {
if _, ok := lat[k]; !ok {
t.Errorf("latency.%s missing; got %s", k, b)
}
@@ -260,9 +261,7 @@ func TestMetricsReport_JSONShape(t *testing.T) {
}
}
-// TestGrafanaURLFor: the URL is only emitted when state.Ports has a
-// grafana_ui entry (signal that the obs profile is on). The dashboard
-// UID is the one we provision under assets/grafana/dashboards/.
+// The URL is emitted only when state.Ports has a grafana_ui entry (obs on).
func TestGrafanaURLFor(t *testing.T) {
t.Run("obs-on", func(t *testing.T) {
state := ®istry.State{Ports: map[string]int{"grafana_ui": 3001}}
diff --git a/internal/metricsq/queries.go b/internal/metricsq/queries.go
index aa3a09f3..12cd9702 100644
--- a/internal/metricsq/queries.go
+++ b/internal/metricsq/queries.go
@@ -1,33 +1,20 @@
-// Package metricsq is the single source of truth for the PromQL
-// queries the CLI's `localnet metrics` and the Web UI's
-// `/api/instances/{name}/metrics/summary` both surface. A single
-// canonical map keeps the two surfaces in parity: adding a headline
-// metric is a one-line edit both pick up automatically, and the same
-// PromQL can never drift between them.
+// Package metricsq is the single source of the PromQL queries the CLI's
+// `localnet metrics` and the Web UI's metrics summary both surface.
//
-// Metric names are probe-verified against a live Splice LocalNet
-// (0.6.4 obs profile): Splice's OpenTelemetry reporter emits `daml_*`
-// (participant / mediator / sequencer), `jvm_*` (heap, threads, GC),
-// and `db_client_connections_*` (HikariCP pool stats) — there is no
-// `canton_*` prefix, and an unverified name fails silently as
-// "no data". See docs/observability.md for the audit notes and the
-// substitute mapping table.
-//
-// The package deliberately exposes ONLY the typed map + the
-// JSON-key string each headline uses on the wire. Rendering /
-// transport / unit conversion stays at the call site — those
-// concerns differ between CLI text mode and HTTP JSON.
+// Metric names are probe-verified against Splice 0.6.4: it emits `daml_*`,
+// `jvm_*`, and `db_client_connections_*` (no `canton_*` prefix), and an
+// unverified name fails silently as "no data". See docs/observability.md.
package metricsq
import "strings"
-// Headline identifies one of the curated summary panels. The
-// frontend's MetricsReport JSON shape and the CLI's text rendering
-// both switch on these constants — keep stable.
+// Headline identifies one curated summary panel. Both surfaces switch on
+// these constants — keep stable.
type Headline string
const (
HeadlineLedgerTPS Headline = "ledger_tps_5m"
+ HeadlineMediatorAvg Headline = "mediator_avg_seconds"
HeadlineMediatorP50 Headline = "mediator_p50_seconds"
HeadlineMediatorP95 Headline = "mediator_p95_seconds"
HeadlineMediatorP99 Headline = "mediator_p99_seconds"
@@ -35,62 +22,29 @@ const (
HeadlinePostgresConn Headline = "postgres_conn_count"
)
-// SummaryQueries is the canonical map both `dpm localnet metrics`
-// and the `/api/instances/{name}/metrics/summary` handler walk
-// when collecting the curated set. Order is irrelevant — both
-// surfaces fan out concurrent queries.
-//
-// Adding an entry here surfaces it on both CLI and Web UI on the
-// next rebuild; no further wiring required. Removing or renaming
-// an entry is a wire-breaking change to the JSON shape — bump
-// the response schema_version when doing it.
-//
-// Query rationale (verified against Splice 0.6.4):
-//
-// - LedgerTPS: `daml_participant_api_indexer_updates` is the
-// counter the indexer increments on each ledger update it
-// ingests — closest analog to "transactions per second" the
-// participant sees post-validation.
+// SummaryQueries is the unscoped curated set (sums across every series).
+// Removing/renaming an entry breaks the JSON shape — bump SchemaVersion.
//
-// - MediatorP95: there is no `daml_mediator_*` histogram on the
-// mediator approval path. The closest end-to-end protocol
-// latency that IS exposed is
-// `daml_sequencer_client_submissions_sequencing_duration_seconds`,
-// which measures the time from sequencer client send-call until
-// the message is sequenced (i.e. includes mediator approval +
-// ordering). For a developer-overview headline this is the
-// right "how fast is my LocalNet committing things" number.
+// Substitute-metric notes (verified against Splice 0.6.4):
+// - LedgerTPS uses daml_participant_api_indexer_updates, the closest
+// analog to committed TPS the participant exposes.
+// - MediatorP*/Avg: no daml_mediator_* histogram exists; the sequencer
+// submission-duration histogram (includes mediator approval + ordering)
+// is the substitute. 0.6.4 exports it with only the +Inf bucket, so
+// histogram_quantile is NaN and the mean (_sum/_count) is the reliable
+// headline; percentiles show only when finite `le` buckets are present.
+// - HeapUsed: OTel labels heap `jvm_memory_type="heap"` (not area="heap").
+// - PostgresConn: no postgres exporter; sum HikariCP's
+// db_client_connections_usage{state="used"} as the substitute.
//
-// - HeapUsed: the OTel JVM reporter uses the label
-// `jvm_memory_type="heap"` (not the old micrometer convention
-// `area="heap"`). Verified on `canton:10013` and `splice:10013`.
-//
-// - PostgresConn: no `pg_stat_activity_count` (no postgres
-// exporter is wired into the obs profile). HikariCP, which the
-// Daml participant + Splice apps use to pool DB connections,
-// emits `db_client_connections_usage{state="used"}` per pool;
-// summing it gives the number of DB connections actively held
-// by the JVM processes — functionally the same headline a user
-// would expect from "postgres conns".
-//
-// SummaryQueries is the unscoped set (sums across every series Prometheus
-// holds). Correct for a per-instance Prometheus that only scrapes one
-// LocalNet. For the SHARED host-level stack, which scrapes every
-// instance, use SummaryQueriesFor() so a headline reflects one
-// instance, not the sum across all of them.
+// For the SHARED multi-instance stack use SummaryQueriesFor() so a
+// headline reflects one instance, not the sum across all of them.
var SummaryQueries = SummaryQueriesFor("")
// SummaryQueriesFor builds the curated PromQL set, optionally scoped to a
-// single instance. When instance is non-empty an `instance=""`
-// label matcher is injected into every metric selector — this matches the
-// `instance` label the shared stack's file_sd attaches to each target. An
-// empty instance reproduces the unscoped queries byte-for-byte (so the
-// per-instance Prometheus path is unchanged).
-//
-// Building the selectors here (rather than hand-writing two copies) keeps
-// the CLI and Web UI byte-identical, and composes the label matcher
-// correctly whether or not the metric already carries its own labels (no
-// invalid trailing comma).
+// single instance. A non-empty instance injects an `instance=""`
+// matcher into every selector (matching the shared stack's file_sd label);
+// empty reproduces the unscoped queries byte-for-byte.
func SummaryQueriesFor(instance string) map[Headline]string {
sel := func(metric, extra string) string {
var matchers []string
@@ -105,9 +59,14 @@ func SummaryQueriesFor(instance string) map[Headline]string {
}
return metric + "{" + strings.Join(matchers, ",") + "}"
}
- const bucket = "daml_sequencer_client_submissions_sequencing_duration_seconds_bucket"
+ const (
+ bucket = "daml_sequencer_client_submissions_sequencing_duration_seconds_bucket"
+ sumM = "daml_sequencer_client_submissions_sequencing_duration_seconds_sum"
+ countM = "daml_sequencer_client_submissions_sequencing_duration_seconds_count"
+ )
return map[Headline]string{
HeadlineLedgerTPS: "sum(rate(" + sel("daml_participant_api_indexer_updates", "") + "[5m])) or vector(0)",
+ HeadlineMediatorAvg: "sum(rate(" + sel(sumM, "") + "[5m])) / sum(rate(" + sel(countM, "") + "[5m]))",
HeadlineMediatorP50: "histogram_quantile(0.50, sum(rate(" + sel(bucket, "") + "[5m])) by (le))",
HeadlineMediatorP95: "histogram_quantile(0.95, sum(rate(" + sel(bucket, "") + "[5m])) by (le))",
HeadlineMediatorP99: "histogram_quantile(0.99, sum(rate(" + sel(bucket, "") + "[5m])) by (le))",
@@ -116,9 +75,6 @@ func SummaryQueriesFor(instance string) map[Headline]string {
}
}
-// SchemaVersion is the wire-stable version of the metrics summary
-// response. Bumped on a wire-breaking change (rename / removal of
-// a Headline). Adding is non-breaking and doesn't require a bump;
-// neither does changing the PromQL string behind an unchanged
-// JSON key.
+// SchemaVersion is bumped on a wire-breaking change (rename/removal of a
+// Headline); adding one, or changing PromQL behind an unchanged key, is not.
const SchemaVersion = 1
diff --git a/internal/metricsq/queries_instance_test.go b/internal/metricsq/queries_instance_test.go
index 2fd6d1c2..0512f7dd 100644
--- a/internal/metricsq/queries_instance_test.go
+++ b/internal/metricsq/queries_instance_test.go
@@ -2,12 +2,11 @@ package metricsq
import "testing"
-// TestSummaryQueriesFor_Unscoped pins that the empty-instance form
-// reproduces the curated PromQL byte-for-byte — so the per-instance
-// Prometheus path is unchanged.
+// The empty-instance form must reproduce the curated PromQL byte-for-byte.
func TestSummaryQueriesFor_Unscoped(t *testing.T) {
want := map[Headline]string{
HeadlineLedgerTPS: "sum(rate(daml_participant_api_indexer_updates[5m])) or vector(0)",
+ HeadlineMediatorAvg: "sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum[5m])) / sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count[5m]))",
HeadlineMediatorP50: "histogram_quantile(0.50, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))",
HeadlineMediatorP95: "histogram_quantile(0.95, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))",
HeadlineMediatorP99: "histogram_quantile(0.99, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m])) by (le))",
@@ -25,18 +24,14 @@ func TestSummaryQueriesFor_Unscoped(t *testing.T) {
}
}
-// TestSummaryQueriesFor_Scoped pins that an instance injects a valid
-// instance="X" matcher into every selector — including composing with a
-// metric's existing label without an invalid trailing comma — so the
-// shared multi-instance Prometheus headline reflects one instance.
+// A scoped instance must inject a valid instance="X" matcher into every
+// selector, composing with existing labels without a trailing comma.
func TestSummaryQueriesFor_Scoped(t *testing.T) {
got := SummaryQueriesFor("demo")
cases := map[Headline]string{
- // no existing label -> braces added with just the instance matcher
- HeadlineLedgerTPS: `sum(rate(daml_participant_api_indexer_updates{instance="demo"}[5m])) or vector(0)`,
- // bucket inside histogram_quantile gets scoped too
- HeadlineMediatorP95: `histogram_quantile(0.95, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket{instance="demo"}[5m])) by (le))`,
- // existing label -> instance matcher composed before it, comma-joined
+ HeadlineLedgerTPS: `sum(rate(daml_participant_api_indexer_updates{instance="demo"}[5m])) or vector(0)`,
+ HeadlineMediatorP95: `histogram_quantile(0.95, sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_bucket{instance="demo"}[5m])) by (le))`,
+ HeadlineMediatorAvg: `sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum{instance="demo"}[5m])) / sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count{instance="demo"}[5m]))`,
HeadlineHeapUsed: `sum(jvm_memory_used_bytes{instance="demo",jvm_memory_type="heap"})`,
HeadlinePostgresConn: `sum(db_client_connections_usage{instance="demo",state="used"})`,
}
diff --git a/internal/metricsq/queries_test.go b/internal/metricsq/queries_test.go
index 49e6f257..a47bb35b 100644
--- a/internal/metricsq/queries_test.go
+++ b/internal/metricsq/queries_test.go
@@ -5,13 +5,11 @@ import (
"testing"
)
-// TestSummaryQueries_AllHeadlinesPresent pins the curated map's
-// membership: adding/removing a Headline is a wire-breaking change
-// to the JSON shape both CLI and Web UI emit, so the set should be
-// reviewed deliberately, not changed by accident.
+// Membership change = wire-breaking JSON change; pin it so it's deliberate.
func TestSummaryQueries_AllHeadlinesPresent(t *testing.T) {
want := []Headline{
HeadlineLedgerTPS,
+ HeadlineMediatorAvg,
HeadlineMediatorP50,
HeadlineMediatorP95,
HeadlineMediatorP99,
@@ -29,10 +27,6 @@ func TestSummaryQueries_AllHeadlinesPresent(t *testing.T) {
}
}
-// TestLatencyQuantiles_WellFormed: the three histogram_quantile
-// queries must share the same bucket source (so a percentile-vs-
-// percentile comparison is meaningful) and pin the requested
-// quantile.
func TestLatencyQuantiles_WellFormed(t *testing.T) {
cases := []struct {
h Headline
@@ -47,9 +41,7 @@ func TestLatencyQuantiles_WellFormed(t *testing.T) {
if !strings.HasPrefix(q, c.want) {
t.Errorf("query for %s = %q, want prefix %q", c.h, q, c.want)
}
- // All three percentiles must share the same bucket source so a
- // p50/p95/p99 comparison is meaningful (mixing histogram
- // families would silently render incomparable numbers).
+ // Same bucket source across all three, else the percentiles are incomparable.
if !strings.Contains(q, "daml_sequencer_client_submissions_sequencing_duration_seconds_bucket[5m]") {
t.Errorf("query for %s must share the sequencer submission-duration histogram; got %q", c.h, q)
}
@@ -58,3 +50,22 @@ func TestLatencyQuantiles_WellFormed(t *testing.T) {
}
}
}
+
+// The avg must be the histogram mean (_sum/_count), which stays exact when
+// the percentiles go NaN on +Inf-only buckets — never a histogram_quantile.
+func TestMediatorAvg_UsesSumOverCount(t *testing.T) {
+ q := SummaryQueries[HeadlineMediatorAvg]
+ base := "daml_sequencer_client_submissions_sequencing_duration_seconds"
+ if !strings.Contains(q, base+"_sum") {
+ t.Errorf("avg query must rate the histogram's _sum series; got %q", q)
+ }
+ if !strings.Contains(q, base+"_count") {
+ t.Errorf("avg query must divide by the histogram's _count series; got %q", q)
+ }
+ if !strings.Contains(q, "/") {
+ t.Errorf("avg query must be a _sum/_count ratio; got %q", q)
+ }
+ if strings.Contains(q, "histogram_quantile") {
+ t.Errorf("avg query must not use histogram_quantile (that is NaN on +Inf-only histograms); got %q", q)
+ }
+}
diff --git a/internal/ui/handlers/metrics.go b/internal/ui/handlers/metrics.go
index 41944050..4f6ad4a4 100644
--- a/internal/ui/handlers/metrics.go
+++ b/internal/ui/handlers/metrics.go
@@ -20,47 +20,26 @@ import (
"github.com/bitdynamics-ab/canton-devkit/internal/registry"
)
-// MountMetrics wires the metrics Web UI face: a per-instance
-// Prometheus passthrough so the Metrics screen can render live
-// charts without the browser scraping Prometheus directly (avoids
-// CORS + lets the handler enforce auth/JWT once that lands).
-//
-// GET /api/instances/{name}/metrics?query=
-//
-// Returns Prometheus's `/api/v1/query` response verbatim when the
-// scrape succeeds. Returns 503 with a structured envelope when
-// the observability profile isn't running for the instance —
-// frontend renders a "raise observability" remediation panel.
-//
-// Per CONTRIBUTING.md "CLI ↔ Web UI parity": this handler shares the
-// scrape config and PromQL grammar with `dpm localnet metrics`.
-// Adding a new built-in panel updates both surfaces.
+// MountMetrics wires the metrics Web UI face: a per-instance Prometheus
+// passthrough so the browser doesn't scrape Prometheus directly (avoids CORS,
+// lets the handler enforce auth later). Returns 503 with a structured envelope
+// when the observability profile isn't running. Shares scrape config + PromQL
+// grammar with `dpm localnet metrics` (CLI ↔ Web UI parity).
func MountMetrics(mux *http.ServeMux) {
mux.HandleFunc("GET /api/instances/{name}/metrics", handleMetricsQuery())
mux.HandleFunc("GET /api/instances/{name}/metrics/summary", handleMetricsSummary())
- // Range query — backs every chart on the Web UI Metrics screen.
- // Wraps Prometheus's /api/v1/query_range so the frontend gets a
- // time series instead of a scalar. Inputs are bounded by the
- // same promQueryRE allowlist as the instant query handler.
mux.HandleFunc("GET /api/instances/{name}/metrics/range", handleMetricsRange())
}
-// metricsTimeout caps how long the handler will wait on the
-// Prometheus subprocess + HTTP request chain. 10s matches the
-// CLI's per-call timeout.
+// metricsTimeout matches the CLI's per-call timeout.
const metricsTimeout = 10 * time.Second
-// maxQueryLen caps PromQL input length before it ever reaches the
-// regex matcher. Defence against catastrophic backtracking + a
-// trivial sanity bound — the largest panel we ship is ~280 bytes.
+// maxQueryLen bounds PromQL input before the regex, guarding against
+// catastrophic backtracking (largest panel we ship is ~280 bytes).
const maxQueryLen = 4096
-// promQueryRE pins the allowed character set for the `query`
-// param — alphanumeric + PromQL operators + whitespace. Blocks
-// shell metachars and request-smuggling attempts before we hand
-// the string to net/url.QueryEscape (defence-in-depth: even
-// though we URL-encode, refusing weird input early gives clearer
-// 400s than a confusing Prometheus error).
+// promQueryRE allows only PromQL grammar, blocking shell metachars and
+// request-smuggling before the string reaches url.QueryEscape.
var promQueryRE = regexp.MustCompile(`^[a-zA-Z0-9_{}\[\]:".,= \-+*/()<>!^]+$`)
func handleMetricsQuery() http.HandlerFunc {
@@ -80,9 +59,6 @@ func handleMetricsQuery() http.HandlerFunc {
"pass ?query= — e.g. ?query=up")
return
}
- // Guard the regex from pathological inputs: a
- // 4 KiB ceiling is generous for any panel we ship and stops
- // catastrophic-backtracking probes early.
if len(query) > maxQueryLen {
writeErrorWithCode(w, http.StatusBadRequest,
ErrCodeInvalidRequest,
@@ -134,24 +110,9 @@ func handleMetricsQuery() http.HandlerFunc {
}
}
-// handleMetricsRange wraps Prometheus's range-query endpoint
-// (/api/v1/query_range). Returned shape matches Prometheus's own
-// response so the frontend can decode {data.result[].values[][t,v]}
-// without an extra projection layer.
-//
-// Parameters:
-// - query : PromQL expression (same allowlist as the instant
-// handler; promQueryRE)
-// - window : duration string ("5m", "1h", "24h"); clamped to
-// [1m, 24h]
-// - step : duration string ("10s", "1m"); clamped to [5s, 1h];
-// defaults to window/60 so a default window yields
-// ~60 samples (good chart resolution without
-// hammering Prometheus)
-//
-// Same OBSERVABILITY_PROFILE_OFF semantics as the summary endpoint:
-// 503 with structured envelope when Prometheus isn't running for
-// the project.
+// handleMetricsRange wraps Prometheus's /api/v1/query_range verbatim. step
+// defaults to window/60 (~60 samples) when unset. Same
+// OBSERVABILITY_PROFILE_OFF semantics as the other endpoints.
func handleMetricsRange() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
@@ -248,10 +209,7 @@ func handleMetricsRange() http.HandlerFunc {
}
// handleMetricsSummary returns the same headline summary the CLI's
-// `dpm localnet metrics --format json` prints. Lets the Web UI
-// render a status card without doing four round-trips for the four
-// queries (and ensures the two surfaces always show the same set
-// of headline numbers).
+// `dpm localnet metrics --format json` prints, from the shared metricsq set.
func handleMetricsSummary() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
@@ -275,16 +233,11 @@ func handleMetricsSummary() http.HandlerFunc {
ctx, cancel := context.WithTimeout(r.Context(), metricsTimeout)
defer cancel()
- // Scope the queries to this instance when it's served by the
- // shared multi-instance stack; "" sums over the single-instance
- // per-instance Prometheus (the fallback). The frontend reads the
- // returned scope to scope its own chart queries identically.
- // Resolved from the SAME cached discovery the fan-out queries use
- // (keyed by compose project) so scope and endpoint can't disagree.
+ // Scope to this instance on the shared stack, "" on the per-instance
+ // fallback. Resolved from the SAME cached discovery the fan-out uses
+ // so scope and endpoint can't disagree; the frontend mirrors it.
scope := promScopeFor(ctx, state.ComposeProject)
- // Queries come from the shared metricsq package so CLI
- // + handler can't drift.
out := map[string]*float64{}
queries := metricsq.SummaryQueriesFor(scope)
type res struct {
@@ -307,9 +260,8 @@ func handleMetricsSummary() http.HandlerFunc {
}
}
if !anyFound {
- // Best signal that observability isn't running — no
- // queries returned data. Surface the structured code
- // so the UI can offer the "enable profile" CTA.
+ // No data from any query — probe `up` to distinguish "obs off"
+ // (structured code for the CTA) from a genuinely empty instance.
if _, err := proxyPrometheus(ctx, state.ComposeProject, "/api/v1/query?query=up"); errors.Is(err, errPrometheusNotRunning) {
writeErrorWithCode(w, http.StatusServiceUnavailable,
"OBSERVABILITY_PROFILE_OFF",
@@ -319,10 +271,10 @@ func handleMetricsSummary() http.HandlerFunc {
return
}
}
- // Build the latency block in milliseconds — same scaling
- // as the CLI's `--format json` shape so the two surfaces
- // stay byte-identical for the curated panels.
+ // avg_ms is the always-computable mean; percentiles are nil on Splice
+ // 0.6.4 (no finite histogram buckets). Same ms scaling as the CLI.
latency := map[string]*float64{
+ "avg_ms": secondsToMs(out[string(metricsq.HeadlineMediatorAvg)]),
"p50_ms": secondsToMs(out[string(metricsq.HeadlineMediatorP50)]),
"p95_ms": secondsToMs(out[string(metricsq.HeadlineMediatorP95)]),
"p99_ms": secondsToMs(out[string(metricsq.HeadlineMediatorP99)]),
@@ -333,10 +285,8 @@ func handleMetricsSummary() http.HandlerFunc {
writeJSON(w, http.StatusOK, map[string]any{
"schema_version": 1,
"instance": name,
- // scope is the instance label to filter chart queries by when
- // non-empty (shared multi-instance stack); "" means the
- // single-instance per-instance Prometheus, so the frontend
- // leaves its chart queries unscoped.
+ // scope is the instance label the frontend filters chart queries by;
+ // "" (per-instance Prometheus) leaves them unscoped.
"scope": scope,
"metrics": out,
"latency": latency,
@@ -345,35 +295,22 @@ func handleMetricsSummary() http.HandlerFunc {
}
}
-// promScopeFor reports the instance label the metrics queries should be
-// filtered by, derived from the SAME cached discovery the chart/range
-// queries use (discoverPrometheus, keyed by compose project) rather than
-// re-resolving independently. The instance name when served by the shared
-// multi-instance stack, or "" on the per-instance fallback (which
-// holds only that instance, so no filter is needed). The frontend mirrors
-// this via the summary response's `scope` field.
-//
-// Resolving through the shared cache closes a skew window: discoverPrometheus
-// caches host:port for a TTL, so a freshly-toggled instance could otherwise
-// have its summary report scope=name (uncached, live) while the cached
-// endpoint still points at the per-instance Prometheus for up to the TTL —
-// transiently filtering charts against series that lack the label. Sharing
-// the cached decision keeps scope and endpoint moving together.
+// promScopeFor reports the instance label to filter queries by, read from the
+// SAME cached discovery the chart queries use rather than re-resolving. This
+// closes a skew window: an independent lookup could report scope=name while
+// the cached endpoint still points at the per-instance Prometheus for a TTL,
+// transiently filtering charts against series that lack the label.
func promScopeFor(ctx context.Context, project string) string {
- // Resolve (and cache) the endpoint, then read the scope that same
- // cached decision recorded.
_, _, _ = discoverPrometheus(ctx, project)
return lookupPromScope(project)
}
-// grafanaDashboardUID pins the bundled Canton LocalNet dashboard UID.
-// Mirrors the CLI's constant so both surfaces deep-link to the same
-// view. See assets/grafana/dashboards/canton-localnet.json.
+// grafanaDashboardUID mirrors the CLI's constant so both surfaces deep-link
+// to the same view. See assets/grafana/dashboards/canton-localnet.json.
const grafanaDashboardUID = "canton-localnet-v1"
-// grafanaURLForState returns the Web UI deep link to the bundled
-// dashboard when observability is on for the instance, or "" so the
-// frontend can render a "enable observability profile" CTA.
+// grafanaURLForState deep-links to the bundled dashboard when obs is on, or ""
+// so the frontend can render an "enable observability" CTA.
func grafanaURLForState(state *registry.State) string {
if state == nil {
return ""
@@ -385,9 +322,8 @@ func grafanaURLForState(state *registry.State) string {
return fmt.Sprintf("http://localhost:%d/d/%s", port, grafanaDashboardUID)
}
-// secondsToMs converts a seconds-valued Prometheus scalar into the
-// milliseconds the frontend expects for latency cards. nil-safe so
-// "no samples yet" stays distinguishable from "0 ms".
+// secondsToMs converts seconds to milliseconds, nil-safe so "no sample" stays
+// distinct from "0 ms".
func secondsToMs(v *float64) *float64 {
if v == nil {
return nil
@@ -396,21 +332,10 @@ func secondsToMs(v *float64) *float64 {
return &ms
}
-// proxyPrometheus does the actual HTTP call against the
-// per-instance prometheus container. Returns errPrometheusNotRunning
-// when no prometheus is present in the project so the caller can
-// map to the OBSERVABILITY_PROFILE_OFF code.
-//
-// Discovery: walks `compose ps` for a service named "prometheus".
-// When found we hit it via 127.0.0.1:.
-//
-// Defence:
-// - dedicated http.Client with Timeout = metricsTimeout, so a
-// misbehaving upstream can't hold the request open unboundedly
-// - response body bounded by io.LimitReader so a runaway Prometheus
-// cannot OOM the devkit. 16 MiB is well above the largest range
-// response we observe in practice (a 24h × 5s step × 50-series
-// scrape is ~6 MiB) but small enough to fail closed.
+// proxyPrometheus does the HTTP call against the per-instance prometheus
+// container. Returns errPrometheusNotRunning when none is present so the
+// caller can map to OBSERVABILITY_PROFILE_OFF. The body is capped at 16 MiB
+// (well above a 24h×5s×50-series ~6 MiB response) so a runaway can't OOM us.
func proxyPrometheus(ctx context.Context, project, path string) ([]byte, error) {
host, port, err := discoverPrometheus(ctx, project)
if err != nil {
@@ -427,7 +352,6 @@ func proxyPrometheus(ctx context.Context, project, path string) ([]byte, error)
return nil, err
}
defer func() { _ = resp.Body.Close() }()
- // Cap body at 16 MiB + 1 so we can detect overrun.
const maxBody = 16 << 20
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1))
if err != nil {
@@ -442,31 +366,19 @@ func proxyPrometheus(ctx context.Context, project, path string) ([]byte, error)
return body, nil
}
-// discoverPrometheus resolves the per-instance Prometheus address.
-// 9090 is the CONTAINER-internal port; the host port is whatever
-// Docker ephemerally assigned, captured into state.Ports["prometheus_ui"]
-// during `localnet up`.
-//
-// The project→state reverse lookup goes through the authoritative
-// registry index (not a brittle "canton-" prefix trim), and the
-// whole result is cached for 5s — without the cache, the Metrics
-// screen polling 9 charts every 5s would fire 9 `docker compose ps`
-// subprocesses per second per instance. The host:port pair doesn't
-// change between Prometheus restarts.
+// discoverPrometheus resolves the per-instance Prometheus host:port, cached
+// for 5s so the Metrics screen's 9-chart 5s polling doesn't fire 9 `docker
+// compose ps` per second per instance.
func discoverPrometheus(ctx context.Context, project string) (string, int, error) {
if host, port, err, ok := lookupPromCache(project); ok {
return host, port, err
}
- // Shared host-level stack first: when this project's instance
- // is registered with it and the stack is up, every metrics surface
- // reads from the one shared Prometheus. Falls through to the
- // per-instance Prometheus below otherwise (no regression for
- // instances brought up before the shared stack existed).
+ // Shared host-level stack first; falls through to per-instance below.
if st, err := registry.LookupByComposeProject(project); err == nil {
if localnet.InstanceObservabilityEnabled(st.Name) {
if h, p, e := localnet.SharedPrometheusEndpoint(ctx); e == nil {
- // scope = instance name: the shared Prometheus scrapes
- // every instance, so headlines/charts must filter to one.
+ // scope = instance name: the shared Prometheus scrapes every
+ // instance, so headlines/charts must filter to one.
storePromCache(project, h, p, st.Name, nil)
return h, p, nil
}
@@ -484,8 +396,7 @@ func discoverPrometheus(ctx context.Context, project string) (string, int, error
}
}
if !running {
- // Cache the negative result too — observability-off
- // screens hammer this just as hard as -on screens.
+ // Cache the negative too — obs-off screens hammer this just as hard.
storePromCache(project, "", 0, "", errPrometheusNotRunning)
return "", 0, errPrometheusNotRunning
}
@@ -503,20 +414,15 @@ func discoverPrometheus(ctx context.Context, project string) (string, int, error
return "127.0.0.1", port, nil
}
-// promCache caches the (host, port) discovery result with a short
-// TTL so the Metrics screen's 9-chart, 5-second polling cadence
-// doesn't run 1.8 docker-compose-ps subprocesses per second per
-// instance. TTL chosen to match the polling interval — a stopped
-// Prometheus surfaces within one tick.
+// promCacheTTL matches the polling interval so a stopped Prometheus surfaces
+// within one tick.
const promCacheTTL = 5 * time.Second
type promCacheEntry struct {
host string
port int
- // scope is the instance label the chart/summary queries should filter
- // by when this project is served by the shared multi-instance stack
- // (the instance name), or "" for the per-instance Prometheus. Stored
- // alongside host:port so promScopeFor reads the same cached decision.
+ // scope is the instance label to filter by (shared stack) or "" (per-
+ // instance), stored so promScopeFor reads the same cached decision.
scope string
err error
expires time.Time
@@ -537,11 +443,8 @@ func lookupPromCache(project string) (host string, port int, err error, ok bool)
return e.host, e.port, e.err, true
}
-// lookupPromScope returns the instance label the cached discovery decided
-// the queries should filter by (the shared multi-instance stack), or ""
-// for the per-instance path or a missing/expired entry. Same cache as
-// discoverPrometheus so the summary's reported scope and the endpoint the
-// charts query can't disagree within a TTL.
+// lookupPromScope returns the cached discovery's scope label, or "" for the
+// per-instance path or a missing/expired entry.
func lookupPromScope(project string) string {
promCacheMu.Lock()
defer promCacheMu.Unlock()
From f08813eb5972b9c843dee740a449d58e6a96729b Mon Sep 17 00:00:00 2001
From: srikanth-bitdynamics
<259878899+srikanth-bitdynamics@users.noreply.github.com>
Date: Fri, 10 Jul 2026 22:25:37 +0530
Subject: [PATCH 07/14] refactor: compress running-commentary comments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Cut the narration-track comments across the Web UI and the token-demo
backend — redesign-history headers, step-by-step narration, section
banners, and prose that merely restated the code — down to load-bearing
why. Kept and tightened the genuine rationale: concurrency/ordering
constraints, accessibility reasons, the iframe-sandbox security note, and
the Splice-0.6.x substitute-metric notes the accuracy bar depends on.
Comments only (plus one empty catch collapsed to a single line). The
frontend type-checks and the full Go suite passes.
---
frontend/src/App.tsx | 13 +-
frontend/src/components/Button.tsx | 22 +--
frontend/src/components/ConfirmDialog.tsx | 8 +-
frontend/src/components/MetricCard.tsx | 14 +-
frontend/src/components/MonoId.tsx | 11 +-
frontend/src/components/Skeleton.tsx | 10 +-
frontend/src/components/StatusBadge.tsx | 9 +-
frontend/src/components/icons.tsx | 15 +-
frontend/src/index.css | 70 +++------
frontend/src/main.tsx | 3 +-
frontend/src/screens/AgentSkillsScreen.tsx | 8 +-
frontend/src/screens/BackupRestore.tsx | 37 +----
frontend/src/screens/ContainerHealth.tsx | 32 +---
frontend/src/screens/ContainerLogsModal.tsx | 19 +--
frontend/src/screens/ContractDetailDrawer.tsx | 38 +----
frontend/src/screens/CreateLocalNetModal.tsx | 138 ++++--------------
frontend/src/screens/CreatingPanel.tsx | 41 ++----
frontend/src/screens/DARDiff.tsx | 8 +-
frontend/src/screens/DARPackageTree.tsx | 14 +-
frontend/src/screens/DARScreen.tsx | 88 +++--------
frontend/src/screens/Dashboard.test.tsx | 56 +------
frontend/src/screens/Dashboard.tsx | 42 ++----
frontend/src/screens/DeveloperSetup.tsx | 30 +---
frontend/src/screens/DoctorScreen.tsx | 38 +----
frontend/src/screens/ExplorerScreen.tsx | 136 +++++------------
frontend/src/screens/InstanceDetail.test.tsx | 36 +----
frontend/src/screens/InstanceDetail.tsx | 73 ++-------
frontend/src/screens/MetricsScreen.tsx | 112 +++-----------
frontend/src/screens/Placeholder.tsx | 4 +-
frontend/src/screens/TokensScreen.tsx | 129 ++++------------
frontend/src/screens/TxReplayDrawer.tsx | 20 +--
frontend/src/screens/WalletScreen.tsx | 61 ++------
frontend/src/shell/CommandPalette.tsx | 23 +--
frontend/src/shell/ErrorBoundary.tsx | 34 ++---
frontend/src/shell/Shell.tsx | 50 ++-----
frontend/src/theme.ts | 11 +-
frontend/src/tokens.ts | 30 +---
internal/cli/localnet/token/demo.go | 9 +-
internal/localnet/token/demo.go | 94 ++++--------
internal/localnet/token/demo_test.go | 20 +--
40 files changed, 356 insertions(+), 1250 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 850172f0..2fbe896b 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -16,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",
@@ -50,16 +49,14 @@ export function App() {
- {/* One confirm-dialog host for the whole app; confirmDialog()
- from anywhere resolves against it. */}
+ {/* 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 dc40ea69..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 (accent
-// 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
index 79a198c4..1030007f 100644
--- a/frontend/src/components/ConfirmDialog.tsx
+++ b/frontend/src/components/ConfirmDialog.tsx
@@ -1,9 +1,6 @@
-// In-app confirm dialog. Promise-based so call sites stay a one-liner:
-//
+// Promise-based confirm dialog:
// if (!(await confirmDialog({ title, body, confirmLabel, danger }))) return;
-//
-// A single ConfirmHost (mounted in App) listens for the event
-// confirmDialog() dispatches and owns the open state.
+// A single ConfirmHost (mounted in App) handles the dispatched event.
import { useEffect, useState } from "react";
import { W, wMono, wSans, R, EASE, FAST } from "../tokens";
@@ -11,7 +8,6 @@ import { Button } from "./Button";
export interface ConfirmOptions {
title: string;
- /** Plain-language consequence. Rendered as-is (string). */
body: string;
/** Optional monospace detail line (the exact command / effect). */
detail?: string;
diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx
index cb29510d..91485ff6 100644
--- a/frontend/src/components/MetricCard.tsx
+++ b/frontend/src/components/MetricCard.tsx
@@ -3,25 +3,16 @@ import { Sparkline } from "./charts/Sparkline";
import { IcArrowUp } from "./icons";
import { W, wMono, wideCaps, R } from "../tokens";
-// MetricCard — the 4-up strip at the top of the Metrics screen.
-// One headline number + a delta vs the prior window + an inline
-// sparkline so the value reads against its trend.
-//
-// Loading and error states are first-class — when the upstream
-// PromQL fetch is in flight the card shows a skeleton; when it
-// fails the card shows the error without taking down the whole
-// grid.
+// Headline number + delta vs prior window + inline sparkline.
export interface MetricCardProps {
title: string;
unit?: string;
- /** Current value (the big number). undefined → loading. */
+ /** undefined → loading. */
value: number | undefined;
/** Delta vs prior window. undefined hides the badge. */
delta?: number;
- /** "up arrow good" or "down arrow good" — affects delta colour. */
deltaPolarity?: "up-is-good" | "down-is-good" | "neutral";
- /** Tiny chart embedded in the card. */
sparkline?: Point[];
sparklineColor?: string;
/** When set, replaces the value + sparkline with the error message. */
@@ -63,7 +54,6 @@ export function MetricCard({
minWidth: 0,
}}
>
- {/* Stat label row — label left, delta chip right (>=8px apart). */}
{
- // clipboard can be unavailable (non-localhost http) or denied; ignore
- // both the throw and the rejection so a failed copy is a no-op.
+ // clipboard may be unavailable (non-localhost http) or denied; failed copy is a no-op.
try {
navigator.clipboard?.writeText(value).catch(() => {});
setCopied(true);
diff --git a/frontend/src/components/Skeleton.tsx b/frontend/src/components/Skeleton.tsx
index 8b2b9e33..3f28e7ca 100644
--- a/frontend/src/components/Skeleton.tsx
+++ b/frontend/src/components/Skeleton.tsx
@@ -1,11 +1,9 @@
-// Skeleton — loading placeholders shaped like the real table so content
-// arrives in place without a layout shift. A short delay avoids a
-// flicker on fast local fetches.
+// Loading placeholders shaped like the real table to avoid layout shift.
import { useEffect, useState, type CSSProperties } from "react";
import { W, R } from "../tokens";
-// Returns true only after `ms`, so a fast fetch never flashes a skeleton.
+// 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(() => {
@@ -36,7 +34,6 @@ export function SkeletonBar({
width,
height,
borderRadius: R.control,
- // Ramp between two neutral surfaces so it reads on either theme.
background: `linear-gradient(90deg, ${W.surface2} 25%, ${W.rowHover} 50%, ${W.surface2} 75%)`,
backgroundSize: "220% 100%",
animation: "cdk-shimmer 1.4s ease-in-out infinite",
@@ -46,8 +43,7 @@ export function SkeletonBar({
);
}
-// Pass the same relative column widths the real table uses so the
-// skeleton lines up with it.
+// Pass the real table's column widths so the skeleton lines up.
export function SkeletonTable({
columns,
rows = 4,
diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx
index b95220a7..bb8c4047 100644
--- a/frontend/src/components/StatusBadge.tsx
+++ b/frontend/src/components/StatusBadge.tsx
@@ -1,5 +1,4 @@
-// StatusBadge — single renderer for instance / container / stream status.
-// Always pairs a colored dot with a text label so color is never the only cue.
+// 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";
@@ -7,7 +6,6 @@ import { Dot } from "./icons";
type Tone = "ok" | "warn" | "danger" | "muted";
-// Known statuses map to a label + tone; unknown values render muted.
const MAP: Record
= {
running: { label: "Running", tone: "ok" },
healthy: { label: "Healthy", tone: "ok" },
@@ -24,7 +22,6 @@ const MAP: Record = {
failed: { label: "Failed", tone: "danger" },
error: { label: "Error", tone: "danger" },
dead: { label: "Dead", tone: "danger" },
- // Explorer stream states.
live: { label: "Live", tone: "ok" },
reconnecting: { label: "Reconnecting", tone: "warn" },
truncated: { label: "Truncated", tone: "warn" },
@@ -50,10 +47,8 @@ function resolve(status: string): { label: string; color: string } {
interface StatusBadgeProps {
status: string;
- /** "text" = dot + colored label (tables, detail rows);
- * "pill" = bordered tinted chip (topbar, cards). */
+ /** "text" = dot + label; "pill" = bordered tinted chip. */
variant?: "text" | "pill";
- /** Pulse the dot (in-flight states). */
pulse?: boolean;
style?: CSSProperties;
}
diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx
index faa73fd0..b24e0927 100644
--- a/frontend/src/components/icons.tsx
+++ b/frontend/src/components/icons.tsx
@@ -1,11 +1,5 @@
-// The single icon system for the Web UI — 16×16 stroke glyphs drawn
-// with currentColor so they inherit the text color of whatever they
-// sit in. Replaces the mixed emoji/unicode controls (⚡ 🔥 ⏸ ↻ …)
-// that read as prototype polish.
-//
-// Usage: 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,8 +142,6 @@ export const IcDroplet = (p: IconProps) => (
);
-// ---- Navigation glyphs (sidebar) ----
-
export const IcOverview = (p: IconProps) => (
@@ -210,8 +202,6 @@ export const IcAgent = (p: IconProps) => (
);
-// ---- Topbar glyphs ----
-
export const IcSun = (p: IconProps) => (
@@ -238,7 +228,6 @@ export const IcBook = (p: IconProps) => (
);
-/** Status dot — the only full-radius element in the system. */
export function Dot({
color,
size = 6,
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f0805025..82f2eff2 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,14 +1,8 @@
-/* Base styles + the design-system token sheet. Every semantic color
- lives here as a CSS variable with a dark default (:root) and a light
- override (:root[data-theme="light"]), so tokens.ts W.* references
- resolve per-theme with no per-screen change. Components own layout
- via tokens.ts; this file owns fonts, the token sheet, focus rings,
- hover states, and keyframes. */
-
-/* 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");
@@ -43,7 +37,7 @@
font-display: swap;
}
-/* ---- Raw ramps (theme-independent) ---- */
+/* Raw ramps (theme-independent). */
:root {
--gray-25: #fcfcfd; --gray-50: #f7f8fa; --gray-100: #eff1f5;
--gray-200: #e2e5ec; --gray-300: #cdd2dd; --gray-400: #9ba3b5;
@@ -56,15 +50,11 @@
--teal-300: #7bd2c6; --teal-500: #189e8c;
--green-500: #2e9e5b; --amber-500: #d89117; --red-500: #d24a38;
- /* Motion — quick, damped, no bounce. */
--ease-out: cubic-bezier(0.2, 0.6, 0.2, 1);
--duration-fast: 120ms;
}
-/* ---- Dark theme (default) — Carbon Slate: true-neutral graphite +
- one indigo-violet accent. Accent hue sits well off the near-zero-hue
- neutrals, so action never collides with chrome. All pairs verified
- WCAG AA. ---- */
+/* Dark theme (default) — Carbon Slate. All pairs verified WCAG AA. */
:root {
color-scheme: dark;
--bg-page: #0f1012; --bg-sunken: #0b0c0e; --bg-surface: #16171a;
@@ -86,7 +76,7 @@
--dot-grid: radial-gradient(circle at 1px 1px, #2a2c31 1px, transparent 1px);
}
-/* ---- Light theme — Carbon Slate ---- */
+/* Light theme — Carbon Slate. */
:root[data-theme="light"] {
color-scheme: light;
--bg-page: #fbfbfc; --bg-sunken: #f7f7f8; --bg-surface: #ffffff;
@@ -143,8 +133,7 @@ a {
color: var(--text-primary);
}
-/* Themed scrollbars — a raised thumb on a transparent track, so the
- chrome doesn't read as OS-default grey against either theme. */
+/* Themed scrollbars — raised thumb on a transparent track. */
::-webkit-scrollbar {
width: 11px;
height: 11px;
@@ -163,14 +152,8 @@ a {
background: transparent;
}
-/* 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 accent outline (blue-500 — identical in light and dark), offset
- * 2px so it doesn't merge into the element's own border. */
+/* 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;
}
@@ -181,10 +164,8 @@ a {
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: flex;
align-items: center;
@@ -210,7 +191,6 @@ a {
font-weight: 500;
}
-/* The nav icon dims with the label and lights up on the active row. */
.side-nav-link svg {
color: var(--text-faint);
flex: none;
@@ -222,10 +202,8 @@ a {
color: var(--accent);
}
-/* Button system (components/Button.tsx). Hover/active tints live
- * here because inline style objects can't express :hover.
- * primary = the solid accent CTA (white text, both themes);
- * secondary = bordered surface; ghost = quiet; danger = filled red. */
+/* Button system (components/Button.tsx) — hover/active tints live here
+ * since inline styles can't express :hover. */
.bd-btn {
appearance: none;
display: inline-flex;
@@ -313,10 +291,8 @@ 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;
@@ -336,10 +312,7 @@ 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; }
@@ -351,16 +324,13 @@ a {
to { opacity: 1; transform: none; }
}
-/* Skeleton shimmer (components/Skeleton). A slow sweep, gated below by
- prefers-reduced-motion. */
+/* Skeleton shimmer (components/Skeleton), gated below by prefers-reduced-motion. */
@keyframes cdk-shimmer {
0% { background-position: -180% 0; }
100% { background-position: 180% 0; }
}
-/* Respect prefers-reduced-motion — the pulse is ambient and
- not load-bearing for the state communication (color carries
- the signal too). */
+/* prefers-reduced-motion: color still carries the state signal. */
@media (prefers-reduced-motion: reduce) {
@keyframes pulse {
0%, 100% { opacity: 1; }
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 49f29fb8..6466e9b4 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -5,8 +5,7 @@ import "./index.css";
import { App } from "./App";
import { initTheme } from "./theme";
-// Apply the persisted theme before the first paint so there is no
-// flash of the wrong palette.
+// 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 75f62509..12332e2a 100644
--- a/frontend/src/screens/AgentSkillsScreen.tsx
+++ b/frontend/src/screens/AgentSkillsScreen.tsx
@@ -9,10 +9,8 @@ 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 */}
` 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({
@@ -106,9 +83,6 @@ export function BackupRestore({ instanceName }: Props) {
return (
- {/* Download row */}
- {/* Download error banner */}
{downloadError && (
)}
- {/* Restore row */}
void onFileChosen(e.target.files?.[0] ?? null)}
/>
- {/* Options row */}
- {/* Result banner */}
{restore.kind === "success" && (
({ 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);
@@ -51,7 +43,6 @@ export function ContainerHealth({ name }: { name: string }) {
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: ` +
@@ -187,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,
@@ -215,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);
};
@@ -246,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",
@@ -332,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;
@@ -352,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 b2debd83..d6318094 100644
--- a/frontend/src/screens/ContainerLogsModal.tsx
+++ b/frontend/src/screens/ContainerLogsModal.tsx
@@ -4,11 +4,9 @@ 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;
@@ -230,9 +224,6 @@ const modalStyle: React.CSSProperties = {
width: "min(900px, 95vw)",
height: "min(700px, 88vh)",
background: W.surface,
- // One depth technique: a slightly stronger hairline, no competing
- // box-shadow — matches the ConfirmDialog treatment so every overlay
- // in the console separates from the page the same way.
border: `1px solid ${W.borderHi}`,
borderRadius: R.card,
display: "flex",
diff --git a/frontend/src/screens/ContractDetailDrawer.tsx b/frontend/src/screens/ContractDetailDrawer.tsx
index 8429021d..95b07fc3 100644
--- a/frontend/src/screens/ContractDetailDrawer.tsx
+++ b/frontend/src/screens/ContractDetailDrawer.tsx
@@ -11,29 +11,16 @@ 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;
}
@@ -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;
@@ -125,11 +111,9 @@ export function ContractDetailDrawer({
right: 0,
bottom: 0,
width: "min(480px, 92vw)",
- // Raised surface — a fixed overlay sits above the page, so
- // surface-on-page would read too flat against it.
background: W.surface2,
borderLeft: `1px solid ${W.borderHi}`,
- // Below the CommandPalette (zIndex 100) but above page content.
+ // Below the CommandPalette (zIndex 100), above page content.
zIndex: 40,
overscrollBehavior: "contain",
overflowY: "auto",
@@ -324,16 +308,13 @@ 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 for a link label — the suffix is the
-// discriminating part, so keep both ends (matches MonoId's discipline).
+// 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)}`;
@@ -422,10 +403,7 @@ function PartyChip({ party, kind }: { party: string; kind: "sig" | "obs" }) {
);
}
-// 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,
diff --git a/frontend/src/screens/CreateLocalNetModal.tsx b/frontend/src/screens/CreateLocalNetModal.tsx
index 30c30c17..59a5e574 100644
--- a/frontend/src/screens/CreateLocalNetModal.tsx
+++ b/frontend/src/screens/CreateLocalNetModal.tsx
@@ -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" }
@@ -928,8 +879,6 @@ function ErrorBody({
);
}
-// ── pieces ────────────────────────────────────────────────────────
-
function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
if (banner.kind === "running") {
return (
@@ -1014,7 +963,6 @@ function BannerStripe({ banner }: { banner: ProgressState["banner"] }) {
);
}
- // cancelled
return (
in every state — disabled placeholder while loading/on
-// error, populated when versions arrive. A free-text fallback would let
-// arbitrary tags route silently to the upstream-resolution path the
-// curated dropdown exists to prevent.
-//
-// Sort order: "latest" first, then descending semver, so the newest
-// catalogued releases sit near the top.
-//
-// Exported so VersionPicker.test.tsx can render the component in
-// isolation and pin the "always a
, never a textbox" invariant.
+// Always a , never free text: a textbox would route arbitrary
+// tags to the upstream-resolution path the curated dropdown prevents.
+// Sorted "latest" first, then descending semver.
export function VersionPicker({
versions,
selected,
@@ -1140,8 +1080,6 @@ export function VersionPicker({
error?: string | null;
}) {
if (versions.length === 0) {
- // Distinct placeholders so a failed fetch doesn't render as an
- // endless "Loading…".
let placeholder = "No curated versions available";
if (loading) placeholder = "Loading curated versions…";
else if (error) placeholder = `Couldn't load versions: ${error}`;
@@ -1182,14 +1120,9 @@ export function VersionPicker({
);
}
-// 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,13 +1169,9 @@ 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,
@@ -1260,11 +1187,6 @@ const selectStyle: React.CSSProperties = {
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") {
@@ -1327,7 +1249,6 @@ function PreflightPanel({ state }: { state: PreflightState }) {
? "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 (
{
const t = setInterval(() => force((n) => n + 1), 1000);
@@ -1485,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 +1420,6 @@ const overlayStyle: React.CSSProperties = {
const modalStyle: React.CSSProperties = {
width: "min(680px, 92vw)",
background: W.surface,
- // Overlay depth matched to the confirm dialog + palette: hairline
- // border + one subtle shadow, not a hard border AND a heavy shadow.
border: `1px solid ${W.border}`,
borderRadius: R.dialog,
boxShadow: "0 10px 32px rgba(0,0,0,0.24)",
diff --git a/frontend/src/screens/CreatingPanel.tsx b/frontend/src/screens/CreatingPanel.tsx
index 83cc179a..53e5fdbf 100644
--- a/frontend/src/screens/CreatingPanel.tsx
+++ b/frontend/src/screens/CreatingPanel.tsx
@@ -16,25 +16,14 @@ import {
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;
}
@@ -42,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();
@@ -62,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);
@@ -75,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();
}
}
@@ -304,8 +286,6 @@ function BannerPill({
banner: ProgressState["banner"];
zombie: boolean;
}) {
- // Route the bring-up banner through the shared StatusBadge so the
- // creating panel reads the same as every other status in the console.
if (zombie) {
return ;
}
@@ -317,7 +297,6 @@ function BannerPill({
case "cancelled":
return ;
default:
- // Live SSE stream: pulse the dot to signal in-progress.
return ;
}
}
diff --git a/frontend/src/screens/DARDiff.tsx b/frontend/src/screens/DARDiff.tsx
index d31bf973..685ef2da 100644
--- a/frontend/src/screens/DARDiff.tsx
+++ b/frontend/src/screens/DARDiff.tsx
@@ -1,9 +1,5 @@
-// 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,
diff --git a/frontend/src/screens/DARPackageTree.tsx b/frontend/src/screens/DARPackageTree.tsx
index a6c966bf..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,
@@ -15,9 +12,8 @@ import { W, wMono, R, tint } from "../tokens";
import { MonoId } from "../components/MonoId";
import { IcChevronDown, IcChevronRight } from "../components/icons";
-// Middle-truncate for ids rendered INSIDE a toggle button, where a
-// full MonoId (itself a button) would nest interactive elements. Keeps
-// the discriminating suffix visible instead of a tail-only slice.
+// 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)}`;
@@ -47,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]));
})
diff --git a/frontend/src/screens/DARScreen.tsx b/frontend/src/screens/DARScreen.tsx
index 0f942f9a..0b0e6e15 100644
--- a/frontend/src/screens/DARScreen.tsx
+++ b/frontend/src/screens/DARScreen.tsx
@@ -29,19 +29,9 @@ import {
import { DARPackageTree } from "./DARPackageTree";
import { DARDiff } from "./DARDiff";
-// DARScreen — three-column DAR manager:
-// LEFT (320px) drag-drop upload + per-participant vetting toggles
-// + Watch-mode card
-// MIDDLE package list (Package · Version · Package-id ·
-// Vetting)
-// RIGHT (360px) inspect drawer with package tree / structural diff
-//
-// Vetting is live end-to-end: the package-list column (VettingCell)
-// and the inspect-drawer toggles (VettingPanel) both read
-// per-participant state from GET …/dar/{id}/vetting and POST to the
-// vet/unvet endpoint. The Watch-mode card reflects SSE events from a
-// `dpm localnet dar watch` process when one is running, and stays
-// "Idle" otherwise.
+// Three-column DAR manager: upload + vetting + watch (left), package
+// list (middle), inspect tree / structural diff (right). Vetting is
+// live per-participant end-to-end.
const ROLES: Role[] = ["app-user", "app-provider", "sv"];
@@ -56,11 +46,8 @@ export function DARScreen() {
const sel = useInstanceSelection();
const name = sel.selected;
const [role, setRole] = useState("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,
@@ -76,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);
@@ -141,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) {
@@ -183,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-") &&
@@ -194,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;
@@ -233,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]);
@@ -299,7 +273,6 @@ export function DARScreen() {
alignItems: "start",
}}
>
- {/* LEFT — upload + vetting + watch mode */}
- {/* MIDDLE — package list */}
- {/* 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(() => {
@@ -643,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`;
@@ -653,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[] }
@@ -716,10 +680,8 @@ function PkgRow({
);
}
-// 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 (
@@ -884,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,
@@ -930,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,
@@ -1102,10 +1060,8 @@ function UploadProgress({
);
}
-// UploadResultBanner renders the per-participant outcome of a
-// multi-target upload. Partial failures still return 200 from the
-// backend, so they land here (not the error banner) and the user sees
-// what landed and what didn't.
+// Per-participant outcome of a multi-target upload. Partial failures
+// still return 200, so they land here, not the error banner.
function UploadResultBanner({
kind,
total,
@@ -1332,8 +1288,6 @@ function FilterBtn({
);
}
-// ─── Tiny shared primitives ─────────────────────────────────
-
function Card({
title,
subtitle,
@@ -1463,9 +1417,7 @@ function Row({
);
}
-// DARListLoading is the middle package-list skeleton: same four-column
-// rhythm as the real list, so rows arrive in place instead of popping
-// in after a bare "Loading…". Gated so a fast local fetch never flashes.
+// Package-list skeleton, gated so a fast local fetch never flashes it.
function DARListLoading() {
const show = useLoadingDelay(true);
return (
diff --git a/frontend/src/screens/Dashboard.test.tsx b/frontend/src/screens/Dashboard.test.tsx
index 9d9870bd..2e1d3791 100644
--- a/frontend/src/screens/Dashboard.test.tsx
+++ b/frontend/src/screens/Dashboard.test.tsx
@@ -5,16 +5,6 @@ import { MemoryRouter } from "react-router-dom";
import { Dashboard } from "./Dashboard";
import { InstanceSelectionProvider } from "../shell/useInstanceSelection";
-// Dashboard tests — the user-facing states for the Overview
-// screen. Pin the table-rendering + click-to-select wiring +
-// the empty/error fallbacks; the InstanceTable's status badge
-// is implementation detail not worth testing in isolation.
-//
-// Three classes of state the user sees:
-// 1. ok with instances → table + InstanceDetail + DeveloperSetup
-// 2. ok with empty list → EmptyState ("run dpm localnet up")
-// 3. error → ErrorPanel with the message
-
function mockListResponse(
instances: Array<{ name: string; status: string }> | "error",
warning?: string,
@@ -23,7 +13,6 @@ function mockListResponse(
vi.stubGlobal(
"fetch",
vi.fn().mockImplementation((url: string) => {
- // /api/instances/:name detail — for InstanceDetail card.
if (url.match(/\/api\/instances\/[^/?]+(?:\?|$)/)) {
return Promise.resolve(
new Response(
@@ -43,9 +32,7 @@ function mockListResponse(
),
);
}
- // /api/instances/{name}/containers — ContainerHealth's
- // 3s poll. Return empty list so the panel renders the
- // "no containers" placeholder rather than the error path.
+ // Empty list so ContainerHealth renders its placeholder, not the error path.
if (url.match(/\/api\/instances\/[^/?]+\/containers/)) {
return Promise.resolve(
new Response(
@@ -63,8 +50,7 @@ function mockListResponse(
),
);
}
- // /api/instances/{name}/transactions — the RecentActivity
- // panel's ledger-event scan, fired only for a running instance.
+ // RecentActivity's ledger-event scan, fired only for a running instance.
if (url.includes("/transactions")) {
if (txOverride) {
return Promise.resolve(
@@ -99,7 +85,6 @@ function mockListResponse(
),
);
}
- // /api/instances list — primary fetch.
if (url.includes("/api/instances")) {
if (instances === "error") {
return Promise.resolve(
@@ -130,9 +115,7 @@ function mockListResponse(
),
);
}
- // JWT + app-config — DeveloperSetup fires these once the
- // instance is selected. Return minimal payloads to keep
- // the components happy.
+ // DeveloperSetup fires these once an instance is selected.
if (url.includes("/jwt")) {
return Promise.resolve(
new Response(
@@ -177,17 +160,12 @@ 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 — StatusBadge renders Title-Case
- // labels so the dot is never the only cue.
const table = screen.getByRole("table");
expect(within(table).getByText("Running")).toBeInTheDocument();
expect(within(table).getByText("Stopped")).toBeInTheDocument();
@@ -199,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();
});
@@ -213,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",
@@ -235,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();
});
});
@@ -260,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();
});
@@ -271,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(),
);
@@ -293,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 db17fe03..ae21898e 100644
--- a/frontend/src/screens/Dashboard.tsx
+++ b/frontend/src/screens/Dashboard.tsx
@@ -19,17 +19,11 @@ 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);
- // Gate the skeleton so a fast local fetch never flashes it.
const showSkeleton = useLoadingDelay(sel.loading);
return (
@@ -46,8 +40,7 @@ export function Dashboard() {
LocalNet instances
}
onClick={() => setCreateOpen(true)}
@@ -61,10 +54,8 @@ export function Dashboard() {
onClose={useCallback(() => setCreateOpen(false), [])}
onCreated={useCallback(
(name: string) => {
- // Refresh the list and promote the new instance to the
- // URL-driven selection so the detail card pops when the
- // modal closes. useCallback'd so the modal's done-effect
- // doesn't see a new identity each render and refire.
+ // useCallback'd so the modal's done-effect keeps a stable
+ // identity and doesn't refire each render.
sel.refresh();
sel.select(name);
},
@@ -122,9 +113,8 @@ export function Dashboard() {
)}
{sel.selected && (() => {
- // Mid-bring-up: show the live progress panel above the static
- // detail and hide the JWT generator — no point signing tokens
- // for an instance that isn't running yet.
+ // While creating, show the live progress panel and hide the JWT
+ // generator — no point signing tokens before it's running.
const selectedRow = sel.instances.find((i) => i.name === sel.selected);
const isCreating = selectedRow?.status === "creating";
return (
@@ -189,8 +179,7 @@ function InstanceTable({ instances, selected, onSelect }: InstanceTableProps) {
onClick={() => onSelect(i.name)}
style={{
borderTop: `1px solid ${W.border}`,
- // Flat active fill — no accent side-bar, no padding
- // swap, so the row never shifts on selection.
+ // Flat fill, no padding swap, so the row never shifts on select.
background: isSel ? W.selRow : undefined,
cursor: "pointer",
}}
@@ -216,8 +205,6 @@ function InstanceTable({ instances, selected, onSelect }: InstanceTableProps) {
);
}
-// Table loading placeholder — mirrors the four-column instance table so
-// rows arrive in place instead of jumping in after a spinner.
function InstanceTableLoading() {
return (
: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 d356d748..ca0e3aab 100644
--- a/frontend/src/screens/DeveloperSetup.tsx
+++ b/frontend/src/screens/DeveloperSetup.tsx
@@ -11,26 +11,11 @@ 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);
@@ -223,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;
@@ -319,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 ebf8a079..fc2a56aa 100644
--- a/frontend/src/screens/DoctorScreen.tsx
+++ b/frontend/src/screens/DoctorScreen.tsx
@@ -12,40 +12,25 @@ 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;
};
@@ -71,8 +56,6 @@ export function DoctorScreen() {
};
}, []);
- // Re-run whenever the selected version changes (including the first
- // mount with the default "latest").
useEffect(() => run(version), [run, version]);
return (
@@ -98,9 +81,6 @@ export function DoctorScreen() {
);
}
-// DoctorError — the endpoint failed. Give a plain-language cause, a
-// Retry, and tuck the raw server message behind a disclosure so the
-// screen leads with what to do, not a stack-shaped string.
function DoctorError({
message,
onRetry,
@@ -148,8 +128,6 @@ function DoctorError({
);
}
-// DoctorLoading mirrors the section-of-rows shape the report renders so
-// content lands in place instead of popping in under a "Running…" line.
function DoctorLoading() {
const shown = useLoadingDelay(true);
if (!shown) return null;
@@ -243,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"),
diff --git a/frontend/src/screens/ExplorerScreen.tsx b/frontend/src/screens/ExplorerScreen.tsx
index e799f3a6..e3e787f0 100644
--- a/frontend/src/screens/ExplorerScreen.tsx
+++ b/frontend/src/screens/ExplorerScreen.tsx
@@ -23,20 +23,9 @@ import { TX_KIND_COLOR, W, wMono, tableCaps, wideCaps, tint, R, FAST } from "../
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",
@@ -66,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) {
@@ -123,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.
}
},
[],
@@ -136,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) => {
@@ -162,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;
}
@@ -199,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);
@@ -212,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;
@@ -229,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;
@@ -250,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();
@@ -266,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();
@@ -302,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);
@@ -379,7 +349,6 @@ export function ExplorerScreen() {
alignItems: "start",
}}
>
- {/* LEFT — filter sidebar */}
- {/* CENTER — ACS table */}
- {/* Column header row */}
)}
- {/* Detail drawer — fixed right-side overlay, outside the grid */}
{state.kind === "ok" && view === "contracts" && selected && (
({ kind: "loading" });
const [openId, setOpenId] = useState(null);
const [replayId, setReplayId] = useState(null);
- // Draft filter inputs (raw strings) vs the applied filters used in
- // the fetch effect; applying on submit avoids a round-trip per
- // keystroke.
+ // Draft inputs vs applied filters; applying on submit avoids a
+ // round-trip per keystroke.
const [draft, setDraft] = useState(emptyDraft);
const [applied, setApplied] = useState({});
@@ -1022,7 +981,7 @@ function TransactionsView({ name, role }: { name: string; role: Role }) {
}, [name, role, applied]);
// Party options for the replay drawer's "visible to" selector,
- // derived from the witnesses present in the loaded rows.
+ // from witnesses in the loaded rows.
const partyOptions = useMemo(() => {
if (state.kind !== "ok") return [];
const set = new Set();
@@ -1220,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
@@ -1519,9 +1473,6 @@ function EventTreeNode({
);
}
-// 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" }
@@ -1530,8 +1481,7 @@ 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.
@@ -1611,11 +1561,8 @@ function TimelineView({ name, role }: { name: string; role: Role }) {
);
const txs = state.data.transactions;
- // Bucket updates into time slots for the strip — newest on the right.
const buckets = bucketByTime(txs, 60);
- // Selection wins over hover: once a glyph is clicked, the side
- // panel sticks to that update so the user can read the events
- // without keeping the cursor over the strip.
+ // Pinned selection wins over hover.
const focusedIdx = selectedIdx ?? hoverIdx;
const focused = focusedIdx !== null ? txs[focusedIdx] ?? null : null;
@@ -1644,7 +1591,6 @@ function TimelineView({ name, role }: { name: string; role: Role }) {
- {/* Activity strip — flat bars; height is the data encoding. */}
- {/* Event glyph row */}
- {/* Detail overlay — hovered/pinned update, fixed to the right
- edge so the timeline strip keeps its full width. */}
{focused && (
void }) {
return (
= 3 ? `${parts[1]}:${parts[2]}` : tpl;
diff --git a/frontend/src/screens/InstanceDetail.test.tsx b/frontend/src/screens/InstanceDetail.test.tsx
index e8de2fb2..d81d9c2e 100644
--- a/frontend/src/screens/InstanceDetail.test.tsx
+++ b/frontend/src/screens/InstanceDetail.test.tsx
@@ -3,13 +3,6 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
import { InstanceDetail } from "./InstanceDetail";
import { ConfirmHost } from "../components/ConfirmDialog";
-// 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
-
function mockInstanceFetch(
body: object | { status: number; error: string },
status = 200,
@@ -45,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(
@@ -153,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",
@@ -170,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("—");
});
});
@@ -231,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(
@@ -281,8 +263,7 @@ describe("InstanceDetail", () => {
const restartBtn = await screen.findByRole("button", { name: /recreate/i });
fireEvent.click(restartBtn);
- // Recreate is destructive-ish, so it routes through the in-app
- // confirm dialog. Approve it by clicking the dialog's confirm.
+ // Recreate routes through the confirm dialog; approve it.
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: /recreate/i }));
@@ -301,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 }));
@@ -343,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"),
@@ -356,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(
@@ -435,8 +412,7 @@ describe("InstanceDetail", () => {
const downBtn = await screen.findByRole("button", { name: /^Down$/ });
fireEvent.click(downBtn);
- // Down removes containers, so it routes through the in-app confirm
- // dialog. Approve it via the dialog's confirm button.
+ // Down routes through the confirm dialog; approve it.
const dialog = await screen.findByRole("dialog");
fireEvent.click(within(dialog).getByRole("button", { name: /^Down$/ }));
@@ -453,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 177189fa..405d0647 100644
--- a/frontend/src/screens/InstanceDetail.tsx
+++ b/frontend/src/screens/InstanceDetail.tsx
@@ -27,27 +27,17 @@ 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;
}
@@ -57,20 +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" });
- // Gate the loading skeleton so a fast local fetch never flashes it.
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);
@@ -101,8 +88,6 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) {
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) {
@@ -155,9 +140,7 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) {
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?.();
@@ -172,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);
@@ -203,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 });
@@ -214,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" });
}
@@ -266,9 +243,6 @@ export function InstanceDetail({ name, statusHint, onChanged }: Props) {
)}
- {/* 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") && (
{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. `mono` marks
- // the machine-string rows (ids, paths, network names) so plain-prose
- // values like status/uptime aren't forced into the monospace column.
+ // `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],
@@ -383,8 +354,6 @@ function DetailGrid({ instance }: { instance: Instance }) {
);
}
-// DetailGridLoading — same 160px / 1fr rhythm as the real grid so the
-// values slot in without a jump.
function DetailGridLoading() {
return (
{
kind: "loading" | "ok" | "err";
data?: T;
error?: string;
}
-// PromQL queries. Sourced from internal/metricsq for parity with the
-// CLI's `localnet metrics` headline; the per-template / phase /
-// heatmap queries are extensions specific to this screen.
-//
-// All metric names are the daml_* / db_client_* / jvm_* families the
-// Splice OTel reporter actually emits (verified against a live obs
-// profile). Some per-screen extensions have no direct daml_*
-// equivalent on Splice 0.6.4 — the closest functional analogue is
-// used instead, marked inline (see docs/observability.md).
const Q = {
// Substitute: indexer-update counter, same as HeadlineLedgerTPS.
throughputSeries:
"sum(rate(daml_participant_api_indexer_updates[1m])) or vector(0)",
- // Splice 0.6.4 exports the sequencing-duration histogram with only the
- // +Inf bucket (no finite `le` boundaries), so histogram_quantile()
- // returns NaN regardless of load — percentiles are not computable here.
- // The average IS (sum/count), so the latency surfaces show that instead,
- // labelled honestly as an average. In milliseconds.
+ // 0.6.4 exports this histogram with only the +Inf bucket, so
+ // histogram_quantile is NaN; use the mean (sum/count), x1000 -> ms.
avgLatency:
"1000 * sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_sum[5m])) / sum(rate(daml_sequencer_client_submissions_sequencing_duration_seconds_count[5m]))",
- // Live Splice does not expose total ACS cardinality as a stock
- // Prometheus metric. The former proxy
- // (daml_participant_api_index_db_active_contract_lookup_batch_buffer_length)
- // is no longer emitted by Splice 0.6.4 — verified absent from a live
- // instance's Prometheus. The active-contracts in-memory buffer gauge
- // is the audited ACS-related signal that exists in 0.6.4; keep UI copy
- // honest and call it a lookup buffer.
+ // No total-ACS-cardinality metric on 0.6.4 (old proxy gone); the
+ // active-contracts buffer gauge is the closest present signal.
acsLookupBuffer:
"sum(daml_participant_api_index_active_contracts_buffer_size)",
- // No daml_* command-rejection counter on Splice 0.6.4 — use the
- // user-error completion-status counter as a proxy for "things
- // the participant refused to commit". Returns 0 if not exposed.
+ // No command-rejection counter on 0.6.4; the non-OK gRPC completion
+ // counter is the substitute for refused commands. 0 if not exposed.
errorsRate:
'sum(rate(daml_grpc_server_handled_total{grpc_code!="OK"}[1m])) or vector(0)',
- // Splice 0.6.x does not expose template-grain submission counters.
- // Use the live gRPC method counter as a command-throughput fallback
- // instead of querying a non-existent `daml_commands_*` family.
+ // No template-grain submission counters on 0.6.x; the gRPC method
+ // counter is the command-throughput substitute.
commandThroughput:
"sum by (grpc_method_name) (rate(daml_grpc_server_handled_total[5m]))",
errors1m:
@@ -82,15 +56,10 @@ const Q = {
'sum by (component) (jvm_memory_used_bytes{jvm_memory_type="heap"})',
};
-// scopeQ injects instance="" into every metric selector of a chart
-// query when the summary reports a scope — i.e. when this instance is
-// served by the shared multi-instance Prometheus, so a chart shows
-// one instance, not the sum across all of them. It targets our known
-// metric-name prefixes, so it never touches function names (sum, rate,
-// histogram_quantile) or `by (...)` label lists, and composes with a
-// metric's existing label without an invalid trailing comma. An empty
-// scope (the single-instance per-instance Prometheus) returns the query
-// unchanged.
+// Injects instance="" into each metric selector so a chart shows
+// one instance on the shared multi-instance Prometheus. Matches only
+// metric-name prefixes, so it skips function names and `by (...)` lists;
+// empty scope returns the query unchanged.
export function scopeQ(query: string, scope: string): string {
if (!scope) return query;
const inst = `instance="${scope}"`;
@@ -143,11 +112,6 @@ export function MetricsScreen() {
useEffect(() => {
if (!name) return;
- // An AbortSignal (not a boolean flag) reaches in-flight loaders:
- // fetch aborts mid-flight and loaders short-circuit on
- // signal.aborted, so nothing setStates on an unmounted component.
- // Polling is gated on document.visibilityState — no point
- // hammering Prometheus when the tab is hidden.
let outer: AbortController | null = null;
const tick = async () => {
// Abort the prior tick's in-flight requests — a slow query from
@@ -155,8 +119,6 @@ export function MetricsScreen() {
outer?.abort();
outer = new AbortController();
const signal = outer.signal;
- // Instance label to scope the chart queries by — set when the
- // summary reports we're reading the shared multi-instance stack.
let scope = "";
try {
const s = await fetchMetricsSummary(name, signal);
@@ -232,11 +194,9 @@ export function MetricsScreen() {
};
}, [name]);
- // These memos MUST sit above every conditional return below so hook
- // order is stable across the (!name) and (observabilityOff)
- // early-exit paths — rules of hooks.
+ // These memos must sit above every conditional return so hook order
+ // is stable across the early-exit paths (rules of hooks).
const tpsDelta = useMemo(() => deltaFromSeries(throughputSeries.data), [throughputSeries.data]);
- // avgLatency is already in ms — no unit scaling for the delta.
const latencyDelta = useMemo(() => deltaFromSeries(latencySeries.data), [latencySeries.data]);
const acsDelta = useMemo(() => deltaFromSeries(acsSeries.data), [acsSeries.data]);
const errDelta = useMemo(() => deltaFromSeries(errorsSeries.data), [errorsSeries.data]);
@@ -262,8 +222,6 @@ export function MetricsScreen() {
{
- // Clearing the empty state lets the ongoing 5s poll
- // repopulate from the newly-running Prometheus.
setObservabilityOff(null);
}}
/>
@@ -272,16 +230,14 @@ export function MetricsScreen() {
}
const m = summary.data?.metrics;
- // The backend latency.p99_ms is histogram_quantile-derived and NaN on
- // Splice 0.6.4 (no finite buckets); use the computable average from the
- // frontend series instead — its latest point, already in ms.
+ // Backend p99_ms is NaN on 0.6.4 (no finite buckets); use the
+ // computable average series' latest point, already in ms.
const latencyValue = latencySeries.data?.points.at(-1)?.v;
return (
- {/* 2-col chart grid */}
- {/* Latency headline triplet — mirrors `dpm localnet metrics` text
- output so CLI and UI agree on the curated quantiles. Splice 0.6.4
- exports the histogram with only the +Inf bucket, so these
- percentiles are NaN there; hide the strip rather than show three
- dashes. It reappears on any version whose histogram carries finite
- buckets. */}
+ {/* Percentiles are NaN on 0.6.4 (+Inf-only histogram); hide the
+ strip rather than show three dashes. Reappears with finite buckets. */}
{[
summary.data?.latency?.p50_ms,
summary.data?.latency?.p95_ms,
@@ -448,7 +399,6 @@ export function MetricsScreen() {
/>
)}
- {/* Top error sources — full width */}
{topErrors.kind === "err" ? (
@@ -462,16 +412,11 @@ export function MetricsScreen() {
)}
- {/* Dashboards — deep link to the bundled Grafana view. Same UID
- the CLI's text output prints, so both surfaces point at the
- same view (CLI ↔ UI parity, see CONTRIBUTING.md). */}
);
}
-// LatencyStrip surfaces the same three quantiles `dpm localnet
-// metrics` prints, making the SLA shape visible at a glance.
function LatencyStrip(props: {
p50?: number;
p95?: number;
@@ -517,9 +462,6 @@ function LatencyStrip(props: {
);
}
-// DashboardsBlock surfaces the Grafana deep link from the summary
-// handler. When the URL is empty we render the same hint as the CLI
-// rather than hiding the section, so users learn the profile exists.
function DashboardsBlock(props: { url?: string }) {
const wrap: CSSProperties = {
marginTop: 16,
@@ -601,10 +543,6 @@ function ChartCard({
);
}
-// ErrLine — a chart card's query failed. The 5 s poll re-issues the
-// query on the next tick, so this states the cause and that a retry is
-// already in flight, with the raw server message tucked behind a
-// disclosure rather than shouting a stack-shaped string.
function ErrLine({ msg }: { msg: string }) {
return (
@@ -642,8 +580,7 @@ function ObservabilityOffPanel({
setBusy(true);
setErr(null);
try {
- // Send BOTH: the Metrics screen needs Prometheus (for data)
- // AND Grafana (for the dashboards link).
+ // Prometheus for data, Grafana for the dashboards link.
await setObservability(name, { prometheus: true, grafana: true });
onEnabled();
} catch (e) {
@@ -709,10 +646,6 @@ function ObservabilityOffPanel({
);
}
-// ── Loaders ──────────────────────────────────────────────────────
-
-// isAborted treats an AbortError thrown by fetch the same as the
-// signal being already aborted at the moment we check it.
function isAborted(signal: AbortSignal, e: unknown): boolean {
if (signal.aborted) return true;
return e instanceof DOMException && e.name === "AbortError";
@@ -809,7 +742,6 @@ async function loadBars(
r as unknown as PrometheusRangeResponse,
labelFn,
);
- // For a "right now" bar chart we just want the latest value per series.
const bars: Bar[] = decoded
.map((s, i) => ({
label: s.label,
@@ -842,8 +774,6 @@ async function loadHeatmap(
r as unknown as PrometheusRangeResponse,
(m) => m.le ?? "+Inf",
);
- // Map le buckets to row indices (6 rows: <5ms, <25ms, <100ms,
- // <500ms, <2s, >2s). Skip series we don't have a row for.
const rowFor = (le: string): number | null => {
const n = Number(le);
if (!Number.isFinite(n)) return 5; // +Inf
@@ -854,7 +784,6 @@ async function loadHeatmap(
if (n <= 2) return 4;
return 5;
};
- // Determine global max for normalisation.
let max = 0;
for (const s of decoded) {
for (const p of s.points) {
@@ -880,11 +809,10 @@ async function loadHeatmap(
}
}
-// deltaFromSeries: latest minus the value 5 minutes back.
+// Latest value minus the point nearest 5 minutes back.
function deltaFromSeries(s: Series | undefined, scale = 1): number | undefined {
if (!s || s.points.length < 2) return undefined;
const last = s.points[s.points.length - 1].v * scale;
- // 5 minutes back in points: assume step is consistent; find nearest.
const targetT = s.points[s.points.length - 1].t - 5 * 60 * 1000;
let nearest = s.points[0];
let nd = Math.abs(s.points[0].t - targetT);
diff --git a/frontend/src/screens/Placeholder.tsx b/frontend/src/screens/Placeholder.tsx
index 34b59967..8133f996 100644
--- a/frontend/src/screens/Placeholder.tsx
+++ b/frontend/src/screens/Placeholder.tsx
@@ -1,8 +1,6 @@
import { W, R } from "../tokens";
-// Placeholder — the route stub for screens whose backend hasn't
-// landed yet. Swap the route in App.tsx to the real screen component
-// as each one becomes available.
+// Route stub for screens whose backend hasn't landed yet.
export function Placeholder({ name }: { name: string }) {
return (
0 ? p.slice(0, i) : p;
}
-// partyLabel prefers a registered alias over the raw prefix:
-// `app_user_v2-localparty-1::1220…` → `app-user` when aliased, else the
-// `::`-prefix fallback.
+// Prefers a registered alias over the `::`-prefix fallback.
function partyLabel(aliases: AliasMap, p: string): string {
return aliases[p] ?? shortParty(p);
}
-// Asset capability guards — gated on the machine generation tag, never
-// the human display label:
-// mint : only a native Token Standard V2 (CIP-0112) instrument we
-// created on-ledger. V1 tokens (Amulet) have no user-mint.
-// burn : no deployable token supports a standalone burn yet (needs
-// AllocationV2/DvP).
+// Capability guards keyed on the machine generation tag, not the display
+// label: mint requires a native V2 (CIP-0112) instrument created on-ledger.
export function mintDisabledReason(t: InstrumentRef): string | null {
if (t.generation !== "v2")
return `${t.symbol} (${t.standard}) has no standard mint. Use the asset's wallet UI.`;
@@ -78,19 +71,12 @@ const BURN_DISABLED_REASON =
"Burn is only available on a native CIP-0112 v2 token created on this " +
"instance. Amulet has no burn surface.";
-// TOKEN_DAR_UNAVAILABLE_HINT is the friendly remediation for the on-ledger
-// create 412 (TEST_TOKEN_DAR_UNAVAILABLE): the test-token DAR isn't
-// published for this instance's Splice version. Shared by the create modal
-// (where on-ledger create surfaces it) and the action-modal banner.
+// Remediation for the on-ledger create 412 (TEST_TOKEN_DAR_UNAVAILABLE).
const TOKEN_DAR_UNAVAILABLE_HINT =
"The test-token DAR isn't published for this instance's Splice version, so on-ledger " +
"V2 tokens can't be created here. Bring up a token-standard-v2 instance " +
"(localnet up --version token-standard-v2 --profile tokens-v2) and re-run.";
-// createErrorText maps a token-create failure to the message shown in the
-// create modal: the actionable DAR remedy for the on-ledger 412
-// (TEST_TOKEN_DAR_UNAVAILABLE), otherwise the raw server message (or a
-// generic fallback for a non-API error). Exported for unit testing.
export function createErrorText(e: unknown): string {
if (e instanceof ApiError && e.code === "TEST_TOKEN_DAR_UNAVAILABLE") {
return TOKEN_DAR_UNAVAILABLE_HINT;
@@ -98,11 +84,6 @@ export function createErrorText(e: unknown): string {
return e instanceof ApiError ? e.message : "create failed";
}
-// TokensScreen — the V2 Token Standard surface: lists every
-// instrument on the selected instance, exposes Mint / Transfer /
-// Burn / Faucet / Accept actions on each, and a Create wizard. The
-// holdings table for the selected instrument refreshes whenever the
-// user picks a row or completes a mutation.
export function TokensScreen() {
const sel = useInstanceSelection();
const instance = sel.selected;
@@ -115,14 +96,11 @@ export function TokensScreen() {
const [holdings, setHoldings] = useState
([]);
const [holdingsErr, setHoldingsErr] = useState(null);
- // holdingsSource: "ledger" = real on-ledger balances; "registry" =
- // the pseudo-balance fallback shown when no live participant is
- // reachable. Drives the disclaimer banner so a user never mistakes a
- // fabricated row for a real holding.
+ // "ledger" = real on-ledger balances; "registry" = pseudo-balance fallback
+ // when no live participant is reachable. Drives the disclaimer banner.
const [holdingsSource, setHoldingsSource] = useState("ledger");
- const [expanded, setExpanded] = useState(null); // party whose UTXOs are shown
+ const [expanded, setExpanded] = useState(null);
const [contracts, setContracts] = useState([]);
- // Monotonic counter behind toggleExpand's latest-click guard.
const expandSeqRef = useRef(0);
const [matrix, setMatrix] = useState(null);
const [matrixErr, setMatrixErr] = useState(null);
@@ -153,8 +131,6 @@ export function TokensScreen() {
return;
}
let cancelled = false;
- // ACS-derived instrument discovery: Amulet + any minted
- // token appear without a state.Tokens seed.
fetchInstruments(instance)
.then((items) => {
if (cancelled) return;
@@ -176,7 +152,6 @@ export function TokensScreen() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [instance, refreshTick]);
- // Matrix lens — one ACS scan, party × instrument.
useEffect(() => {
if (!instance || view !== "matrix") return;
let cancelled = false;
@@ -221,8 +196,6 @@ export function TokensScreen() {
};
}, [instance, activeSymbol, refreshTick]);
- // Party alias registry: one fetch per instance powers the
- // alias labels across every lens and the party manager.
useEffect(() => {
if (!instance) {
setParties([]);
@@ -241,9 +214,7 @@ export function TokensScreen() {
};
}, [instance, refreshTick]);
- // Instrument-first KPI summary: supply, holder +
- // contract counts, holder distribution. One ACS scan; best-effort —
- // a failure just hides the KPI strip, the holdings table still loads.
+ // Best-effort: a failure just hides the KPI strip; holdings still load.
useEffect(() => {
if (!instance || !activeSymbol) {
setSummary(null);
@@ -262,10 +233,8 @@ export function TokensScreen() {
};
}, [instance, activeSymbol, refreshTick]);
- // Activity feed: transfer/mint/burn history
- // reconstructed from the ledger transaction stream. Fetched lazily —
- // only when the Activity tab is open — since it's a full historical
- // scan, heavier than the ACS snapshots the other lenses use.
+ // Lazy: only fetched when the Activity tab is open, since it's a full
+ // historical scan, heavier than the other lenses' ACS snapshots.
useEffect(() => {
if (!instance || !activeSymbol || detailTab !== "activity") return;
let cancelled = false;
@@ -293,10 +262,8 @@ export function TokensScreen() {
[list, activeSymbol],
);
- // Expand a party's balance into its individual Holding contracts
- // (UTXOs). expandSeq increments on every click; the in-flight
- // closure captures its own seq and bails when it no longer matches,
- // so a stale fetch can't overwrite a newer click's state.
+ // Each click bumps expandSeq; the in-flight closure bails when its seq
+ // no longer matches, so a stale fetch can't overwrite a newer click.
function toggleExpand(party: string) {
if (expanded === party) {
setExpanded(null);
@@ -336,8 +303,7 @@ export function TokensScreen() {
return { tone: "err", text: e instanceof ApiError ? e.message : fallback };
}
- // launchDemo provisions a live, transferable demo token in one click:
- // the server composes issuer-party → create → mint → faucet-a-holder.
+ // Server composes issuer-party → create → mint → faucet-a-holder.
async function launchDemo() {
if (!instance) return;
setDemoBusy(true);
@@ -408,7 +374,6 @@ export function TokensScreen() {
{topNotice.text}
)}
- {/* Lens switcher */}
{(["instruments", "matrix"] as const).map((v) => (
) : (
- {/* Left rail: instrument list (ACS-discovered) */}
{list.map((t) => {
const sym = t.symbol ?? t.instrument_id;
@@ -478,7 +442,6 @@ export function TokensScreen() {
})}
- {/* Right pane: detail + holdings + actions */}
{active && (() => {
const sym = active.symbol ?? active.instrument_id;
@@ -518,7 +481,6 @@ export function TokensScreen() {
- {/* Overview / Activity tab switcher */}
{(["overview", "activity"] as const).map((tab) => (
{
bump();
if (offered) {
- // Offer transfer: hand the id straight to a prefilled Accept
- // modal so the receiver can settle it without copy-pasting.
+ // Hand the id to a prefilled Accept modal so the receiver settles it.
setTopNotice({ tone: "ok", text: `Transfer offered. Accept instruction ${offered.instructionId.slice(0, 12)}… to settle it.` });
setModal({ kind: "accept", id: offered.instructionId, party: offered.receiver });
} else {
@@ -727,10 +688,8 @@ export function TokensScreen() {
);
}
-// TransferModal — From/To/Amount plus a live coin-selection preview:
-// as the user fills From + Amount, it dry-runs the transfer
-// (planTransfer) and shows which Holding contracts would be consumed
-// and the change returned — the Canton UTXO reality, before any submit.
+// From/To/Amount plus a live coin-selection preview: dry-runs the transfer
+// as From + Amount fill in, showing which Holding contracts get consumed.
function TransferModal({
instance, symbol, parties, onPartiesChanged, onClose, onDone, onError,
}: {
@@ -739,9 +698,8 @@ function TransferModal({
parties: PartyRef[];
onPartiesChanged?: () => void;
onClose: () => void;
- // offered is set when the transfer created a pending TransferInstruction
- // (Offer kind, no auto-accept) the caller should route to Accept;
- // undefined when it already settled.
+ // offered set when a pending TransferInstruction needs routing to Accept;
+ // undefined when the transfer already settled.
onDone: (offered?: { instructionId: string; receiver: string }) => void;
onError: (e: unknown) => void;
}) {
@@ -753,7 +711,6 @@ function TransferModal({
const [busy, setBusy] = useState(false);
const [plan, setPlan] = useState(null);
- // Debounced dry-run whenever from + amount are both present.
useEffect(() => {
if (!from || !amount) {
setPlan(null);
@@ -773,8 +730,7 @@ function TransferModal({
setBusy(true);
try {
const res = await transferToken(instance, symbol, from, to, amount, reason || undefined, undefined, autoAccept);
- // A non-auto-accept Offer hands back an instruction id to accept;
- // anything settled (auto-accept / Direct / self) just closes.
+ // A non-auto-accept Offer returns an instruction id; anything settled just closes.
onDone(!res.settled && res.transferInstructionId
? { instructionId: res.transferInstructionId, receiver: to }
: undefined);
@@ -855,9 +811,7 @@ function TransferModal({
);
}
-// KpiRow — the instrument KPI strip: supply, circulating (= supply on
-// a UTXO ledger), holder count, and the number of Holding contracts
-// backing it. All derived from one ACS scan.
+// KPI strip from one ACS scan. Circulating == total supply on a UTXO ledger.
function KpiRow({ s }: { s: InstrumentSummary }) {
const cards: Array<{ label: string; value: string; full?: string; hint?: string }> = [
{ label: "Total supply", value: statAmount(s.total_supply), full: s.total_supply },
@@ -894,9 +848,7 @@ function KpiRow({ s }: { s: InstrumentSummary }) {
fontSize: 20,
fontFamily: wMono,
fontVariantNumeric: "tabular-nums",
- // Belt and braces: a value that still can't fit its card
- // ellipsizes (full precision lives in the title) rather
- // than clipping mid-digit.
+ // Ellipsize an oversized value (full precision in the title).
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
@@ -911,18 +863,14 @@ function KpiRow({ s }: { s: InstrumentSummary }) {
);
}
-// A stat headline must fit its card: group thousands, keep at most
-// two decimals, and let the title attribute carry full precision.
-// Non-numeric strings pass through untouched.
+// Group thousands, cap at two decimals; non-numeric strings pass through.
function statAmount(raw: string): string {
const n = Number(raw);
if (!Number.isFinite(n)) return raw;
return n.toLocaleString("en-US", { maximumFractionDigits: 2 });
}
-// HolderDistribution — per-holder stake table: balance, share of
-// supply (with an inline bar), and how many Holding contracts back
-// each holder. Sorted biggest-first by the backend.
+// Per-holder stake table (balance, share, UTXO count); backend sorts biggest-first.
function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: AliasMap }) {
return (
<>
@@ -973,9 +921,7 @@ function HolderDistribution({ s, aliases }: { s: InstrumentSummary; aliases: Ali
);
}
-// ActivityFeed — the instrument's transfer/mint/burn history,
-// reconstructed from the ledger transaction stream. Each row is one
-// netted transaction: kind, amount, and who sent → received.
+// Transfer/mint/burn history from the ledger stream; one netted transaction per row.
function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null; err: string | null; aliases: AliasMap }) {
if (err) return {err}
;
if (events === null) return Scanning ledger history…
;
@@ -1041,9 +987,8 @@ function ActivityFeed({ events, err, aliases }: { events: ActivityEvent[] | null
);
}
-// MatrixLens — the party × instrument balance table. One ACS scan;
-// rows = parties, columns = instruments, plus a totals row. Only the
-// parties the role's JWT can read appear.
+// Party × instrument balance table from one ACS scan; only parties the
+// role's JWT can read appear.
function MatrixLens({ matrix, err, aliases }: { matrix: BalanceMatrix | null; err: string | null; aliases: AliasMap }) {
if (err) return {err}
;
if (!matrix) return Scanning ACS…
;
@@ -1108,10 +1053,8 @@ function Header({ right }: { right?: React.ReactNode }) {
);
}
-// PartyManagerModal — list the instance's aliased parties, allocate a
-// new one by name, or forget an alias. New parties immediately become
-// visible in the matrix / activity (the scan grants read-as for every
-// registered party).
+// List/allocate/forget aliased parties. New parties are immediately visible
+// in the matrix/activity (the scan grants read-as for every registered party).
function PartyManagerModal({
instance,
parties,
@@ -1348,12 +1291,9 @@ function ActionModal({
);
}
-// PartyPicker — alias-aware party selector for the token modals: pick
-// a registered alias, create one inline (POST /api/parties), or fall
-// back to typing a raw id. Always emits the resolved party_id, which
-// the backend's ResolveAlias passes through unchanged — correct for
-// both the create path (no alias resolution) and the
-// mint/transfer/burn/faucet paths (which do resolve).
+// Alias-aware party selector: pick a registered alias, create one inline,
+// or type a raw id. Always emits the resolved party_id (ResolveAlias passes
+// it through unchanged, so it's correct on both create and action paths).
function PartyPicker({
instance,
parties,
@@ -1375,8 +1315,7 @@ function PartyPicker({
const [newAlias, setNewAlias] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState(null);
- // Locally-created parties show in the list instantly, before the
- // parent's onPartiesChanged refetch lands.
+ // Locally-created parties show instantly, before the parent's refetch lands.
const [extra, setExtra] = useState([]);
const all = useMemo(() => {
@@ -1529,12 +1468,8 @@ const input: React.CSSProperties = {
background: W.surface2, color: W.text, border: `1px solid ${W.border}`,
borderRadius: 2, padding: "6px 8px", fontSize: 13,
};
-// Table column headers are an allowed wide-caps site; the 10px cell
-// side-padding keeps >=12px of air between adjacent columns.
const th: React.CSSProperties = { ...tableCaps, padding: "6px 10px", borderBottom: `1px solid ${W.border}`, fontSize: 11 };
const td: React.CSSProperties = { padding: "6px 10px", borderBottom: `1px solid ${W.border}`, color: W.text };
-// Numeric columns (amounts, balances, counts) right-align with tabular
-// figures so digits line up column-wise.
const thNum: React.CSSProperties = { ...th, textAlign: "right" };
const tdNum: React.CSSProperties = { ...td, textAlign: "right", fontFamily: wMono, fontVariantNumeric: "tabular-nums" };
diff --git a/frontend/src/screens/TxReplayDrawer.tsx b/frontend/src/screens/TxReplayDrawer.tsx
index 5eed589f..655741fc 100644
--- a/frontend/src/screens/TxReplayDrawer.tsx
+++ b/frontend/src/screens/TxReplayDrawer.tsx
@@ -11,14 +11,10 @@ import { Button } from "../components/Button";
import { MonoId } from "../components/MonoId";
import { IcX } from "../components/icons";
-// TxReplayDrawer — the Web UI counterpart of `dpm localnet tx replay
-// --id `, rendered as a fixed right-side overlay below the topbar
-// so the transactions table keeps its full width.
-// Fetches one transaction with the LEDGER_EFFECTS shape
-// (exercised choices, not just the ACS delta) projected through a
-// party set and renders the event tree. The party selector answers
-// "what did party P see in this transaction?" — the same id queried
-// as different parties returns different event sets.
+// Replays one transaction with the LEDGER_EFFECTS shape (exercised
+// choices, not just the ACS delta) projected through a party set. The
+// party selector answers "what did party P see?" — the same id returns
+// different event sets per party.
const EVENT_COLOR: Record = {
created: "#7CC89A",
@@ -40,8 +36,7 @@ export function TxReplayDrawer({
partyOptions: string[];
onClose: () => void;
}) {
- // "" = project through the JWT's own parties (the default the
- // backend uses when no ?party is passed).
+ // "" = project through the JWT's own parties (the backend default).
const [party, setParty] = useState("");
const [state, setState] = useState<
| { kind: "loading" }
@@ -98,12 +93,9 @@ export function TxReplayDrawer({
right: 0,
bottom: 0,
width: "min(480px, 92vw)",
- // Raised surface — a fixed overlay sits above the page, and
- // surface-on-page was reading dark-on-dark. One depth technique
- // for a dense-console drawer: hairline border, no shadow.
background: W.surface2,
borderLeft: `1px solid ${W.borderHi}`,
- // Below the CommandPalette (zIndex 100) but above page content.
+ // Below the CommandPalette (zIndex 100), above page content.
zIndex: 40,
overscrollBehavior: "contain",
overflowY: "auto",
diff --git a/frontend/src/screens/WalletScreen.tsx b/frontend/src/screens/WalletScreen.tsx
index 011e203e..f35726f5 100644
--- a/frontend/src/screens/WalletScreen.tsx
+++ b/frontend/src/screens/WalletScreen.tsx
@@ -5,39 +5,27 @@ import { ROLE_COLOR, W, wMono, tint, R, FAST } from "../tokens";
import { Button } from "../components/Button";
import { Dot, IcAlert, IcRefresh } from "../components/icons";
-// WalletScreen embeds Splice's per-role Wallet UI inside the DevKit
-// shell so users don't juggle three browser tabs (one per party).
-// The iframe target is the `_ui` host port from state.json —
-// Splice already exposes its wallet there; we just frame it with a
-// role switcher.
-//
-// X-Frame-Options is empty on Splice 0.6.4's wallet UI (nginx sends
-// no frame-options header), so the iframe loads directly. If a future
-// Splice release ships SAMEORIGIN, the "Open in new tab" fallback
-// covers it.
+// Embeds Splice's per-role Wallet UI (the `_ui` host port from
+// state.json) in an iframe. Splice 0.6.4 sends no X-Frame-Options, so it
+// loads directly; the "Open in new tab" fallback covers a future SAMEORIGIN.
const ROLES: Role[] = ["app-user", "app-provider", "sv"];
-// LocalNet wallet login user names — the hardcoded
-// AUTH__WALLET_ADMIN_USER_NAME values from
-// `env/-auth-on.env`. Password is ignored: LocalNet auth is
-// dev-only HS-256 with the literal secret "unsafe" (no MetaMask,
-// no real OAuth provider).
+// Hardcoded AUTH__WALLET_ADMIN_USER_NAME values from env/-auth-on.env.
+// Password is ignored: LocalNet auth is dev-only HS-256 with the secret "unsafe".
const LOGIN_USER_FOR: Record = {
"app-user": "app-user",
"app-provider": "app-provider",
sv: "sv",
};
-// Per-role wallet endpoint keys — the logical port names from
-// state.json. Endpoints are matched by key; labels are display-only.
+// Logical port names from state.json; endpoints match by key, labels are display-only.
const WALLET_ENDPOINT_KEY: Record = {
"app-user": "app_user_ui",
"app-provider": "app_provider_ui",
sv: "sv_ui",
};
-// walletEndpointFor returns the whole endpoint so callers can read
-// both the URL and the backend's reachability verdict.
+// Returns the whole endpoint so callers get both URL and reachability verdict.
function walletEndpointFor(role: Role, endpoints: Instance["endpoints"]) {
if (!endpoints) return null;
const want = WALLET_ENDPOINT_KEY[role];
@@ -53,8 +41,7 @@ export function WalletScreen() {
| { kind: "ok"; instance: Instance }
| { kind: "err"; error: string }
>({ kind: "loading" });
- // Bumped by Retry to re-fetch the instance, which re-runs the
- // backend reachability probe.
+ // Bumped by Retry to re-fetch and re-run the backend reachability probe.
const [refetchNonce, setRefetchNonce] = useState(0);
useEffect(() => {
@@ -103,12 +90,10 @@ export function WalletScreen() {
);
}
- // null when the instance doesn't yet have endpoints surfaced.
const walletEndpoint = walletEndpointFor(role, state.instance.endpoints);
const walletURL = walletEndpoint?.url ?? null;
- // Backend status probe verdict. An iframe pointed at a dead port
- // renders the browser's own gray error page; own the failure state
- // instead and point at the fix.
+ // Own the failure state rather than let an iframe render the browser's
+ // gray error page on a dead port.
const walletUnreachable = walletEndpoint?.reachability === "unreachable";
return (
@@ -121,7 +106,6 @@ export function WalletScreen() {
gap: 14,
}}
>
- {/* Header */}
- {/* Login help — surface the dev-mode credentials inline so
- users don't have to dig through env files. */}
+ {/* Login help — dev-mode credentials inline, no env-file digging. */}
- {/* Active wallet info strip */}
- {/* Embedded wallet iframe */}
- {/* Fake browser chrome so devs know they're looking at the
- real Splice UI inside our shell, not a re-implementation. */}
+ {/* Fake browser chrome: signals this is the real Splice UI, not a reimplementation. */}
) and the iframe
- // (wallet.localhost:
) differ in host AND
- // port, so they are cross-origin: `allow-same-origin`
- // lets the wallet's scripts read ITS OWN cookies /
- // localStorage (needed for its auth session) but not the
- // parent's origin or `window.top`. Without it the iframe
- // would get a unique opaque origin and the wallet's
- // cookie-based auth would break. Deliberately omitted:
- // allow-top-navigation, allow-modals, allow-downloads,
- // allow-popups-to-escape-sandbox.
+ // The allow-same-origin + allow-scripts foot-gun only bites when
+ // iframe and parent share an origin; here they differ in host and
+ // port, so allow-same-origin only lets the wallet reach its own
+ // cookies/localStorage (needed for its auth), not the parent.
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
referrerPolicy="no-referrer"
style={{ flex: 1, border: 0, background: "#FCFCFD" }}
diff --git a/frontend/src/shell/CommandPalette.tsx b/frontend/src/shell/CommandPalette.tsx
index d56e45ed..c41d545f 100644
--- a/frontend/src/shell/CommandPalette.tsx
+++ b/frontend/src/shell/CommandPalette.tsx
@@ -11,21 +11,14 @@ import { W, wMono, wSans, tint, R, wideCaps } from "../tokens";
import { useInstanceSelection } from "./useInstanceSelection";
import { NAV, isInstanceScoped, linkTo } from "./routes";
-// CommandPalette — ⌘K (Ctrl+K elsewhere) opens a search modal with two
-// groups: route navigation and instance switching. ↑/↓ move, Enter
-// activates, Esc dismisses.
-
interface Action {
id: string;
group: "Navigate" | "Switch instance";
label: string;
- // Secondary line shown beneath the label (path, instance status).
hint?: string;
perform: () => void;
}
-// Derive nav rows from the shared NAV table so the sidebar and palette
-// can't drift on routes / labels / instance-scoping.
const NAV_ACTIONS: Array & { path: string }> = NAV.map(
(n) => ({
id: `nav-${n.to === "/" ? "overview" : n.to.replace(/^\//, "")}`,
@@ -36,8 +29,7 @@ const NAV_ACTIONS: Array & { path: string }> = NAV.map(
}),
);
-// Lets non-keyboard callers (the topbar "Commands" button) open the
-// palette without owning its open state.
+// Lets the topbar "Commands" button open the palette without owning its state.
const OPEN_EVENT = "cdk-open-palette";
export function openPalette(): void {
window.dispatchEvent(new CustomEvent(OPEN_EVENT));
@@ -50,14 +42,11 @@ export function CommandPalette() {
const inputRef = useRef(null);
const navigate = useNavigate();
const sel = useInstanceSelection();
- // Carry `?instance=` into instance-scoped routes (same as the Shell
- // sidebar); otherwise ⌘K → "Wallet" lands on the empty state even
- // though the header still shows an instance.
+ // Carry `?instance=` into instance-scoped routes; otherwise ⌘K →
+ // "Wallet" lands on the empty state despite a selected instance.
const [searchParams] = useSearchParams();
const instance = searchParams.get("instance");
- // Global hotkey: ⌘K on Mac, Ctrl+K elsewhere. Accept either modifier
- // rather than UA-sniffing.
useEffect(() => {
function onKey(e: globalThis.KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
@@ -78,7 +67,6 @@ export function CommandPalette() {
};
}, [open]);
- // Focus the input on open; reset so each open is a fresh search.
useEffect(() => {
if (open) {
setQuery("");
@@ -223,8 +211,6 @@ export function CommandPalette() {
);
}
-// Groups consecutive actions sharing a `group` label under a section
-// header.
function renderGroups(
filtered: Action[],
cursor: number,
@@ -318,8 +304,7 @@ function titleCase(s: string): string {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
-// Case-insensitive substring match across label + hint; keeps input
-// order so the grouping stays stable.
+// Case-insensitive substring match; preserves input order so grouping stays stable.
export function filter(actions: Action[], query: string): Action[] {
const q = query.trim().toLowerCase();
if (!q) return actions;
diff --git a/frontend/src/shell/ErrorBoundary.tsx b/frontend/src/shell/ErrorBoundary.tsx
index d0cf998b..8615ad1f 100644
--- a/frontend/src/shell/ErrorBoundary.tsx
+++ b/frontend/src/shell/ErrorBoundary.tsx
@@ -2,30 +2,20 @@ import { Component, type ErrorInfo, type ReactNode } from "react";
import { W, wMono, tint, R } from "../tokens";
import { Button } from "../components/Button";
-// ErrorBoundary — catches render-time exceptions from descendants and
-// renders a fallback so one crashed screen doesn't take the whole UI
-// down. Wrapped per-route so the shell stays interactive and sibling
-// routes stay renderable. Class component because React's
-// error-boundary API has no hook equivalent.
-//
-// No automatic recovery: a render that threw once will almost
-// certainly throw again on the same state, so auto-retry would
-// busy-loop. The "Retry" button instead forces a re-mount via a reset
-// key so transient state corruption gets a fresh shot.
+// Catches render-time exceptions per-route so one crashed screen doesn't
+// take the whole UI down. Class component because the error-boundary API
+// has no hook equivalent. No auto-retry (would busy-loop on the same
+// state); Retry forces a re-mount via resetCount.
interface Props {
- // routeKey lets the parent force a reset when navigating to a
- // new screen — without this, an error from /explorer would
- // stick around when the user clicks /overview because the
- // boundary itself stays mounted across the Routes switch.
+ // Force a reset on navigation; the boundary stays mounted across the
+ // Routes switch, so without this a crashed screen's error persists.
routeKey?: string;
children: ReactNode;
}
interface State {
error: Error | null;
- // Bump to force the boundary to drop its error and re-render
- // children. Used by the Retry button.
resetCount: number;
}
@@ -37,16 +27,13 @@ export class ErrorBoundary extends Component {
}
componentDidCatch(error: Error, info: ErrorInfo) {
- // The fallback shows only .name/.message; the full stack goes to
- // the console. Don't ship to a remote telemetry endpoint without
- // explicit user consent.
+ // Full stack to the console only; no remote telemetry without consent.
// eslint-disable-next-line no-console
console.error("ErrorBoundary caught a render error:", error, info);
}
componentDidUpdate(prev: Props) {
- // Route change → drop the error; otherwise navigating away from a
- // crashed screen keeps showing the fallback.
+ // Route change → drop the error, else the fallback persists after nav.
if (prev.routeKey !== this.props.routeKey && this.state.error) {
this.setState({ error: null });
}
@@ -57,9 +44,8 @@ export class ErrorBoundary extends Component {
return ;
}
return (
- // The reset key forces a clean remount of children when
- // the user clicks Retry — without remount, a stale closure
- // or torn fetch can re-throw the same error immediately.
+ // resetCount key forces a clean remount on Retry; without it a stale
+ // closure or torn fetch re-throws the same error immediately.
{this.props.children}
);
}
diff --git a/frontend/src/shell/Shell.tsx b/frontend/src/shell/Shell.tsx
index 35de4370..b9570716 100644
--- a/frontend/src/shell/Shell.tsx
+++ b/frontend/src/shell/Shell.tsx
@@ -23,14 +23,6 @@ import { type InstanceSelection, useInstanceSelection } from "./useInstanceSelec
import { CommandPalette, openPalette } from "./CommandPalette";
import { NAV, linkTo } from "./routes";
-// Shell — sidebar + topbar layout; children render in the main
-// content area. The sidebar order comes from the shared NAV table
-// (./routes), a design decision rather than an alphabetical accident.
-// The logo is drawn as an inline SVG so the shell renders correctly
-// even before public/assets/ files are served.
-
-// Route → sidebar icon. Kept next to the shell (not in routes.ts) so
-// the shared route table stays presentation-free.
const NAV_ICON: Record JSX.Element> = {
"/": IcOverview,
"/doctor": IcDoctor,
@@ -42,7 +34,6 @@ const NAV_ICON: Record JSX.Element> = {
"/agent": IcAgent,
};
-// Published docs site (astro.config site + base).
const DOCS_URL = "https://bitdynamics-ab.github.io/canton-devkit/";
interface ShellProps {
@@ -66,9 +57,7 @@ export function Shell({ children }: ShellProps) {
{children}
- {/* Palette is a portal-style overlay (position: fixed),
- rendered inside the grid but escaping it visually. */}
);
}
function SkipLink() {
- // First focusable element on the page; visually hidden until
- // focused. Anchor (not button) so screen readers announce
- // "main, region" after activation — the standard pattern.
return (
n.to === pathname);
return hit ? hit.label : "";
@@ -212,8 +192,6 @@ function ThemeToggle() {
function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
const [open, setOpen] = useState(false);
- // Empty / loading / error states degrade to a muted label rather than
- // a dropdown; the Dashboard owns the empty-state messaging.
if (sel.loading) {
return Loading instances… ;
}
@@ -226,9 +204,7 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
setOpen((v) => !v)}
onBlur={() => {
- // Defer so a click on a menu item registers before we
- // unmount. 100ms = below the click-vs-tap perception
- // threshold.
+ // Defer so a click on a menu item registers before we unmount.
setTimeout(() => setOpen(false), 100);
}}
aria-haspopup="listbox"
@@ -291,9 +267,8 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
role="option"
aria-selected={i.name === sel.selected}
onMouseDown={(e) => {
- // mouseDown beats the button's own onBlur from
- // firing first and closing the menu. Without
- // this the click never lands.
+ // mouseDown fires before the button's onBlur, so the
+ // menu doesn't close before the click lands.
e.preventDefault();
sel.select(i.name);
setOpen(false);
@@ -304,8 +279,6 @@ function InstanceSwitcher({ sel }: { sel: InstanceSelection }) {
gap: 10,
width: "100%",
padding: "7px 10px",
- // Flat active fill, constant padding — no accent
- // side-bar, no content shift on selection.
background:
i.name === sel.selected ? W.brandSoft : "transparent",
border: "none",
@@ -352,8 +325,7 @@ function StatusDot({ status }: { status: string }) {
}
function PaletteHint() {
- // Opens the ⌘K palette; the keycap is a discovery hint. UA-sniff
- // for the glyph because Mac users expect ⌘ and others expect Ctrl.
+ // ⌘ on Mac, Ctrl elsewhere.
const isMac =
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const mod = isMac ? "⌘" : "Ctrl";
@@ -459,9 +431,8 @@ function HealthPill({ conn }: { conn: ConnectionState }) {
}
function Sidebar() {
- // Thread the currently-selected instance into per-instance routes
- // so sidebar clicks don't drop the selection. NAV + linkTo live in
- // ./routes so the ⌘K palette shares the same table.
+ // Thread the selected instance into per-instance routes so sidebar
+ // clicks don't drop the selection.
const [params] = useSearchParams();
const instance = params.get("instance");
const conn = useConnectionHealth();
@@ -494,8 +465,6 @@ function Sidebar() {
key={item.to}
to={linkTo(item.to, item.instanceScoped, instance)}
end={item.to === "/"}
- // Visuals live in index.css (.side-nav-link) so :hover and
- // the router-managed .active class can carry the states.
className="side-nav-link"
>
@@ -545,7 +514,6 @@ function LogoLockup() {
; setting it re-themes
-// every W.* token. Persisted in localStorage.
+// Theme state. Palettes live in index.css as CSS variables keyed off
+// `data-theme` on ; setting it re-themes every W.* token.
import { useSyncExternalStore } from "react";
@@ -14,14 +13,11 @@ function read(): Theme {
const v = window.localStorage.getItem(STORAGE_KEY);
if (v === "light" || v === "dark") return v;
} catch {
- // localStorage may be unavailable (private mode / sandbox); fall
- // through to the default.
+ // localStorage may be unavailable (private mode / sandbox).
}
return "dark";
}
-// Sets the attribute that drives the CSS variables. Called before the
-// first render (main.tsx) to avoid a flash, and on every change.
export function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
}
@@ -49,7 +45,6 @@ export function initTheme(): void {
applyTheme(read());
}
-// Subscribe a component to theme changes so a toggle re-renders it.
export function useTheme(): Theme {
return useSyncExternalStore(
(cb) => {
diff --git a/frontend/src/tokens.ts b/frontend/src/tokens.ts
index d002de64..1fc08f71 100644
--- a/frontend/src/tokens.ts
+++ b/frontend/src/tokens.ts
@@ -1,11 +1,6 @@
-// Web UI design tokens — the Canton Infrastructure Design System.
-//
-// Every semantic color resolves through a CSS variable defined in
-// index.css under :root (dark) and :root[data-theme="light"], so the
-// same W.* reference renders correctly in both themes. Structure comes
-// from 1px hairlines, not shadows; one interactive accent; teal/amber
-// are data-only accents (series, parties, throughput) held at fixed
-// mid-tones legible on either background.
+// Design tokens. Each semantic color resolves through a CSS variable in
+// index.css (:root dark, :root[data-theme="light"] light), so one W.*
+// reference renders correctly in both themes.
export const W = {
bg: "var(--bg-page)",
surface: "var(--bg-surface)", // cards, sidebars, inputs
@@ -30,7 +25,6 @@ export const W = {
rowHover: "var(--hover-tint)",
selRow: "var(--active-tint)",
- // CDS roles.
sunken: "var(--bg-sunken)", // nav rail, card footers
inset: "var(--bg-inset)", // wells, disabled fields
onAccent: "var(--on-accent)", // text on accent-filled controls
@@ -55,9 +49,8 @@ export const W = {
focus: "var(--blue-500)", // 2px focus outline — identical in both themes
} as const;
-// Translucent tint of a themed color. W.x is a CSS var, so a `${W.x}1A`
-// hex-alpha concat is invalid; color-mix over transparent is the
-// equivalent.
+// W.x is a CSS var, so `${W.x}1A` hex-alpha concat is invalid; color-mix
+// over transparent is the equivalent.
export function tint(color: string, pct: number): string {
return `color-mix(in srgb, ${color} ${pct}%, transparent)`;
}
@@ -67,16 +60,11 @@ export const wMono =
export const wSans =
"'Archivo', -apple-system, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif";
-// Radius language: 2 controls / 4 cards / 8 dialogs. Full radius is
-// reserved for status dots.
export const R = { control: 2, card: 4, dialog: 8 } as const;
-// Motion: quick and damped, no bounce.
export const EASE = "cubic-bezier(0.2, 0.6, 0.2, 1)";
export const FAST = "120ms";
-// Wide structural caps for the wordmark, section headers, and stat-card
-// labels.
export const wideCaps = {
fontWeight: 600,
fontStretch: "118%",
@@ -84,24 +72,20 @@ export const wideCaps = {
textTransform: "uppercase",
} as const;
-// Quieter caps for data-table column headers — the wide structural cut
-// repeats on every table and reads as chrome, so tables tone it down.
+// Quieter caps for data-table column headers.
export const tableCaps = {
fontWeight: 500,
letterSpacing: "0.05em",
textTransform: "uppercase",
} as const;
-// Role-to-color map shared by every screen. Parties are data: the
-// accent / teal / amber triad from the dataviz ramp.
export const ROLE_COLOR: Record<"app-user" | "app-provider" | "sv", string> = {
"app-user": W.brand,
"app-provider": W.teal,
sv: W.warn,
};
-// Transaction-kind palette used by the Explorer Timeline + table.
-// Single source of truth so table and strip never disagree.
+// Shared by the Explorer Timeline + table so they never disagree.
export const TX_KIND_COLOR: Record<
"transaction" | "reassignment" | "topology" | "checkpoint",
string
diff --git a/internal/cli/localnet/token/demo.go b/internal/cli/localnet/token/demo.go
index f9179405..8de5f76b 100644
--- a/internal/cli/localnet/token/demo.go
+++ b/internal/cli/localnet/token/demo.go
@@ -8,12 +8,9 @@ import (
"github.com/spf13/cobra"
)
-// buildDemo returns `token demo` — a one-step "launch a transferable
-// demo token" that adapts to the instance: on a token-standard-v2
-// instance it creates a new on-ledger V2 instrument (issuer + minted
-// supply + funded holder); on a standard instance it funds a holder
-// with the existing V1 Amulet. The Web UI's "Launch demo token" button
-// drives the same token.RunDemo via POST /api/tokens/demo.
+// buildDemo returns `token demo`, a one-step launch of a transferable demo
+// token. The Web UI's "Launch demo token" button drives the same
+// token.RunDemo via POST /api/tokens/demo.
func buildDemo() *cobra.Command {
var (
instance string
diff --git a/internal/localnet/token/demo.go b/internal/localnet/token/demo.go
index 33aa9c97..c22fb46c 100644
--- a/internal/localnet/token/demo.go
+++ b/internal/localnet/token/demo.go
@@ -25,16 +25,13 @@ type DemoOptions struct {
SeedHolder bool // allocate a holder party + fund it so it's transferable
SeedAmount string // default "1000"
- // Aliases for the provisioned parties. Exposed so a caller can target
- // distinct parties; defaults are demo-issuer / demo-holder.
+ // Party aliases; default demo-issuer / demo-holder.
IssuerAlias string
HolderAlias string
}
-// DemoResult is the outcome of RunDemo: the created instrument, the
-// issuer party, and (when seeded) the funded holder. Shared by the CLI
-// `token demo --format json` and POST /api/tokens/demo so both surfaces
-// emit an identical shape.
+// DemoResult is the outcome of RunDemo, shared by the CLI `token demo
+// --format json` and POST /api/tokens/demo so both surfaces emit one shape.
type DemoResult struct {
Token registry.TokenRef `json:"token"`
Issuer registry.PartyRef `json:"issuer"`
@@ -43,39 +40,22 @@ type DemoResult struct {
}
// Orchestration seams — package vars so RunDemo's choreography can be
-// unit-tested without a live ledger (mirrors the runTokenCreate
-// indirection the UI handlers use). Default to the real Run* functions.
+// unit-tested without a live ledger. Default to the real Run* functions.
var (
demoPartyNew = RunPartyNew
demoCreate = RunCreate
demoMint = RunMint
demoFaucet = RunFaucet
- // demoV2Capable routes the demo: true → create a new V2 instrument;
- // false → the V1 Amulet demo. A seam so the choreography of each path
- // can be unit-tested without a real registry/catalogue.
+ // demoV2Capable routes the demo: true → new V2 instrument, false → V1 Amulet.
demoV2Capable = v2InstrumentCreateCapable
)
-// RunDemo provisions a live, transferable demo token in one call:
-//
-// allocate an issuer party
-// → create a V2 instrument on-ledger (issuer = admin)
-// → mint the initial supply to the issuer (create records the
-// instrument but does NOT mint)
-// → optionally allocate a holder party and faucet it some tokens so a
-// transfer works immediately.
-//
-// It composes the same Run* functions the individual CLI/UI verbs use,
-// so its behaviour can't drift from them. A live ledger endpoint is
-// required (empty → ErrNeedsV2LocalNet).
-//
-// The path is chosen by the instance's capability:
-// - a token-standard-v2 instance CAN create a new on-ledger instrument,
-// so the demo creates + mints + seeds a "DEMO" token (the V2 flow);
-// - a standard release instance can only read/transfer the existing V1
-// Amulet, so the demo funds a fresh holder with Amulet moved from the
-// network-funded role party — a transferable token in one click,
-// without needing the alpha token-standard-v2 DAR.
+// RunDemo provisions a live, transferable demo token in one call, composing
+// the same Run* verbs the CLI/UI use so it can't drift. A live ledger endpoint
+// is required (empty → ErrNeedsV2LocalNet). The path is capability-chosen:
+// - token-standard-v2 instance: create + mint + seed a new "DEMO" instrument;
+// - standard (V1) instance: fund a fresh holder with Amulet from the
+// network-funded role party (no create/mint exists there).
func RunDemo(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResult, error) {
if opts.Instance == "" {
return nil, fmt.Errorf("demo: instance is required")
@@ -89,9 +69,8 @@ func RunDemo(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResult,
return runDemoV1(ctx, out, applyDemoV1Defaults(opts))
}
-// runDemoV2 creates a new on-ledger V2 instrument, mints its supply, and
-// optionally seeds a holder — the one-click demo on a token-standard-v2
-// instance. opts is already defaulted.
+// runDemoV2 creates a V2 instrument, mints its supply, and optionally seeds a
+// holder. opts is already defaulted.
func runDemoV2(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResult, error) {
step := func(format string, a ...any) {
if out != nil {
@@ -99,14 +78,12 @@ func runDemoV2(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResul
}
}
- // 1. Issuer party (idempotent: reuse an existing alias on a re-run).
step("Allocating issuer party %q…", opts.IssuerAlias)
issuer, err := ensureDemoParty(ctx, opts, opts.IssuerAlias)
if err != nil {
return nil, fmt.Errorf("demo: allocate issuer: %w", err)
}
- // 2. Create the V2 instrument on-ledger (issuer is the admin).
step("Creating %s (supply %s, %d decimals)…", opts.Symbol, opts.InitialSupply, opts.Decimals)
created, err := demoCreate(out, CreateOptions{
Instance: opts.Instance,
@@ -120,10 +97,8 @@ func runDemoV2(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResul
Insecure: opts.Insecure,
})
if err != nil {
- // Re-running the demo with the same symbol is the obvious "click it
- // twice" case — surface it as an actionable conflict rather than a
- // raw "symbol in use". Still wraps ErrSymbolInUse so both surfaces
- // map it to 409.
+ // The "click it twice" case: give an actionable message but still
+ // wrap ErrSymbolInUse so both surfaces map it to 409.
if errors.Is(err, ErrSymbolInUse) {
return nil, fmt.Errorf(
"a demo token %q already exists on %q — open it on the Tokens screen, "+
@@ -133,7 +108,6 @@ func runDemoV2(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResul
return nil, fmt.Errorf("demo: create instrument: %w", err)
}
- // 3. Mint the initial supply to the issuer.
step("Minting %s %s to the issuer…", opts.InitialSupply, opts.Symbol)
if err := demoMint(ctx, out, MintOptions{
Instance: opts.Instance,
@@ -149,7 +123,6 @@ func runDemoV2(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResul
result := &DemoResult{Token: created.TokenRef, Issuer: *issuer}
- // 4. Optionally seed a holder so the token is transferable in one click.
if opts.SeedHolder {
step("Allocating holder party %q…", opts.HolderAlias)
holder, herr := ensureDemoParty(ctx, opts, opts.HolderAlias)
@@ -177,8 +150,8 @@ func runDemoV2(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResul
return result, nil
}
-// ensureDemoParty allocates a party by alias, reusing an existing one
-// (ErrAliasInUse) so re-running the demo doesn't fail on a name clash.
+// ensureDemoParty allocates a party by alias, reusing an existing one on
+// ErrAliasInUse so a re-run doesn't fail on a name clash.
func ensureDemoParty(ctx context.Context, opts DemoOptions, alias string) (*registry.PartyRef, error) {
ref, err := demoPartyNew(ctx, PartyOptions{
Instance: opts.Instance,
@@ -191,8 +164,7 @@ func ensureDemoParty(ctx context.Context, opts DemoOptions, alias string) (*regi
return ref, nil
}
if errors.Is(err, ErrAliasInUse) {
- // Already allocated on a prior run — reuse the recorded party so
- // the demo is idempotent rather than failing the whole flow.
+ // Reuse the party recorded on a prior run so the demo is idempotent.
if state, rerr := registry.Read(opts.Instance); rerr == nil {
if existing, ok := state.Parties[alias]; ok {
return &existing, nil
@@ -202,20 +174,16 @@ func ensureDemoParty(ctx context.Context, opts DemoOptions, alias string) (*regi
return nil, err
}
-// runDemoV1 provisions a live, transferable demo on a standard (V1)
-// instance. CIP-0056 V1 has no create/mint — Amulet is the only
-// instrument and the network-funded role party (app-user) holds it — so
-// the demo allocates a fresh holder and moves some Amulet to it via the
-// faucet (a funded transfer), giving a transferable balance in one click.
-// opts is already defaulted (applyDemoV1Defaults).
+// runDemoV1 provisions a demo on a standard (V1) instance: with no create/mint
+// (Amulet is the only instrument, held by the network-funded role party), it
+// funds a fresh holder via a faucet transfer. opts is already defaulted.
func runDemoV1(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResult, error) {
step := func(format string, a ...any) {
if out != nil {
_, _ = fmt.Fprintf(out, format+"\n", a...)
}
}
- // The network's Amulet lives on the role's seeded party (app-user by
- // default); it is the demo's funding source.
+ // The role's seeded party (app-user by default) holds the Amulet.
source := roleOrDefault(opts.Role)
step("Allocating holder party %q…", opts.HolderAlias)
@@ -240,8 +208,6 @@ func runDemoV1(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResul
step("Amulet demo is live and transferable — %s now holds %s Amulet.", opts.HolderAlias, opts.SeedAmount)
return &DemoResult{
- // Amulet is the pre-existing V1 instrument; there is no created
- // token or minted supply on this path.
Token: registry.TokenRef{Name: amuletSymbol, Symbol: amuletSymbol, InstrumentID: amuletSymbol},
Issuer: registry.PartyRef{Alias: source, Role: roleOrDefault(opts.Role)},
Holder: holder,
@@ -249,16 +215,11 @@ func runDemoV1(ctx context.Context, out io.Writer, opts DemoOptions) (*DemoResul
}, nil
}
-// amuletSymbol is the network's V1 instrument used by the V1 demo.
const amuletSymbol = "Amulet"
-// v2InstrumentCreateCapable reports whether the instance can create a NEW
-// on-ledger V2 instrument — i.e. its Splice version ships the
-// splice-test-token-v2 example DAR. Today that is the alpha
-// (token-standard-v2) channel; standard releases (0.6.x) can only
-// read/transfer the existing V1 Amulet. Unknown/uncurated versions
-// default to false so the demo takes the V1 path, which works on any
-// running instance.
+// v2InstrumentCreateCapable reports whether the instance can create a new V2
+// instrument (its Splice version ships the splice-test-token-v2 DAR, i.e. the
+// alpha channel). Unknown versions default to false → the V1 path.
func v2InstrumentCreateCapable(instance string) bool {
st, err := registry.Read(instance)
if err != nil {
@@ -268,9 +229,8 @@ func v2InstrumentCreateCapable(instance string) bool {
return ok && v.IsAlpha()
}
-// applyDemoV1Defaults fills the V1-demo tunables. The seed is smaller than
-// the V2 default (1000) because it comes out of the funded role party's
-// finite genesis Amulet rather than a freshly-minted supply.
+// applyDemoV1Defaults fills the V1-demo tunables. The seed is smaller than the
+// V2 default since it comes from the role party's finite genesis Amulet.
func applyDemoV1Defaults(o DemoOptions) DemoOptions {
if o.SeedAmount == "" {
o.SeedAmount = "100"
diff --git a/internal/localnet/token/demo_test.go b/internal/localnet/token/demo_test.go
index 291dae9e..421eb315 100644
--- a/internal/localnet/token/demo_test.go
+++ b/internal/localnet/token/demo_test.go
@@ -12,8 +12,7 @@ import (
"github.com/bitdynamics-ab/canton-devkit/internal/registry"
)
-// stubDemoSeams swaps the four RunDemo orchestration seams for fakes and
-// returns a restore func — lets us pin the choreography without a ledger.
+// stubDemoSeams swaps the four RunDemo orchestration seams for fakes.
func stubDemoSeams(
t *testing.T,
party func(context.Context, PartyOptions) (*registry.PartyRef, error),
@@ -27,8 +26,7 @@ func stubDemoSeams(
t.Cleanup(func() { demoPartyNew, demoCreate, demoMint, demoFaucet = op, oc, om, of })
}
-// stubDemoV2Capable pins the V1/V2 routing decision so a test exercises the
-// chosen path without a real catalogue/registry lookup.
+// stubDemoV2Capable pins the V1/V2 routing decision.
func stubDemoV2Capable(t *testing.T, v bool) {
t.Helper()
prev := demoV2Capable
@@ -88,8 +86,6 @@ func TestRunDemo_ComposesPartyCreateMintFaucet(t *testing.T) {
if !slices.Equal(order, want) {
t.Fatalf("call order = %v, want %v", order, want)
}
- // Issuer party id threads into create (admin), mint (recipient) and the
- // faucet source; defaults applied for symbol/supply/decimals.
if createOpts.Issuer != "demo-issuer::pid" || createOpts.Symbol != "DEMO" || createOpts.InitialSupply != "1000000" || createOpts.Decimals != 6 {
t.Errorf("create opts wrong: %+v", createOpts)
}
@@ -155,9 +151,8 @@ func TestRunDemo_StopsOnCreateError(t *testing.T) {
}
}
-// Re-running the demo with the same symbol (the "click it twice" case)
-// must give an actionable "already exists" message, while still wrapping
-// ErrSymbolInUse so both surfaces map it to 409.
+// Re-run must give an actionable "already exists" message while still wrapping
+// ErrSymbolInUse (→ 409 on both surfaces).
func TestRunDemo_DuplicateSymbolIsActionable(t *testing.T) {
stubDemoV2Capable(t, true)
stubDemoSeams(t,
@@ -208,9 +203,7 @@ func TestRunDemo_ReusesExistingIssuerAlias(t *testing.T) {
}
}
-// On a standard (V1) instance the demo can't create/mint a new instrument;
-// it allocates a holder and faucets Amulet to it from the funded role
-// party. No create, no mint.
+// On a V1 instance the demo faucets Amulet to a holder — no create, no mint.
func TestRunDemo_V1FundsHolderWithAmulet(t *testing.T) {
stubDemoV2Capable(t, false)
var order []string
@@ -239,12 +232,9 @@ func TestRunDemo_V1FundsHolderWithAmulet(t *testing.T) {
t.Fatalf("RunDemo: %v", err)
}
- // Only allocate-holder + faucet — never create or mint on V1.
if want := []string{"party:demo-holder", "faucet:demo-holder::pid"}; !slices.Equal(order, want) {
t.Fatalf("V1 call order = %v, want %v (no create/mint)", order, want)
}
- // Faucet moves Amulet from the role's funded party (app-user), with the
- // smaller V1 seed default.
if faucetOpts.Instrument != "Amulet" || faucetOpts.Source != "app-user" ||
faucetOpts.To != "demo-holder::pid" || faucetOpts.Amount != "100" {
t.Errorf("V1 faucet opts wrong: %+v", faucetOpts)
From 92afbad2f5e71bb525eb5d538c889a8e2bc26eb2 Mon Sep 17 00:00:00 2001
From: Zhe Li
Date: Fri, 10 Jul 2026 18:58:21 +0200
Subject: [PATCH 08/14] Match frontend wordmark to docs site (CANTON DEVKIT)
---
frontend/src/shell/Shell.tsx | 11 ++---------
1 file changed, 2 insertions(+), 9 deletions(-)
diff --git a/frontend/src/shell/Shell.tsx b/frontend/src/shell/Shell.tsx
index b9570716..91fb5925 100644
--- a/frontend/src/shell/Shell.tsx
+++ b/frontend/src/shell/Shell.tsx
@@ -506,12 +506,6 @@ function LogoLockup() {
lineHeight: 1,
}}
>
-
-
-
-
-
-
- BITDYNAMICS
+ CANTON DEVKIT
- .cc
);
}
From cf9f14cbb4ce80506319b69aa72078a7173208fc Mon Sep 17 00:00:00 2001
From: srikanth-bitdynamics
<259878899+srikanth-bitdynamics@users.noreply.github.com>
Date: Fri, 10 Jul 2026 22:41:14 +0530
Subject: [PATCH 09/14] ui: default to the light theme
New sessions (no stored preference) now open in light. The JS default and
the useSyncExternalStore server snapshot return "light", and the root
carries data-theme="light" so the first paint is light too, with a
matching theme-color. A stored preference and the toggle still win.
---
frontend/index.html | 4 ++--
frontend/src/theme.ts | 4 ++--
2 files changed, 4 insertions(+), 4 deletions(-)
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/theme.ts b/frontend/src/theme.ts
index 3c103bef..67f4f2ef 100644
--- a/frontend/src/theme.ts
+++ b/frontend/src/theme.ts
@@ -15,7 +15,7 @@ function read(): Theme {
} catch {
// localStorage may be unavailable (private mode / sandbox).
}
- return "dark";
+ return "light";
}
export function applyTheme(t: Theme): void {
@@ -52,6 +52,6 @@ export function useTheme(): Theme {
return () => listeners.delete(cb);
},
getTheme,
- () => "dark",
+ () => "light",
);
}
From 3875ba1890948aa6a3a0cfc1774b87bedd76ba57 Mon Sep 17 00:00:00 2001
From: srikanth-bitdynamics
<259878899+srikanth-bitdynamics@users.noreply.github.com>
Date: Fri, 10 Jul 2026 22:41:14 +0530
Subject: [PATCH 10/14] fix(charts): give the area-fill gradient an id-safe
handle
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The AreaChart built its gradient id from series.label, so a label with
spaces ("ACS lookup buffer") produced url(#area-ACS lookup buffer) — an
invalid reference, which falls back to the initial fill value, black. It
was invisible on the dark background and became a solid black block once
light became the default. Derive the id from useId() instead, so it is
always url-safe and unique.
---
frontend/src/components/charts/AreaChart.tsx | 10 +++++++---
1 file changed, 7 insertions(+), 3 deletions(-)
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 ? (
<>
-
+
Date: Fri, 10 Jul 2026 22:41:14 +0530
Subject: [PATCH 11/14] ui: make the catch-all route read as a 404, not an
unshipped stub
Placeholder renders only for the path="*" wildcard, but its comment and
copy ("Route stub for screens whose backend hasn't landed yet", "Not
implemented yet in this build") implied unfinished features exist. Reword
to a plain not-found message.
---
frontend/src/screens/Placeholder.tsx | 6 +++---
internal/ui/dist/index.html | 6 +++---
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/frontend/src/screens/Placeholder.tsx b/frontend/src/screens/Placeholder.tsx
index 8133f996..e0f94f4e 100644
--- a/frontend/src/screens/Placeholder.tsx
+++ b/frontend/src/screens/Placeholder.tsx
@@ -1,6 +1,6 @@
import { W, R } from "../tokens";
-// Route stub for screens whose backend hasn't landed yet.
+// 404 page for the `path="*"` catch-all — the only place this renders.
export function Placeholder({ name }: { name: string }) {
return (
- Not implemented yet in this build. Pick another screen from the
- sidebar or press ⌘K.
+ That route doesn’t exist. Pick another screen from the sidebar or
+ press ⌘K.
);
diff --git a/internal/ui/dist/index.html b/internal/ui/dist/index.html
index d31c3e8e..ff6b1792 100644
--- a/internal/ui/dist/index.html
+++ b/internal/ui/dist/index.html
@@ -1,12 +1,12 @@
-
+
-
+
canton-devkit
-
+
From 8180a806bd2fb02871341230cc8d840d71099745 Mon Sep 17 00:00:00 2001
From: srikanth-bitdynamics
<259878899+srikanth-bitdynamics@users.noreply.github.com>
Date: Fri, 10 Jul 2026 23:19:32 +0530
Subject: [PATCH 12/14] grafana: switch the bundled dashboard to figures that
work on 0.6.4
Third surface of the same fix already applied to the CLI and Web UI. The
bundled canton-localnet dashboard led with histogram_quantile p95/p50
latency panels, which are NaN on stock Splice 0.6.4 (its sequencing
histogram exports only the +Inf bucket), and an ACS panel querying
daml_participant_api_index_db_active_contract_lookup_batch_buffer_length,
a metric 0.6.4 no longer emits.
- Both latency panels now show the mean (sum/count) in seconds, labelled
as an average, with a note that percentiles return on versions whose
histograms carry finite buckets.
- The ACS panel points at the live daml_participant_api_index_active_
contracts_buffer_size gauge.
- docs/dashboard-customization.md updated to match, and its claim that the
p95 panel was "audited on stock Splice 0.6.4" (the NaN case) is removed.
---
.../grafana/dashboards/canton-localnet.json | 26 +++++++++----------
docs/dashboard-customization.md | 6 ++---
2 files changed, 16 insertions(+), 16 deletions(-)
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
From 40a169ff46538653a6b68f8467f73a02b6328c20 Mon Sep 17 00:00:00 2001
From: Zhe Li
Date: Fri, 10 Jul 2026 19:11:21 +0200
Subject: [PATCH 13/14] fix(ui): restore dist/index.html placeholder + guard
test
The committed index.html referenced hashed Vite assets
(assets/index-*.js/.css) that are git-ignored and thus never committed,
so a fresh checkout pointed at nonexistent files. Restore the tracked
DEVKIT_FRONTEND_PLACEHOLDER version and document, in the file and
.gitignore, that a real build must not be committed over it.
Add TestFrontend_TrackedDistIsPlaceholder, which asserts the
git-tracked (HEAD) dist/index.html still carries the placeholder
sentinel. Existing tests only checked the working-tree file (which
make frontend legitimately overwrites) and accepted either a
placeholder or a real build, so neither caught this regression class.
---
.gitignore | 5 +++++
internal/ui/dist/index.html | 34 +++++++++++++++++++++--------
internal/ui/frontend_schema_test.go | 31 ++++++++++++++++++++++++++
3 files changed, 61 insertions(+), 9 deletions(-)
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/internal/ui/dist/index.html b/internal/ui/dist/index.html
index ff6b1792..3f4376ef 100644
--- a/internal/ui/dist/index.html
+++ b/internal/ui/dist/index.html
@@ -1,15 +1,31 @@
-
+
+
-
-
-
-
- canton-devkit
-
-
+
+ canton-devkit Web UI — frontend not built
+
+
+
-
+ canton-devkit Web UI
+ This is the placeholder page. The real Vite/React UI
+ wasn't built, so only this committed index.html is embedded
+ (the JS/CSS bundle is git-ignored and produced at build time).
+ Build the frontend, then rebuild the binary:
+ make frontend && make build
+# or: cd frontend && npm ci && npm run build (then go build ./cmd/canton-devkit)
+ Released binaries already include the bundle — this page only
+ appears on a plain go build from source. The CLI is unaffected.
diff --git a/internal/ui/frontend_schema_test.go b/internal/ui/frontend_schema_test.go
index 279b1cd5..4fe582bc 100644
--- a/internal/ui/frontend_schema_test.go
+++ b/internal/ui/frontend_schema_test.go
@@ -2,6 +2,7 @@ package ui
import (
"os"
+ "os/exec"
"path/filepath"
"regexp"
"strconv"
@@ -66,3 +67,33 @@ func TestFrontend_DistContainsRealBuildOrPlaceholder(t *testing.T) {
string(body[:min(200, len(body))]))
}
}
+
+// TestFrontend_TrackedDistIsPlaceholder pins the git-tracked content of
+// dist/index.html to the placeholder. The working-tree file is
+// legitimately overwritten by `make frontend`, so this checks what's
+// committed (git show HEAD:...) rather than the file on disk.
+//
+// Why this matters: a real Vite build's index.html references hashed
+// /assets/index-*.js and *.css files that are git-ignored. Committing
+// such a build over the placeholder means a fresh checkout points at
+// files that were never committed. Keep the placeholder tracked; let
+// `make frontend` produce the real bundle at build time.
+func TestFrontend_TrackedDistIsPlaceholder(t *testing.T) {
+ // Package dir is internal/ui; repo root is two levels up.
+ cmd := exec.Command("git", "show", "HEAD:internal/ui/dist/index.html")
+ cmd.Dir = filepath.Join("..", "..")
+ out, err := cmd.Output()
+ if err != nil {
+ // No git (source tarball) or path not tracked: nothing to
+ // assert about committed content. Skip, consistent with the
+ // other frontend tests that tolerate a missing source tree.
+ t.Skipf("cannot read git-tracked dist/index.html, skipping: %v", err)
+ }
+ if !strings.Contains(string(out), placeholderSentinel) {
+ t.Errorf("git-tracked internal/ui/dist/index.html is not the placeholder "+
+ "(missing %q). A real Vite build was likely committed over it; the "+
+ "hashed /assets/*.js and *.css it references are git-ignored, so a "+
+ "fresh checkout breaks. Restore the placeholder version.\n%s",
+ placeholderSentinel, string(out[:min(200, len(out))]))
+ }
+}
From 2e8b136046a9ca857aa75e692040214692bdb9fe Mon Sep 17 00:00:00 2001
From: Zhe Li
Date: Fri, 10 Jul 2026 21:39:46 +0200
Subject: [PATCH 14/14] test(assets): sync ACS panel title pin with dashboard
rename
Commit 8180a80 renamed Grafana panel id=14 from "ACS Lookup Buffer
Length" to "ACS Lookup Buffer" (and switched its metric query to the
live 0.6.4 signal) but did not update the pinning test, so
TestDashboardHasACSAndThroughputPanels failed in CI. Align the expected
title with the shipped dashboard JSON.
---
assets/dashboard_test.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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{}