@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/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@cyvest/cyvest-vis",
3
+ "version": "2.0.1",
4
+ "main": "dist/index.cjs",
5
+ "module": "dist/index.mjs",
6
+ "types": "dist/index.d.ts",
7
+ "sideEffects": false,
8
+ "peerDependencies": {
9
+ "react": "^18.0.0",
10
+ "react-dom": "^18.0.0"
11
+ },
12
+ "dependencies": {
13
+ "@dagrejs/dagre": "^1.1.4",
14
+ "@xyflow/react": "^12.5.0",
15
+ "d3-force": "^3.0.0",
16
+ "@cyvest/cyvest-js": "2.0.1"
17
+ },
18
+ "devDependencies": {
19
+ "@types/d3-force": "^3.0.10",
20
+ "@types/react": "^19.2.7",
21
+ "@types/react-dom": "^19.2.3",
22
+ "tsup": "^8.0.0",
23
+ "typescript": "^5.6.0",
24
+ "vitest": "^2.0.0"
25
+ },
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "scripts": {
30
+ "build": "tsup src/index.ts --dts --format cjs,esm --external react --external react-dom",
31
+ "test": "vitest",
32
+ "test:ci": "vitest run --reporter=dot"
33
+ }
34
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * CyvestGraph component - combined view with toggle between observables and investigation views.
3
+ */
4
+
5
+ import React, { useState, useCallback } from "react";
6
+ import type { CyvestGraphProps, InvestigationNodeType } from "../types";
7
+ import { ObservablesGraph } from "./ObservablesGraph";
8
+ import { InvestigationGraph } from "./InvestigationGraph";
9
+
10
+ /**
11
+ * View toggle button component.
12
+ */
13
+ const ViewToggle: React.FC<{
14
+ currentView: "observables" | "investigation";
15
+ onChange: (view: "observables" | "investigation") => void;
16
+ }> = ({ currentView, onChange }) => {
17
+ return (
18
+ <div
19
+ style={{
20
+ position: "absolute",
21
+ top: 10,
22
+ left: 10,
23
+ display: "flex",
24
+ gap: 4,
25
+ background: "white",
26
+ padding: 4,
27
+ borderRadius: 8,
28
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
29
+ zIndex: 10,
30
+ fontFamily: "system-ui, sans-serif",
31
+ }}
32
+ >
33
+ <button
34
+ onClick={() => onChange("observables")}
35
+ style={{
36
+ padding: "6px 12px",
37
+ border: "none",
38
+ borderRadius: 4,
39
+ cursor: "pointer",
40
+ fontSize: 12,
41
+ fontWeight: currentView === "observables" ? 600 : 400,
42
+ background: currentView === "observables" ? "#3b82f6" : "#f3f4f6",
43
+ color: currentView === "observables" ? "white" : "#374151",
44
+ }}
45
+ >
46
+ Observables
47
+ </button>
48
+ <button
49
+ onClick={() => onChange("investigation")}
50
+ style={{
51
+ padding: "6px 12px",
52
+ border: "none",
53
+ borderRadius: 4,
54
+ cursor: "pointer",
55
+ fontSize: 12,
56
+ fontWeight: currentView === "investigation" ? 600 : 400,
57
+ background: currentView === "investigation" ? "#3b82f6" : "#f3f4f6",
58
+ color: currentView === "investigation" ? "white" : "#374151",
59
+ }}
60
+ >
61
+ Investigation
62
+ </button>
63
+ </div>
64
+ );
65
+ };
66
+
67
+ /**
68
+ * CyvestGraph component - provides toggle between ObservablesGraph and InvestigationGraph.
69
+ */
70
+ export const CyvestGraph: React.FC<CyvestGraphProps> = ({
71
+ investigation,
72
+ height = 500,
73
+ width = "100%",
74
+ initialView = "observables",
75
+ onNodeClick,
76
+ className,
77
+ showViewToggle = true,
78
+ }) => {
79
+ const [view, setView] = useState<"observables" | "investigation">(initialView);
80
+
81
+ const handleNodeClick = useCallback(
82
+ (nodeId: string, _nodeType?: InvestigationNodeType) => {
83
+ onNodeClick?.(nodeId);
84
+ },
85
+ [onNodeClick]
86
+ );
87
+
88
+ return (
89
+ <div
90
+ className={className}
91
+ style={{
92
+ width,
93
+ height,
94
+ position: "relative",
95
+ }}
96
+ >
97
+ {showViewToggle && <ViewToggle currentView={view} onChange={setView} />}
98
+
99
+ {view === "observables" ? (
100
+ <ObservablesGraph
101
+ investigation={investigation}
102
+ height="100%"
103
+ width="100%"
104
+ onNodeClick={handleNodeClick}
105
+ showControls={true}
106
+ />
107
+ ) : (
108
+ <InvestigationGraph
109
+ investigation={investigation}
110
+ height="100%"
111
+ width="100%"
112
+ onNodeClick={handleNodeClick}
113
+ />
114
+ )}
115
+ </div>
116
+ );
117
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Floating Edge component for use with force-directed layout.
3
+ * Uses simple straight lines that connect to node edges.
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { BaseEdge, getStraightPath, type EdgeProps } from "@xyflow/react";
8
+
9
+ /**
10
+ * Floating edge component that uses straight lines.
11
+ * React Flow passes sourceX, sourceY, targetX, targetY based on node positions.
12
+ */
13
+ function FloatingEdgeComponent({
14
+ id,
15
+ sourceX,
16
+ sourceY,
17
+ targetX,
18
+ targetY,
19
+ style,
20
+ markerEnd,
21
+ }: EdgeProps) {
22
+ const [edgePath] = getStraightPath({
23
+ sourceX,
24
+ sourceY,
25
+ targetX,
26
+ targetY,
27
+ });
28
+
29
+ return (
30
+ <BaseEdge
31
+ id={id}
32
+ path={edgePath}
33
+ style={{
34
+ strokeWidth: 1.5,
35
+ stroke: "#94a3b8",
36
+ ...style,
37
+ }}
38
+ markerEnd={markerEnd}
39
+ />
40
+ );
41
+ }
42
+
43
+ export const FloatingEdge = memo(FloatingEdgeComponent);
@@ -0,0 +1,257 @@
1
+ /**
2
+ * InvestigationGraph component - displays investigation structure with Dagre layout.
3
+ * Shows root observable, checks, and containers in a hierarchical view.
4
+ */
5
+
6
+ import React, { useMemo, useCallback } from "react";
7
+ import {
8
+ ReactFlow,
9
+ Background,
10
+ Controls,
11
+ MiniMap,
12
+ useNodesState,
13
+ useEdgesState,
14
+ type Node,
15
+ type Edge,
16
+ type NodeTypes,
17
+ } from "@xyflow/react";
18
+ import "@xyflow/react/dist/style.css";
19
+
20
+ import type { CyvestInvestigation, Check, Container } from "@cyvest/cyvest-js";
21
+ import { findRootObservables } from "@cyvest/cyvest-js";
22
+
23
+ import type {
24
+ InvestigationGraphProps,
25
+ InvestigationNodeData,
26
+ InvestigationNodeType,
27
+ } from "../types";
28
+ import { InvestigationNode } from "./InvestigationNode";
29
+ import {
30
+ getInvestigationNodeEmoji,
31
+ getLevelColor,
32
+ truncateLabel,
33
+ } from "../utils/observables";
34
+ import { computeDagreLayout } from "../hooks/useDagreLayout";
35
+
36
+ /**
37
+ * Custom node types for React Flow.
38
+ */
39
+ const nodeTypes: NodeTypes = {
40
+ investigation: InvestigationNode,
41
+ };
42
+
43
+ /**
44
+ * Flatten containers recursively to get all container keys.
45
+ */
46
+ function flattenContainers(
47
+ containers: Record<string, Container>
48
+ ): Container[] {
49
+ const result: Container[] = [];
50
+
51
+ for (const container of Object.values(containers)) {
52
+ result.push(container);
53
+ if (container.sub_containers) {
54
+ result.push(...flattenContainers(container.sub_containers));
55
+ }
56
+ }
57
+
58
+ return result;
59
+ }
60
+
61
+ /**
62
+ * Create investigation graph nodes and edges.
63
+ */
64
+ function createInvestigationGraph(
65
+ investigation: CyvestInvestigation
66
+ ): { nodes: Node<InvestigationNodeData>[]; edges: Edge[] } {
67
+ const nodes: Node<InvestigationNodeData>[] = [];
68
+ const edges: Edge[] = [];
69
+
70
+ // Find root observable(s)
71
+ const roots = findRootObservables(investigation);
72
+ const primaryRoot = roots.length > 0 ? roots[0] : null;
73
+
74
+ // If no root found, use the first observable or create a placeholder
75
+ const rootKey = primaryRoot?.key ?? "investigation-root";
76
+ const rootValue = primaryRoot?.value ?? "Investigation";
77
+ const rootLevel = primaryRoot?.level ?? investigation.level;
78
+
79
+ // Create root node
80
+ const rootNodeData: InvestigationNodeData = {
81
+ label: truncateLabel(rootValue, 24),
82
+ nodeType: "root",
83
+ level: rootLevel,
84
+ score: primaryRoot?.score ?? investigation.score,
85
+ emoji: getInvestigationNodeEmoji("root"),
86
+ };
87
+
88
+ nodes.push({
89
+ id: rootKey,
90
+ type: "investigation",
91
+ position: { x: 0, y: 0 },
92
+ data: rootNodeData,
93
+ });
94
+
95
+ // Add check nodes
96
+ // Group checks by scope for better organization
97
+ const allChecks: Check[] = [];
98
+ for (const checksForKey of Object.values(investigation.checks)) {
99
+ allChecks.push(...checksForKey);
100
+ }
101
+
102
+ // Create unique check nodes (by check_id to avoid duplicates)
103
+ const seenCheckIds = new Set<string>();
104
+ for (const check of allChecks) {
105
+ if (seenCheckIds.has(check.key)) continue;
106
+ seenCheckIds.add(check.key);
107
+
108
+ const checkNodeData: InvestigationNodeData = {
109
+ label: truncateLabel(check.check_id, 20),
110
+ nodeType: "check",
111
+ level: check.level,
112
+ score: check.score,
113
+ description: truncateLabel(check.description, 30),
114
+ emoji: getInvestigationNodeEmoji("check"),
115
+ };
116
+
117
+ nodes.push({
118
+ id: `check-${check.key}`,
119
+ type: "investigation",
120
+ position: { x: 0, y: 0 },
121
+ data: checkNodeData,
122
+ });
123
+
124
+ // Edge from root to check
125
+ edges.push({
126
+ id: `edge-root-${check.key}`,
127
+ source: rootKey,
128
+ target: `check-${check.key}`,
129
+ type: "default",
130
+ });
131
+ }
132
+
133
+ // Add container nodes
134
+ const allContainers = flattenContainers(investigation.containers);
135
+
136
+ for (const container of allContainers) {
137
+ const containerNodeData: InvestigationNodeData = {
138
+ label: truncateLabel(container.path.split("/").pop() ?? container.path, 20),
139
+ nodeType: "container",
140
+ level: container.aggregated_level,
141
+ score: container.aggregated_score,
142
+ path: container.path,
143
+ emoji: getInvestigationNodeEmoji("container"),
144
+ };
145
+
146
+ nodes.push({
147
+ id: `container-${container.key}`,
148
+ type: "investigation",
149
+ position: { x: 0, y: 0 },
150
+ data: containerNodeData,
151
+ });
152
+
153
+ // Edge from root to container
154
+ edges.push({
155
+ id: `edge-root-container-${container.key}`,
156
+ source: rootKey,
157
+ target: `container-${container.key}`,
158
+ type: "default",
159
+ });
160
+
161
+ // Edges from container to its checks
162
+ for (const checkKey of container.checks) {
163
+ if (seenCheckIds.has(checkKey)) {
164
+ edges.push({
165
+ id: `edge-container-check-${container.key}-${checkKey}`,
166
+ source: `container-${container.key}`,
167
+ target: `check-${checkKey}`,
168
+ type: "default",
169
+ style: { strokeDasharray: "5,5" },
170
+ });
171
+ }
172
+ }
173
+ }
174
+
175
+ return { nodes, edges };
176
+ }
177
+
178
+ /**
179
+ * InvestigationGraph component.
180
+ * Displays investigation structure with horizontal Dagre layout.
181
+ */
182
+ export const InvestigationGraph: React.FC<InvestigationGraphProps> = ({
183
+ investigation,
184
+ height = 500,
185
+ width = "100%",
186
+ onNodeClick,
187
+ className,
188
+ }) => {
189
+ // Create initial graph structure
190
+ const { initialNodes, initialEdges } = useMemo(() => {
191
+ const { nodes, edges } = createInvestigationGraph(investigation);
192
+ return { initialNodes: nodes, initialEdges: edges };
193
+ }, [investigation]);
194
+
195
+ // Apply Dagre layout
196
+ const { nodes: layoutNodes, edges: layoutEdges } = useMemo(() => {
197
+ return computeDagreLayout(initialNodes, initialEdges, {
198
+ direction: "LR",
199
+ nodeSpacing: 30,
200
+ rankSpacing: 120,
201
+ });
202
+ }, [initialNodes, initialEdges]);
203
+
204
+ // React Flow state
205
+ const [nodes, setNodes, onNodesChange] = useNodesState(layoutNodes);
206
+ const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges);
207
+
208
+ // Update nodes when layout changes
209
+ React.useEffect(() => {
210
+ setNodes(layoutNodes);
211
+ setEdges(layoutEdges);
212
+ }, [layoutNodes, layoutEdges, setNodes, setEdges]);
213
+
214
+ // Handle node click
215
+ const handleNodeClick = useCallback(
216
+ (_: React.MouseEvent, node: Node) => {
217
+ const data = node.data as unknown as InvestigationNodeData;
218
+ onNodeClick?.(node.id, data.nodeType);
219
+ },
220
+ [onNodeClick]
221
+ );
222
+
223
+ // MiniMap node color based on level
224
+ const miniMapNodeColor = useCallback((node: Node) => {
225
+ const data = node.data as unknown as InvestigationNodeData;
226
+ return getLevelColor(data.level);
227
+ }, []);
228
+
229
+ return (
230
+ <div
231
+ className={className}
232
+ style={{
233
+ width,
234
+ height,
235
+ position: "relative",
236
+ }}
237
+ >
238
+ <ReactFlow
239
+ nodes={nodes}
240
+ edges={edges}
241
+ onNodesChange={onNodesChange}
242
+ onEdgesChange={onEdgesChange}
243
+ onNodeClick={handleNodeClick}
244
+ nodeTypes={nodeTypes}
245
+ fitView
246
+ fitViewOptions={{ padding: 0.2 }}
247
+ minZoom={0.1}
248
+ maxZoom={2}
249
+ proOptions={{ hideAttribution: true }}
250
+ >
251
+ <Background />
252
+ <Controls />
253
+ <MiniMap nodeColor={miniMapNodeColor} zoomable pannable />
254
+ </ReactFlow>
255
+ </div>
256
+ );
257
+ };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Custom node component for the Investigation Graph (Dagre layout).
3
+ * Renders root, check, and container nodes.
4
+ */
5
+
6
+ import React, { memo } from "react";
7
+ import { Handle, Position, type NodeProps } from "@xyflow/react";
8
+ import type { InvestigationNodeData } from "../types";
9
+ import { getLevelColor, getLevelBackgroundColor } from "../utils/observables";
10
+
11
+ /**
12
+ * Investigation node component.
13
+ */
14
+ function InvestigationNodeComponent({
15
+ data,
16
+ selected,
17
+ }: NodeProps) {
18
+ const nodeData = data as unknown as InvestigationNodeData;
19
+ const {
20
+ label,
21
+ emoji,
22
+ nodeType,
23
+ level,
24
+ description,
25
+ } = nodeData;
26
+
27
+ const borderColor = getLevelColor(level);
28
+ const backgroundColor = getLevelBackgroundColor(level);
29
+
30
+ // Different styles based on node type
31
+ const getNodeStyle = () => {
32
+ switch (nodeType) {
33
+ case "root":
34
+ return {
35
+ minWidth: 120,
36
+ padding: "8px 16px",
37
+ borderRadius: 8,
38
+ fontWeight: 600 as const,
39
+ };
40
+ case "check":
41
+ return {
42
+ minWidth: 100,
43
+ padding: "6px 12px",
44
+ borderRadius: 4,
45
+ fontWeight: 400 as const,
46
+ };
47
+ case "container":
48
+ return {
49
+ minWidth: 100,
50
+ padding: "6px 12px",
51
+ borderRadius: 12,
52
+ fontWeight: 400 as const,
53
+ };
54
+ default:
55
+ return {
56
+ minWidth: 80,
57
+ padding: "6px 12px",
58
+ borderRadius: 4,
59
+ fontWeight: 400 as const,
60
+ };
61
+ }
62
+ };
63
+
64
+ const style = getNodeStyle();
65
+
66
+ return (
67
+ <div
68
+ className="investigation-node"
69
+ style={{
70
+ ...style,
71
+ display: "flex",
72
+ flexDirection: "column",
73
+ alignItems: "center",
74
+ backgroundColor,
75
+ border: `${selected ? 3 : 2}px solid ${borderColor}`,
76
+ cursor: "pointer",
77
+ fontFamily: "system-ui, sans-serif",
78
+ }}
79
+ >
80
+ {/* Header with emoji and label */}
81
+ <div
82
+ style={{
83
+ display: "flex",
84
+ alignItems: "center",
85
+ gap: 6,
86
+ }}
87
+ >
88
+ <span style={{ fontSize: 14 }}>{emoji}</span>
89
+ <span
90
+ style={{
91
+ fontSize: 12,
92
+ fontWeight: style.fontWeight,
93
+ maxWidth: 150,
94
+ overflow: "hidden",
95
+ textOverflow: "ellipsis",
96
+ whiteSpace: "nowrap",
97
+ }}
98
+ title={label}
99
+ >
100
+ {label}
101
+ </span>
102
+ </div>
103
+
104
+ {/* Description for checks */}
105
+ {description && (
106
+ <div
107
+ style={{
108
+ marginTop: 4,
109
+ fontSize: 10,
110
+ color: "#6b7280",
111
+ maxWidth: 140,
112
+ overflow: "hidden",
113
+ textOverflow: "ellipsis",
114
+ whiteSpace: "nowrap",
115
+ }}
116
+ title={description}
117
+ >
118
+ {description}
119
+ </div>
120
+ )}
121
+
122
+ {/* Handles for edges */}
123
+ <Handle
124
+ type="target"
125
+ position={Position.Left}
126
+ style={{
127
+ width: 8,
128
+ height: 8,
129
+ background: borderColor,
130
+ }}
131
+ />
132
+ <Handle
133
+ type="source"
134
+ position={Position.Right}
135
+ style={{
136
+ width: 8,
137
+ height: 8,
138
+ background: borderColor,
139
+ }}
140
+ />
141
+ </div>
142
+ );
143
+ }
144
+
145
+ export const InvestigationNode = memo(InvestigationNodeComponent);