feat: add live H1-H7 trace explorer
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api, type HarnessGateNode, type HarnessGateStatus, type HarnessTraceGraph } from '../../lib/api';
|
||||
import { Card, StatusBadge } from '../ui/Card';
|
||||
|
||||
const NODE_TONE: Record<HarnessGateStatus, string> = {
|
||||
queued: 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
running: 'border-blue-300 bg-blue-50 text-blue-800 shadow-[0_0_0_4px_rgba(59,130,246,0.1)]',
|
||||
pass: 'border-emerald-300 bg-emerald-50 text-emerald-800',
|
||||
warning: 'border-amber-300 bg-amber-50 text-amber-800',
|
||||
blocked: 'border-rose-400 bg-rose-50 text-rose-800 shadow-[0_0_0_4px_rgba(244,63,94,0.09)]',
|
||||
error: 'border-rose-400 bg-rose-50 text-rose-800 shadow-[0_0_0_4px_rgba(244,63,94,0.09)]',
|
||||
skipped: 'border-slate-200 bg-slate-100 text-slate-400',
|
||||
};
|
||||
|
||||
const STATUS_MARK: Record<HarnessGateStatus, string> = {
|
||||
queued: '○',
|
||||
running: '●',
|
||||
pass: '✓',
|
||||
warning: '!',
|
||||
blocked: '×',
|
||||
error: '×',
|
||||
skipped: '–',
|
||||
};
|
||||
|
||||
function Evidence({ node }: { node: HarnessGateNode }) {
|
||||
const entries = Object.entries(node.evidence);
|
||||
return (
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.8fr)]">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{node.title}</h3>
|
||||
<StatusBadge value={node.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-500">{node.description}</p>
|
||||
<div className="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Latest decision</div>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-700">{node.reason}</p>
|
||||
{node.updated_at && <p className="mt-2 text-xs text-slate-400">{node.updated_at.replace('T', ' ').replace('Z', '')}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-950 p-4 text-slate-100">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-slate-400">Safe evidence</div>
|
||||
{entries.length > 0 ? (
|
||||
<dl className="mt-3 space-y-2 text-xs">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="grid grid-cols-[8rem_minmax(0,1fr)] gap-3 border-b border-slate-800 pb-2 last:border-0">
|
||||
<dt className="font-medium text-slate-400">{key}</dt>
|
||||
<dd className="break-all font-mono text-slate-200">{typeof value === 'string' ? value : JSON.stringify(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : <p className="mt-3 text-sm text-slate-500">No evidence has been emitted for this gate yet.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraceExplorer({ traceId, onClose }: { traceId: string; onClose?: () => void }) {
|
||||
const query = useQuery({
|
||||
queryKey: ['trace-graph', traceId],
|
||||
queryFn: () => api.traceGraph(traceId),
|
||||
enabled: Boolean(traceId),
|
||||
});
|
||||
const [graph, setGraph] = useState<HarnessTraceGraph | null>(null);
|
||||
const [selectedId, setSelectedId] = useState('H1-context');
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) setGraph(query.data);
|
||||
}, [query.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!traceId) return undefined;
|
||||
return api.watchTrace(traceId, setGraph);
|
||||
}, [traceId]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => graph?.nodes.find((node) => node.id === selectedId) ?? graph?.nodes[0] ?? null,
|
||||
[graph, selectedId],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Harness trace explorer"
|
||||
right={onClose && <button type="button" onClick={onClose} className="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:bg-slate-50">Close</button>}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.13em] text-slate-400">Trace ID</p>
|
||||
<p className="mt-1 break-all font-mono text-sm text-slate-700">{traceId}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<span className={`h-2 w-2 rounded-full ${graph?.terminal ? 'bg-emerald-500' : 'animate-pulse bg-blue-500'}`} />
|
||||
{graph?.terminal ? 'Completed' : 'Live · waiting for events'}
|
||||
<span className="text-slate-300">·</span>
|
||||
{graph?.progress ?? 0}/7 gates
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading && !graph ? <div className="py-10 text-center text-sm text-slate-500">Loading trace…</div> : (
|
||||
<>
|
||||
<div className="overflow-x-auto pb-5">
|
||||
<div className="flex min-w-max items-center px-1 py-3">
|
||||
{graph?.nodes.map((node, index) => {
|
||||
const next = graph.nodes[index + 1];
|
||||
const edgeActive = node.status === 'pass' || node.status === 'running' || node.status === 'warning' || next?.status === 'running';
|
||||
return (
|
||||
<div key={node.id} className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selectedId === node.id}
|
||||
onClick={() => setSelectedId(node.id)}
|
||||
className={`w-40 rounded-xl border p-3 text-left transition duration-200 hover:-translate-y-0.5 hover:shadow-md ${NODE_TONE[node.status]} ${selectedId === node.id ? 'ring-2 ring-slate-900/10' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-bold tracking-wide">{node.title.split(' · ')[0]}</span>
|
||||
<span className={`flex h-6 w-6 items-center justify-center rounded-full border border-current text-xs ${node.status === 'running' ? 'animate-pulse' : ''}`}>{STATUS_MARK[node.status]}</span>
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold">{node.title.split(' · ')[1]}</div>
|
||||
<div className="mt-1 truncate text-[11px] opacity-70">{node.reason}</div>
|
||||
</button>
|
||||
{next && <div className={`trace-edge mx-2 ${edgeActive ? 'trace-edge-active' : ''}`} aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{selected && <Evidence node={selected} />}
|
||||
{!graph?.found && <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">This is a legacy or not-yet-started trace. New chat turns emit detailed H1–H7 events automatically.</div>}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -23,8 +23,9 @@ export function StatTile({ label, value, sub }: { label: string; value: ReactNod
|
||||
}
|
||||
const TONE: Record<string, string> = {
|
||||
ok: 'border-emerald-200 bg-emerald-50 text-emerald-700', pass: 'border-emerald-200 bg-emerald-50 text-emerald-700', success: 'border-emerald-200 bg-emerald-50 text-emerald-700', allow: 'border-emerald-200 bg-emerald-50 text-emerald-700', approved: 'border-emerald-200 bg-emerald-50 text-emerald-700', answered: 'border-emerald-200 bg-emerald-50 text-emerald-700', certified: 'border-emerald-200 bg-emerald-50 text-emerald-700', verified: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||
warn: 'border-amber-200 bg-amber-50 text-amber-700', stale: 'border-amber-200 bg-amber-50 text-amber-700', draft: 'border-amber-200 bg-amber-50 text-amber-700', pending: 'border-amber-200 bg-amber-50 text-amber-700', attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
fail: 'border-rose-200 bg-rose-50 text-rose-700', failed: 'border-rose-200 bg-rose-50 text-rose-700', denied: 'border-rose-200 bg-rose-50 text-rose-700', blocked: 'border-rose-200 bg-rose-50 text-rose-700', deny: 'border-rose-200 bg-rose-50 text-rose-700', block: 'border-rose-200 bg-rose-50 text-rose-700', crit: 'border-rose-200 bg-rose-50 text-rose-700', breach: 'border-rose-200 bg-rose-50 text-rose-700', halted: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
running: 'border-blue-200 bg-blue-50 text-blue-700', streaming: 'border-blue-200 bg-blue-50 text-blue-700',
|
||||
warn: 'border-amber-200 bg-amber-50 text-amber-700', warning: 'border-amber-200 bg-amber-50 text-amber-700', stale: 'border-amber-200 bg-amber-50 text-amber-700', draft: 'border-amber-200 bg-amber-50 text-amber-700', pending: 'border-amber-200 bg-amber-50 text-amber-700', attention: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
fail: 'border-rose-200 bg-rose-50 text-rose-700', failed: 'border-rose-200 bg-rose-50 text-rose-700', error: 'border-rose-200 bg-rose-50 text-rose-700', denied: 'border-rose-200 bg-rose-50 text-rose-700', blocked: 'border-rose-200 bg-rose-50 text-rose-700', deny: 'border-rose-200 bg-rose-50 text-rose-700', block: 'border-rose-200 bg-rose-50 text-rose-700', crit: 'border-rose-200 bg-rose-50 text-rose-700', breach: 'border-rose-200 bg-rose-50 text-rose-700', halted: 'border-rose-200 bg-rose-50 text-rose-700',
|
||||
};
|
||||
export function StatusBadge({ value }: { value: string }) {
|
||||
const tone = TONE[String(value).toLowerCase()] ?? 'border-slate-200 bg-slate-50 text-slate-600';
|
||||
|
||||
@@ -27,3 +27,37 @@ button, input, select, textarea { font: inherit; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #c8d0dd; border: 3px solid transparent; border-radius: 999px; background-clip: padding-box; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
@keyframes trace-flow {
|
||||
from { transform: translateX(-150%); }
|
||||
to { transform: translateX(350%); }
|
||||
}
|
||||
|
||||
.trace-edge {
|
||||
position: relative;
|
||||
width: 2.75rem;
|
||||
height: 2px;
|
||||
flex: 0 0 2.75rem;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.trace-edge-active {
|
||||
background: #93c5fd;
|
||||
}
|
||||
|
||||
.trace-edge-active::after {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: 0;
|
||||
width: 35%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, #2563eb, transparent);
|
||||
content: "";
|
||||
animation: trace-flow 1.2s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trace-edge-active::after { animation: none; }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,17 @@ async function post<T>(path: string, body: unknown, headers: Record<string, stri
|
||||
|
||||
export interface Freshness { stale: boolean; age_s: number | null; stale_after_s: number }
|
||||
|
||||
export interface HarnessRunRecord {
|
||||
timestamp?: string;
|
||||
trace_id?: string;
|
||||
harness?: string;
|
||||
step?: string;
|
||||
status?: string;
|
||||
latency_ms?: number;
|
||||
total_tokens?: number;
|
||||
cost_estimate?: number;
|
||||
}
|
||||
|
||||
export interface Overview extends Freshness {
|
||||
totals: {
|
||||
runs: number; total_cost: number; avg_latency_ms: number; failures: number;
|
||||
@@ -217,6 +228,38 @@ export interface ProviderAuthStatus {
|
||||
authMethod: string;
|
||||
}
|
||||
|
||||
export type HarnessGateStatus = 'queued' | 'running' | 'pass' | 'warning' | 'blocked' | 'error' | 'skipped';
|
||||
|
||||
export interface HarnessTraceEvent {
|
||||
timestamp: string;
|
||||
trace_id: string;
|
||||
gate_id: string;
|
||||
status: HarnessGateStatus;
|
||||
reason: string;
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface HarnessGateNode {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: HarnessGateStatus;
|
||||
reason: string;
|
||||
updated_at: string | null;
|
||||
evidence: Record<string, unknown>;
|
||||
events: HarnessTraceEvent[];
|
||||
}
|
||||
|
||||
export interface HarnessTraceGraph {
|
||||
found: boolean;
|
||||
trace_id: string;
|
||||
updated_at: string | null;
|
||||
terminal: boolean;
|
||||
progress: number;
|
||||
nodes: HarnessGateNode[];
|
||||
events: HarnessTraceEvent[];
|
||||
}
|
||||
|
||||
export interface ChatReplay {
|
||||
ok: boolean;
|
||||
decision: 'MATCH' | 'DRIFT' | 'BREAK' | string;
|
||||
@@ -278,7 +321,22 @@ function actorHeaders(actor: SettingsActor): Record<string, string> {
|
||||
|
||||
export const api = {
|
||||
overview: () => get<Overview>('overview'),
|
||||
runs: (limit = 50) => get<Freshness & { count: number; runs: any[] }>(`runs?limit=${limit}`),
|
||||
runs: (limit = 50) => get<Freshness & { count: number; runs: HarnessRunRecord[] }>(`runs?limit=${limit}`),
|
||||
traceGraph: (traceId: string) => get<HarnessTraceGraph>(`runs/${encodeURIComponent(traceId)}/graph`),
|
||||
watchTrace: (traceId: string, onGraph: (graph: HarnessTraceGraph) => void, onError?: () => void) => {
|
||||
const base = import.meta.env.VITE_API_BASE_URL ?? '/api/v1';
|
||||
const stream = new EventSource(`${base}/runs/${encodeURIComponent(traceId)}/events`, { withCredentials: true });
|
||||
const handler = (event: MessageEvent<string>) => {
|
||||
try {
|
||||
onGraph(JSON.parse(event.data) as HarnessTraceGraph);
|
||||
} catch {
|
||||
onError?.();
|
||||
}
|
||||
};
|
||||
stream.addEventListener('trace', handler as EventListener);
|
||||
stream.onerror = () => onError?.();
|
||||
return () => stream.close();
|
||||
},
|
||||
governance: () => get<Freshness & { records: number; by_decision: Record<string, number>; head: string | null; recent: any[] }>('governance'),
|
||||
security: () => get<Freshness & { verdicts: number; by_status: Record<string, number>; benign_fp: any; recent: any[] }>('security'),
|
||||
incidents: () => get<Freshness & { total: number; kill_switch_scopes: string[]; incidents: any[] }>('incidents'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type KeyboardEvent, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api, ChatAction, ChatAgent, ChatAnswer, ChatHistoryTurn, ChatModelProvider, ChatStreamPhase, SettingsActor } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
import { ModelConnectionPanel } from '../components/chat/ModelConnectionPanel';
|
||||
@@ -295,7 +296,7 @@ export function Chat() {
|
||||
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Latest verification</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-3"><div className="text-xs text-slate-400">Audit anchor</div><div className="mt-1 break-all font-mono text-[11px] text-slate-700">{auditHash(last).slice(0, 22)}{auditHash(last) !== 'n/a' ? '…' : ''}</div></div>
|
||||
{last && <><div className="rounded-xl border border-slate-200 bg-white p-3"><div className="flex flex-wrap gap-1.5"><StatusBadge value={last.mode} /><StatusBadge value={last.risk} /><StatusBadge value={last.decision} /><StatusBadge value={last.certified ? 'certified' : 'held'} /></div><div className="mt-2 text-xs text-slate-500">{last.router?.reason ?? 'Policy route verified'}{last.synthesis?.fallback_from ? ` · fell back from ${last.synthesis.fallback_from} to local` : ''}</div></div><Card title="Evidence" className="p-3.5"><div className="space-y-2">{last.sources.slice(0, 3).map((source) => <div key={`${source.path}-${source.line ?? ''}`} className="rounded-lg bg-slate-50 p-2.5"><div className="truncate text-xs font-medium text-slate-700">{source.title || source.path}</div><div className="mt-1 text-[11px] leading-4 text-slate-500">{source.preview || source.excerpt || 'Verified source'}</div></div>)}{last.sources.length === 0 && <div className="text-xs text-slate-500">No source was returned for this decision.</div>}</div></Card></>}
|
||||
{last && <><div className="rounded-xl border border-slate-200 bg-white p-3"><div className="flex flex-wrap gap-1.5"><StatusBadge value={last.mode} /><StatusBadge value={last.risk} /><StatusBadge value={last.decision} /><StatusBadge value={last.certified ? 'certified' : 'held'} /></div><div className="mt-2 text-xs text-slate-500">{last.router?.reason ?? 'Policy route verified'}{last.synthesis?.fallback_from ? ` · fell back from ${last.synthesis.fallback_from} to local` : ''}</div>{last.trace_id && <Link to={`/runs?trace=${encodeURIComponent(last.trace_id)}`} className="mt-3 flex w-full items-center justify-center gap-2 rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100">Open live H1–H7 trace <Glyph name="chevron" /></Link>}</div><Card title="Evidence" className="p-3.5"><div className="space-y-2">{last.sources.slice(0, 3).map((source) => <div key={`${source.path}-${source.line ?? ''}`} className="rounded-lg bg-slate-50 p-2.5"><div className="truncate text-xs font-medium text-slate-700">{source.title || source.path}</div><div className="mt-1 text-[11px] leading-4 text-slate-500">{source.preview || source.excerpt || 'Verified source'}</div></div>)}{last.sources.length === 0 && <div className="text-xs text-slate-500">No source was returned for this decision.</div>}</div></Card></>}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 text-[10px] font-bold uppercase tracking-[0.15em] text-slate-400">Registered actions</div>
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api, type HarnessRunRecord } from '../lib/api';
|
||||
import { Card, StatusBadge } from '../components/ui/Card';
|
||||
import { TraceExplorer } from '../components/trace/TraceExplorer';
|
||||
|
||||
export function Runs() {
|
||||
const { data, isLoading } = useQuery({ queryKey: ['runs'], queryFn: () => api.runs(100) });
|
||||
if (isLoading || !data) return <div className="text-gray-500">Loading…</div>;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedTrace = searchParams.get('trace') ?? '';
|
||||
|
||||
if (isLoading || !data) return <div className="text-slate-500">Loading…</div>;
|
||||
return (
|
||||
<Card title={`Recent runs (${data.count})`}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="text-left text-gray-500 border-b border-gray-200">
|
||||
<th className="py-2">time</th><th>step</th><th>status</th><th>latency</th><th>tokens</th><th>cost</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{data.runs.map((r: any, i: number) => (
|
||||
<tr key={i} className="border-b border-gray-100">
|
||||
<td className="py-2 text-gray-500">{r.timestamp?.replace('T', ' ').replace('Z', '')}</td>
|
||||
<td className="text-gray-700">{r.step ?? r.harness}</td>
|
||||
<td><StatusBadge value={r.status ?? '—'} /></td>
|
||||
<td>{r.latency_ms ?? '—'} ms</td>
|
||||
<td>{r.total_tokens ?? '—'}</td>
|
||||
<td>${Number(r.cost_estimate ?? 0).toFixed(5)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
<div className="space-y-5">
|
||||
{selectedTrace && <TraceExplorer traceId={selectedTrace} onClose={() => setSearchParams({})} />}
|
||||
<Card
|
||||
title={`Recent runs (${data.count})`}
|
||||
right={<span className="text-xs font-normal normal-case tracking-normal text-slate-400">Select a run to inspect H1–H7</span>}
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead><tr className="border-b border-slate-200 text-left text-slate-500">
|
||||
<th className="py-2">time</th><th>step</th><th>status</th><th>latency</th><th>tokens</th><th>cost</th><th className="text-right">trace</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{data.runs.map((run: HarnessRunRecord, index: number) => (
|
||||
<tr key={`${run.trace_id ?? 'run'}-${index}`} className="border-b border-slate-100 transition-colors hover:bg-slate-50/80">
|
||||
<td className="py-2 text-slate-500">{run.timestamp?.replace('T', ' ').replace('Z', '')}</td>
|
||||
<td className="text-slate-700">{run.step ?? run.harness}</td>
|
||||
<td><StatusBadge value={run.status ?? '—'} /></td>
|
||||
<td>{run.latency_ms ?? '—'} ms</td>
|
||||
<td>{run.total_tokens ?? '—'}</td>
|
||||
<td>${Number(run.cost_estimate ?? 0).toFixed(5)}</td>
|
||||
<td className="py-2 text-right">
|
||||
{run.trace_id ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchParams({ trace: run.trace_id ?? '' })}
|
||||
className="rounded-lg border border-blue-200 bg-blue-50 px-3 py-1.5 text-xs font-semibold text-blue-700 transition-colors hover:bg-blue-100"
|
||||
>
|
||||
Open H1–H7
|
||||
</button>
|
||||
) : <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user