@cyvest/cyvest-vis 4.0.0 โ 4.1.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/README.md +122 -10
- package/dist/index.d.mts +156 -6
- package/dist/index.d.ts +156 -6
- package/dist/index.js +1445 -650
- package/dist/index.mjs +1457 -665
- package/package.json +2 -2
- package/src/components/CyvestGraph.tsx +115 -46
- package/src/components/FloatingEdge.tsx +41 -11
- package/src/components/Icons.tsx +730 -0
- package/src/components/InvestigationGraph.tsx +107 -36
- package/src/components/InvestigationNode.tsx +129 -112
- package/src/components/ObservableNode.tsx +116 -135
- package/src/components/ObservablesGraph.tsx +241 -111
- package/src/hooks/useForceLayout.ts +136 -62
- package/src/index.ts +25 -2
- package/src/types.ts +9 -11
- package/src/utils/observables.ts +9 -97
- package/tests/observables.test.ts +10 -17
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook for computing force-directed layout using d3-force.
|
|
3
|
-
* Uses
|
|
3
|
+
* Uses a stable simulation that persists across renders.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { useEffect, useRef, useCallback, useMemo } from "react";
|
|
@@ -46,10 +46,12 @@ interface SimLink extends SimulationLinkDatum<SimNode> {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Selector to
|
|
49
|
+
* Selector to get node IDs for change detection
|
|
50
50
|
*/
|
|
51
|
-
const
|
|
52
|
-
state.nodeLookup.
|
|
51
|
+
const nodeIdsSelector = (state: { nodeLookup: Map<string, Node> }) => {
|
|
52
|
+
const ids = Array.from(state.nodeLookup.keys()).sort();
|
|
53
|
+
return ids.join(",");
|
|
54
|
+
};
|
|
53
55
|
|
|
54
56
|
/**
|
|
55
57
|
* Hook that applies iterative force-directed layout to React Flow nodes.
|
|
@@ -61,7 +63,7 @@ export function useForceLayout(
|
|
|
61
63
|
) {
|
|
62
64
|
const { getNodes, getEdges, setNodes } = useReactFlow();
|
|
63
65
|
const nodesInitialized = useNodesInitialized();
|
|
64
|
-
const
|
|
66
|
+
const nodeIds = useStore(nodeIdsSelector);
|
|
65
67
|
|
|
66
68
|
// Merge config with defaults
|
|
67
69
|
const forceConfig = useMemo(
|
|
@@ -72,29 +74,57 @@ export function useForceLayout(
|
|
|
72
74
|
// Store the simulation reference
|
|
73
75
|
const simulationRef = useRef<Simulation<SimNode, SimLink> | null>(null);
|
|
74
76
|
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
+
// Track dragging state with ref for immediate access
|
|
78
|
+
const draggingRef = useRef<{
|
|
79
|
+
nodeId: string | null;
|
|
80
|
+
active: boolean;
|
|
81
|
+
}>({ nodeId: null, active: false });
|
|
82
|
+
|
|
83
|
+
// Store node positions in a ref to avoid React state conflicts during drag
|
|
84
|
+
const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(
|
|
85
|
+
new Map()
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Animation frame ref for smooth updates
|
|
89
|
+
const rafRef = useRef<number | null>(null);
|
|
77
90
|
|
|
78
91
|
// Initialize and run the simulation
|
|
79
92
|
useEffect(() => {
|
|
80
|
-
if (!nodesInitialized ||
|
|
93
|
+
if (!nodesInitialized || !nodeIds) {
|
|
81
94
|
return;
|
|
82
95
|
}
|
|
83
96
|
|
|
84
97
|
const nodes = getNodes();
|
|
85
98
|
const edges = getEdges();
|
|
86
99
|
|
|
100
|
+
if (nodes.length === 0) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
87
104
|
// Create simulation nodes from React Flow nodes
|
|
88
105
|
const simNodes: SimNode[] = nodes.map((node) => {
|
|
89
106
|
// Check if this node already exists in the simulation
|
|
90
|
-
const existingNode = simulationRef.current
|
|
107
|
+
const existingNode = simulationRef.current
|
|
108
|
+
?.nodes()
|
|
109
|
+
.find((n) => n.id === node.id);
|
|
110
|
+
|
|
111
|
+
// Use existing position if available, otherwise use node position
|
|
112
|
+
const x =
|
|
113
|
+
existingNode?.x ??
|
|
114
|
+
nodePositionsRef.current.get(node.id)?.x ??
|
|
115
|
+
node.position.x ??
|
|
116
|
+
Math.random() * 500 - 250;
|
|
117
|
+
const y =
|
|
118
|
+
existingNode?.y ??
|
|
119
|
+
nodePositionsRef.current.get(node.id)?.y ??
|
|
120
|
+
node.position.y ??
|
|
121
|
+
Math.random() * 500 - 250;
|
|
91
122
|
|
|
92
123
|
return {
|
|
93
124
|
id: node.id,
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// Preserve fixed positions for dragged nodes
|
|
125
|
+
x,
|
|
126
|
+
y,
|
|
127
|
+
// Preserve fixed positions for dragged nodes or root
|
|
98
128
|
fx: existingNode?.fx ?? null,
|
|
99
129
|
fy: existingNode?.fy ?? null,
|
|
100
130
|
};
|
|
@@ -122,6 +152,12 @@ export function useForceLayout(
|
|
|
122
152
|
simulationRef.current.stop();
|
|
123
153
|
}
|
|
124
154
|
|
|
155
|
+
// Cancel any pending animation frame
|
|
156
|
+
if (rafRef.current) {
|
|
157
|
+
cancelAnimationFrame(rafRef.current);
|
|
158
|
+
rafRef.current = null;
|
|
159
|
+
}
|
|
160
|
+
|
|
125
161
|
// Create the force simulation
|
|
126
162
|
const simulation = forceSimulation<SimNode>(simNodes)
|
|
127
163
|
.force(
|
|
@@ -129,38 +165,45 @@ export function useForceLayout(
|
|
|
129
165
|
forceLink<SimNode, SimLink>(simLinks)
|
|
130
166
|
.id((d) => d.id)
|
|
131
167
|
.distance(forceConfig.linkDistance)
|
|
132
|
-
.strength(0.
|
|
168
|
+
.strength(0.4)
|
|
133
169
|
)
|
|
134
170
|
.force(
|
|
135
171
|
"charge",
|
|
136
172
|
forceManyBody<SimNode>().strength(forceConfig.chargeStrength)
|
|
137
173
|
)
|
|
138
|
-
.force(
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
)
|
|
142
|
-
.force(
|
|
143
|
-
"collision",
|
|
144
|
-
forceCollide<SimNode>(forceConfig.collisionRadius)
|
|
145
|
-
)
|
|
146
|
-
.force(
|
|
147
|
-
"x",
|
|
148
|
-
forceX<SimNode>(0).strength(0.01)
|
|
149
|
-
)
|
|
150
|
-
.force(
|
|
151
|
-
"y",
|
|
152
|
-
forceY<SimNode>(0).strength(0.01)
|
|
153
|
-
)
|
|
174
|
+
.force("center", forceCenter(0, 0).strength(forceConfig.centerStrength))
|
|
175
|
+
.force("collision", forceCollide<SimNode>(forceConfig.collisionRadius))
|
|
176
|
+
.force("x", forceX<SimNode>(0).strength(0.008))
|
|
177
|
+
.force("y", forceY<SimNode>(0).strength(0.008))
|
|
154
178
|
.alphaDecay(0.02)
|
|
155
|
-
.velocityDecay(0.
|
|
179
|
+
.velocityDecay(0.35);
|
|
180
|
+
|
|
181
|
+
// Batch updates using requestAnimationFrame for smoother rendering
|
|
182
|
+
const updateNodes = () => {
|
|
183
|
+
if (draggingRef.current.active) {
|
|
184
|
+
// Don't update node positions while actively dragging
|
|
185
|
+
rafRef.current = requestAnimationFrame(updateNodes);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
156
188
|
|
|
157
|
-
|
|
158
|
-
|
|
189
|
+
const simNodes = simulation.nodes();
|
|
190
|
+
|
|
191
|
+
// Update position cache
|
|
192
|
+
for (const simNode of simNodes) {
|
|
193
|
+
nodePositionsRef.current.set(simNode.id, { x: simNode.x, y: simNode.y });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Batch update React Flow nodes
|
|
159
197
|
setNodes((currentNodes) =>
|
|
160
198
|
currentNodes.map((node) => {
|
|
161
|
-
const simNode =
|
|
199
|
+
const simNode = simNodes.find((n) => n.id === node.id);
|
|
162
200
|
if (!simNode) return node;
|
|
163
201
|
|
|
202
|
+
// Skip update if position hasn't changed significantly
|
|
203
|
+
const dx = Math.abs(node.position.x - simNode.x);
|
|
204
|
+
const dy = Math.abs(node.position.y - simNode.y);
|
|
205
|
+
if (dx < 0.1 && dy < 0.1) return node;
|
|
206
|
+
|
|
164
207
|
return {
|
|
165
208
|
...node,
|
|
166
209
|
position: {
|
|
@@ -170,6 +213,16 @@ export function useForceLayout(
|
|
|
170
213
|
};
|
|
171
214
|
})
|
|
172
215
|
);
|
|
216
|
+
|
|
217
|
+
if (simulation.alpha() > 0.001) {
|
|
218
|
+
rafRef.current = requestAnimationFrame(updateNodes);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
simulation.on("tick", () => {
|
|
223
|
+
if (rafRef.current === null && simulation.alpha() > 0.001) {
|
|
224
|
+
rafRef.current = requestAnimationFrame(updateNodes);
|
|
225
|
+
}
|
|
173
226
|
});
|
|
174
227
|
|
|
175
228
|
simulationRef.current = simulation;
|
|
@@ -177,10 +230,14 @@ export function useForceLayout(
|
|
|
177
230
|
// Cleanup: stop simulation when unmounting or dependencies change
|
|
178
231
|
return () => {
|
|
179
232
|
simulation.stop();
|
|
233
|
+
if (rafRef.current) {
|
|
234
|
+
cancelAnimationFrame(rafRef.current);
|
|
235
|
+
rafRef.current = null;
|
|
236
|
+
}
|
|
180
237
|
};
|
|
181
238
|
}, [
|
|
182
239
|
nodesInitialized,
|
|
183
|
-
|
|
240
|
+
nodeIds,
|
|
184
241
|
getNodes,
|
|
185
242
|
getEdges,
|
|
186
243
|
setNodes,
|
|
@@ -189,44 +246,47 @@ export function useForceLayout(
|
|
|
189
246
|
]);
|
|
190
247
|
|
|
191
248
|
/**
|
|
192
|
-
* Handle drag start - fix the node position
|
|
249
|
+
* Handle drag start - fix the node position
|
|
193
250
|
*/
|
|
194
251
|
const onNodeDragStart = useCallback(
|
|
195
252
|
(_: React.MouseEvent, node: Node) => {
|
|
196
253
|
const simulation = simulationRef.current;
|
|
197
254
|
if (!simulation) return;
|
|
198
255
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
// Reheat the simulation
|
|
202
|
-
simulation.alphaTarget(0.3).restart();
|
|
256
|
+
// Mark as dragging immediately
|
|
257
|
+
draggingRef.current = { nodeId: node.id, active: true };
|
|
203
258
|
|
|
204
|
-
//
|
|
259
|
+
// Find and fix the simulation node
|
|
205
260
|
const simNode = simulation.nodes().find((n) => n.id === node.id);
|
|
206
261
|
if (simNode) {
|
|
207
|
-
simNode.fx =
|
|
208
|
-
simNode.fy =
|
|
262
|
+
simNode.fx = node.position.x;
|
|
263
|
+
simNode.fy = node.position.y;
|
|
209
264
|
}
|
|
265
|
+
|
|
266
|
+
// Gently reheat the simulation
|
|
267
|
+
simulation.alphaTarget(0.1).restart();
|
|
210
268
|
},
|
|
211
269
|
[]
|
|
212
270
|
);
|
|
213
271
|
|
|
214
272
|
/**
|
|
215
|
-
* Handle drag - update the fixed position
|
|
273
|
+
* Handle drag - update the fixed position without triggering React updates
|
|
216
274
|
*/
|
|
217
|
-
const onNodeDrag = useCallback(
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (!simulation) return;
|
|
275
|
+
const onNodeDrag = useCallback((_: React.MouseEvent, node: Node) => {
|
|
276
|
+
const simulation = simulationRef.current;
|
|
277
|
+
if (!simulation) return;
|
|
221
278
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
279
|
+
const simNode = simulation.nodes().find((n) => n.id === node.id);
|
|
280
|
+
if (simNode) {
|
|
281
|
+
simNode.fx = node.position.x;
|
|
282
|
+
simNode.fy = node.position.y;
|
|
283
|
+
// Update cache
|
|
284
|
+
nodePositionsRef.current.set(node.id, {
|
|
285
|
+
x: node.position.x,
|
|
286
|
+
y: node.position.y,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}, []);
|
|
230
290
|
|
|
231
291
|
/**
|
|
232
292
|
* Handle drag end - unfix the node and let simulation cool down
|
|
@@ -234,11 +294,13 @@ export function useForceLayout(
|
|
|
234
294
|
const onNodeDragStop = useCallback(
|
|
235
295
|
(_: React.MouseEvent, node: Node) => {
|
|
236
296
|
const simulation = simulationRef.current;
|
|
237
|
-
if (!simulation) return;
|
|
238
297
|
|
|
239
|
-
|
|
298
|
+
// Clear dragging state first
|
|
299
|
+
draggingRef.current = { nodeId: null, active: false };
|
|
240
300
|
|
|
241
|
-
|
|
301
|
+
if (!simulation) return;
|
|
302
|
+
|
|
303
|
+
// Let simulation cool down gradually
|
|
242
304
|
simulation.alphaTarget(0);
|
|
243
305
|
|
|
244
306
|
// Unfix the node (unless it's the root)
|
|
@@ -249,6 +311,13 @@ export function useForceLayout(
|
|
|
249
311
|
simNode.fy = null;
|
|
250
312
|
}
|
|
251
313
|
}
|
|
314
|
+
|
|
315
|
+
// Schedule a gentle restart to let the graph settle
|
|
316
|
+
setTimeout(() => {
|
|
317
|
+
if (simulationRef.current && !draggingRef.current.active) {
|
|
318
|
+
simulationRef.current.alpha(0.1).restart();
|
|
319
|
+
}
|
|
320
|
+
}, 50);
|
|
252
321
|
},
|
|
253
322
|
[rootNodeId]
|
|
254
323
|
);
|
|
@@ -269,7 +338,9 @@ export function useForceLayout(
|
|
|
269
338
|
}
|
|
270
339
|
|
|
271
340
|
if (updates.linkDistance !== undefined) {
|
|
272
|
-
const linkForce = simulation.force("link") as
|
|
341
|
+
const linkForce = simulation.force("link") as
|
|
342
|
+
| ReturnType<typeof forceLink<SimNode, SimLink>>
|
|
343
|
+
| undefined;
|
|
273
344
|
if (linkForce) {
|
|
274
345
|
linkForce.distance(updates.linkDistance);
|
|
275
346
|
}
|
|
@@ -283,7 +354,7 @@ export function useForceLayout(
|
|
|
283
354
|
}
|
|
284
355
|
|
|
285
356
|
// Reheat simulation to apply changes
|
|
286
|
-
simulation.alpha(0.
|
|
357
|
+
simulation.alpha(0.3).restart();
|
|
287
358
|
},
|
|
288
359
|
[]
|
|
289
360
|
);
|
|
@@ -358,7 +429,10 @@ export function computeForceLayout(
|
|
|
358
429
|
.distance(config.linkDistance)
|
|
359
430
|
)
|
|
360
431
|
.force("charge", forceManyBody().strength(config.chargeStrength))
|
|
361
|
-
.force(
|
|
432
|
+
.force(
|
|
433
|
+
"center",
|
|
434
|
+
forceCenter(centerX, centerY).strength(config.centerStrength)
|
|
435
|
+
)
|
|
362
436
|
.force("collision", forceCollide(config.collisionRadius))
|
|
363
437
|
.stop();
|
|
364
438
|
|
package/src/index.ts
CHANGED
|
@@ -6,8 +6,31 @@
|
|
|
6
6
|
* @packageDocumentation
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
// Main component
|
|
9
|
+
// Main component exports
|
|
10
10
|
export { CyvestGraph } from "./components/CyvestGraph";
|
|
11
|
+
export { ObservablesGraph } from "./components/ObservablesGraph";
|
|
12
|
+
export { InvestigationGraph } from "./components/InvestigationGraph";
|
|
13
|
+
|
|
14
|
+
// Icon exports for customization
|
|
15
|
+
export {
|
|
16
|
+
getObservableIcon,
|
|
17
|
+
getInvestigationIcon,
|
|
18
|
+
OBSERVABLE_ICON_MAP,
|
|
19
|
+
INVESTIGATION_ICON_MAP,
|
|
20
|
+
type IconProps,
|
|
21
|
+
} from "./components/Icons";
|
|
11
22
|
|
|
12
23
|
// Re-export types for consumers
|
|
13
|
-
export type {
|
|
24
|
+
export type {
|
|
25
|
+
CyvestGraphProps,
|
|
26
|
+
ObservablesGraphProps,
|
|
27
|
+
InvestigationGraphProps,
|
|
28
|
+
ForceLayoutConfig,
|
|
29
|
+
ObservableNodeData,
|
|
30
|
+
ObservableEdgeData,
|
|
31
|
+
InvestigationNodeData,
|
|
32
|
+
InvestigationNodeType,
|
|
33
|
+
ObservableShape,
|
|
34
|
+
} from "./types";
|
|
35
|
+
|
|
36
|
+
export { DEFAULT_FORCE_CONFIG } from "./types";
|
package/src/types.ts
CHANGED
|
@@ -11,8 +11,9 @@ import type { Level } from "@cyvest/cyvest-js";
|
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
13
|
* Shape types for observable nodes.
|
|
14
|
+
* In the current design, all non-root nodes are circles.
|
|
14
15
|
*/
|
|
15
|
-
export type ObservableShape = "
|
|
16
|
+
export type ObservableShape = "circle" | "rectangle";
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Data attached to observable graph nodes.
|
|
@@ -28,8 +29,6 @@ export interface ObservableNodeData extends Record<string, unknown> {
|
|
|
28
29
|
level: Level;
|
|
29
30
|
/** Numeric score */
|
|
30
31
|
score: number;
|
|
31
|
-
/** Emoji representing the observable type */
|
|
32
|
-
emoji: string;
|
|
33
32
|
/** Shape for this node type */
|
|
34
33
|
shape: ObservableShape;
|
|
35
34
|
/** Whether this is the root observable */
|
|
@@ -85,8 +84,6 @@ export interface InvestigationNodeData extends Record<string, unknown> {
|
|
|
85
84
|
description?: string;
|
|
86
85
|
/** Path (for containers) */
|
|
87
86
|
path?: string;
|
|
88
|
-
/** Emoji for the node */
|
|
89
|
-
emoji: string;
|
|
90
87
|
}
|
|
91
88
|
|
|
92
89
|
/**
|
|
@@ -107,26 +104,27 @@ export type InvestigationEdge = Edge;
|
|
|
107
104
|
* Configuration options for d3-force layout.
|
|
108
105
|
*/
|
|
109
106
|
export interface ForceLayoutConfig {
|
|
110
|
-
/** Strength of the charge force (repulsion). Default: -
|
|
107
|
+
/** Strength of the charge force (repulsion). Default: -200 */
|
|
111
108
|
chargeStrength: number;
|
|
112
|
-
/** Target distance between linked nodes. Default:
|
|
109
|
+
/** Target distance between linked nodes. Default: 80 */
|
|
113
110
|
linkDistance: number;
|
|
114
|
-
/** Strength of the centering force. Default: 0.
|
|
111
|
+
/** Strength of the centering force. Default: 0.05 */
|
|
115
112
|
centerStrength: number;
|
|
116
|
-
/**
|
|
113
|
+
/** Radius for collision detection. Default: 40 */
|
|
117
114
|
collisionRadius: number;
|
|
118
|
-
/** Number of simulation iterations. Default: 300 */
|
|
115
|
+
/** Number of simulation iterations (for static layout). Default: 300 */
|
|
119
116
|
iterations: number;
|
|
120
117
|
}
|
|
121
118
|
|
|
122
119
|
/**
|
|
123
120
|
* Default force layout configuration.
|
|
121
|
+
* Tuned for good visual separation and smooth animations.
|
|
124
122
|
*/
|
|
125
123
|
export const DEFAULT_FORCE_CONFIG: ForceLayoutConfig = {
|
|
126
124
|
chargeStrength: -200,
|
|
127
125
|
linkDistance: 80,
|
|
128
126
|
centerStrength: 0.05,
|
|
129
|
-
collisionRadius:
|
|
127
|
+
collisionRadius: 45,
|
|
130
128
|
iterations: 300,
|
|
131
129
|
};
|
|
132
130
|
|
package/src/utils/observables.ts
CHANGED
|
@@ -5,95 +5,20 @@
|
|
|
5
5
|
import { getColorForLevel, type Level } from "@cyvest/cyvest-js";
|
|
6
6
|
import type { ObservableShape } from "../types";
|
|
7
7
|
|
|
8
|
-
/**
|
|
9
|
-
* Map observable types to emojis.
|
|
10
|
-
*/
|
|
11
|
-
const OBSERVABLE_EMOJI_MAP: Record<string, string> = {
|
|
12
|
-
// Network
|
|
13
|
-
"ipv4-addr": "๐",
|
|
14
|
-
"ipv6-addr": "๐",
|
|
15
|
-
"domain-name": "๐ ",
|
|
16
|
-
url: "๐",
|
|
17
|
-
"autonomous-system": "๐",
|
|
18
|
-
"mac-addr": "๐ถ",
|
|
19
|
-
|
|
20
|
-
// Email
|
|
21
|
-
"email-addr": "๐ง",
|
|
22
|
-
"email-message": "โ๏ธ",
|
|
23
|
-
|
|
24
|
-
// File
|
|
25
|
-
file: "๐",
|
|
26
|
-
"file-hash": "๐",
|
|
27
|
-
"file:hash:md5": "๐",
|
|
28
|
-
"file:hash:sha1": "๐",
|
|
29
|
-
"file:hash:sha256": "๐",
|
|
30
|
-
|
|
31
|
-
// User/Identity
|
|
32
|
-
user: "๐ค",
|
|
33
|
-
"user-account": "๐ค",
|
|
34
|
-
identity: "๐ชช",
|
|
35
|
-
|
|
36
|
-
// Process/System
|
|
37
|
-
process: "โ๏ธ",
|
|
38
|
-
software: "๐ฟ",
|
|
39
|
-
"windows-registry-key": "๐",
|
|
40
|
-
|
|
41
|
-
// Threat Intelligence
|
|
42
|
-
"threat-actor": "๐น",
|
|
43
|
-
malware: "๐ฆ ",
|
|
44
|
-
"attack-pattern": "โ๏ธ",
|
|
45
|
-
campaign: "๐ฏ",
|
|
46
|
-
indicator: "๐จ",
|
|
47
|
-
|
|
48
|
-
// Artifacts
|
|
49
|
-
artifact: "๐งช",
|
|
50
|
-
certificate: "๐",
|
|
51
|
-
"x509-certificate": "๐",
|
|
52
|
-
|
|
53
|
-
// Default
|
|
54
|
-
unknown: "โ",
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Get the emoji for an observable type.
|
|
59
|
-
* Falls back to a generic icon if type is unknown.
|
|
60
|
-
*/
|
|
61
|
-
export function getObservableEmoji(observableType: string): string {
|
|
62
|
-
const normalized = observableType.toLowerCase().trim();
|
|
63
|
-
return OBSERVABLE_EMOJI_MAP[normalized] ?? OBSERVABLE_EMOJI_MAP.unknown;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Map observable types to shapes.
|
|
68
|
-
*/
|
|
69
|
-
const OBSERVABLE_SHAPE_MAP: Record<string, ObservableShape> = {
|
|
70
|
-
// Domains get squares
|
|
71
|
-
"domain-name": "square",
|
|
72
|
-
|
|
73
|
-
// URLs get circles
|
|
74
|
-
url: "circle",
|
|
75
|
-
|
|
76
|
-
// IPs get triangles
|
|
77
|
-
"ipv4-addr": "triangle",
|
|
78
|
-
"ipv6-addr": "triangle",
|
|
79
|
-
|
|
80
|
-
// Root/files get rectangles (default for root)
|
|
81
|
-
file: "rectangle",
|
|
82
|
-
"email-message": "rectangle",
|
|
83
|
-
};
|
|
84
|
-
|
|
85
8
|
/**
|
|
86
9
|
* Get the shape for an observable type.
|
|
10
|
+
* All non-root nodes are circles for a cleaner look.
|
|
87
11
|
*/
|
|
88
12
|
export function getObservableShape(
|
|
89
|
-
|
|
13
|
+
_observableType: string,
|
|
90
14
|
isRoot: boolean
|
|
91
15
|
): ObservableShape {
|
|
16
|
+
// Root nodes get a rounded rectangle (pill shape)
|
|
92
17
|
if (isRoot) {
|
|
93
18
|
return "rectangle";
|
|
94
19
|
}
|
|
95
|
-
|
|
96
|
-
return
|
|
20
|
+
// All other nodes are circles
|
|
21
|
+
return "circle";
|
|
97
22
|
}
|
|
98
23
|
|
|
99
24
|
/**
|
|
@@ -123,6 +48,9 @@ export function getLevelColor(level: Level): string {
|
|
|
123
48
|
return getColorForLevel(level);
|
|
124
49
|
}
|
|
125
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Lighten a hex color by a percentage.
|
|
53
|
+
*/
|
|
126
54
|
function lightenHexColor(hex: string, amount: number): string {
|
|
127
55
|
const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
|
|
128
56
|
if (normalized.length !== 6) {
|
|
@@ -145,21 +73,5 @@ function lightenHexColor(hex: string, amount: number): string {
|
|
|
145
73
|
* Get background color for security level (lighter version).
|
|
146
74
|
*/
|
|
147
75
|
export function getLevelBackgroundColor(level: Level): string {
|
|
148
|
-
return lightenHexColor(getLevelColor(level), 0.
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Emoji map for investigation node types.
|
|
153
|
-
*/
|
|
154
|
-
const INVESTIGATION_NODE_EMOJI: Record<string, string> = {
|
|
155
|
-
root: "๐ฏ",
|
|
156
|
-
check: "โ
",
|
|
157
|
-
container: "๐ฆ",
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
/**
|
|
161
|
-
* Get emoji for investigation node type.
|
|
162
|
-
*/
|
|
163
|
-
export function getInvestigationNodeEmoji(nodeType: string): string {
|
|
164
|
-
return INVESTIGATION_NODE_EMOJI[nodeType] ?? "โ";
|
|
76
|
+
return lightenHexColor(getLevelColor(level), 0.88);
|
|
165
77
|
}
|
|
@@ -2,26 +2,21 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import { LEVEL_COLORS } from "@cyvest/cyvest-js";
|
|
4
4
|
import {
|
|
5
|
-
getInvestigationNodeEmoji,
|
|
6
5
|
getLevelBackgroundColor,
|
|
7
6
|
getLevelColor,
|
|
8
|
-
getObservableEmoji,
|
|
9
7
|
getObservableShape,
|
|
10
8
|
truncateLabel,
|
|
11
9
|
} from "../src/utils/observables";
|
|
12
10
|
|
|
13
11
|
describe("observables utils", () => {
|
|
14
|
-
it("returns
|
|
15
|
-
|
|
16
|
-
expect(
|
|
17
|
-
expect(
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("returns shapes based on type and root flag", () => {
|
|
21
|
-
expect(getObservableShape("domain-name", false)).toBe("square");
|
|
22
|
-
expect(getObservableShape("ipv6-addr", false)).toBe("triangle");
|
|
12
|
+
it("returns shapes based on root flag (all non-root nodes are circles)", () => {
|
|
13
|
+
// All non-root nodes are now circles for a cleaner design
|
|
14
|
+
expect(getObservableShape("domain-name", false)).toBe("circle");
|
|
15
|
+
expect(getObservableShape("ipv6-addr", false)).toBe("circle");
|
|
23
16
|
expect(getObservableShape("anything-else", false)).toBe("circle");
|
|
17
|
+
// Root nodes get a rectangle (pill shape)
|
|
24
18
|
expect(getObservableShape("anything-else", true)).toBe("rectangle");
|
|
19
|
+
expect(getObservableShape("domain-name", true)).toBe("rectangle");
|
|
25
20
|
});
|
|
26
21
|
|
|
27
22
|
it("truncates long labels in the middle by default", () => {
|
|
@@ -32,11 +27,9 @@ describe("observables utils", () => {
|
|
|
32
27
|
|
|
33
28
|
it("maps levels to colors", () => {
|
|
34
29
|
expect(getLevelColor("SUSPICIOUS")).toBe(LEVEL_COLORS.SUSPICIOUS);
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
expect(getInvestigationNodeEmoji("root")).toBe("๐ฏ");
|
|
40
|
-
expect(getInvestigationNodeEmoji("missing")).toBe("โ");
|
|
30
|
+
// Background color is the level color lightened by 88%
|
|
31
|
+
const bgColor = getLevelBackgroundColor("SUSPICIOUS");
|
|
32
|
+
// Just verify it's a valid hex color that's lighter than the original
|
|
33
|
+
expect(bgColor).toMatch(/^#[0-9a-f]{6}$/i);
|
|
41
34
|
});
|
|
42
35
|
});
|