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:
249
apps/web/src/components/knowledge/StatsDashboard.tsx
Normal file
249
apps/web/src/components/knowledge/StatsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user