diff --git a/dashboard/src/components/EmptyState.tsx b/dashboard/src/components/EmptyState.tsx new file mode 100644 index 0000000..b28b63b --- /dev/null +++ b/dashboard/src/components/EmptyState.tsx @@ -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 = { + 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 ( +
+ +
+

{title}

+

{description}

+ {action && ( +
+ {'href' in action ? ( + + {action.label} + + ) : ( + + )} +
+ )} + {hint &&

{hint}

} +
+
+ ); +} \ No newline at end of file diff --git a/dashboard/src/pages/admin/BootstrapEditor.tsx b/dashboard/src/pages/admin/BootstrapEditor.tsx index 14b1348..f9dcd20 100644 --- a/dashboard/src/pages/admin/BootstrapEditor.tsx +++ b/dashboard/src/pages/admin/BootstrapEditor.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import type { BootstrapIssuer } from '../../api/admin'; +import { EmptyState } from '../../components/EmptyState'; export interface BootstrapEditorProps { issuers: BootstrapIssuer[]; @@ -10,7 +11,18 @@ const STEPS = [1.0, 0.5, 0.25, 0]; export function BootstrapEditor({ issuers, onUpdate }: BootstrapEditorProps) { if (issuers.length === 0) { - return

No bootstrap issuers registered.

; + return ( + + ); } return ( diff --git a/dashboard/src/pages/admin/GraphHealth.tsx b/dashboard/src/pages/admin/GraphHealth.tsx index 84c97fc..e32e5c8 100644 --- a/dashboard/src/pages/admin/GraphHealth.tsx +++ b/dashboard/src/pages/admin/GraphHealth.tsx @@ -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

Loading graph health...

; + + if (summary.principals.total === 0) { + return ( + + ); + } + return (
diff --git a/dashboard/src/pages/admin/IssuerVerificationQueue.tsx b/dashboard/src/pages/admin/IssuerVerificationQueue.tsx index d0279fa..526178c 100644 --- a/dashboard/src/pages/admin/IssuerVerificationQueue.tsx +++ b/dashboard/src/pages/admin/IssuerVerificationQueue.tsx @@ -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

No issuers awaiting verification.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/admin/TenantList.tsx b/dashboard/src/pages/admin/TenantList.tsx index 6d4d113..5add062 100644 --- a/dashboard/src/pages/admin/TenantList.tsx +++ b/dashboard/src/pages/admin/TenantList.tsx @@ -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

No tenants visible to this account.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/agent-builder/AttestationFeed.tsx b/dashboard/src/pages/agent-builder/AttestationFeed.tsx index 9f45d5c..fc67020 100644 --- a/dashboard/src/pages/agent-builder/AttestationFeed.tsx +++ b/dashboard/src/pages/agent-builder/AttestationFeed.tsx @@ -1,4 +1,5 @@ import type { AttestationRow } from '../../api/agentBuilder'; +import { EmptyState } from '../../components/EmptyState'; function shortId(id: string): string { const parts = id.split(':'); @@ -14,10 +15,30 @@ export function AttestationFeed({ items: AttestationRow[]; }) { if (items.length === 0) { + const isIn = direction === 'in'; return ( -

- No {direction === 'in' ? 'incoming' : 'outgoing'} attestations visible. -

+ ); } return ( diff --git a/dashboard/src/pages/agent-builder/KeyList.tsx b/dashboard/src/pages/agent-builder/KeyList.tsx index 25d83b2..137c338 100644 --- a/dashboard/src/pages/agent-builder/KeyList.tsx +++ b/dashboard/src/pages/agent-builder/KeyList.tsx @@ -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

No keys registered for this principal.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/agent-builder/PrincipalList.tsx b/dashboard/src/pages/agent-builder/PrincipalList.tsx index eb0705b..e502182 100644 --- a/dashboard/src/pages/agent-builder/PrincipalList.tsx +++ b/dashboard/src/pages/agent-builder/PrincipalList.tsx @@ -1,4 +1,5 @@ import type { OwnedPrincipal } from '../../api/agentBuilder'; +import { EmptyState } from '../../components/EmptyState'; export interface PrincipalListProps { principals: OwnedPrincipal[]; @@ -8,7 +9,18 @@ export interface PrincipalListProps { export function PrincipalList({ principals, selectedId, onSelect }: PrincipalListProps) { if (principals.length === 0) { - return

No owned principals yet. Create one to start building.

; + return ( + + ); } return (
    @@ -22,7 +34,7 @@ export function PrincipalList({ principals, selectedId, onSelect }: PrincipalLis {p.name ?? p.id} {p.entity_kind} - {' · '} + {' \u00B7 '} {p.assurance_level === 'verified_key' ? ( verified key ) : ( diff --git a/dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx b/dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx index f6c896e..2631fd9 100644 --- a/dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx +++ b/dashboard/src/pages/agent-builder/ScoreHistoryChart.tsx @@ -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

    No score history recorded for this principal yet.

    ; + return ( + + ); } return (
    diff --git a/dashboard/src/pages/provider/AgentList.tsx b/dashboard/src/pages/provider/AgentList.tsx index 81f0306..9c64175 100644 --- a/dashboard/src/pages/provider/AgentList.tsx +++ b/dashboard/src/pages/provider/AgentList.tsx @@ -1,7 +1,7 @@ import type { AgentRow } from '../../api/provider'; +import { EmptyState } from '../../components/EmptyState'; function shortId(id: string): string { - // vrl:p: -> 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; @@ -9,7 +9,18 @@ function shortId(id: string): string { export function AgentList({ agents }: { agents: AgentRow[] }) { if (agents.length === 0) { - return

    No agents observed in the last 24 hours.

    ; + return ( + + ); } return (
@@ -33,7 +44,6 @@ export function AgentList({ agents }: { agents: AgentRow[] }) {
{a.decisions} {a.score ?? -} - {/* blacklisted comes from network_scores only - never inferred from score */} {a.blacklisted ? ( blacklisted ) : a.score === null ? ( diff --git a/dashboard/src/pages/provider/DecisionFeed.tsx b/dashboard/src/pages/provider/DecisionFeed.tsx index 57f5c4e..b45e3e1 100644 --- a/dashboard/src/pages/provider/DecisionFeed.tsx +++ b/dashboard/src/pages/provider/DecisionFeed.tsx @@ -1,4 +1,5 @@ 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; @@ -6,7 +7,18 @@ function shortFingerprint(fp: string): string { export function DecisionFeed({ samples }: { samples: SampleRow[] }) { if (samples.length === 0) { - return

No sampled decisions in the last 24 hours.

; + return ( + + ); } return ( diff --git a/dashboard/src/pages/provider/EdgeSyncStatus.tsx b/dashboard/src/pages/provider/EdgeSyncStatus.tsx index 6193e57..714b756 100644 --- a/dashboard/src/pages/provider/EdgeSyncStatus.tsx +++ b/dashboard/src/pages/provider/EdgeSyncStatus.tsx @@ -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

No edge nodes registered for this tenant yet.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/provider/PolicyCard.tsx b/dashboard/src/pages/provider/PolicyCard.tsx index 3456711..d0e81f4 100644 --- a/dashboard/src/pages/provider/PolicyCard.tsx +++ b/dashboard/src/pages/provider/PolicyCard.tsx @@ -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

No active policy for this tenant.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/provider/StalenessBanner.tsx b/dashboard/src/pages/provider/StalenessBanner.tsx index 7d615d1..3fd3c03 100644 --- a/dashboard/src/pages/provider/StalenessBanner.tsx +++ b/dashboard/src/pages/provider/StalenessBanner.tsx @@ -2,7 +2,16 @@ export function StalenessBanner({ show }: { show: boolean }) { if (!show) return null; return (
- Scores may be stale — no successful network score write in over an hour. + +
+

Scores may be stale

+

+ No successful network score write in over an hour. This usually means the trust-engine is not running or cannot reach the control plane. +

+
+ + Troubleshoot +
); } diff --git a/dashboard/src/pages/provider/TrustSummaryChart.tsx b/dashboard/src/pages/provider/TrustSummaryChart.tsx index ac034a9..1e2d295 100644 --- a/dashboard/src/pages/provider/TrustSummaryChart.tsx +++ b/dashboard/src/pages/provider/TrustSummaryChart.tsx @@ -9,6 +9,7 @@ import { YAxis, } from 'recharts'; import type { BucketPoint } from '../../lib/aggregates'; +import { EmptyState } from '../../components/EmptyState'; const ACTION_COLORS: Record = { allow: '#0f6b4c', @@ -18,7 +19,14 @@ const ACTION_COLORS: Record = { export function TrustSummaryChart({ points }: { points: BucketPoint[] }) { if (points.length === 0) { - return

No decisions in the last 24 hours.

; + return ( + + ); } return (
diff --git a/dashboard/src/pages/provider/components.test.tsx b/dashboard/src/pages/provider/components.test.tsx index c7a4be8..d661152 100644 --- a/dashboard/src/pages/provider/components.test.tsx +++ b/dashboard/src/pages/provider/components.test.tsx @@ -84,6 +84,6 @@ describe('DecisionFeed', () => { it('renders an empty state', () => { render(); - expect(screen.getByText(/No sampled decisions/)).toBeTruthy(); + expect(screen.getByText(/No decisions recorded/)).toBeTruthy(); }); }); diff --git a/dashboard/src/styles/index.css b/dashboard/src/styles/index.css index 7055cdd..634867a 100644 --- a/dashboard/src/styles/index.css +++ b/dashboard/src/styles/index.css @@ -424,3 +424,94 @@ a { background: var(--danger); color: #fff; } + +/* Empty state component */ +.empty-state { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 1.5rem 1rem; +} +.empty-state__icon { + font-size: 2rem; + line-height: 1; + flex-shrink: 0; + padding-top: 0.25rem; +} +.empty-state__body { + flex: 1; +} +.empty-state__title { + font-weight: 600; + margin: 0 0 0.25rem 0; + color: var(--ink); +} +.empty-state__desc { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + line-height: 1.4; +} +.empty-state__action { + margin-bottom: 0.5rem; +} +.empty-state__cta { + display: inline-block; + padding: 0.4rem 0.9rem; + border: 1px solid var(--accent); + border-radius: 4px; + background: transparent; + color: var(--accent); + font-size: 0.875rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.empty-state__cta:hover { + background: var(--accent); + color: #fff; +} +.empty-state__hint { + font-size: 0.8rem; + margin: 0; + font-style: italic; +} + +/* Enhanced staleness banner */ +.banner--stale { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border: 1px solid var(--danger); + border-radius: 6px; + background: #fff5f5; + color: var(--danger); +} +.banner--stale__icon { + font-size: 1.25rem; + flex-shrink: 0; +} +.banner--stale__body { + flex: 1; +} +.banner--stale__title { + font-weight: 600; + margin: 0; +} +.banner--stale__desc { + font-size: 0.8rem; + margin: 0.25rem 0 0 0; + color: var(--muted); +} +.banner--stale__action { + font-size: 0.8rem; + color: var(--accent); + text-decoration: none; + font-weight: 500; + white-space: nowrap; +} +.banner--stale__action:hover { + text-decoration: underline; +}