@nahisaho/yata-ui 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/yata-ui.js +42 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +548 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
- package/src/index.test.ts +160 -0
- package/src/index.ts +685 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +14 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YATA UI Server
|
|
3
|
+
*
|
|
4
|
+
* Web-based visualization and management interface for YATA knowledge graphs.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
* @module @nahisaho/yata-ui
|
|
8
|
+
*
|
|
9
|
+
* @see REQ-YI-WEB-001 - Web-based Visualization
|
|
10
|
+
* @see REQ-YI-WEB-002 - Interactive Graph Editing
|
|
11
|
+
* @see REQ-YI-WEB-003 - Real-time Updates
|
|
12
|
+
* @see DES-YATA-IMPROVEMENTS-001 - Design Document
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import express, { Express, Request, Response, Router } from 'express';
|
|
16
|
+
import * as http from 'http';
|
|
17
|
+
|
|
18
|
+
// ============================================================
|
|
19
|
+
// Types
|
|
20
|
+
// ============================================================
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* UI Server configuration
|
|
24
|
+
* @see REQ-YI-WEB-001
|
|
25
|
+
*/
|
|
26
|
+
export interface UIServerConfig {
|
|
27
|
+
/** Server port */
|
|
28
|
+
port: number;
|
|
29
|
+
/** Host to bind to */
|
|
30
|
+
host?: string;
|
|
31
|
+
/** Enable CORS */
|
|
32
|
+
cors?: boolean;
|
|
33
|
+
/** Static files directory */
|
|
34
|
+
staticDir?: string;
|
|
35
|
+
/** API base path */
|
|
36
|
+
apiBasePath?: string;
|
|
37
|
+
/** Enable real-time updates via SSE */
|
|
38
|
+
enableRealtime?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Graph data for visualization
|
|
43
|
+
*/
|
|
44
|
+
export interface GraphData {
|
|
45
|
+
/** Nodes (entities) */
|
|
46
|
+
nodes: GraphNode[];
|
|
47
|
+
/** Edges (relationships) */
|
|
48
|
+
edges: GraphEdge[];
|
|
49
|
+
/** Graph metadata */
|
|
50
|
+
metadata?: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Graph node
|
|
55
|
+
*/
|
|
56
|
+
export interface GraphNode {
|
|
57
|
+
/** Node ID */
|
|
58
|
+
id: string;
|
|
59
|
+
/** Node label */
|
|
60
|
+
label: string;
|
|
61
|
+
/** Node type */
|
|
62
|
+
type: string;
|
|
63
|
+
/** Namespace */
|
|
64
|
+
namespace?: string;
|
|
65
|
+
/** Position (optional) */
|
|
66
|
+
position?: { x: number; y: number };
|
|
67
|
+
/** Custom data */
|
|
68
|
+
data?: Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Graph edge
|
|
73
|
+
*/
|
|
74
|
+
export interface GraphEdge {
|
|
75
|
+
/** Edge ID */
|
|
76
|
+
id: string;
|
|
77
|
+
/** Source node ID */
|
|
78
|
+
source: string;
|
|
79
|
+
/** Target node ID */
|
|
80
|
+
target: string;
|
|
81
|
+
/** Relationship type */
|
|
82
|
+
type: string;
|
|
83
|
+
/** Edge label */
|
|
84
|
+
label?: string;
|
|
85
|
+
/** Edge weight */
|
|
86
|
+
weight?: number;
|
|
87
|
+
/** Custom data */
|
|
88
|
+
data?: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* API response
|
|
93
|
+
*/
|
|
94
|
+
export interface ApiResponse<T> {
|
|
95
|
+
/** Success status */
|
|
96
|
+
success: boolean;
|
|
97
|
+
/** Response data */
|
|
98
|
+
data?: T;
|
|
99
|
+
/** Error message */
|
|
100
|
+
error?: string;
|
|
101
|
+
/** Timestamp */
|
|
102
|
+
timestamp: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* SSE client
|
|
107
|
+
*/
|
|
108
|
+
interface SSEClient {
|
|
109
|
+
id: string;
|
|
110
|
+
response: Response;
|
|
111
|
+
namespace?: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Default configuration
|
|
116
|
+
*/
|
|
117
|
+
export const DEFAULT_UI_CONFIG: Partial<UIServerConfig> = {
|
|
118
|
+
port: 3000,
|
|
119
|
+
host: 'localhost',
|
|
120
|
+
cors: true,
|
|
121
|
+
apiBasePath: '/api',
|
|
122
|
+
enableRealtime: true,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// ============================================================
|
|
126
|
+
// YataUIServer Class
|
|
127
|
+
// ============================================================
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* YATA Knowledge Graph Web UI Server
|
|
131
|
+
*
|
|
132
|
+
* Provides web-based visualization and management for YATA knowledge graphs.
|
|
133
|
+
*
|
|
134
|
+
* @example
|
|
135
|
+
* ```typescript
|
|
136
|
+
* const server = new YataUIServer({
|
|
137
|
+
* port: 3000,
|
|
138
|
+
* enableRealtime: true,
|
|
139
|
+
* });
|
|
140
|
+
*
|
|
141
|
+
* // Set data provider
|
|
142
|
+
* server.setDataProvider(async () => {
|
|
143
|
+
* return {
|
|
144
|
+
* nodes: [{ id: '1', label: 'Node 1', type: 'entity' }],
|
|
145
|
+
* edges: [],
|
|
146
|
+
* };
|
|
147
|
+
* });
|
|
148
|
+
*
|
|
149
|
+
* await server.start();
|
|
150
|
+
* console.log('UI available at http://localhost:3000');
|
|
151
|
+
* ```
|
|
152
|
+
*
|
|
153
|
+
* @see REQ-YI-WEB-001
|
|
154
|
+
* @see REQ-YI-WEB-002
|
|
155
|
+
* @see REQ-YI-WEB-003
|
|
156
|
+
*/
|
|
157
|
+
export class YataUIServer {
|
|
158
|
+
private app: Express;
|
|
159
|
+
private server: http.Server | null = null;
|
|
160
|
+
private config: UIServerConfig;
|
|
161
|
+
private sseClients: Map<string, SSEClient> = new Map();
|
|
162
|
+
private dataProvider: (() => Promise<GraphData>) | null = null;
|
|
163
|
+
|
|
164
|
+
constructor(config: Partial<UIServerConfig> = {}) {
|
|
165
|
+
this.config = { ...DEFAULT_UI_CONFIG, ...config } as UIServerConfig;
|
|
166
|
+
this.app = express();
|
|
167
|
+
this.setupMiddleware();
|
|
168
|
+
this.setupRoutes();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ============================================================
|
|
172
|
+
// Public API
|
|
173
|
+
// ============================================================
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Set data provider function
|
|
177
|
+
* @param provider - Function that returns graph data
|
|
178
|
+
*/
|
|
179
|
+
setDataProvider(provider: () => Promise<GraphData>): void {
|
|
180
|
+
this.dataProvider = provider;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Start the server
|
|
185
|
+
* @see REQ-YI-WEB-001
|
|
186
|
+
*/
|
|
187
|
+
async start(): Promise<void> {
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
try {
|
|
190
|
+
const host = this.config.host ?? '0.0.0.0';
|
|
191
|
+
this.server = this.app.listen(this.config.port, host, () => {
|
|
192
|
+
resolve();
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
reject(error);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Stop the server
|
|
202
|
+
*/
|
|
203
|
+
async stop(): Promise<void> {
|
|
204
|
+
// Close all SSE connections
|
|
205
|
+
for (const client of this.sseClients.values()) {
|
|
206
|
+
client.response.end();
|
|
207
|
+
}
|
|
208
|
+
this.sseClients.clear();
|
|
209
|
+
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
if (!this.server) {
|
|
212
|
+
resolve();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.server.close((err) => {
|
|
217
|
+
if (err) {
|
|
218
|
+
reject(err);
|
|
219
|
+
} else {
|
|
220
|
+
this.server = null;
|
|
221
|
+
resolve();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Get server URL
|
|
229
|
+
*/
|
|
230
|
+
getUrl(): string {
|
|
231
|
+
return `http://${this.config.host}:${this.config.port}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check if server is running
|
|
236
|
+
*/
|
|
237
|
+
isRunning(): boolean {
|
|
238
|
+
return this.server !== null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Broadcast update to all SSE clients
|
|
243
|
+
* @see REQ-YI-WEB-003
|
|
244
|
+
*/
|
|
245
|
+
broadcastUpdate(event: string, data: unknown): void {
|
|
246
|
+
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
247
|
+
|
|
248
|
+
for (const client of this.sseClients.values()) {
|
|
249
|
+
client.response.write(message);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get Express app instance for testing
|
|
255
|
+
*/
|
|
256
|
+
getApp(): Express {
|
|
257
|
+
return this.app;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ============================================================
|
|
261
|
+
// Internal: Middleware
|
|
262
|
+
// ============================================================
|
|
263
|
+
|
|
264
|
+
private setupMiddleware(): void {
|
|
265
|
+
// JSON body parser
|
|
266
|
+
this.app.use(express.json());
|
|
267
|
+
|
|
268
|
+
// CORS
|
|
269
|
+
if (this.config.cors) {
|
|
270
|
+
this.app.use((_req, res, next) => {
|
|
271
|
+
res.header('Access-Control-Allow-Origin', '*');
|
|
272
|
+
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
273
|
+
res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
|
274
|
+
next();
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ============================================================
|
|
280
|
+
// Internal: Routes
|
|
281
|
+
// ============================================================
|
|
282
|
+
|
|
283
|
+
private setupRoutes(): void {
|
|
284
|
+
const router = Router();
|
|
285
|
+
|
|
286
|
+
// Health check
|
|
287
|
+
router.get('/health', (_req, res) => {
|
|
288
|
+
this.sendResponse(res, { status: 'ok', timestamp: new Date().toISOString() });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
// Get graph data
|
|
292
|
+
router.get('/graph', async (_req, res) => {
|
|
293
|
+
try {
|
|
294
|
+
const data = await this.getGraphData();
|
|
295
|
+
this.sendResponse(res, data);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get graph data');
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// Get nodes
|
|
302
|
+
router.get('/nodes', async (_req, res) => {
|
|
303
|
+
try {
|
|
304
|
+
const data = await this.getGraphData();
|
|
305
|
+
this.sendResponse(res, data.nodes);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get nodes');
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Get edges
|
|
312
|
+
router.get('/edges', async (_req, res) => {
|
|
313
|
+
try {
|
|
314
|
+
const data = await this.getGraphData();
|
|
315
|
+
this.sendResponse(res, data.edges);
|
|
316
|
+
} catch (error) {
|
|
317
|
+
this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get edges');
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// Get single node
|
|
322
|
+
router.get('/nodes/:id', async (req, res) => {
|
|
323
|
+
try {
|
|
324
|
+
const data = await this.getGraphData();
|
|
325
|
+
const node = data.nodes.find(n => n.id === req.params.id);
|
|
326
|
+
if (!node) {
|
|
327
|
+
this.sendError(res, 404, 'Node not found');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
this.sendResponse(res, node);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get node');
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// SSE endpoint for real-time updates
|
|
337
|
+
if (this.config.enableRealtime) {
|
|
338
|
+
router.get('/events', (req, res) => {
|
|
339
|
+
this.handleSSEConnection(req, res);
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Cytoscape data format endpoint
|
|
344
|
+
router.get('/cytoscape', async (_req, res) => {
|
|
345
|
+
try {
|
|
346
|
+
const data = await this.getGraphData();
|
|
347
|
+
const cytoscapeData = this.toCytoscapeFormat(data);
|
|
348
|
+
this.sendResponse(res, cytoscapeData);
|
|
349
|
+
} catch (error) {
|
|
350
|
+
this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get Cytoscape data');
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Statistics endpoint
|
|
355
|
+
router.get('/stats', async (_req, res) => {
|
|
356
|
+
try {
|
|
357
|
+
const data = await this.getGraphData();
|
|
358
|
+
const stats = {
|
|
359
|
+
nodeCount: data.nodes.length,
|
|
360
|
+
edgeCount: data.edges.length,
|
|
361
|
+
nodeTypes: this.countByType(data.nodes),
|
|
362
|
+
edgeTypes: this.countByType(data.edges),
|
|
363
|
+
namespaces: [...new Set(data.nodes.map(n => n.namespace).filter(Boolean))],
|
|
364
|
+
};
|
|
365
|
+
this.sendResponse(res, stats);
|
|
366
|
+
} catch (error) {
|
|
367
|
+
this.sendError(res, 500, error instanceof Error ? error.message : 'Failed to get stats');
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Mount API routes
|
|
372
|
+
this.app.use(this.config.apiBasePath || '/api', router);
|
|
373
|
+
|
|
374
|
+
// Serve static files (for embedded UI)
|
|
375
|
+
if (this.config.staticDir) {
|
|
376
|
+
this.app.use(express.static(this.config.staticDir));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Serve built-in UI
|
|
380
|
+
this.app.get('/', (_req, res) => {
|
|
381
|
+
res.send(this.getBuiltInUI());
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ============================================================
|
|
386
|
+
// Internal: SSE
|
|
387
|
+
// ============================================================
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Handle SSE connection
|
|
391
|
+
* @see REQ-YI-WEB-003
|
|
392
|
+
*/
|
|
393
|
+
private handleSSEConnection(req: Request, res: Response): void {
|
|
394
|
+
// Set SSE headers
|
|
395
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
396
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
397
|
+
res.setHeader('Connection', 'keep-alive');
|
|
398
|
+
|
|
399
|
+
// Generate client ID
|
|
400
|
+
const clientId = `client-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
401
|
+
|
|
402
|
+
// Register client
|
|
403
|
+
this.sseClients.set(clientId, {
|
|
404
|
+
id: clientId,
|
|
405
|
+
response: res,
|
|
406
|
+
namespace: req.query.namespace as string | undefined,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Send initial connection event
|
|
410
|
+
res.write(`event: connected\ndata: ${JSON.stringify({ clientId })}\n\n`);
|
|
411
|
+
|
|
412
|
+
// Handle client disconnect
|
|
413
|
+
req.on('close', () => {
|
|
414
|
+
this.sseClients.delete(clientId);
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ============================================================
|
|
419
|
+
// Internal: Helpers
|
|
420
|
+
// ============================================================
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Get graph data from provider
|
|
424
|
+
*/
|
|
425
|
+
private async getGraphData(): Promise<GraphData> {
|
|
426
|
+
if (!this.dataProvider) {
|
|
427
|
+
return { nodes: [], edges: [] };
|
|
428
|
+
}
|
|
429
|
+
return this.dataProvider();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Send API response
|
|
434
|
+
*/
|
|
435
|
+
private sendResponse<T>(res: Response, data: T): void {
|
|
436
|
+
const response: ApiResponse<T> = {
|
|
437
|
+
success: true,
|
|
438
|
+
data,
|
|
439
|
+
timestamp: new Date().toISOString(),
|
|
440
|
+
};
|
|
441
|
+
res.json(response);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Send error response
|
|
446
|
+
*/
|
|
447
|
+
private sendError(res: Response, status: number, error: string): void {
|
|
448
|
+
const response: ApiResponse<null> = {
|
|
449
|
+
success: false,
|
|
450
|
+
error,
|
|
451
|
+
timestamp: new Date().toISOString(),
|
|
452
|
+
};
|
|
453
|
+
res.status(status).json(response);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Convert to Cytoscape.js format
|
|
458
|
+
* @see REQ-YI-WEB-001
|
|
459
|
+
*/
|
|
460
|
+
private toCytoscapeFormat(data: GraphData): object {
|
|
461
|
+
const elements: object[] = [];
|
|
462
|
+
|
|
463
|
+
// Add nodes
|
|
464
|
+
for (const node of data.nodes) {
|
|
465
|
+
elements.push({
|
|
466
|
+
data: {
|
|
467
|
+
id: node.id,
|
|
468
|
+
label: node.label,
|
|
469
|
+
type: node.type,
|
|
470
|
+
namespace: node.namespace,
|
|
471
|
+
...node.data,
|
|
472
|
+
},
|
|
473
|
+
position: node.position,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Add edges
|
|
478
|
+
for (const edge of data.edges) {
|
|
479
|
+
elements.push({
|
|
480
|
+
data: {
|
|
481
|
+
id: edge.id,
|
|
482
|
+
source: edge.source,
|
|
483
|
+
target: edge.target,
|
|
484
|
+
label: edge.label || edge.type,
|
|
485
|
+
type: edge.type,
|
|
486
|
+
weight: edge.weight,
|
|
487
|
+
...edge.data,
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return { elements };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Count items by type
|
|
497
|
+
*/
|
|
498
|
+
private countByType(items: Array<{ type: string }>): Record<string, number> {
|
|
499
|
+
const counts: Record<string, number> = {};
|
|
500
|
+
for (const item of items) {
|
|
501
|
+
counts[item.type] = (counts[item.type] || 0) + 1;
|
|
502
|
+
}
|
|
503
|
+
return counts;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Get built-in UI HTML
|
|
508
|
+
*/
|
|
509
|
+
private getBuiltInUI(): string {
|
|
510
|
+
return `<!DOCTYPE html>
|
|
511
|
+
<html lang="en">
|
|
512
|
+
<head>
|
|
513
|
+
<meta charset="UTF-8">
|
|
514
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
515
|
+
<title>YATA Knowledge Graph</title>
|
|
516
|
+
<script src="https://unpkg.com/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
|
|
517
|
+
<style>
|
|
518
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
519
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
|
520
|
+
.container { display: flex; height: 100vh; }
|
|
521
|
+
.sidebar { width: 300px; background: #f5f5f5; padding: 20px; overflow-y: auto; }
|
|
522
|
+
.graph-container { flex: 1; position: relative; }
|
|
523
|
+
#cy { width: 100%; height: 100%; }
|
|
524
|
+
h1 { font-size: 1.5rem; margin-bottom: 20px; color: #333; }
|
|
525
|
+
.stats { margin-bottom: 20px; padding: 15px; background: white; border-radius: 8px; }
|
|
526
|
+
.stat-item { display: flex; justify-content: space-between; margin: 8px 0; }
|
|
527
|
+
.stat-label { color: #666; }
|
|
528
|
+
.stat-value { font-weight: bold; color: #333; }
|
|
529
|
+
.controls { margin-top: 20px; }
|
|
530
|
+
button { padding: 10px 20px; margin: 5px 0; width: 100%; border: none; border-radius: 6px;
|
|
531
|
+
cursor: pointer; font-size: 14px; transition: background 0.2s; }
|
|
532
|
+
.btn-primary { background: #0066cc; color: white; }
|
|
533
|
+
.btn-primary:hover { background: #0052a3; }
|
|
534
|
+
.btn-secondary { background: #e0e0e0; color: #333; }
|
|
535
|
+
.btn-secondary:hover { background: #d0d0d0; }
|
|
536
|
+
.node-info { margin-top: 20px; padding: 15px; background: white; border-radius: 8px; display: none; }
|
|
537
|
+
.node-info.active { display: block; }
|
|
538
|
+
.node-info h3 { margin-bottom: 10px; color: #333; }
|
|
539
|
+
.node-info p { margin: 5px 0; color: #666; font-size: 14px; }
|
|
540
|
+
.legend { margin-top: 20px; }
|
|
541
|
+
.legend-item { display: flex; align-items: center; margin: 5px 0; }
|
|
542
|
+
.legend-color { width: 16px; height: 16px; border-radius: 4px; margin-right: 8px; }
|
|
543
|
+
.connection-status { position: fixed; top: 10px; right: 10px; padding: 8px 16px;
|
|
544
|
+
border-radius: 20px; font-size: 12px; }
|
|
545
|
+
.status-connected { background: #d4edda; color: #155724; }
|
|
546
|
+
.status-disconnected { background: #f8d7da; color: #721c24; }
|
|
547
|
+
</style>
|
|
548
|
+
</head>
|
|
549
|
+
<body>
|
|
550
|
+
<div id="connection-status" class="connection-status status-disconnected">Disconnected</div>
|
|
551
|
+
<div class="container">
|
|
552
|
+
<div class="sidebar">
|
|
553
|
+
<h1>📊 YATA Graph</h1>
|
|
554
|
+
<div id="stats" class="stats">
|
|
555
|
+
<div class="stat-item"><span class="stat-label">Nodes:</span><span id="node-count" class="stat-value">-</span></div>
|
|
556
|
+
<div class="stat-item"><span class="stat-label">Edges:</span><span id="edge-count" class="stat-value">-</span></div>
|
|
557
|
+
</div>
|
|
558
|
+
<div class="controls">
|
|
559
|
+
<button class="btn-primary" onclick="refreshGraph()">🔄 Refresh</button>
|
|
560
|
+
<button class="btn-secondary" onclick="fitGraph()">📐 Fit to View</button>
|
|
561
|
+
<button class="btn-secondary" onclick="exportPNG()">📸 Export PNG</button>
|
|
562
|
+
</div>
|
|
563
|
+
<div id="node-info" class="node-info">
|
|
564
|
+
<h3 id="selected-name">Selected Node</h3>
|
|
565
|
+
<p><strong>ID:</strong> <span id="selected-id">-</span></p>
|
|
566
|
+
<p><strong>Type:</strong> <span id="selected-type">-</span></p>
|
|
567
|
+
<p><strong>Namespace:</strong> <span id="selected-ns">-</span></p>
|
|
568
|
+
</div>
|
|
569
|
+
<div class="legend">
|
|
570
|
+
<h4>Node Types</h4>
|
|
571
|
+
<div class="legend-item"><div class="legend-color" style="background:#4CAF50"></div>Entity</div>
|
|
572
|
+
<div class="legend-item"><div class="legend-color" style="background:#2196F3"></div>Class</div>
|
|
573
|
+
<div class="legend-item"><div class="legend-color" style="background:#FF9800"></div>Function</div>
|
|
574
|
+
<div class="legend-item"><div class="legend-color" style="background:#9C27B0"></div>Interface</div>
|
|
575
|
+
</div>
|
|
576
|
+
</div>
|
|
577
|
+
<div class="graph-container">
|
|
578
|
+
<div id="cy"></div>
|
|
579
|
+
</div>
|
|
580
|
+
</div>
|
|
581
|
+
<script>
|
|
582
|
+
const API_BASE = '${this.config.apiBasePath || '/api'}';
|
|
583
|
+
let cy;
|
|
584
|
+
let eventSource;
|
|
585
|
+
|
|
586
|
+
const typeColors = {
|
|
587
|
+
entity: '#4CAF50', class: '#2196F3', function: '#FF9800',
|
|
588
|
+
interface: '#9C27B0', module: '#607D8B', default: '#9E9E9E'
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
async function initGraph() {
|
|
592
|
+
cy = cytoscape({
|
|
593
|
+
container: document.getElementById('cy'),
|
|
594
|
+
style: [
|
|
595
|
+
{ selector: 'node', style: {
|
|
596
|
+
'label': 'data(label)', 'text-valign': 'center', 'text-halign': 'center',
|
|
597
|
+
'background-color': 'data(color)', 'color': '#fff', 'font-size': '12px',
|
|
598
|
+
'text-outline-width': 2, 'text-outline-color': 'data(color)',
|
|
599
|
+
'width': 60, 'height': 60
|
|
600
|
+
}},
|
|
601
|
+
{ selector: 'edge', style: {
|
|
602
|
+
'label': 'data(label)', 'curve-style': 'bezier', 'target-arrow-shape': 'triangle',
|
|
603
|
+
'line-color': '#999', 'target-arrow-color': '#999', 'font-size': '10px',
|
|
604
|
+
'text-background-color': '#fff', 'text-background-opacity': 0.8,
|
|
605
|
+
'text-background-padding': '2px'
|
|
606
|
+
}},
|
|
607
|
+
{ selector: ':selected', style: {
|
|
608
|
+
'border-width': 3, 'border-color': '#ff0066'
|
|
609
|
+
}}
|
|
610
|
+
],
|
|
611
|
+
layout: { name: 'cose', animate: false }
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
cy.on('tap', 'node', function(e) {
|
|
615
|
+
const node = e.target.data();
|
|
616
|
+
document.getElementById('selected-name').textContent = node.label;
|
|
617
|
+
document.getElementById('selected-id').textContent = node.id;
|
|
618
|
+
document.getElementById('selected-type').textContent = node.type || '-';
|
|
619
|
+
document.getElementById('selected-ns').textContent = node.namespace || '-';
|
|
620
|
+
document.getElementById('node-info').classList.add('active');
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
cy.on('tap', function(e) {
|
|
624
|
+
if (e.target === cy) {
|
|
625
|
+
document.getElementById('node-info').classList.remove('active');
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
await refreshGraph();
|
|
630
|
+
connectSSE();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async function refreshGraph() {
|
|
634
|
+
try {
|
|
635
|
+
const res = await fetch(API_BASE + '/cytoscape');
|
|
636
|
+
const json = await res.json();
|
|
637
|
+
if (json.success) {
|
|
638
|
+
const elements = json.data.elements.map(el => {
|
|
639
|
+
if (el.data && !el.data.source) {
|
|
640
|
+
el.data.color = typeColors[el.data.type] || typeColors.default;
|
|
641
|
+
}
|
|
642
|
+
return el;
|
|
643
|
+
});
|
|
644
|
+
cy.json({ elements });
|
|
645
|
+
cy.layout({ name: 'cose', animate: true, animationDuration: 500 }).run();
|
|
646
|
+
updateStats();
|
|
647
|
+
}
|
|
648
|
+
} catch (err) { console.error('Failed to refresh:', err); }
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function updateStats() {
|
|
652
|
+
document.getElementById('node-count').textContent = cy.nodes().length;
|
|
653
|
+
document.getElementById('edge-count').textContent = cy.edges().length;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function fitGraph() { cy.fit(50); }
|
|
657
|
+
function exportPNG() { const png = cy.png({ full: true }); window.open(png, '_blank'); }
|
|
658
|
+
|
|
659
|
+
function connectSSE() {
|
|
660
|
+
if (eventSource) eventSource.close();
|
|
661
|
+
eventSource = new EventSource(API_BASE + '/events');
|
|
662
|
+
eventSource.onopen = () => {
|
|
663
|
+
document.getElementById('connection-status').textContent = 'Connected';
|
|
664
|
+
document.getElementById('connection-status').className = 'connection-status status-connected';
|
|
665
|
+
};
|
|
666
|
+
eventSource.onerror = () => {
|
|
667
|
+
document.getElementById('connection-status').textContent = 'Disconnected';
|
|
668
|
+
document.getElementById('connection-status').className = 'connection-status status-disconnected';
|
|
669
|
+
};
|
|
670
|
+
eventSource.addEventListener('update', () => refreshGraph());
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
initGraph();
|
|
674
|
+
</script>
|
|
675
|
+
</body>
|
|
676
|
+
</html>`;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Factory function to create YataUIServer
|
|
682
|
+
*/
|
|
683
|
+
export function createYataUIServer(config?: Partial<UIServerConfig>): YataUIServer {
|
|
684
|
+
return new YataUIServer(config);
|
|
685
|
+
}
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['src/**/*.{test,spec}.ts', '__tests__/**/*.{test,spec}.ts'],
|
|
8
|
+
exclude: ['**/node_modules/**', '**/dist/**'],
|
|
9
|
+
coverage: {
|
|
10
|
+
provider: 'v8',
|
|
11
|
+
reporter: ['text', 'json', 'html'],
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
});
|