568 lines
18 KiB
TypeScript
568 lines
18 KiB
TypeScript
|
|
import { useMemo, useState } from "react";
|
||
|
|
import { useNavigate, useParams } from "react-router-dom";
|
||
|
|
import {
|
||
|
|
AlertCircle,
|
||
|
|
ArrowLeft,
|
||
|
|
CircleDot,
|
||
|
|
GitBranch,
|
||
|
|
Network,
|
||
|
|
RefreshCw,
|
||
|
|
Search,
|
||
|
|
SlidersHorizontal,
|
||
|
|
} from "lucide-react";
|
||
|
|
import {
|
||
|
|
Card,
|
||
|
|
CardContent,
|
||
|
|
CardDescription,
|
||
|
|
CardHeader,
|
||
|
|
CardTitle,
|
||
|
|
} from "@/components/ui/card";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Progress } from "@/components/ui/progress";
|
||
|
|
import { Select } from "@/components/ui/select";
|
||
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
||
|
|
import { useGraphNeighborhood } from "@/hooks/usePlatform";
|
||
|
|
import { useProject } from "@/hooks/useProjects";
|
||
|
|
import { GraphEdge, GraphNode } from "@/lib/api/platform";
|
||
|
|
import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display";
|
||
|
|
|
||
|
|
const COLORS = [
|
||
|
|
"#2563eb",
|
||
|
|
"#059669",
|
||
|
|
"#d97706",
|
||
|
|
"#7c3aed",
|
||
|
|
"#db2777",
|
||
|
|
"#0891b2",
|
||
|
|
"#4b5563",
|
||
|
|
"#dc2626",
|
||
|
|
];
|
||
|
|
|
||
|
|
interface VisualNode {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
type: string;
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
radius: number;
|
||
|
|
color: string;
|
||
|
|
source: "entity" | "literal";
|
||
|
|
raw?: GraphNode;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface VisualEdge {
|
||
|
|
id: string;
|
||
|
|
source: string;
|
||
|
|
target: string;
|
||
|
|
predicate: string;
|
||
|
|
confidence: number;
|
||
|
|
raw: GraphEdge;
|
||
|
|
}
|
||
|
|
|
||
|
|
function nodeColor(type: string, types: string[]): string {
|
||
|
|
const index = Math.max(types.indexOf(type), 0);
|
||
|
|
return COLORS[index % COLORS.length];
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildGraph(
|
||
|
|
nodes: GraphNode[],
|
||
|
|
edges: GraphEdge[],
|
||
|
|
options: {
|
||
|
|
search: string;
|
||
|
|
predicate: string;
|
||
|
|
entityType: string;
|
||
|
|
minConfidence: number;
|
||
|
|
},
|
||
|
|
) {
|
||
|
|
const search = options.search.trim().toLowerCase();
|
||
|
|
const types = Array.from(
|
||
|
|
new Set(nodes.map((node) => node.type || "Entity").concat("Literal")),
|
||
|
|
).sort();
|
||
|
|
const nodeMap = new Map<string, VisualNode>();
|
||
|
|
nodes.forEach((node) => {
|
||
|
|
const type = node.type || "Entity";
|
||
|
|
nodeMap.set(String(node.id), {
|
||
|
|
id: String(node.id),
|
||
|
|
name: node.name,
|
||
|
|
type,
|
||
|
|
x: 0,
|
||
|
|
y: 0,
|
||
|
|
radius: 18,
|
||
|
|
color: nodeColor(type, types),
|
||
|
|
source: "entity",
|
||
|
|
raw: node,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
const visualEdges: VisualEdge[] = [];
|
||
|
|
edges.forEach((edge) => {
|
||
|
|
const confidence = edge.confidence ?? 0;
|
||
|
|
if (confidence < options.minConfidence) return;
|
||
|
|
if (options.predicate && edge.predicate !== options.predicate) return;
|
||
|
|
const source = String(edge.source);
|
||
|
|
const target =
|
||
|
|
edge.target !== null && edge.target !== undefined
|
||
|
|
? String(edge.target)
|
||
|
|
: `literal-${edge.claim_id}`;
|
||
|
|
if (!nodeMap.has(source)) return;
|
||
|
|
if (!nodeMap.has(target)) {
|
||
|
|
const label = humanizeValue(edge.target_value ?? edge.object?.name);
|
||
|
|
nodeMap.set(target, {
|
||
|
|
id: target,
|
||
|
|
name: label || "(empty value)",
|
||
|
|
type: "Literal",
|
||
|
|
x: 0,
|
||
|
|
y: 0,
|
||
|
|
radius: 14,
|
||
|
|
color: nodeColor("Literal", types),
|
||
|
|
source: "literal",
|
||
|
|
});
|
||
|
|
}
|
||
|
|
visualEdges.push({
|
||
|
|
id: String(edge.claim_id),
|
||
|
|
source,
|
||
|
|
target,
|
||
|
|
predicate: edge.predicate,
|
||
|
|
confidence,
|
||
|
|
raw: edge,
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
let visualNodes = Array.from(nodeMap.values()).filter((node) => {
|
||
|
|
if (options.entityType && node.type !== options.entityType) return false;
|
||
|
|
if (!search) return true;
|
||
|
|
return (
|
||
|
|
node.name.toLowerCase().includes(search) ||
|
||
|
|
node.type.toLowerCase().includes(search)
|
||
|
|
);
|
||
|
|
});
|
||
|
|
const visibleIds = new Set(visualNodes.map((node) => node.id));
|
||
|
|
const filteredEdges = visualEdges.filter(
|
||
|
|
(edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target),
|
||
|
|
);
|
||
|
|
const connectedIds = new Set<string>();
|
||
|
|
filteredEdges.forEach((edge) => {
|
||
|
|
connectedIds.add(edge.source);
|
||
|
|
connectedIds.add(edge.target);
|
||
|
|
});
|
||
|
|
if (options.predicate || options.minConfidence > 0 || search) {
|
||
|
|
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
|
||
|
|
}
|
||
|
|
|
||
|
|
const centerX = 430;
|
||
|
|
const centerY = 300;
|
||
|
|
const radius = Math.max(160, Math.min(260, visualNodes.length * 13));
|
||
|
|
visualNodes.forEach((node, index) => {
|
||
|
|
const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1);
|
||
|
|
node.x = centerX + Math.cos(angle) * radius;
|
||
|
|
node.y = centerY + Math.sin(angle) * radius;
|
||
|
|
});
|
||
|
|
return { nodes: visualNodes, edges: filteredEdges, types };
|
||
|
|
}
|
||
|
|
|
||
|
|
function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
|
||
|
|
const source = nodes.find((node) => node.id === edge.source);
|
||
|
|
const target = nodes.find((node) => node.id === edge.target);
|
||
|
|
return { source, target };
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function GraphViewPage() {
|
||
|
|
const navigate = useNavigate();
|
||
|
|
const { projectId } = useParams<{ projectId: string }>();
|
||
|
|
const projectName = projectId ?? "";
|
||
|
|
const { data: project } = useProject(projectName);
|
||
|
|
const [statusScope, setStatusScope] = useState("validated_claim");
|
||
|
|
const [includeCandidates, setIncludeCandidates] = useState(true);
|
||
|
|
const [search, setSearch] = useState("");
|
||
|
|
const [predicate, setPredicate] = useState("");
|
||
|
|
const [entityType, setEntityType] = useState("");
|
||
|
|
const [minConfidence, setMinConfidence] = useState(0);
|
||
|
|
const [selected, setSelected] = useState<VisualNode | VisualEdge | null>(null);
|
||
|
|
const graph = useGraphNeighborhood(projectName, {
|
||
|
|
includeCandidates: includeCandidates && statusScope === "validated_claim",
|
||
|
|
limit: 300,
|
||
|
|
status:
|
||
|
|
includeCandidates && statusScope === "validated_claim"
|
||
|
|
? undefined
|
||
|
|
: statusScope,
|
||
|
|
});
|
||
|
|
|
||
|
|
const predicates = useMemo(
|
||
|
|
() =>
|
||
|
|
Array.from(
|
||
|
|
new Set((graph.data?.edges ?? []).map((edge) => edge.predicate)),
|
||
|
|
).sort(),
|
||
|
|
[graph.data?.edges],
|
||
|
|
);
|
||
|
|
const visual = useMemo(
|
||
|
|
() =>
|
||
|
|
buildGraph(graph.data?.nodes ?? [], graph.data?.edges ?? [], {
|
||
|
|
search,
|
||
|
|
predicate,
|
||
|
|
entityType,
|
||
|
|
minConfidence,
|
||
|
|
}),
|
||
|
|
[
|
||
|
|
entityType,
|
||
|
|
graph.data?.edges,
|
||
|
|
graph.data?.nodes,
|
||
|
|
minConfidence,
|
||
|
|
predicate,
|
||
|
|
search,
|
||
|
|
],
|
||
|
|
);
|
||
|
|
const averageConfidence = visual.edges.length
|
||
|
|
? visual.edges.reduce((sum, edge) => sum + edge.confidence, 0) /
|
||
|
|
visual.edges.length
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto max-w-7xl px-6 py-8">
|
||
|
|
<div className="mb-6 flex items-center gap-3">
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
onClick={() => navigate(`/quality/${projectName}`)}
|
||
|
|
aria-label="Back"
|
||
|
|
>
|
||
|
|
<ArrowLeft className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
<div className="flex-1">
|
||
|
|
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||
|
|
<Network className="h-6 w-6 text-primary" />
|
||
|
|
Graph View
|
||
|
|
</h1>
|
||
|
|
<p className="text-sm text-muted-foreground">
|
||
|
|
{project?.name ?? projectName} relation graph, candidates, and edge evidence.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<Button variant="outline" onClick={() => graph.refetch()}>
|
||
|
|
<RefreshCw className="h-4 w-4" />
|
||
|
|
Refresh
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{graph.isError && (
|
||
|
|
<Card className="mb-6 border-destructive">
|
||
|
|
<CardContent className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||
|
|
<AlertCircle className="h-4 w-4" />
|
||
|
|
{(graph.error as Error).message}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="mb-4 grid gap-3 xl:grid-cols-[1fr_180px_180px_180px_220px]">
|
||
|
|
<div className="relative">
|
||
|
|
<Search className="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
value={search}
|
||
|
|
onChange={(event) => setSearch(event.target.value)}
|
||
|
|
className="pl-9"
|
||
|
|
placeholder="Search node or type"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<Select
|
||
|
|
value={entityType}
|
||
|
|
onChange={(event) => setEntityType(event.target.value)}
|
||
|
|
>
|
||
|
|
<option value="">All types</option>
|
||
|
|
{visual.types.map((type) => (
|
||
|
|
<option key={type} value={type}>
|
||
|
|
{type}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</Select>
|
||
|
|
<Select
|
||
|
|
value={predicate}
|
||
|
|
onChange={(event) => setPredicate(event.target.value)}
|
||
|
|
>
|
||
|
|
<option value="">All predicates</option>
|
||
|
|
{predicates.map((name) => (
|
||
|
|
<option key={name} value={name}>
|
||
|
|
{name}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</Select>
|
||
|
|
<Select
|
||
|
|
value={statusScope}
|
||
|
|
onChange={(event) => setStatusScope(event.target.value)}
|
||
|
|
>
|
||
|
|
<option value="validated_claim">Validated</option>
|
||
|
|
<option value="candidate_claim">Candidate</option>
|
||
|
|
<option value="rule_candidate">Rule candidate</option>
|
||
|
|
<option value="active">Active</option>
|
||
|
|
<option value="all">All statuses</option>
|
||
|
|
</Select>
|
||
|
|
<label className="flex items-center gap-2 rounded-md border px-3 text-sm">
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={includeCandidates}
|
||
|
|
disabled={statusScope !== "validated_claim"}
|
||
|
|
onChange={(event) => setIncludeCandidates(event.target.checked)}
|
||
|
|
/>
|
||
|
|
Blend candidates
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mb-6 rounded-md border px-4 py-3">
|
||
|
|
<div className="mb-2 flex items-center justify-between text-sm">
|
||
|
|
<span className="flex items-center gap-2 text-muted-foreground">
|
||
|
|
<SlidersHorizontal className="h-4 w-4" />
|
||
|
|
Minimum confidence
|
||
|
|
</span>
|
||
|
|
<span>{formatPercent(minConfidence)}</span>
|
||
|
|
</div>
|
||
|
|
<input
|
||
|
|
type="range"
|
||
|
|
min={0}
|
||
|
|
max={1}
|
||
|
|
step={0.05}
|
||
|
|
value={minConfidence}
|
||
|
|
onChange={(event) => setMinConfidence(Number(event.target.value))}
|
||
|
|
className="w-full"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid gap-6 xl:grid-cols-[1fr_360px]">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
|
|
<div>
|
||
|
|
<CardTitle>Ontology Network</CardTitle>
|
||
|
|
<CardDescription>
|
||
|
|
{visual.nodes.length} nodes / {visual.edges.length} edges shown
|
||
|
|
</CardDescription>
|
||
|
|
</div>
|
||
|
|
<div className="flex flex-wrap gap-2">
|
||
|
|
{visual.types.slice(0, 8).map((type) => (
|
||
|
|
<span key={type} className="flex items-center gap-1 text-xs">
|
||
|
|
<span
|
||
|
|
className="h-2.5 w-2.5 rounded-full"
|
||
|
|
style={{ backgroundColor: nodeColor(type, visual.types) }}
|
||
|
|
/>
|
||
|
|
{type}
|
||
|
|
</span>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{graph.isLoading ? (
|
||
|
|
<Skeleton className="h-[640px]" />
|
||
|
|
) : visual.nodes.length === 0 ? (
|
||
|
|
<div className="flex h-[500px] flex-col items-center justify-center gap-2 text-center text-sm text-muted-foreground">
|
||
|
|
<GitBranch className="h-12 w-12 opacity-50" />
|
||
|
|
No graph is available for the current filters.
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<svg
|
||
|
|
viewBox="0 0 860 600"
|
||
|
|
className="h-[640px] w-full rounded-md border bg-secondary/20"
|
||
|
|
role="img"
|
||
|
|
aria-label="Ontology graph"
|
||
|
|
>
|
||
|
|
<defs>
|
||
|
|
<marker
|
||
|
|
id="arrow"
|
||
|
|
markerWidth="10"
|
||
|
|
markerHeight="10"
|
||
|
|
refX="10"
|
||
|
|
refY="3"
|
||
|
|
orient="auto"
|
||
|
|
markerUnits="strokeWidth"
|
||
|
|
>
|
||
|
|
<path d="M0,0 L0,6 L9,3 z" fill="#64748b" />
|
||
|
|
</marker>
|
||
|
|
</defs>
|
||
|
|
{visual.edges.map((edge) => {
|
||
|
|
const { source, target } = edgeEndpoint(edge, visual.nodes);
|
||
|
|
if (!source || !target) return null;
|
||
|
|
const midX = (source.x + target.x) / 2;
|
||
|
|
const midY = (source.y + target.y) / 2;
|
||
|
|
return (
|
||
|
|
<g
|
||
|
|
key={edge.id}
|
||
|
|
className="cursor-pointer"
|
||
|
|
onClick={() => setSelected(edge)}
|
||
|
|
>
|
||
|
|
<line
|
||
|
|
x1={source.x}
|
||
|
|
y1={source.y}
|
||
|
|
x2={target.x}
|
||
|
|
y2={target.y}
|
||
|
|
stroke="#64748b"
|
||
|
|
strokeWidth={1 + edge.confidence * 3}
|
||
|
|
strokeOpacity="0.65"
|
||
|
|
markerEnd="url(#arrow)"
|
||
|
|
/>
|
||
|
|
<text
|
||
|
|
x={midX}
|
||
|
|
y={midY}
|
||
|
|
textAnchor="middle"
|
||
|
|
className="fill-slate-600 text-[10px]"
|
||
|
|
>
|
||
|
|
{edge.predicate}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{visual.nodes.map((node) => (
|
||
|
|
<g
|
||
|
|
key={node.id}
|
||
|
|
className="cursor-pointer"
|
||
|
|
onClick={() => setSelected(node)}
|
||
|
|
>
|
||
|
|
<circle
|
||
|
|
cx={node.x}
|
||
|
|
cy={node.y}
|
||
|
|
r={node.radius}
|
||
|
|
fill={node.color}
|
||
|
|
stroke={
|
||
|
|
selected && "id" in selected && selected.id === node.id
|
||
|
|
? "#111827"
|
||
|
|
: "#ffffff"
|
||
|
|
}
|
||
|
|
strokeWidth="2"
|
||
|
|
/>
|
||
|
|
<text
|
||
|
|
x={node.x}
|
||
|
|
y={node.y + node.radius + 14}
|
||
|
|
textAnchor="middle"
|
||
|
|
className="fill-slate-800 text-[11px] font-medium"
|
||
|
|
>
|
||
|
|
{node.name.length > 24
|
||
|
|
? `${node.name.slice(0, 24)}...`
|
||
|
|
: node.name}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
))}
|
||
|
|
</svg>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<aside className="space-y-6">
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Selection</CardTitle>
|
||
|
|
<CardDescription>Click a node or edge to inspect it.</CardDescription>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
{!selected && (
|
||
|
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||
|
|
Nothing selected.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{selected && "predicate" in selected && (
|
||
|
|
<div className="space-y-3">
|
||
|
|
<Badge variant="secondary">{selected.predicate}</Badge>
|
||
|
|
<Detail label="Claim" value={`#${selected.id}`} />
|
||
|
|
<Detail label="Status" value={selected.raw.status} />
|
||
|
|
<Detail
|
||
|
|
label="Confidence"
|
||
|
|
value={formatPercent(selected.confidence)}
|
||
|
|
/>
|
||
|
|
<Detail
|
||
|
|
label="Object"
|
||
|
|
value={selected.raw.object?.name ?? selected.raw.target_value}
|
||
|
|
/>
|
||
|
|
<Detail
|
||
|
|
label="Last seen"
|
||
|
|
value={formatDateTime(selected.raw.last_seen_at)}
|
||
|
|
/>
|
||
|
|
<Detail
|
||
|
|
label="Metadata"
|
||
|
|
value={humanizeValue(selected.raw.metadata)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{selected && !("predicate" in selected) && (
|
||
|
|
<div className="space-y-3">
|
||
|
|
<Badge variant="outline">{selected.type}</Badge>
|
||
|
|
<Detail label="Name" value={selected.name} />
|
||
|
|
<Detail label="Source" value={selected.source} />
|
||
|
|
<Detail label="Canonical" value={selected.raw?.canonical_name} />
|
||
|
|
<Detail
|
||
|
|
label="Metadata"
|
||
|
|
value={humanizeValue(selected.raw?.metadata)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle>Graph Health</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-3">
|
||
|
|
<HealthRow label="Visible nodes" value={visual.nodes.length} />
|
||
|
|
<HealthRow label="Visible edges" value={visual.edges.length} />
|
||
|
|
<HealthRow
|
||
|
|
label="Avg confidence"
|
||
|
|
value={
|
||
|
|
visual.edges.length ? formatPercent(averageConfidence) : "-"
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
<Progress value={averageConfidence * 100} />
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="flex items-center gap-2">
|
||
|
|
<CircleDot className="h-5 w-5" />
|
||
|
|
Predicate Counts
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<ul className="space-y-2 text-sm">
|
||
|
|
{predicates.map((name) => {
|
||
|
|
const count = (graph.data?.edges ?? []).filter(
|
||
|
|
(edge) => edge.predicate === name,
|
||
|
|
).length;
|
||
|
|
return (
|
||
|
|
<li
|
||
|
|
key={name}
|
||
|
|
className="flex items-center justify-between rounded-md border px-3 py-2"
|
||
|
|
>
|
||
|
|
<span>{name}</span>
|
||
|
|
<span className="font-medium">{count}</span>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{predicates.length === 0 && (
|
||
|
|
<li className="py-4 text-center text-muted-foreground">
|
||
|
|
No predicates found.
|
||
|
|
</li>
|
||
|
|
)}
|
||
|
|
</ul>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</aside>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function Detail({ label, value }: { label: string; value: unknown }) {
|
||
|
|
return (
|
||
|
|
<div className="rounded-md border px-3 py-2 text-sm">
|
||
|
|
<div className="text-xs text-muted-foreground">{label}</div>
|
||
|
|
<div className="mt-1 break-words font-medium">{humanizeValue(value)}</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function HealthRow({ label, value }: { label: string; value: number | string }) {
|
||
|
|
return (
|
||
|
|
<div className="flex items-center justify-between text-sm">
|
||
|
|
<span className="text-muted-foreground">{label}</span>
|
||
|
|
<span className="font-medium">
|
||
|
|
{typeof value === "number" ? value.toLocaleString() : value}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|