This commit is contained in:
@@ -804,18 +804,22 @@ model KnowledgeLink {
|
||||
sourceId String @map("source_id") @db.Uuid
|
||||
source KnowledgeEntry @relation("SourceEntry", fields: [sourceId], references: [id], onDelete: Cascade)
|
||||
|
||||
targetId String @map("target_id") @db.Uuid
|
||||
target KnowledgeEntry @relation("TargetEntry", fields: [targetId], references: [id], onDelete: Cascade)
|
||||
targetId String? @map("target_id") @db.Uuid
|
||||
target KnowledgeEntry? @relation("TargetEntry", fields: [targetId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Link metadata
|
||||
linkText String @map("link_text")
|
||||
context String?
|
||||
linkText String @map("link_text")
|
||||
displayText String @map("display_text")
|
||||
positionStart Int @map("position_start")
|
||||
positionEnd Int @map("position_end")
|
||||
resolved Boolean @default(false)
|
||||
context String?
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
|
||||
@@unique([sourceId, targetId])
|
||||
@@index([sourceId])
|
||||
@@index([targetId])
|
||||
@@index([sourceId, resolved])
|
||||
@@map("knowledge_links")
|
||||
}
|
||||
|
||||
|
||||
14
apps/api/src/knowledge/dto/graph-query.dto.ts
Normal file
14
apps/api/src/knowledge/dto/graph-query.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsOptional, IsInt, Min, Max } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
/**
|
||||
* Query parameters for entry-centered graph view
|
||||
*/
|
||||
export class GraphQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(5)
|
||||
depth?: number = 1;
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export {
|
||||
TagSearchDto,
|
||||
RecentEntriesDto,
|
||||
} from "./search-query.dto";
|
||||
export { GraphQueryDto } from "./graph-query.dto";
|
||||
|
||||
40
apps/api/src/knowledge/entities/graph.entity.ts
Normal file
40
apps/api/src/knowledge/entities/graph.entity.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Represents a node in the knowledge graph
|
||||
*/
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string | null;
|
||||
tags: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string | null;
|
||||
}>;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an edge/link in the knowledge graph
|
||||
*/
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
linkText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry-centered graph response
|
||||
*/
|
||||
export interface EntryGraphResponse {
|
||||
centerNode: GraphNode;
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
stats: {
|
||||
totalNodes: number;
|
||||
totalEdges: number;
|
||||
maxDepth: number;
|
||||
};
|
||||
}
|
||||
35
apps/api/src/knowledge/entities/stats.entity.ts
Normal file
35
apps/api/src/knowledge/entities/stats.entity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Knowledge base statistics
|
||||
*/
|
||||
export 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: Date;
|
||||
status: string;
|
||||
}>;
|
||||
tagDistribution: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
color: string | null;
|
||||
entryCount: number;
|
||||
}>;
|
||||
}
|
||||
@@ -10,12 +10,12 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { KnowledgeService } from "./knowledge.service";
|
||||
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto } from "./dto";
|
||||
import { CreateEntryDto, UpdateEntryDto, EntryQueryDto, GraphQueryDto } from "./dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import { LinkSyncService } from "./services/link-sync.service";
|
||||
import { LinkSyncService, GraphService } from "./services";
|
||||
|
||||
/**
|
||||
* Controller for knowledge entry endpoints
|
||||
@@ -27,7 +27,8 @@ import { LinkSyncService } from "./services/link-sync.service";
|
||||
export class KnowledgeController {
|
||||
constructor(
|
||||
private readonly knowledgeService: KnowledgeService,
|
||||
private readonly linkSync: LinkSyncService
|
||||
private readonly linkSync: LinkSyncService,
|
||||
private readonly graphService: GraphService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -132,4 +133,31 @@ export class KnowledgeController {
|
||||
count: backlinks.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries/:slug/graph
|
||||
* Get entry-centered graph view
|
||||
* Returns the entry and connected nodes with specified depth
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get(":slug/graph")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getEntryGraph(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("slug") slug: string,
|
||||
@Query() query: GraphQueryDto
|
||||
) {
|
||||
// Find the entry to get its ID
|
||||
const entry = await this.knowledgeService.findOne(workspaceId, slug);
|
||||
|
||||
// Get graph
|
||||
const graph = await this.graphService.getEntryGraph(
|
||||
workspaceId,
|
||||
entry.id,
|
||||
query.depth || 1
|
||||
);
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,13 +4,26 @@ import { AuthModule } from "../auth/auth.module";
|
||||
import { KnowledgeService } from "./knowledge.service";
|
||||
import { KnowledgeController } from "./knowledge.controller";
|
||||
import { SearchController } from "./search.controller";
|
||||
import { LinkResolutionService } from "./services/link-resolution.service";
|
||||
import { SearchService } from "./services/search.service";
|
||||
import { KnowledgeStatsController } from "./stats.controller";
|
||||
import {
|
||||
LinkResolutionService,
|
||||
SearchService,
|
||||
LinkSyncService,
|
||||
GraphService,
|
||||
StatsService,
|
||||
} from "./services";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [KnowledgeController, SearchController],
|
||||
providers: [KnowledgeService, LinkResolutionService, SearchService],
|
||||
controllers: [KnowledgeController, SearchController, KnowledgeStatsController],
|
||||
providers: [
|
||||
KnowledgeService,
|
||||
LinkResolutionService,
|
||||
SearchService,
|
||||
LinkSyncService,
|
||||
GraphService,
|
||||
StatsService,
|
||||
],
|
||||
exports: [KnowledgeService, LinkResolutionService, SearchService],
|
||||
})
|
||||
export class KnowledgeModule {}
|
||||
|
||||
126
apps/api/src/knowledge/services/graph.service.spec.ts
Normal file
126
apps/api/src/knowledge/services/graph.service.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { GraphService } from "./graph.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
describe("GraphService", () => {
|
||||
let service: GraphService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockEntry = {
|
||||
id: "entry-1",
|
||||
workspaceId: "workspace-1",
|
||||
slug: "test-entry",
|
||||
title: "Test Entry",
|
||||
content: "Test content",
|
||||
contentHtml: "<p>Test content</p>",
|
||||
summary: "Test summary",
|
||||
status: "PUBLISHED",
|
||||
visibility: "WORKSPACE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: "user-1",
|
||||
updatedBy: "user-1",
|
||||
tags: [],
|
||||
outgoingLinks: [],
|
||||
incomingLinks: [],
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
knowledgeEntry: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
GraphService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<GraphService>(GraphService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getEntryGraph", () => {
|
||||
it("should throw NotFoundException if entry does not exist", async () => {
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getEntryGraph("workspace-1", "non-existent", 1)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if entry belongs to different workspace", async () => {
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue({
|
||||
...mockEntry,
|
||||
workspaceId: "different-workspace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getEntryGraph("workspace-1", "entry-1", 1)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should return graph with center node when depth is 0", async () => {
|
||||
mockPrismaService.knowledgeEntry.findUnique.mockResolvedValue(mockEntry);
|
||||
|
||||
const result = await service.getEntryGraph("workspace-1", "entry-1", 0);
|
||||
|
||||
expect(result.centerNode.id).toBe("entry-1");
|
||||
expect(result.nodes).toHaveLength(1);
|
||||
expect(result.edges).toHaveLength(0);
|
||||
expect(result.stats.totalNodes).toBe(1);
|
||||
expect(result.stats.totalEdges).toBe(0);
|
||||
});
|
||||
|
||||
it("should build graph with connected nodes at depth 1", async () => {
|
||||
const linkedEntry = {
|
||||
id: "entry-2",
|
||||
slug: "linked-entry",
|
||||
title: "Linked Entry",
|
||||
summary: null,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
mockPrismaService.knowledgeEntry.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
...mockEntry,
|
||||
outgoingLinks: [
|
||||
{
|
||||
id: "link-1",
|
||||
sourceId: "entry-1",
|
||||
targetId: "entry-2",
|
||||
linkText: "link to entry 2",
|
||||
target: linkedEntry,
|
||||
},
|
||||
],
|
||||
incomingLinks: [],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
...linkedEntry,
|
||||
tags: [],
|
||||
outgoingLinks: [],
|
||||
incomingLinks: [],
|
||||
});
|
||||
|
||||
const result = await service.getEntryGraph("workspace-1", "entry-1", 1);
|
||||
|
||||
expect(result.nodes).toHaveLength(2);
|
||||
expect(result.edges).toHaveLength(1);
|
||||
expect(result.stats.totalNodes).toBe(2);
|
||||
expect(result.stats.totalEdges).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
170
apps/api/src/knowledge/services/graph.service.ts
Normal file
170
apps/api/src/knowledge/services/graph.service.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { EntryGraphResponse, GraphNode, GraphEdge } from "../entities/graph.entity";
|
||||
|
||||
/**
|
||||
* Service for knowledge graph operations
|
||||
*/
|
||||
@Injectable()
|
||||
export class GraphService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Get entry-centered graph view
|
||||
* Returns the entry and all connected nodes up to specified depth
|
||||
*/
|
||||
async getEntryGraph(
|
||||
workspaceId: string,
|
||||
entryId: string,
|
||||
maxDepth: number = 1
|
||||
): Promise<EntryGraphResponse> {
|
||||
// Verify entry exists
|
||||
const centerEntry = await this.prisma.knowledgeEntry.findUnique({
|
||||
where: { id: entryId },
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!centerEntry || centerEntry.workspaceId !== workspaceId) {
|
||||
throw new NotFoundException("Entry not found");
|
||||
}
|
||||
|
||||
// Build graph using BFS
|
||||
const visitedNodes = new Set<string>();
|
||||
const nodes: GraphNode[] = [];
|
||||
const edges: GraphEdge[] = [];
|
||||
const nodeDepths = new Map<string, number>();
|
||||
|
||||
// Queue: [entryId, depth]
|
||||
const queue: Array<[string, number]> = [[entryId, 0]];
|
||||
visitedNodes.add(entryId);
|
||||
nodeDepths.set(entryId, 0);
|
||||
|
||||
while (queue.length > 0) {
|
||||
const [currentId, depth] = queue.shift()!;
|
||||
|
||||
// Fetch current entry with related data
|
||||
const currentEntry = await this.prisma.knowledgeEntry.findUnique({
|
||||
where: { id: currentId },
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true,
|
||||
},
|
||||
},
|
||||
outgoingLinks: {
|
||||
include: {
|
||||
target: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
summary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
incomingLinks: {
|
||||
include: {
|
||||
source: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
summary: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!currentEntry) continue;
|
||||
|
||||
// Add current node
|
||||
const graphNode: GraphNode = {
|
||||
id: currentEntry.id,
|
||||
slug: currentEntry.slug,
|
||||
title: currentEntry.title,
|
||||
summary: currentEntry.summary,
|
||||
tags: currentEntry.tags.map((et) => ({
|
||||
id: et.tag.id,
|
||||
name: et.tag.name,
|
||||
slug: et.tag.slug,
|
||||
color: et.tag.color,
|
||||
})),
|
||||
depth,
|
||||
};
|
||||
nodes.push(graphNode);
|
||||
|
||||
// Continue BFS if not at max depth
|
||||
if (depth < maxDepth) {
|
||||
// Process outgoing links (only resolved ones)
|
||||
for (const link of currentEntry.outgoingLinks) {
|
||||
// Skip unresolved links
|
||||
if (!link.targetId || !link.resolved) continue;
|
||||
|
||||
// Add edge
|
||||
edges.push({
|
||||
id: link.id,
|
||||
sourceId: link.sourceId,
|
||||
targetId: link.targetId,
|
||||
linkText: link.linkText,
|
||||
});
|
||||
|
||||
// Add target to queue if not visited
|
||||
if (!visitedNodes.has(link.targetId)) {
|
||||
visitedNodes.add(link.targetId);
|
||||
nodeDepths.set(link.targetId, depth + 1);
|
||||
queue.push([link.targetId, depth + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
// Process incoming links (only resolved ones)
|
||||
for (const link of currentEntry.incomingLinks) {
|
||||
// Skip unresolved links
|
||||
if (!link.targetId || !link.resolved) continue;
|
||||
|
||||
// Add edge
|
||||
const edgeExists = edges.some(
|
||||
(e) => e.sourceId === link.sourceId && e.targetId === link.targetId
|
||||
);
|
||||
if (!edgeExists) {
|
||||
edges.push({
|
||||
id: link.id,
|
||||
sourceId: link.sourceId,
|
||||
targetId: link.targetId,
|
||||
linkText: link.linkText,
|
||||
});
|
||||
}
|
||||
|
||||
// Add source to queue if not visited
|
||||
if (!visitedNodes.has(link.sourceId)) {
|
||||
visitedNodes.add(link.sourceId);
|
||||
nodeDepths.set(link.sourceId, depth + 1);
|
||||
queue.push([link.sourceId, depth + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find center node
|
||||
const centerNode = nodes.find((n) => n.id === entryId)!;
|
||||
|
||||
return {
|
||||
centerNode,
|
||||
nodes,
|
||||
edges,
|
||||
stats: {
|
||||
totalNodes: nodes.length,
|
||||
totalEdges: edges.length,
|
||||
maxDepth,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,3 +4,7 @@ export type {
|
||||
ResolvedLink,
|
||||
Backlink,
|
||||
} from "./link-resolution.service";
|
||||
export { LinkSyncService } from "./link-sync.service";
|
||||
export { SearchService } from "./search.service";
|
||||
export { GraphService } from "./graph.service";
|
||||
export { StatsService } from "./stats.service";
|
||||
|
||||
@@ -131,7 +131,9 @@ export class LinkResolutionService {
|
||||
return null;
|
||||
}
|
||||
|
||||
return fuzzyMatches[0].id;
|
||||
// Return the single match
|
||||
const match = fuzzyMatches[0];
|
||||
return match ? match.id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { LinkResolutionService } from "./link-resolution.service";
|
||||
import { parseWikiLinks, WikiLink } from "../utils/wiki-link-parser";
|
||||
import { parseWikiLinks } from "../utils/wiki-link-parser";
|
||||
|
||||
/**
|
||||
* Represents a backlink to a knowledge entry
|
||||
|
||||
122
apps/api/src/knowledge/services/stats.service.spec.ts
Normal file
122
apps/api/src/knowledge/services/stats.service.spec.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { StatsService } from "./stats.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
|
||||
describe("StatsService", () => {
|
||||
let service: StatsService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
knowledgeEntry: {
|
||||
count: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
knowledgeTag: {
|
||||
count: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
knowledgeLink: {
|
||||
count: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
StatsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<StatsService>(StatsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getStats", () => {
|
||||
it("should return knowledge base statistics", async () => {
|
||||
// Mock all the parallel queries
|
||||
mockPrismaService.knowledgeEntry.count
|
||||
.mockResolvedValueOnce(10) // total entries
|
||||
.mockResolvedValueOnce(5) // published
|
||||
.mockResolvedValueOnce(3) // drafts
|
||||
.mockResolvedValueOnce(2); // archived
|
||||
|
||||
mockPrismaService.knowledgeTag.count.mockResolvedValue(7);
|
||||
mockPrismaService.knowledgeLink.count.mockResolvedValue(15);
|
||||
|
||||
mockPrismaService.knowledgeEntry.findMany
|
||||
.mockResolvedValueOnce([
|
||||
// most connected
|
||||
{
|
||||
id: "entry-1",
|
||||
slug: "test-entry",
|
||||
title: "Test Entry",
|
||||
_count: { incomingLinks: 5, outgoingLinks: 3 },
|
||||
},
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
// recent activity
|
||||
{
|
||||
id: "entry-2",
|
||||
slug: "recent-entry",
|
||||
title: "Recent Entry",
|
||||
updatedAt: new Date(),
|
||||
status: EntryStatus.PUBLISHED,
|
||||
},
|
||||
]);
|
||||
|
||||
mockPrismaService.knowledgeTag.findMany.mockResolvedValue([
|
||||
{
|
||||
id: "tag-1",
|
||||
name: "Test Tag",
|
||||
slug: "test-tag",
|
||||
color: "#FF0000",
|
||||
_count: { entries: 3 },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getStats("workspace-1");
|
||||
|
||||
expect(result.overview.totalEntries).toBe(10);
|
||||
expect(result.overview.totalTags).toBe(7);
|
||||
expect(result.overview.totalLinks).toBe(15);
|
||||
expect(result.overview.publishedEntries).toBe(5);
|
||||
expect(result.overview.draftEntries).toBe(3);
|
||||
expect(result.overview.archivedEntries).toBe(2);
|
||||
|
||||
expect(result.mostConnected).toHaveLength(1);
|
||||
expect(result.mostConnected[0].totalConnections).toBe(8);
|
||||
|
||||
expect(result.recentActivity).toHaveLength(1);
|
||||
expect(result.tagDistribution).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should handle empty knowledge base", async () => {
|
||||
// Mock all counts as 0
|
||||
mockPrismaService.knowledgeEntry.count.mockResolvedValue(0);
|
||||
mockPrismaService.knowledgeTag.count.mockResolvedValue(0);
|
||||
mockPrismaService.knowledgeLink.count.mockResolvedValue(0);
|
||||
mockPrismaService.knowledgeEntry.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.knowledgeTag.findMany.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getStats("workspace-1");
|
||||
|
||||
expect(result.overview.totalEntries).toBe(0);
|
||||
expect(result.overview.totalTags).toBe(0);
|
||||
expect(result.overview.totalLinks).toBe(0);
|
||||
expect(result.mostConnected).toHaveLength(0);
|
||||
expect(result.recentActivity).toHaveLength(0);
|
||||
expect(result.tagDistribution).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
apps/api/src/knowledge/services/stats.service.ts
Normal file
169
apps/api/src/knowledge/services/stats.service.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
import type { KnowledgeStats } from "../entities/stats.entity";
|
||||
|
||||
/**
|
||||
* Service for knowledge base statistics
|
||||
*/
|
||||
@Injectable()
|
||||
export class StatsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Get comprehensive knowledge base statistics
|
||||
*/
|
||||
async getStats(workspaceId: string): Promise<KnowledgeStats> {
|
||||
// Run queries in parallel for better performance
|
||||
const [
|
||||
totalEntries,
|
||||
totalTags,
|
||||
totalLinks,
|
||||
publishedEntries,
|
||||
draftEntries,
|
||||
archivedEntries,
|
||||
entriesWithLinkCounts,
|
||||
recentEntries,
|
||||
tagsWithCounts,
|
||||
] = await Promise.all([
|
||||
// Total entries
|
||||
this.prisma.knowledgeEntry.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
|
||||
// Total tags
|
||||
this.prisma.knowledgeTag.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
|
||||
// Total links
|
||||
this.prisma.knowledgeLink.count({
|
||||
where: {
|
||||
source: { workspaceId },
|
||||
},
|
||||
}),
|
||||
|
||||
// Published entries
|
||||
this.prisma.knowledgeEntry.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: EntryStatus.PUBLISHED,
|
||||
},
|
||||
}),
|
||||
|
||||
// Draft entries
|
||||
this.prisma.knowledgeEntry.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: EntryStatus.DRAFT,
|
||||
},
|
||||
}),
|
||||
|
||||
// Archived entries
|
||||
this.prisma.knowledgeEntry.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: EntryStatus.ARCHIVED,
|
||||
},
|
||||
}),
|
||||
|
||||
// Most connected entries
|
||||
this.prisma.knowledgeEntry.findMany({
|
||||
where: { workspaceId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
incomingLinks: true,
|
||||
outgoingLinks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
incomingLinks: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
take: 10,
|
||||
}),
|
||||
|
||||
// Recent activity
|
||||
this.prisma.knowledgeEntry.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: 10,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
updatedAt: true,
|
||||
status: true,
|
||||
},
|
||||
}),
|
||||
|
||||
// Tag distribution
|
||||
this.prisma.knowledgeTag.findMany({
|
||||
where: { workspaceId },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
entries: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
entries: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Transform most connected entries
|
||||
const mostConnected = entriesWithLinkCounts.map((entry) => {
|
||||
const incomingLinks = entry._count.incomingLinks;
|
||||
const outgoingLinks = entry._count.outgoingLinks;
|
||||
return {
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
incomingLinks,
|
||||
outgoingLinks,
|
||||
totalConnections: incomingLinks + outgoingLinks,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort by total connections
|
||||
mostConnected.sort((a, b) => b.totalConnections - a.totalConnections);
|
||||
|
||||
// Transform tag distribution
|
||||
const tagDistribution = tagsWithCounts.map((tag) => ({
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
slug: tag.slug,
|
||||
color: tag.color,
|
||||
entryCount: tag._count.entries,
|
||||
}));
|
||||
|
||||
return {
|
||||
overview: {
|
||||
totalEntries,
|
||||
totalTags,
|
||||
totalLinks,
|
||||
publishedEntries,
|
||||
draftEntries,
|
||||
archivedEntries,
|
||||
},
|
||||
mostConnected,
|
||||
recentActivity: recentEntries.map((entry) => ({
|
||||
id: entry.id,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
updatedAt: entry.updatedAt,
|
||||
status: entry.status,
|
||||
})),
|
||||
tagDistribution,
|
||||
};
|
||||
}
|
||||
}
|
||||
25
apps/api/src/knowledge/stats.controller.ts
Normal file
25
apps/api/src/knowledge/stats.controller.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, RequirePermission, Permission } from "../common/decorators";
|
||||
import { StatsService } from "./services";
|
||||
|
||||
/**
|
||||
* Controller for knowledge statistics endpoints
|
||||
*/
|
||||
@Controller("knowledge/stats")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class KnowledgeStatsController {
|
||||
constructor(private readonly statsService: StatsService) {}
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/stats
|
||||
* Get knowledge base statistics
|
||||
* Requires: Any workspace member
|
||||
*/
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getStats(@Workspace() workspaceId: string) {
|
||||
return this.statsService.getStats(workspaceId);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { EntryStatus, Visibility } from "@mosaic/shared";
|
||||
import { EntryViewer } from "@/components/knowledge/EntryViewer";
|
||||
import { EntryEditor } from "@/components/knowledge/EntryEditor";
|
||||
import { EntryMetadata } from "@/components/knowledge/EntryMetadata";
|
||||
import { EntryGraphViewer } from "@/components/knowledge/EntryGraphViewer";
|
||||
import { fetchEntry, updateEntry, deleteEntry, fetchTags } from "@/lib/api/knowledge";
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export default function EntryPage() {
|
||||
|
||||
const [entry, setEntry] = useState<KnowledgeEntryWithTags | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showGraph, setShowGraph] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -44,7 +46,7 @@ export default function EntryPage() {
|
||||
setEditContent(data.content);
|
||||
setEditStatus(data.status);
|
||||
setEditVisibility(data.visibility);
|
||||
setEditTags(data.tags.map((tag) => tag.id));
|
||||
setEditTags(data.tags.map((tag: { id: string }) => tag.id));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load entry");
|
||||
} finally {
|
||||
@@ -80,7 +82,7 @@ export default function EntryPage() {
|
||||
editStatus !== entry.status ||
|
||||
editVisibility !== entry.visibility ||
|
||||
JSON.stringify(editTags.sort()) !==
|
||||
JSON.stringify(entry.tags.map((t) => t.id).sort());
|
||||
JSON.stringify(entry.tags.map((t: { id: string }) => t.id).sort());
|
||||
|
||||
setHasUnsavedChanges(changed);
|
||||
}, [entry, isEditing, editTitle, editContent, editStatus, editVisibility, editTags]);
|
||||
@@ -156,7 +158,7 @@ export default function EntryPage() {
|
||||
setEditContent(entry.content);
|
||||
setEditStatus(entry.status);
|
||||
setEditVisibility(entry.visibility);
|
||||
setEditTags(entry.tags.map((tag) => tag.id));
|
||||
setEditTags(entry.tags.map((tag: { id: string }) => tag.id));
|
||||
setIsEditing(false);
|
||||
setHasUnsavedChanges(false);
|
||||
}
|
||||
@@ -248,7 +250,7 @@ export default function EntryPage() {
|
||||
</span>
|
||||
|
||||
{/* Tags */}
|
||||
{entry.tags.map((tag) => (
|
||||
{entry.tags.map((tag: { id: string; name: string; color: string | null }) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
@@ -268,10 +270,42 @@ export default function EntryPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Tabs */}
|
||||
{!isEditing && (
|
||||
<div className="mb-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setShowGraph(false)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
!showGraph
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowGraph(true)}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
showGraph
|
||||
? "border-blue-500 text-blue-600 dark:text-blue-400"
|
||||
: "border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
Graph View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="mb-6">
|
||||
{isEditing ? (
|
||||
<EntryEditor content={editContent} onChange={setEditContent} />
|
||||
) : showGraph ? (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden" style={{ height: '600px' }}>
|
||||
<EntryGraphViewer slug={slug} initialDepth={1} />
|
||||
</div>
|
||||
) : (
|
||||
<EntryViewer entry={entry} />
|
||||
)}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function KnowledgePage() {
|
||||
// Filter by tag
|
||||
if (selectedTag !== "all") {
|
||||
filtered = filtered.filter((entry) =>
|
||||
entry.tags.some((tag) => tag.slug === selectedTag)
|
||||
entry.tags.some((tag: { slug: string }) => tag.slug === selectedTag)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default function KnowledgePage() {
|
||||
(entry) =>
|
||||
entry.title.toLowerCase().includes(query) ||
|
||||
entry.summary?.toLowerCase().includes(query) ||
|
||||
entry.tags.some((tag) => tag.name.toLowerCase().includes(query))
|
||||
entry.tags.some((tag: { name: string }) => tag.name.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { StatsDashboard } from "@/components/knowledge";
|
||||
|
||||
export default function KnowledgeStatsPage() {
|
||||
return <StatsDashboard />;
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export function EntryCard({ entry }: EntryCardProps) {
|
||||
{/* Tags */}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{entry.tags.map((tag) => (
|
||||
{entry.tags.map((tag: { id: string; name: string; color: string | null }) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
@@ -79,10 +79,12 @@ export function EntryCard({ entry }: EntryCardProps) {
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
|
||||
{/* Status */}
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full ${statusInfo.className}`}>
|
||||
<span>{statusInfo.icon}</span>
|
||||
<span>{statusInfo.label}</span>
|
||||
</span>
|
||||
{statusInfo && (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full ${statusInfo.className}`}>
|
||||
<span>{statusInfo.icon}</span>
|
||||
<span>{statusInfo.label}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Visibility */}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
|
||||
293
apps/web/src/components/knowledge/EntryGraphViewer.tsx
Normal file
293
apps/web/src/components/knowledge/EntryGraphViewer.tsx
Normal 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 };
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -128,6 +128,22 @@ export async function fetchTags(): Promise<KnowledgeTag[]> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch entry-centered graph view
|
||||
*/
|
||||
export async function fetchEntryGraph(slug: string, depth: number = 1) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("depth", depth.toString());
|
||||
return apiGet(`/api/knowledge/entries/${slug}/graph?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch knowledge base statistics
|
||||
*/
|
||||
export async function fetchKnowledgeStats() {
|
||||
return apiGet("/api/knowledge/stats");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock entries for development (until backend endpoints are ready)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user