Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions dashboard/src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@


export type EmptyStateAction =
| { label: string; href: string; onClick?: never }
| { label: string; onClick: () => void; href?: never };

export interface EmptyStateProps {
icon: string;
title: string;
description: string;
action?: EmptyStateAction;
hint?: string;
}

const ICONS: Record<string, string> = {
edge: '\u{1F5A8}',
agents: '\u{1F916}',
decisions: '\u{1F4CB}',
policy: '\u{2696}',
graph: '\u{1F578}',
tenants: '\u{1F3E2}',
bootstrap: '\u{1F331}',
shield: '\u{1F6E1}',
keys: '\u{1F511}',
score: '\u{1F4C8}',
attest: '\u{1F4DD}',
issuer: '\u{1F5D3}',
clock: '\u{23F0}',
chart: '\u{1F4CA}',
plug: '\u{1F50C}',
seedling: '\u{1F331}',
warning: '\u{26A0}',
empty: '\u{1F4ED}',
};

export function EmptyState({ icon, title, description, action, hint }: EmptyStateProps) {
const glyph = ICONS[icon] ?? ICONS.empty;
return (
<div className="empty-state">
<div className="empty-state__icon" aria-hidden="true">{glyph}</div>
<div className="empty-state__body">
<p className="empty-state__title">{title}</p>
<p className="empty-state__desc muted">{description}</p>
{action && (
<div className="empty-state__action">
{'href' in action ? (
<a href={action.href} className="empty-state__cta" target="_blank" rel="noopener noreferrer">
{action.label}
</a>
) : (
<button type="button" className="empty-state__cta" onClick={action.onClick}>
{action.label}
</button>
)}
</div>
)}
{hint && <p className="empty-state__hint muted">{hint}</p>}
</div>
</div>
);
}
14 changes: 13 additions & 1 deletion dashboard/src/pages/admin/BootstrapEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState } from 'react';
import type { BootstrapIssuer } from '../../api/admin';
import { EmptyState } from '../../components/EmptyState';

export interface BootstrapEditorProps {
issuers: BootstrapIssuer[];
Expand All @@ -10,7 +11,18 @@ const STEPS = [1.0, 0.5, 0.25, 0];

export function BootstrapEditor({ issuers, onUpdate }: BootstrapEditorProps) {
if (issuers.length === 0) {
return <p className="muted">No bootstrap issuers registered.</p>;
return (
<EmptyState
icon="bootstrap"
title="No bootstrap issuers registered"
description="The bootstrap registry is empty. Run the seed script to populate the curated root-of-truth with the VeriLink bootstrap issuer and initial seed agents."
action={{
label: 'Open bootstrap runbook',
href: 'https://github.com/Numeracode/verilink/blob/main/docs/superpowers/plans/2026-08-11-plan-10-bootstrap-cold-start.md',
}}
hint="The seed is idempotent: reruns are no-ops and staff de-emphasis state is preserved."
/>
);
}
return (
<table className="table">
Expand Down
18 changes: 17 additions & 1 deletion dashboard/src/pages/admin/GraphHealth.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,24 @@
import type { GraphSummary } from '../../api/provider';
import { EmptyState } from '../../components/EmptyState';

/** Read-only graph health (Plan 9 Decision 5: no path explorer). */
export function GraphHealth({ summary }: { summary: GraphSummary | undefined }) {
if (!summary) return <p className="muted">Loading graph health...</p>;

if (summary.principals.total === 0) {
return (
<EmptyState
icon="graph"
title="Trust graph is empty"
description="The graph will populate once principals (agents and issuers) are registered and attestations start flowing. Run the bootstrap seed to initialize the root-of-trust registry."
action={{
label: 'Open bootstrap runbook',
href: 'https://github.com/Numeracode/verilink/blob/main/docs/superpowers/plans/2026-08-11-plan-10-bootstrap-cold-start.md',
}}
hint="The seed creates the VeriLink bootstrap issuer and initial attestations for a non-empty cold-start graph."
/>
);
}

return (
<dl className="kv">
<div>
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/pages/admin/IssuerVerificationQueue.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { UnverifiedIssuer } from '../../api/admin';
import { EmptyState } from '../../components/EmptyState';

export function IssuerVerificationQueue({ issuers }: { issuers: UnverifiedIssuer[] }) {
if (issuers.length === 0) {
return <p className="muted">No issuers awaiting verification.</p>;
return (
<EmptyState
icon="issuer"
title="No issuers awaiting verification"
description="New issuers that have submitted a key-control proof but not yet been staff-verified appear here. The queue is empty when all known issuers are verified."
hint="Verification is a manual staff action that sets issuers.verified_at."
/>
);
}
return (
<table className="table">
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/pages/admin/TenantList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import type { TenantRow } from '../../api/admin';
import { EmptyState } from '../../components/EmptyState';

export function TenantList({ tenants }: { tenants: TenantRow[] }) {
if (tenants.length === 0) {
return <p className="muted">No tenants visible to this account.</p>;
return (
<EmptyState
icon="tenants"
title="No tenants visible"
description="Tenants appear here once they are created. Your API key may be scoped to a single tenant; switch to a platform-staff key to see all tenants."
hint="Tenants are the top-level isolation boundary for principals, policies, and edge nodes."
/>
);
}
return (
<table className="table">
Expand Down
27 changes: 24 additions & 3 deletions dashboard/src/pages/agent-builder/AttestationFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { AttestationRow } from '../../api/agentBuilder';
import { EmptyState } from '../../components/EmptyState';

function shortId(id: string): string {
const parts = id.split(':');
Expand All @@ -14,10 +15,30 @@ export function AttestationFeed({
items: AttestationRow[];
}) {
if (items.length === 0) {
const isIn = direction === 'in';
return (
<p className="muted">
No {direction === 'in' ? 'incoming' : 'outgoing'} attestations visible.
</p>
<EmptyState
icon="attest"
title={isIn ? 'No incoming attestations' : 'No outgoing attestations'}
description={
isIn
? 'Other issuers can attest to this principal\'s behaviour. Incoming attestations appear here once a counterparty submits a signed JWS attestation naming this principal as the subject.'
: 'Submit a signed attestation about another principal to start building a trust relationship. Use the Go or Node client to sign and submit a JWS token to the control plane.'
}
action={
isIn
? undefined
: {
label: 'View client docs',
href: 'https://github.com/Numeracode/verilink/tree/main/client',
}
}
hint={
isIn
? 'Attestations are cryptographically signed JWS tokens with RFC 8785 canonicalized facts.'
: 'The client signs with your Ed25519 private key; the control plane verifies and stores the attestation.'
}
/>
);
}
return (
Expand Down
14 changes: 13 additions & 1 deletion dashboard/src/pages/agent-builder/KeyList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import type { PrincipalKey } from '../../api/agentBuilder';
import { EmptyState } from '../../components/EmptyState';

export function KeyList({ keys }: { keys: PrincipalKey[] }) {
if (keys.length === 0) {
return <p className="muted">No keys registered for this principal.</p>;
return (
<EmptyState
icon="keys"
title="No keys registered"
description="This principal has no registered Ed25519 keys. Register a key by submitting an attestation signed with the key, or use the keygen tool to generate a new keypair and register the public key."
action={{
label: 'Generate keypair',
href: 'https://github.com/Numeracode/verilink#keygen',
}}
hint="Key control is verified when the principal proves ownership via a signed challenge."
/>
);
}
return (
<table className="table">
Expand Down
16 changes: 14 additions & 2 deletions dashboard/src/pages/agent-builder/PrincipalList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { OwnedPrincipal } from '../../api/agentBuilder';
import { EmptyState } from '../../components/EmptyState';

export interface PrincipalListProps {
principals: OwnedPrincipal[];
Expand All @@ -8,7 +9,18 @@ export interface PrincipalListProps {

export function PrincipalList({ principals, selectedId, onSelect }: PrincipalListProps) {
if (principals.length === 0) {
return <p className="muted">No owned principals yet. Create one to start building.</p>;
return (
<EmptyState
icon="keys"
title="No owned principals yet"
description="Principals are the identity layer for agents and issuers. Create one by submitting an attestation with a new vrl:p:<uuid> subject, or use the keygen tool to generate a keypair."
action={{
label: 'Generate keypair',
href: 'https://github.com/Numeracode/verilink#keygen',
}}
hint="Each principal has an Ed25519 keypair. The private key stays with you; the public key is registered with VeriLink."
/>
);
}
return (
<ul className="select-list">
Expand All @@ -22,7 +34,7 @@ export function PrincipalList({ principals, selectedId, onSelect }: PrincipalLis
<span className="select-list__title">{p.name ?? p.id}</span>
<span className="select-list__meta">
{p.entity_kind}
{' · '}
{' \u00B7 '}
{p.assurance_level === 'verified_key' ? (
<span className="badge badge--allow">verified key</span>
) : (
Expand Down
10 changes: 9 additions & 1 deletion dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,18 @@ import {
YAxis,
} from 'recharts';
import type { ScoreSeriesPoint } from '../../lib/scoreSeries';
import { EmptyState } from '../../components/EmptyState';

export function ScoreHistoryChart({ points }: { points: ScoreSeriesPoint[] }) {
if (points.length === 0) {
return <p className="muted">No score history recorded for this principal yet.</p>;
return (
<EmptyState
icon="score"
title="No score history yet"
description="This principal\'s network score will appear here once the trust engine computes a score. Scores are recomputed periodically (hourly) and on every new attestation."
hint="The score is derived from the transitive trust graph using the VeriRank algorithm with time decay and distance decay."
/>
);
}
return (
<div className="chart" aria-label="Network score history">
Expand Down
16 changes: 13 additions & 3 deletions dashboard/src/pages/provider/AgentList.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import type { AgentRow } from '../../api/provider';
import { EmptyState } from '../../components/EmptyState';

function shortId(id: string): string {
// vrl:p:<uuid> -> trailing 8 chars keeps tables readable
const parts = id.split(':');
const tail = parts[parts.length - 1] ?? id;
return tail.length > 8 ? `...${tail.slice(-8)}` : tail;
}

export function AgentList({ agents }: { agents: AgentRow[] }) {
if (agents.length === 0) {
return <p className="muted">No agents observed in the last 24 hours.</p>;
return (
<EmptyState
icon="agents"
title="No agents observed yet"
description="Agents appear here once the edge verifier starts processing requests. Deploy the edge verifier in front of your API to start collecting decision data."
action={{
label: 'Deploy edge verifier',
href: 'https://github.com/Numeracode/verilink#edge-verifier',
}}
hint="Agents are identified by their cryptographic fingerprint and named vrl:p:<uuid>."
/>
);
}
return (
<table className="table">
Expand All @@ -33,7 +44,6 @@ export function AgentList({ agents }: { agents: AgentRow[] }) {
<td className="num">{a.decisions}</td>
<td className="num">{a.score ?? <span className="muted">-</span>}</td>
<td>
{/* blacklisted comes from network_scores only - never inferred from score */}
{a.blacklisted ? (
<span className="badge badge--deny">blacklisted</span>
) : a.score === null ? (
Expand Down
14 changes: 13 additions & 1 deletion dashboard/src/pages/provider/DecisionFeed.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import type { SampleRow } from '../../api/provider';
import { EmptyState } from '../../components/EmptyState';

function shortFingerprint(fp: string): string {
return fp.length > 12 ? `${fp.slice(0, 12)}...` : fp;
}

export function DecisionFeed({ samples }: { samples: SampleRow[] }) {
if (samples.length === 0) {
return <p className="muted">No sampled decisions in the last 24 hours.</p>;
return (
<EmptyState
icon="decisions"
title="No decisions recorded yet"
description="The decision feed populates once the edge verifier starts evaluating incoming API requests against your trust policy. All deny decisions are kept; allows are sampled."
action={{
label: 'Configure policy',
href: 'https://github.com/Numeracode/verilink#policies',
}}
hint="Decisions are categorized as allow, deny, or passthrough based on your active policy threshold."
/>
);
}
return (
<table className="table">
Expand Down
14 changes: 13 additions & 1 deletion dashboard/src/pages/provider/EdgeSyncStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import type { EdgeNodeRow } from '../../api/provider';
import { EmptyState } from '../../components/EmptyState';

export function EdgeSyncStatus({ edges }: { edges: EdgeNodeRow[] }) {
if (edges.length === 0) {
return <p className="muted">No edge nodes registered for this tenant yet.</p>;
return (
<EmptyState
icon="edge"
title="No edge nodes registered"
description="Register an edge verifier node to start receiving trust decisions. The edge verifier sits in front of your API and enforces allow/deny based on VeriLink trust scores."
action={{
label: 'View setup guide',
href: 'https://github.com/Numeracode/verilink/blob/main/docs/superpowers/specs/2026-07-25-verilink-productization-design.md',
}}
hint="The edge verifier connects to the control plane via SSE sync and enforces your active policy."
/>
);
}
return (
<table className="table">
Expand Down
15 changes: 13 additions & 2 deletions dashboard/src/pages/provider/PolicyCard.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
import type { Policy } from '../../api/provider';
import { EmptyState } from '../../components/EmptyState';

/** Read-only active policy summary. Editing lands in Plan 9 PR C. */
export function PolicyCard({ policy }: { policy: Policy | null }) {
if (!policy) {
return <p className="muted">No active policy for this tenant.</p>;
return (
<EmptyState
icon="policy"
title="No active policy set"
description="An active policy defines the trust score threshold, below-threshold action, and unsigned request handling. Set one to start enforcing trust decisions at the edge."
action={{
label: 'Set active policy',
href: 'https://github.com/Numeracode/verilink#policies',
}}
hint="The policy is per-tenant and synced to all edge nodes via SSE."
/>
);
}
return (
<dl className="kv">
Expand Down
11 changes: 10 additions & 1 deletion dashboard/src/pages/provider/StalenessBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,16 @@ export function StalenessBanner({ show }: { show: boolean }) {
if (!show) return null;
return (
<div className="banner banner--stale" role="status">
Scores may be stale — no successful network score write in over an hour.
<span className="banner--stale__icon" aria-hidden="true">{'\u26A0'}</span>
<div className="banner--stale__body">
<p className="banner--stale__title">Scores may be stale</p>
<p className="banner--stale__desc">
No successful network score write in over an hour. This usually means the trust-engine is not running or cannot reach the control plane.
</p>
</div>
<a href="https://github.com/Numeracode/verilink/blob/main/docs/gate-contract.md" className="banner--stale__action" target="_blank" rel="noopener noreferrer">
Troubleshoot
</a>
</div>
);
}
Loading
Loading