@cyvest/cyvest-vis 2.0.1

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/dist/index.mjs ADDED
@@ -0,0 +1,1284 @@
1
+ // src/components/CyvestGraph.tsx
2
+ import { useState as useState2, useCallback as useCallback4 } from "react";
3
+
4
+ // src/components/ObservablesGraph.tsx
5
+ import React3, { useMemo as useMemo2, useCallback as useCallback2, useState } from "react";
6
+ import {
7
+ ReactFlow,
8
+ ReactFlowProvider,
9
+ Background,
10
+ Controls,
11
+ MiniMap,
12
+ useNodesState,
13
+ useEdgesState,
14
+ ConnectionMode
15
+ } from "@xyflow/react";
16
+ import "@xyflow/react/dist/style.css";
17
+ import { getObservableGraph, findRootObservables } from "@cyvest/cyvest-js";
18
+
19
+ // src/types.ts
20
+ var DEFAULT_FORCE_CONFIG = {
21
+ chargeStrength: -200,
22
+ linkDistance: 80,
23
+ centerStrength: 0.05,
24
+ collisionRadius: 40,
25
+ iterations: 300
26
+ };
27
+
28
+ // src/components/ObservableNode.tsx
29
+ import { memo } from "react";
30
+ import { Handle, Position } from "@xyflow/react";
31
+
32
+ // src/utils/observables.ts
33
+ var OBSERVABLE_EMOJI_MAP = {
34
+ // Network
35
+ "ipv4-addr": "\u{1F310}",
36
+ "ipv6-addr": "\u{1F310}",
37
+ "domain-name": "\u{1F3E0}",
38
+ url: "\u{1F517}",
39
+ "autonomous-system": "\u{1F30D}",
40
+ "mac-addr": "\u{1F4F6}",
41
+ // Email
42
+ "email-addr": "\u{1F4E7}",
43
+ "email-message": "\u2709\uFE0F",
44
+ // File
45
+ file: "\u{1F4C4}",
46
+ "file-hash": "\u{1F510}",
47
+ "file:hash:md5": "\u{1F510}",
48
+ "file:hash:sha1": "\u{1F510}",
49
+ "file:hash:sha256": "\u{1F510}",
50
+ // User/Identity
51
+ user: "\u{1F464}",
52
+ "user-account": "\u{1F464}",
53
+ identity: "\u{1FAAA}",
54
+ // Process/System
55
+ process: "\u2699\uFE0F",
56
+ software: "\u{1F4BF}",
57
+ "windows-registry-key": "\u{1F4DD}",
58
+ // Threat Intelligence
59
+ "threat-actor": "\u{1F479}",
60
+ malware: "\u{1F9A0}",
61
+ "attack-pattern": "\u2694\uFE0F",
62
+ campaign: "\u{1F3AF}",
63
+ indicator: "\u{1F6A8}",
64
+ // Artifacts
65
+ artifact: "\u{1F9EA}",
66
+ certificate: "\u{1F4DC}",
67
+ "x509-certificate": "\u{1F4DC}",
68
+ // Default
69
+ unknown: "\u2753"
70
+ };
71
+ function getObservableEmoji(observableType) {
72
+ const normalized = observableType.toLowerCase().trim();
73
+ return OBSERVABLE_EMOJI_MAP[normalized] ?? OBSERVABLE_EMOJI_MAP.unknown;
74
+ }
75
+ var OBSERVABLE_SHAPE_MAP = {
76
+ // Domains get squares
77
+ "domain-name": "square",
78
+ // URLs get circles
79
+ url: "circle",
80
+ // IPs get triangles
81
+ "ipv4-addr": "triangle",
82
+ "ipv6-addr": "triangle",
83
+ // Root/files get rectangles (default for root)
84
+ file: "rectangle",
85
+ "email-message": "rectangle"
86
+ };
87
+ function getObservableShape(observableType, isRoot) {
88
+ if (isRoot) {
89
+ return "rectangle";
90
+ }
91
+ const normalized = observableType.toLowerCase().trim();
92
+ return OBSERVABLE_SHAPE_MAP[normalized] ?? "circle";
93
+ }
94
+ function truncateLabel(value, maxLength = 20, truncateMiddle = true) {
95
+ if (value.length <= maxLength) {
96
+ return value;
97
+ }
98
+ if (truncateMiddle) {
99
+ const halfLen = Math.floor((maxLength - 3) / 2);
100
+ return `${value.slice(0, halfLen)}\u2026${value.slice(-halfLen)}`;
101
+ }
102
+ return `${value.slice(0, maxLength - 1)}\u2026`;
103
+ }
104
+ function getLevelColor(level) {
105
+ const colors = {
106
+ NONE: "#6b7280",
107
+ // gray-500
108
+ TRUSTED: "#22c55e",
109
+ // green-500
110
+ INFO: "#3b82f6",
111
+ // blue-500
112
+ SAFE: "#22c55e",
113
+ // green-500
114
+ NOTABLE: "#eab308",
115
+ // yellow-500
116
+ SUSPICIOUS: "#f97316",
117
+ // orange-500
118
+ MALICIOUS: "#ef4444"
119
+ // red-500
120
+ };
121
+ return colors[level] ?? colors.NONE;
122
+ }
123
+ function getLevelBackgroundColor(level) {
124
+ const colors = {
125
+ NONE: "#f3f4f6",
126
+ // gray-100
127
+ TRUSTED: "#dcfce7",
128
+ // green-100
129
+ INFO: "#dbeafe",
130
+ // blue-100
131
+ SAFE: "#dcfce7",
132
+ // green-100
133
+ NOTABLE: "#fef9c3",
134
+ // yellow-100
135
+ SUSPICIOUS: "#ffedd5",
136
+ // orange-100
137
+ MALICIOUS: "#fee2e2"
138
+ // red-100
139
+ };
140
+ return colors[level] ?? colors.NONE;
141
+ }
142
+ var INVESTIGATION_NODE_EMOJI = {
143
+ root: "\u{1F3AF}",
144
+ check: "\u2705",
145
+ container: "\u{1F4E6}"
146
+ };
147
+ function getInvestigationNodeEmoji(nodeType) {
148
+ return INVESTIGATION_NODE_EMOJI[nodeType] ?? "\u2753";
149
+ }
150
+
151
+ // src/components/ObservableNode.tsx
152
+ import { jsx, jsxs } from "react/jsx-runtime";
153
+ var NODE_SIZE = 28;
154
+ var ROOT_NODE_SIZE = 36;
155
+ function ObservableNodeComponent({
156
+ data,
157
+ selected
158
+ }) {
159
+ const nodeData = data;
160
+ const {
161
+ label,
162
+ emoji,
163
+ shape,
164
+ level,
165
+ isRoot,
166
+ whitelisted,
167
+ fullValue
168
+ } = nodeData;
169
+ const size = isRoot ? ROOT_NODE_SIZE : NODE_SIZE;
170
+ const borderColor = getLevelColor(level);
171
+ const backgroundColor = getLevelBackgroundColor(level);
172
+ const getShapeStyle = () => {
173
+ const baseStyle = {
174
+ width: size,
175
+ height: size,
176
+ display: "flex",
177
+ alignItems: "center",
178
+ justifyContent: "center",
179
+ backgroundColor,
180
+ border: `${selected ? 3 : 2}px solid ${borderColor}`,
181
+ opacity: whitelisted ? 0.5 : 1,
182
+ fontSize: isRoot ? 14 : 12
183
+ };
184
+ switch (shape) {
185
+ case "square":
186
+ return { ...baseStyle, borderRadius: 4 };
187
+ case "circle":
188
+ return { ...baseStyle, borderRadius: "50%" };
189
+ case "triangle":
190
+ return {
191
+ ...baseStyle,
192
+ borderRadius: 0,
193
+ border: "none",
194
+ background: `linear-gradient(to bottom right, ${backgroundColor} 50%, transparent 50%)`,
195
+ clipPath: "polygon(50% 0%, 100% 100%, 0% 100%)",
196
+ position: "relative"
197
+ };
198
+ case "rectangle":
199
+ default:
200
+ return { ...baseStyle, width: size * 1.4, borderRadius: 6 };
201
+ }
202
+ };
203
+ const isTriangle = shape === "triangle";
204
+ return /* @__PURE__ */ jsxs(
205
+ "div",
206
+ {
207
+ className: "observable-node",
208
+ style: {
209
+ display: "flex",
210
+ flexDirection: "column",
211
+ alignItems: "center",
212
+ cursor: "pointer"
213
+ },
214
+ children: [
215
+ /* @__PURE__ */ jsxs("div", { style: { position: "relative" }, children: [
216
+ isTriangle ? (
217
+ // Triangle using SVG
218
+ /* @__PURE__ */ jsxs("svg", { width: size, height: size, viewBox: "0 0 100 100", children: [
219
+ /* @__PURE__ */ jsx(
220
+ "polygon",
221
+ {
222
+ points: "50,10 90,90 10,90",
223
+ fill: backgroundColor,
224
+ stroke: borderColor,
225
+ strokeWidth: selected ? 6 : 4,
226
+ opacity: whitelisted ? 0.5 : 1
227
+ }
228
+ ),
229
+ /* @__PURE__ */ jsx(
230
+ "text",
231
+ {
232
+ x: "50",
233
+ y: "65",
234
+ textAnchor: "middle",
235
+ fontSize: "32",
236
+ dominantBaseline: "middle",
237
+ children: emoji
238
+ }
239
+ )
240
+ ] })
241
+ ) : (
242
+ // Other shapes using CSS
243
+ /* @__PURE__ */ jsx("div", { style: getShapeStyle(), children: /* @__PURE__ */ jsx("span", { style: { userSelect: "none" }, children: emoji }) })
244
+ ),
245
+ /* @__PURE__ */ jsx(
246
+ Handle,
247
+ {
248
+ type: "source",
249
+ position: Position.Right,
250
+ id: "source",
251
+ style: {
252
+ position: "absolute",
253
+ top: "50%",
254
+ left: "50%",
255
+ transform: "translate(-50%, -50%)",
256
+ width: 1,
257
+ height: 1,
258
+ background: "transparent",
259
+ border: "none",
260
+ opacity: 0
261
+ }
262
+ }
263
+ ),
264
+ /* @__PURE__ */ jsx(
265
+ Handle,
266
+ {
267
+ type: "target",
268
+ position: Position.Left,
269
+ id: "target",
270
+ style: {
271
+ position: "absolute",
272
+ top: "50%",
273
+ left: "50%",
274
+ transform: "translate(-50%, -50%)",
275
+ width: 1,
276
+ height: 1,
277
+ background: "transparent",
278
+ border: "none",
279
+ opacity: 0
280
+ }
281
+ }
282
+ )
283
+ ] }),
284
+ /* @__PURE__ */ jsx(
285
+ "div",
286
+ {
287
+ style: {
288
+ marginTop: 2,
289
+ fontSize: 9,
290
+ maxWidth: 70,
291
+ textAlign: "center",
292
+ overflow: "hidden",
293
+ textOverflow: "ellipsis",
294
+ whiteSpace: "nowrap",
295
+ color: "#374151",
296
+ fontFamily: "system-ui, sans-serif"
297
+ },
298
+ title: fullValue,
299
+ children: label
300
+ }
301
+ )
302
+ ]
303
+ }
304
+ );
305
+ }
306
+ var ObservableNode = memo(ObservableNodeComponent);
307
+
308
+ // src/components/FloatingEdge.tsx
309
+ import { memo as memo2 } from "react";
310
+ import { BaseEdge, getStraightPath } from "@xyflow/react";
311
+ import { jsx as jsx2 } from "react/jsx-runtime";
312
+ function FloatingEdgeComponent({
313
+ id,
314
+ sourceX,
315
+ sourceY,
316
+ targetX,
317
+ targetY,
318
+ style,
319
+ markerEnd
320
+ }) {
321
+ const [edgePath] = getStraightPath({
322
+ sourceX,
323
+ sourceY,
324
+ targetX,
325
+ targetY
326
+ });
327
+ return /* @__PURE__ */ jsx2(
328
+ BaseEdge,
329
+ {
330
+ id,
331
+ path: edgePath,
332
+ style: {
333
+ strokeWidth: 1.5,
334
+ stroke: "#94a3b8",
335
+ ...style
336
+ },
337
+ markerEnd
338
+ }
339
+ );
340
+ }
341
+ var FloatingEdge = memo2(FloatingEdgeComponent);
342
+
343
+ // src/hooks/useForceLayout.ts
344
+ import { useEffect, useRef, useCallback, useMemo } from "react";
345
+ import {
346
+ forceSimulation,
347
+ forceLink,
348
+ forceManyBody,
349
+ forceCenter,
350
+ forceCollide,
351
+ forceX,
352
+ forceY
353
+ } from "d3-force";
354
+ import {
355
+ useReactFlow,
356
+ useNodesInitialized,
357
+ useStore
358
+ } from "@xyflow/react";
359
+ var nodeCountSelector = (state) => state.nodeLookup.size;
360
+ function useForceLayout(config = {}, rootNodeId) {
361
+ const { getNodes, getEdges, setNodes } = useReactFlow();
362
+ const nodesInitialized = useNodesInitialized();
363
+ const nodeCount = useStore(nodeCountSelector);
364
+ const forceConfig = useMemo(
365
+ () => ({ ...DEFAULT_FORCE_CONFIG, ...config }),
366
+ [config]
367
+ );
368
+ const simulationRef = useRef(null);
369
+ const draggingNodeRef = useRef(null);
370
+ useEffect(() => {
371
+ if (!nodesInitialized || nodeCount === 0) {
372
+ return;
373
+ }
374
+ const nodes = getNodes();
375
+ const edges = getEdges();
376
+ const simNodes = nodes.map((node) => {
377
+ const existingNode = simulationRef.current?.nodes().find((n) => n.id === node.id);
378
+ return {
379
+ id: node.id,
380
+ // Use existing simulation position or node position
381
+ x: existingNode?.x ?? node.position.x ?? Math.random() * 500 - 250,
382
+ y: existingNode?.y ?? node.position.y ?? Math.random() * 500 - 250,
383
+ // Preserve fixed positions for dragged nodes
384
+ fx: existingNode?.fx ?? null,
385
+ fy: existingNode?.fy ?? null
386
+ };
387
+ });
388
+ if (rootNodeId) {
389
+ const rootNode = simNodes.find((n) => n.id === rootNodeId);
390
+ if (rootNode) {
391
+ rootNode.x = 0;
392
+ rootNode.y = 0;
393
+ rootNode.fx = 0;
394
+ rootNode.fy = 0;
395
+ }
396
+ }
397
+ const simLinks = edges.map((edge) => ({
398
+ source: edge.source,
399
+ target: edge.target
400
+ }));
401
+ if (simulationRef.current) {
402
+ simulationRef.current.stop();
403
+ }
404
+ const simulation = forceSimulation(simNodes).force(
405
+ "link",
406
+ forceLink(simLinks).id((d) => d.id).distance(forceConfig.linkDistance).strength(0.5)
407
+ ).force(
408
+ "charge",
409
+ forceManyBody().strength(forceConfig.chargeStrength)
410
+ ).force(
411
+ "center",
412
+ forceCenter(0, 0).strength(forceConfig.centerStrength)
413
+ ).force(
414
+ "collision",
415
+ forceCollide(forceConfig.collisionRadius)
416
+ ).force(
417
+ "x",
418
+ forceX(0).strength(0.01)
419
+ ).force(
420
+ "y",
421
+ forceY(0).strength(0.01)
422
+ ).alphaDecay(0.02).velocityDecay(0.4);
423
+ simulation.on("tick", () => {
424
+ setNodes(
425
+ (currentNodes) => currentNodes.map((node) => {
426
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
427
+ if (!simNode) return node;
428
+ return {
429
+ ...node,
430
+ position: {
431
+ x: simNode.x,
432
+ y: simNode.y
433
+ }
434
+ };
435
+ })
436
+ );
437
+ });
438
+ simulationRef.current = simulation;
439
+ return () => {
440
+ simulation.stop();
441
+ };
442
+ }, [
443
+ nodesInitialized,
444
+ nodeCount,
445
+ getNodes,
446
+ getEdges,
447
+ setNodes,
448
+ forceConfig,
449
+ rootNodeId
450
+ ]);
451
+ const onNodeDragStart = useCallback(
452
+ (_, node) => {
453
+ const simulation = simulationRef.current;
454
+ if (!simulation) return;
455
+ draggingNodeRef.current = node.id;
456
+ simulation.alphaTarget(0.3).restart();
457
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
458
+ if (simNode) {
459
+ simNode.fx = simNode.x;
460
+ simNode.fy = simNode.y;
461
+ }
462
+ },
463
+ []
464
+ );
465
+ const onNodeDrag = useCallback(
466
+ (_, node) => {
467
+ const simulation = simulationRef.current;
468
+ if (!simulation) return;
469
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
470
+ if (simNode) {
471
+ simNode.fx = node.position.x;
472
+ simNode.fy = node.position.y;
473
+ }
474
+ },
475
+ []
476
+ );
477
+ const onNodeDragStop = useCallback(
478
+ (_, node) => {
479
+ const simulation = simulationRef.current;
480
+ if (!simulation) return;
481
+ draggingNodeRef.current = null;
482
+ simulation.alphaTarget(0);
483
+ if (node.id !== rootNodeId) {
484
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
485
+ if (simNode) {
486
+ simNode.fx = null;
487
+ simNode.fy = null;
488
+ }
489
+ }
490
+ },
491
+ [rootNodeId]
492
+ );
493
+ const updateForceConfig = useCallback(
494
+ (updates) => {
495
+ const simulation = simulationRef.current;
496
+ if (!simulation) return;
497
+ if (updates.chargeStrength !== void 0) {
498
+ simulation.force(
499
+ "charge",
500
+ forceManyBody().strength(updates.chargeStrength)
501
+ );
502
+ }
503
+ if (updates.linkDistance !== void 0) {
504
+ const linkForce = simulation.force("link");
505
+ if (linkForce) {
506
+ linkForce.distance(updates.linkDistance);
507
+ }
508
+ }
509
+ if (updates.collisionRadius !== void 0) {
510
+ simulation.force(
511
+ "collision",
512
+ forceCollide(updates.collisionRadius)
513
+ );
514
+ }
515
+ simulation.alpha(0.5).restart();
516
+ },
517
+ []
518
+ );
519
+ const restartSimulation = useCallback(() => {
520
+ const simulation = simulationRef.current;
521
+ if (!simulation) return;
522
+ simulation.alpha(1).restart();
523
+ }, []);
524
+ return {
525
+ onNodeDragStart,
526
+ onNodeDrag,
527
+ onNodeDragStop,
528
+ updateForceConfig,
529
+ restartSimulation
530
+ };
531
+ }
532
+
533
+ // src/components/ObservablesGraph.tsx
534
+ import { jsx as jsx3, jsxs as jsxs2 } from "react/jsx-runtime";
535
+ var nodeTypes = {
536
+ observable: ObservableNode
537
+ };
538
+ var edgeTypes = {
539
+ floating: FloatingEdge
540
+ };
541
+ function createObservableNodes(investigation, rootObservableIds) {
542
+ const graph = getObservableGraph(investigation);
543
+ return graph.nodes.map((graphNode, index) => {
544
+ const isRoot = rootObservableIds.has(graphNode.id);
545
+ const shape = getObservableShape(graphNode.type, isRoot);
546
+ const nodeData = {
547
+ label: truncateLabel(graphNode.value, 18),
548
+ fullValue: graphNode.value,
549
+ observableType: graphNode.type,
550
+ level: graphNode.level,
551
+ score: graphNode.score,
552
+ emoji: getObservableEmoji(graphNode.type),
553
+ shape,
554
+ isRoot,
555
+ whitelisted: graphNode.whitelisted,
556
+ internal: graphNode.internal
557
+ };
558
+ const angle = index / graph.nodes.length * 2 * Math.PI;
559
+ const radius = isRoot ? 0 : 150;
560
+ return {
561
+ id: graphNode.id,
562
+ type: "observable",
563
+ position: {
564
+ x: Math.cos(angle) * radius,
565
+ y: Math.sin(angle) * radius
566
+ },
567
+ data: nodeData
568
+ };
569
+ });
570
+ }
571
+ function createObservableEdges(investigation) {
572
+ const graph = getObservableGraph(investigation);
573
+ return graph.edges.map((graphEdge, index) => {
574
+ const edgeData = {
575
+ relationshipType: graphEdge.type,
576
+ bidirectional: graphEdge.direction === "bidirectional"
577
+ };
578
+ return {
579
+ id: `edge-${graphEdge.source}-${graphEdge.target}-${index}`,
580
+ source: graphEdge.source,
581
+ target: graphEdge.target,
582
+ type: "floating",
583
+ data: edgeData,
584
+ style: { stroke: "#94a3b8", strokeWidth: 1.5 }
585
+ };
586
+ });
587
+ }
588
+ var ForceControls = ({ config, onChange, onRestart }) => {
589
+ return /* @__PURE__ */ jsxs2(
590
+ "div",
591
+ {
592
+ style: {
593
+ position: "absolute",
594
+ top: 10,
595
+ right: 10,
596
+ background: "white",
597
+ padding: 12,
598
+ borderRadius: 8,
599
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
600
+ fontSize: 12,
601
+ fontFamily: "system-ui, sans-serif",
602
+ zIndex: 10,
603
+ minWidth: 160
604
+ },
605
+ children: [
606
+ /* @__PURE__ */ jsx3("div", { style: { fontWeight: 600, marginBottom: 8 }, children: "Force Layout" }),
607
+ /* @__PURE__ */ jsxs2("div", { style: { marginBottom: 8 }, children: [
608
+ /* @__PURE__ */ jsxs2("label", { style: { display: "block", marginBottom: 2 }, children: [
609
+ "Repulsion: ",
610
+ config.chargeStrength
611
+ ] }),
612
+ /* @__PURE__ */ jsx3(
613
+ "input",
614
+ {
615
+ type: "range",
616
+ min: "-500",
617
+ max: "-50",
618
+ value: config.chargeStrength,
619
+ onChange: (e) => onChange({ chargeStrength: Number(e.target.value) }),
620
+ style: { width: "100%" }
621
+ }
622
+ )
623
+ ] }),
624
+ /* @__PURE__ */ jsxs2("div", { style: { marginBottom: 8 }, children: [
625
+ /* @__PURE__ */ jsxs2("label", { style: { display: "block", marginBottom: 2 }, children: [
626
+ "Link Distance: ",
627
+ config.linkDistance
628
+ ] }),
629
+ /* @__PURE__ */ jsx3(
630
+ "input",
631
+ {
632
+ type: "range",
633
+ min: "30",
634
+ max: "200",
635
+ value: config.linkDistance,
636
+ onChange: (e) => onChange({ linkDistance: Number(e.target.value) }),
637
+ style: { width: "100%" }
638
+ }
639
+ )
640
+ ] }),
641
+ /* @__PURE__ */ jsxs2("div", { style: { marginBottom: 8 }, children: [
642
+ /* @__PURE__ */ jsxs2("label", { style: { display: "block", marginBottom: 2 }, children: [
643
+ "Collision: ",
644
+ config.collisionRadius
645
+ ] }),
646
+ /* @__PURE__ */ jsx3(
647
+ "input",
648
+ {
649
+ type: "range",
650
+ min: "10",
651
+ max: "80",
652
+ value: config.collisionRadius,
653
+ onChange: (e) => onChange({ collisionRadius: Number(e.target.value) }),
654
+ style: { width: "100%" }
655
+ }
656
+ )
657
+ ] }),
658
+ /* @__PURE__ */ jsx3(
659
+ "button",
660
+ {
661
+ onClick: onRestart,
662
+ style: {
663
+ width: "100%",
664
+ padding: "6px 12px",
665
+ border: "none",
666
+ borderRadius: 4,
667
+ background: "#3b82f6",
668
+ color: "white",
669
+ cursor: "pointer",
670
+ fontSize: 12
671
+ },
672
+ children: "Restart Simulation"
673
+ }
674
+ )
675
+ ]
676
+ }
677
+ );
678
+ };
679
+ var ObservablesGraphInner = ({
680
+ initialNodes,
681
+ initialEdges,
682
+ primaryRootId,
683
+ height,
684
+ width,
685
+ forceConfig: initialForceConfig = {},
686
+ onNodeClick,
687
+ onNodeDoubleClick,
688
+ className,
689
+ showControls = true
690
+ }) => {
691
+ const [forceConfig, setForceConfig] = useState({
692
+ ...DEFAULT_FORCE_CONFIG,
693
+ ...initialForceConfig
694
+ });
695
+ const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
696
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
697
+ React3.useEffect(() => {
698
+ setNodes(initialNodes);
699
+ setEdges(initialEdges);
700
+ }, [initialNodes, initialEdges, setNodes, setEdges]);
701
+ const {
702
+ onNodeDragStart,
703
+ onNodeDrag,
704
+ onNodeDragStop,
705
+ updateForceConfig,
706
+ restartSimulation
707
+ } = useForceLayout(forceConfig, primaryRootId);
708
+ const handleNodeClick = useCallback2(
709
+ (_, node) => {
710
+ onNodeClick?.(node.id);
711
+ },
712
+ [onNodeClick]
713
+ );
714
+ const handleNodeDoubleClick = useCallback2(
715
+ (_, node) => {
716
+ onNodeDoubleClick?.(node.id);
717
+ },
718
+ [onNodeDoubleClick]
719
+ );
720
+ const handleConfigChange = useCallback2(
721
+ (updates) => {
722
+ setForceConfig((prev) => ({ ...prev, ...updates }));
723
+ updateForceConfig(updates);
724
+ },
725
+ [updateForceConfig]
726
+ );
727
+ const miniMapNodeColor = useCallback2((node) => {
728
+ const data = node.data;
729
+ return getLevelColor(data.level);
730
+ }, []);
731
+ return /* @__PURE__ */ jsxs2(
732
+ "div",
733
+ {
734
+ className,
735
+ style: {
736
+ width,
737
+ height,
738
+ position: "relative"
739
+ },
740
+ children: [
741
+ /* @__PURE__ */ jsxs2(
742
+ ReactFlow,
743
+ {
744
+ nodes,
745
+ edges,
746
+ onNodesChange,
747
+ onEdgesChange,
748
+ onNodeClick: handleNodeClick,
749
+ onNodeDoubleClick: handleNodeDoubleClick,
750
+ onNodeDragStart,
751
+ onNodeDrag,
752
+ onNodeDragStop,
753
+ nodeTypes,
754
+ edgeTypes,
755
+ connectionMode: ConnectionMode.Loose,
756
+ fitView: true,
757
+ fitViewOptions: { padding: 0.3 },
758
+ minZoom: 0.1,
759
+ maxZoom: 2,
760
+ proOptions: { hideAttribution: true },
761
+ children: [
762
+ /* @__PURE__ */ jsx3(Background, {}),
763
+ /* @__PURE__ */ jsx3(Controls, {}),
764
+ /* @__PURE__ */ jsx3(MiniMap, { nodeColor: miniMapNodeColor, zoomable: true, pannable: true })
765
+ ]
766
+ }
767
+ ),
768
+ showControls && /* @__PURE__ */ jsx3(
769
+ ForceControls,
770
+ {
771
+ config: forceConfig,
772
+ onChange: handleConfigChange,
773
+ onRestart: restartSimulation
774
+ }
775
+ )
776
+ ]
777
+ }
778
+ );
779
+ };
780
+ var ObservablesGraph = (props) => {
781
+ const { investigation } = props;
782
+ const rootObservables = useMemo2(() => {
783
+ const roots = findRootObservables(investigation);
784
+ return new Set(roots.map((r) => r.key));
785
+ }, [investigation]);
786
+ const primaryRootId = useMemo2(() => {
787
+ const roots = findRootObservables(investigation);
788
+ return roots.length > 0 ? roots[0].key : void 0;
789
+ }, [investigation]);
790
+ const { initialNodes, initialEdges } = useMemo2(() => {
791
+ const nodes = createObservableNodes(investigation, rootObservables);
792
+ const edges = createObservableEdges(investigation);
793
+ return { initialNodes: nodes, initialEdges: edges };
794
+ }, [investigation, rootObservables]);
795
+ return /* @__PURE__ */ jsx3(ReactFlowProvider, { children: /* @__PURE__ */ jsx3(
796
+ ObservablesGraphInner,
797
+ {
798
+ ...props,
799
+ initialNodes,
800
+ initialEdges,
801
+ primaryRootId
802
+ }
803
+ ) });
804
+ };
805
+
806
+ // src/components/InvestigationGraph.tsx
807
+ import React5, { useMemo as useMemo4, useCallback as useCallback3 } from "react";
808
+ import {
809
+ ReactFlow as ReactFlow2,
810
+ Background as Background2,
811
+ Controls as Controls2,
812
+ MiniMap as MiniMap2,
813
+ useNodesState as useNodesState2,
814
+ useEdgesState as useEdgesState2
815
+ } from "@xyflow/react";
816
+ import "@xyflow/react/dist/style.css";
817
+ import { findRootObservables as findRootObservables2 } from "@cyvest/cyvest-js";
818
+
819
+ // src/components/InvestigationNode.tsx
820
+ import { memo as memo3 } from "react";
821
+ import { Handle as Handle2, Position as Position2 } from "@xyflow/react";
822
+ import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
823
+ function InvestigationNodeComponent({
824
+ data,
825
+ selected
826
+ }) {
827
+ const nodeData = data;
828
+ const {
829
+ label,
830
+ emoji,
831
+ nodeType,
832
+ level,
833
+ description
834
+ } = nodeData;
835
+ const borderColor = getLevelColor(level);
836
+ const backgroundColor = getLevelBackgroundColor(level);
837
+ const getNodeStyle = () => {
838
+ switch (nodeType) {
839
+ case "root":
840
+ return {
841
+ minWidth: 120,
842
+ padding: "8px 16px",
843
+ borderRadius: 8,
844
+ fontWeight: 600
845
+ };
846
+ case "check":
847
+ return {
848
+ minWidth: 100,
849
+ padding: "6px 12px",
850
+ borderRadius: 4,
851
+ fontWeight: 400
852
+ };
853
+ case "container":
854
+ return {
855
+ minWidth: 100,
856
+ padding: "6px 12px",
857
+ borderRadius: 12,
858
+ fontWeight: 400
859
+ };
860
+ default:
861
+ return {
862
+ minWidth: 80,
863
+ padding: "6px 12px",
864
+ borderRadius: 4,
865
+ fontWeight: 400
866
+ };
867
+ }
868
+ };
869
+ const style = getNodeStyle();
870
+ return /* @__PURE__ */ jsxs3(
871
+ "div",
872
+ {
873
+ className: "investigation-node",
874
+ style: {
875
+ ...style,
876
+ display: "flex",
877
+ flexDirection: "column",
878
+ alignItems: "center",
879
+ backgroundColor,
880
+ border: `${selected ? 3 : 2}px solid ${borderColor}`,
881
+ cursor: "pointer",
882
+ fontFamily: "system-ui, sans-serif"
883
+ },
884
+ children: [
885
+ /* @__PURE__ */ jsxs3(
886
+ "div",
887
+ {
888
+ style: {
889
+ display: "flex",
890
+ alignItems: "center",
891
+ gap: 6
892
+ },
893
+ children: [
894
+ /* @__PURE__ */ jsx4("span", { style: { fontSize: 14 }, children: emoji }),
895
+ /* @__PURE__ */ jsx4(
896
+ "span",
897
+ {
898
+ style: {
899
+ fontSize: 12,
900
+ fontWeight: style.fontWeight,
901
+ maxWidth: 150,
902
+ overflow: "hidden",
903
+ textOverflow: "ellipsis",
904
+ whiteSpace: "nowrap"
905
+ },
906
+ title: label,
907
+ children: label
908
+ }
909
+ )
910
+ ]
911
+ }
912
+ ),
913
+ description && /* @__PURE__ */ jsx4(
914
+ "div",
915
+ {
916
+ style: {
917
+ marginTop: 4,
918
+ fontSize: 10,
919
+ color: "#6b7280",
920
+ maxWidth: 140,
921
+ overflow: "hidden",
922
+ textOverflow: "ellipsis",
923
+ whiteSpace: "nowrap"
924
+ },
925
+ title: description,
926
+ children: description
927
+ }
928
+ ),
929
+ /* @__PURE__ */ jsx4(
930
+ Handle2,
931
+ {
932
+ type: "target",
933
+ position: Position2.Left,
934
+ style: {
935
+ width: 8,
936
+ height: 8,
937
+ background: borderColor
938
+ }
939
+ }
940
+ ),
941
+ /* @__PURE__ */ jsx4(
942
+ Handle2,
943
+ {
944
+ type: "source",
945
+ position: Position2.Right,
946
+ style: {
947
+ width: 8,
948
+ height: 8,
949
+ background: borderColor
950
+ }
951
+ }
952
+ )
953
+ ]
954
+ }
955
+ );
956
+ }
957
+ var InvestigationNode = memo3(InvestigationNodeComponent);
958
+
959
+ // src/hooks/useDagreLayout.ts
960
+ import { useMemo as useMemo3 } from "react";
961
+ import Dagre from "@dagrejs/dagre";
962
+ var DEFAULT_OPTIONS = {
963
+ direction: "LR",
964
+ // Horizontal layout by default
965
+ nodeSpacing: 50,
966
+ rankSpacing: 100
967
+ };
968
+ function computeDagreLayout(nodes, edges, options = {}) {
969
+ if (nodes.length === 0) {
970
+ return { nodes, edges };
971
+ }
972
+ const opts = { ...DEFAULT_OPTIONS, ...options };
973
+ const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
974
+ g.setGraph({
975
+ rankdir: opts.direction,
976
+ nodesep: opts.nodeSpacing,
977
+ ranksep: opts.rankSpacing,
978
+ marginx: 20,
979
+ marginy: 20
980
+ });
981
+ for (const node of nodes) {
982
+ const width = node.measured?.width ?? 150;
983
+ const height = node.measured?.height ?? 50;
984
+ g.setNode(node.id, { width, height });
985
+ }
986
+ for (const edge of edges) {
987
+ g.setEdge(edge.source, edge.target);
988
+ }
989
+ Dagre.layout(g);
990
+ const positionedNodes = nodes.map((node) => {
991
+ const dagNode = g.node(node.id);
992
+ const width = node.measured?.width ?? 150;
993
+ const height = node.measured?.height ?? 50;
994
+ return {
995
+ ...node,
996
+ position: {
997
+ x: dagNode.x - width / 2,
998
+ y: dagNode.y - height / 2
999
+ }
1000
+ };
1001
+ });
1002
+ return { nodes: positionedNodes, edges };
1003
+ }
1004
+
1005
+ // src/components/InvestigationGraph.tsx
1006
+ import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
1007
+ var nodeTypes2 = {
1008
+ investigation: InvestigationNode
1009
+ };
1010
+ function flattenContainers(containers) {
1011
+ const result = [];
1012
+ for (const container of Object.values(containers)) {
1013
+ result.push(container);
1014
+ if (container.sub_containers) {
1015
+ result.push(...flattenContainers(container.sub_containers));
1016
+ }
1017
+ }
1018
+ return result;
1019
+ }
1020
+ function createInvestigationGraph(investigation) {
1021
+ const nodes = [];
1022
+ const edges = [];
1023
+ const roots = findRootObservables2(investigation);
1024
+ const primaryRoot = roots.length > 0 ? roots[0] : null;
1025
+ const rootKey = primaryRoot?.key ?? "investigation-root";
1026
+ const rootValue = primaryRoot?.value ?? "Investigation";
1027
+ const rootLevel = primaryRoot?.level ?? investigation.level;
1028
+ const rootNodeData = {
1029
+ label: truncateLabel(rootValue, 24),
1030
+ nodeType: "root",
1031
+ level: rootLevel,
1032
+ score: primaryRoot?.score ?? investigation.score,
1033
+ emoji: getInvestigationNodeEmoji("root")
1034
+ };
1035
+ nodes.push({
1036
+ id: rootKey,
1037
+ type: "investigation",
1038
+ position: { x: 0, y: 0 },
1039
+ data: rootNodeData
1040
+ });
1041
+ const allChecks = [];
1042
+ for (const checksForKey of Object.values(investigation.checks)) {
1043
+ allChecks.push(...checksForKey);
1044
+ }
1045
+ const seenCheckIds = /* @__PURE__ */ new Set();
1046
+ for (const check of allChecks) {
1047
+ if (seenCheckIds.has(check.key)) continue;
1048
+ seenCheckIds.add(check.key);
1049
+ const checkNodeData = {
1050
+ label: truncateLabel(check.check_id, 20),
1051
+ nodeType: "check",
1052
+ level: check.level,
1053
+ score: check.score,
1054
+ description: truncateLabel(check.description, 30),
1055
+ emoji: getInvestigationNodeEmoji("check")
1056
+ };
1057
+ nodes.push({
1058
+ id: `check-${check.key}`,
1059
+ type: "investigation",
1060
+ position: { x: 0, y: 0 },
1061
+ data: checkNodeData
1062
+ });
1063
+ edges.push({
1064
+ id: `edge-root-${check.key}`,
1065
+ source: rootKey,
1066
+ target: `check-${check.key}`,
1067
+ type: "default"
1068
+ });
1069
+ }
1070
+ const allContainers = flattenContainers(investigation.containers);
1071
+ for (const container of allContainers) {
1072
+ const containerNodeData = {
1073
+ label: truncateLabel(container.path.split("/").pop() ?? container.path, 20),
1074
+ nodeType: "container",
1075
+ level: container.aggregated_level,
1076
+ score: container.aggregated_score,
1077
+ path: container.path,
1078
+ emoji: getInvestigationNodeEmoji("container")
1079
+ };
1080
+ nodes.push({
1081
+ id: `container-${container.key}`,
1082
+ type: "investigation",
1083
+ position: { x: 0, y: 0 },
1084
+ data: containerNodeData
1085
+ });
1086
+ edges.push({
1087
+ id: `edge-root-container-${container.key}`,
1088
+ source: rootKey,
1089
+ target: `container-${container.key}`,
1090
+ type: "default"
1091
+ });
1092
+ for (const checkKey of container.checks) {
1093
+ if (seenCheckIds.has(checkKey)) {
1094
+ edges.push({
1095
+ id: `edge-container-check-${container.key}-${checkKey}`,
1096
+ source: `container-${container.key}`,
1097
+ target: `check-${checkKey}`,
1098
+ type: "default",
1099
+ style: { strokeDasharray: "5,5" }
1100
+ });
1101
+ }
1102
+ }
1103
+ }
1104
+ return { nodes, edges };
1105
+ }
1106
+ var InvestigationGraph = ({
1107
+ investigation,
1108
+ height = 500,
1109
+ width = "100%",
1110
+ onNodeClick,
1111
+ className
1112
+ }) => {
1113
+ const { initialNodes, initialEdges } = useMemo4(() => {
1114
+ const { nodes: nodes2, edges: edges2 } = createInvestigationGraph(investigation);
1115
+ return { initialNodes: nodes2, initialEdges: edges2 };
1116
+ }, [investigation]);
1117
+ const { nodes: layoutNodes, edges: layoutEdges } = useMemo4(() => {
1118
+ return computeDagreLayout(initialNodes, initialEdges, {
1119
+ direction: "LR",
1120
+ nodeSpacing: 30,
1121
+ rankSpacing: 120
1122
+ });
1123
+ }, [initialNodes, initialEdges]);
1124
+ const [nodes, setNodes, onNodesChange] = useNodesState2(layoutNodes);
1125
+ const [edges, setEdges, onEdgesChange] = useEdgesState2(layoutEdges);
1126
+ React5.useEffect(() => {
1127
+ setNodes(layoutNodes);
1128
+ setEdges(layoutEdges);
1129
+ }, [layoutNodes, layoutEdges, setNodes, setEdges]);
1130
+ const handleNodeClick = useCallback3(
1131
+ (_, node) => {
1132
+ const data = node.data;
1133
+ onNodeClick?.(node.id, data.nodeType);
1134
+ },
1135
+ [onNodeClick]
1136
+ );
1137
+ const miniMapNodeColor = useCallback3((node) => {
1138
+ const data = node.data;
1139
+ return getLevelColor(data.level);
1140
+ }, []);
1141
+ return /* @__PURE__ */ jsx5(
1142
+ "div",
1143
+ {
1144
+ className,
1145
+ style: {
1146
+ width,
1147
+ height,
1148
+ position: "relative"
1149
+ },
1150
+ children: /* @__PURE__ */ jsxs4(
1151
+ ReactFlow2,
1152
+ {
1153
+ nodes,
1154
+ edges,
1155
+ onNodesChange,
1156
+ onEdgesChange,
1157
+ onNodeClick: handleNodeClick,
1158
+ nodeTypes: nodeTypes2,
1159
+ fitView: true,
1160
+ fitViewOptions: { padding: 0.2 },
1161
+ minZoom: 0.1,
1162
+ maxZoom: 2,
1163
+ proOptions: { hideAttribution: true },
1164
+ children: [
1165
+ /* @__PURE__ */ jsx5(Background2, {}),
1166
+ /* @__PURE__ */ jsx5(Controls2, {}),
1167
+ /* @__PURE__ */ jsx5(MiniMap2, { nodeColor: miniMapNodeColor, zoomable: true, pannable: true })
1168
+ ]
1169
+ }
1170
+ )
1171
+ }
1172
+ );
1173
+ };
1174
+
1175
+ // src/components/CyvestGraph.tsx
1176
+ import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
1177
+ var ViewToggle = ({ currentView, onChange }) => {
1178
+ return /* @__PURE__ */ jsxs5(
1179
+ "div",
1180
+ {
1181
+ style: {
1182
+ position: "absolute",
1183
+ top: 10,
1184
+ left: 10,
1185
+ display: "flex",
1186
+ gap: 4,
1187
+ background: "white",
1188
+ padding: 4,
1189
+ borderRadius: 8,
1190
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
1191
+ zIndex: 10,
1192
+ fontFamily: "system-ui, sans-serif"
1193
+ },
1194
+ children: [
1195
+ /* @__PURE__ */ jsx6(
1196
+ "button",
1197
+ {
1198
+ onClick: () => onChange("observables"),
1199
+ style: {
1200
+ padding: "6px 12px",
1201
+ border: "none",
1202
+ borderRadius: 4,
1203
+ cursor: "pointer",
1204
+ fontSize: 12,
1205
+ fontWeight: currentView === "observables" ? 600 : 400,
1206
+ background: currentView === "observables" ? "#3b82f6" : "#f3f4f6",
1207
+ color: currentView === "observables" ? "white" : "#374151"
1208
+ },
1209
+ children: "Observables"
1210
+ }
1211
+ ),
1212
+ /* @__PURE__ */ jsx6(
1213
+ "button",
1214
+ {
1215
+ onClick: () => onChange("investigation"),
1216
+ style: {
1217
+ padding: "6px 12px",
1218
+ border: "none",
1219
+ borderRadius: 4,
1220
+ cursor: "pointer",
1221
+ fontSize: 12,
1222
+ fontWeight: currentView === "investigation" ? 600 : 400,
1223
+ background: currentView === "investigation" ? "#3b82f6" : "#f3f4f6",
1224
+ color: currentView === "investigation" ? "white" : "#374151"
1225
+ },
1226
+ children: "Investigation"
1227
+ }
1228
+ )
1229
+ ]
1230
+ }
1231
+ );
1232
+ };
1233
+ var CyvestGraph = ({
1234
+ investigation,
1235
+ height = 500,
1236
+ width = "100%",
1237
+ initialView = "observables",
1238
+ onNodeClick,
1239
+ className,
1240
+ showViewToggle = true
1241
+ }) => {
1242
+ const [view, setView] = useState2(initialView);
1243
+ const handleNodeClick = useCallback4(
1244
+ (nodeId, _nodeType) => {
1245
+ onNodeClick?.(nodeId);
1246
+ },
1247
+ [onNodeClick]
1248
+ );
1249
+ return /* @__PURE__ */ jsxs5(
1250
+ "div",
1251
+ {
1252
+ className,
1253
+ style: {
1254
+ width,
1255
+ height,
1256
+ position: "relative"
1257
+ },
1258
+ children: [
1259
+ showViewToggle && /* @__PURE__ */ jsx6(ViewToggle, { currentView: view, onChange: setView }),
1260
+ view === "observables" ? /* @__PURE__ */ jsx6(
1261
+ ObservablesGraph,
1262
+ {
1263
+ investigation,
1264
+ height: "100%",
1265
+ width: "100%",
1266
+ onNodeClick: handleNodeClick,
1267
+ showControls: true
1268
+ }
1269
+ ) : /* @__PURE__ */ jsx6(
1270
+ InvestigationGraph,
1271
+ {
1272
+ investigation,
1273
+ height: "100%",
1274
+ width: "100%",
1275
+ onNodeClick: handleNodeClick
1276
+ }
1277
+ )
1278
+ ]
1279
+ }
1280
+ );
1281
+ };
1282
+ export {
1283
+ CyvestGraph
1284
+ };