feat: add knowledge graph views and stats (closes #73, closes #74)

Issue #73 - Entry-Centered Graph View:
- Added GET /api/knowledge/entries/:id/graph endpoint with depth parameter
- Returns entry + connected nodes with link relationships
- Created GraphService for graph traversal using BFS
- Added EntryGraphViewer component for frontend
- Integrated graph view tab into entry detail page

Issue #74 - Graph Statistics Dashboard:
- Added GET /api/knowledge/stats endpoint
- Returns overview stats (entries, tags, links by status)
- Includes most connected entries, recent activity, tag distribution
- Created StatsDashboard component with visual stats
- Added route at /knowledge/stats

Backend:
- GraphService: BFS-based graph traversal with configurable depth
- StatsService: Parallel queries for comprehensive statistics
- GraphQueryDto: Validation for depth parameter (1-5)
- Entity types for graph nodes/edges and statistics
- Unit tests for both services

Frontend:
- EntryGraphViewer: Entry-centered graph visualization
- StatsDashboard: Statistics overview with charts
- Graph view tab on entry detail page
- API client functions for new endpoints
- TypeScript strict typing throughout
This commit is contained in:
Jason Woltje
2026-01-29 23:25:29 -06:00
parent 59aec28d5c
commit 26a334c677
18 changed files with 1351 additions and 12 deletions

View File

@@ -0,0 +1,293 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fetchEntryGraph } from "@/lib/api/knowledge";
import Link from "next/link";
interface GraphNode {
id: string;
slug: string;
title: string;
summary: string | null;
tags: Array<{
id: string;
name: string;
slug: string;
color: string | null;
}>;
depth: number;
}
interface GraphEdge {
id: string;
sourceId: string;
targetId: string;
linkText: string;
}
interface EntryGraphResponse {
centerNode: GraphNode;
nodes: GraphNode[];
edges: GraphEdge[];
stats: {
totalNodes: number;
totalEdges: number;
maxDepth: number;
};
}
interface EntryGraphViewerProps {
slug: string;
initialDepth?: number;
}
export function EntryGraphViewer({ slug, initialDepth = 1 }: EntryGraphViewerProps) {
const [graphData, setGraphData] = useState<EntryGraphResponse | null>(null);
const [depth, setDepth] = useState(initialDepth);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const loadGraph = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const data = await fetchEntryGraph(slug, depth);
setGraphData(data as EntryGraphResponse);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load graph");
} finally {
setIsLoading(false);
}
}, [slug, depth]);
useEffect(() => {
void loadGraph();
}, [loadGraph]);
const handleDepthChange = (newDepth: number) => {
setDepth(newDepth);
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
);
}
if (error || !graphData) {
return (
<div className="p-8 text-center">
<div className="text-red-500 mb-2">Error loading graph</div>
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
</div>
);
}
const { centerNode, nodes, edges, stats } = graphData;
// Group nodes by depth for better visualization
const nodesByDepth = nodes.reduce((acc, node) => {
const d = node.depth;
if (!acc[d]) acc[d] = [];
acc[d].push(node);
return acc;
}, {} as Record<number, GraphNode[]>);
return (
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<div className="flex items-center gap-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Graph View
</h2>
<div className="text-sm text-gray-500 dark:text-gray-400">
{stats.totalNodes} nodes {stats.totalEdges} connections
</div>
</div>
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
Depth:
</label>
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
{[1, 2, 3].map((d) => (
<button
key={d}
onClick={() => handleDepthChange(d)}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
depth === d
? "bg-blue-500 text-white"
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
}`}
>
{d}
</button>
))}
</div>
</div>
</div>
{/* Graph Visualization - Simple List View */}
<div className="flex-1 overflow-auto p-6">
<div className="max-w-4xl mx-auto space-y-6">
{/* Center Node */}
<div className="text-center mb-8">
<div className="inline-block">
<NodeCard
node={centerNode}
isCenter
onClick={() => setSelectedNode(centerNode)}
isSelected={selectedNode?.id === centerNode.id}
/>
</div>
</div>
{/* Nodes by Depth */}
{Object.entries(nodesByDepth)
.filter(([d]) => d !== "0")
.sort(([a], [b]) => Number(a) - Number(b))
.map(([depthLevel, depthNodes]) => (
<div key={depthLevel} className="space-y-3">
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
Depth {depthLevel} ({depthNodes.length} {depthNodes.length === 1 ? "node" : "nodes"})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{depthNodes.map((node) => (
<NodeCard
key={node.id}
node={node}
onClick={() => setSelectedNode(node)}
isSelected={selectedNode?.id === node.id}
connections={getNodeConnections(node.id, edges)}
/>
))}
</div>
</div>
))}
</div>
</div>
{/* Selected Node Details */}
{selectedNode && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-800">
<div className="flex items-start justify-between max-w-4xl mx-auto">
<div className="flex-1">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 text-lg">
{selectedNode.title}
</h3>
{selectedNode.summary && (
<p className="mt-2 text-sm text-gray-600 dark:text-gray-300">
{selectedNode.summary}
</p>
)}
{selectedNode.tags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{selectedNode.tags.map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2.5 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: tag.color || "#6B7280",
color: "#FFFFFF",
}}
>
{tag.name}
</span>
))}
</div>
)}
<div className="mt-4">
<Link
href={`/knowledge/${selectedNode.slug}`}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
View Full Entry
</Link>
</div>
</div>
<button
onClick={() => setSelectedNode(null)}
className="ml-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
</div>
);
}
interface NodeCardProps {
node: GraphNode;
isCenter?: boolean;
onClick?: () => void;
isSelected?: boolean;
connections?: { incoming: number; outgoing: number };
}
function NodeCard({ node, isCenter, onClick, isSelected, connections }: NodeCardProps) {
return (
<div
onClick={onClick}
className={`p-4 rounded-lg border-2 transition-all cursor-pointer ${
isCenter
? "bg-blue-50 dark:bg-blue-900/20 border-blue-500 dark:border-blue-500"
: isSelected
? "bg-gray-100 dark:bg-gray-700 border-blue-400 dark:border-blue-400"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-gray-900 dark:text-gray-100 truncate">
{node.title}
</h4>
{node.summary && (
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{node.summary}
</p>
)}
{node.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{node.tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: tag.color || "#6B7280",
color: "#FFFFFF",
}}
>
{tag.name}
</span>
))}
{node.tags.length > 3 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300">
+{node.tags.length - 3}
</span>
)}
</div>
)}
{connections && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{connections.incoming} incoming {connections.outgoing} outgoing
</div>
)}
</div>
</div>
</div>
);
}
function getNodeConnections(nodeId: string, edges: GraphEdge[]) {
const incoming = edges.filter((e) => e.targetId === nodeId).length;
const outgoing = edges.filter((e) => e.sourceId === nodeId).length;
return { incoming, outgoing };
}

View File

@@ -0,0 +1,249 @@
"use client";
import { useEffect, useState } from "react";
import { fetchKnowledgeStats } from "@/lib/api/knowledge";
import Link from "next/link";
interface KnowledgeStats {
overview: {
totalEntries: number;
totalTags: number;
totalLinks: number;
publishedEntries: number;
draftEntries: number;
archivedEntries: number;
};
mostConnected: Array<{
id: string;
slug: string;
title: string;
incomingLinks: number;
outgoingLinks: number;
totalConnections: number;
}>;
recentActivity: Array<{
id: string;
slug: string;
title: string;
updatedAt: string;
status: string;
}>;
tagDistribution: Array<{
id: string;
name: string;
slug: string;
color: string | null;
entryCount: number;
}>;
}
export function StatsDashboard() {
const [stats, setStats] = useState<KnowledgeStats | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function loadStats() {
try {
setIsLoading(true);
const data = await fetchKnowledgeStats();
setStats(data as KnowledgeStats);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load statistics");
} finally {
setIsLoading(false);
}
}
void loadStats();
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
</div>
);
}
if (error || !stats) {
return (
<div className="p-8 text-center text-red-500">
Error loading statistics: {error}
</div>
);
}
const { overview, mostConnected, recentActivity, tagDistribution } = stats;
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
Knowledge Base Statistics
</h1>
<Link
href="/knowledge"
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
Back to Entries
</Link>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<StatsCard
title="Total Entries"
value={overview.totalEntries}
subtitle={`${overview.publishedEntries} published • ${overview.draftEntries} drafts`}
icon="📚"
/>
<StatsCard
title="Tags"
value={overview.totalTags}
subtitle="Organize your knowledge"
icon="🏷️"
/>
<StatsCard
title="Connections"
value={overview.totalLinks}
subtitle="Links between entries"
icon="🔗"
/>
</div>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Most Connected Entries */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Most Connected Entries
</h2>
<div className="space-y-3">
{mostConnected.slice(0, 10).map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
>
<div className="flex-1 min-w-0">
<Link
href={`/knowledge/${entry.slug}`}
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
>
{entry.title}
</Link>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{entry.incomingLinks} incoming {entry.outgoingLinks} outgoing
</div>
</div>
<div className="ml-4 flex-shrink-0">
<span className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 font-semibold text-sm">
{entry.totalConnections}
</span>
</div>
</div>
))}
{mostConnected.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No connections yet
</p>
)}
</div>
</div>
{/* Recent Activity */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Recent Activity
</h2>
<div className="space-y-3">
{recentActivity.slice(0, 10).map((entry) => (
<div
key={entry.id}
className="flex items-start justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
>
<div className="flex-1 min-w-0">
<Link
href={`/knowledge/${entry.slug}`}
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-blue-600 dark:hover:text-blue-400 truncate block"
>
{entry.title}
</Link>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{new Date(entry.updatedAt).toLocaleDateString()} {" "}
<span className="capitalize">{entry.status.toLowerCase()}</span>
</div>
</div>
</div>
))}
{recentActivity.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
No recent activity
</p>
)}
</div>
</div>
</div>
{/* Tag Distribution */}
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
Tag Distribution
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{tagDistribution.slice(0, 12).map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between p-3 rounded bg-gray-50 dark:bg-gray-700"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{tag.color && (
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: tag.color }}
/>
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
{tag.name}
</span>
</div>
<span className="ml-2 text-xs font-semibold text-gray-600 dark:text-gray-400 flex-shrink-0">
{tag.entryCount}
</span>
</div>
))}
{tagDistribution.length === 0 && (
<p className="text-sm text-gray-500 dark:text-gray-400 col-span-full text-center py-4">
No tags yet
</p>
)}
</div>
</div>
</div>
);
}
function StatsCard({
title,
value,
subtitle,
icon,
}: {
title: string;
value: number;
subtitle: string;
icon: string;
}) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p className="text-3xl font-bold text-gray-900 dark:text-gray-100 mt-2">{value}</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">{subtitle}</p>
</div>
<div className="text-4xl">{icon}</div>
</div>
</div>
);
}

View File

@@ -1,7 +1,8 @@
/**
* Knowledge module components
*/
export { EntryViewer } from "./EntryViewer";
export { EntryCard } from "./EntryCard";
export { EntryEditor } from "./EntryEditor";
export { EntryFilters } from "./EntryFilters";
export { EntryList } from "./EntryList";
export { EntryMetadata } from "./EntryMetadata";
export { EntryViewer } from "./EntryViewer";
export { StatsDashboard } from "./StatsDashboard";
export { EntryGraphViewer } from "./EntryGraphViewer";