Compare commits

...

13 Commits

Author SHA1 Message Date
Abhi Kumar
519355a692 chore: minor fix 2026-05-06 18:59:26 +05:30
Abhi Kumar
55bc8592ed chore: updated ui for service map component 2026-05-06 18:55:24 +05:30
Abhi Kumar
8fa566d1d0 Merge branch 'main' of https://github.com/SigNoz/signoz into feat/service-map 2026-05-06 17:49:42 +05:30
Tushar Vats
9301b2fb1c fix: dashboard date refresh (#11201)
* fix: dashboard invalid date state upon refresh

* fix: dashboard invalid date state upon refresh

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-05-06 09:25:47 +00:00
primus-bot[bot]
6d0e60822c chore(release): bump to v0.122.0 (#11204)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-05-06 08:01:25 +00:00
Abhi kumar
0b1aba04f4 Merge branch 'main' into feat/service-map 2026-05-05 17:10:46 +05:30
Abhi Kumar
5c8d33290b chore: updated particle color + added service icon 2026-05-05 17:08:40 +05:30
Abhi kumar
30424a3829 Merge branch 'main' into feat/service-map 2026-05-05 14:13:01 +05:30
Abhi Kumar
d2ede7bb16 chore: added changes related to edge color and node size 2026-05-05 14:04:54 +05:30
Abhi Kumar
c622bbd112 Merge branch 'feat/service-map' of https://github.com/SigNoz/signoz into feat/service-map 2026-05-05 11:15:12 +05:30
Abhi kumar
b92d5e934b Merge branch 'main' into feat/service-map 2026-05-05 02:34:43 +05:30
Abhi Kumar
c20520da5f chore: fixed bgcolor 2026-05-05 02:28:03 +05:30
Abhi Kumar
0ccb26cbfc feat: revamped the service map component 2026-05-05 02:14:13 +05:30
23 changed files with 1384 additions and 313 deletions

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.122.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.121.1
image: signoz/signoz:v0.122.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.121.1}
image: signoz/signoz:${VERSION:-v0.122.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.121.1}
image: signoz/signoz:${VERSION:-v0.122.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -35,6 +35,7 @@
"@ant-design/icons": "4.8.0",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/lang-javascript": "6.2.3",
"@dagrejs/dagre": "3.0.0",
"@dnd-kit/core": "6.1.0",
"@dnd-kit/modifiers": "7.0.0",
"@dnd-kit/sortable": "8.0.0",
@@ -62,6 +63,7 @@
"@visx/shape": "3.5.0",
"@visx/tooltip": "3.3.0",
"@vitejs/plugin-react": "5.1.4",
"@xyflow/react": "12.10.2",
"ansi-to-html": "0.7.2",
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
@@ -114,7 +116,6 @@
"react-dom": "18.2.0",
"react-drag-listview": "2.0.0",
"react-error-boundary": "4.0.11",
"react-force-graph-2d": "^1.29.1",
"react-full-screen": "1.1.1",
"react-grid-layout": "^1.3.4",
"react-helmet-async": "1.3.0",

View File

@@ -0,0 +1,158 @@
import { act, render } from '@testing-library/react';
import { Modal } from 'antd';
import { useDashboardBootstrap } from 'hooks/dashboard/useDashboardBootstrap';
import { useTransformDashboardVariables } from 'hooks/dashboard/useTransformDashboardVariables';
import useTabVisibility from 'hooks/useTabFocus';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { useDashboardQuery } from './useDashboardQuery';
const mockDispatch = jest.fn();
const mockSetDashboardData = jest.fn();
const mockSetLayouts = jest.fn();
const mockSetPanelMap = jest.fn();
const mockResetDashboardStore = jest.fn();
const mockGetUrlVariables = jest.fn();
const mockUpdateUrlVariable = jest.fn();
const mockRefetch = jest.fn();
let mockGlobalTime = {
selectedTime: 'custom',
minTime: 1710000000000000000,
maxTime: 1710000300000000000,
isAutoRefreshDisabled: true,
};
let currentQueryData: unknown;
jest.mock('react-i18next', () => ({
useTranslation: (): { t: (key: string) => string } => ({
t: (key: string): string => key,
}),
}));
jest.mock('react-redux', () => ({
useDispatch: jest.fn(() => mockDispatch),
useSelector: jest.fn(
(
selectorFn: (state: { globalTime: typeof mockGlobalTime }) => unknown,
): unknown => selectorFn({ globalTime: mockGlobalTime }),
),
}));
jest.mock('hooks/useTabFocus', () => jest.fn(() => true));
jest.mock('hooks/dashboard/useDashboardVariablesSync', () => ({
useDashboardVariablesSync: jest.fn(),
}));
jest.mock('./useDashboardQuery', () => ({
useDashboardQuery: jest.fn(),
}));
jest.mock('hooks/dashboard/useTransformDashboardVariables', () => ({
useTransformDashboardVariables: jest.fn(),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: jest.fn(),
}));
jest.mock('providers/Dashboard/initializeDefaultVariables', () => ({
initializeDefaultVariables: jest.fn(),
}));
jest.mock('lib/dashboard/getUpdatedLayout', () => ({
getUpdatedLayout: jest.fn(() => []),
}));
jest.mock('providers/Dashboard/util', () => ({
sortLayout: jest.fn((layout) => layout),
}));
jest.mock('lib/getMinMax', () => ({
getMinMaxForSelectedTime: jest.fn(),
}));
function TestComponent({ confirm }: { confirm: typeof Modal.confirm }): null {
useDashboardBootstrap('dashboard-1', { confirm });
return null;
}
describe('useDashboardBootstrap', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGlobalTime = {
selectedTime: 'custom',
minTime: 1710000000000000000,
maxTime: 1710000300000000000,
isAutoRefreshDisabled: true,
};
jest.mocked(useDashboardStore as unknown as jest.Mock).mockReturnValue({
setDashboardData: mockSetDashboardData,
setLayouts: mockSetLayouts,
setPanelMap: mockSetPanelMap,
resetDashboardStore: mockResetDashboardStore,
});
jest
.mocked(useTransformDashboardVariables as unknown as jest.Mock)
.mockReturnValue({
getUrlVariables: mockGetUrlVariables,
updateUrlVariable: mockUpdateUrlVariable,
transformDashboardVariables: <T,>(data: T): T => data,
});
jest.mocked(useTabVisibility as unknown as jest.Mock).mockReturnValue(true);
jest
.mocked(useDashboardQuery as unknown as jest.Mock)
.mockImplementation(() => ({
data: currentQueryData,
isLoading: false,
isError: false,
isFetching: false,
error: null,
refetch: mockRefetch,
}));
});
it('keeps minTime and maxTime unchanged for custom range on refresh confirm', () => {
const initialDashboard = {
id: 'dashboard-1',
updatedAt: '2024-01-01T00:00:00.000Z',
data: { layout: [], panelMap: {}, variables: {} },
};
const updatedDashboard = {
id: 'dashboard-1',
updatedAt: '2024-01-01T01:00:00.000Z',
data: { layout: [], panelMap: {}, variables: {} },
};
const mockConfirm = jest.fn<
ReturnType<typeof Modal.confirm>,
Parameters<typeof Modal.confirm>
>(() => ({ destroy: jest.fn(), update: jest.fn() }));
currentQueryData = { data: initialDashboard };
const { rerender } = render(<TestComponent confirm={mockConfirm} />);
expect(mockConfirm).not.toHaveBeenCalled();
currentQueryData = { data: updatedDashboard };
rerender(<TestComponent confirm={mockConfirm} />);
expect(mockConfirm).toHaveBeenCalledTimes(1);
const firstCall = mockConfirm.mock.calls[0];
expect(firstCall).toBeDefined();
const [confirmProps] = firstCall as Parameters<typeof Modal.confirm>;
act(() => {
confirmProps.onOk?.();
});
expect(getMinMaxForSelectedTime).not.toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith({
type: 'UPDATE_TIME_INTERVAL',
payload: {
selectedTime: 'custom',
minTime: mockGlobalTime.minTime,
maxTime: mockGlobalTime.maxTime,
},
});
});
});

View File

@@ -102,11 +102,19 @@ export function useDashboardBootstrap(
onOk() {
setDashboardData(updatedDashboardData);
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
const { maxTime, minTime } =
globalTime.selectedTime === 'custom'
? {
// For custom ranges, min/max are already stored in nanoseconds.
// Recomputing via getMinMaxForSelectedTime would multiply them again.
maxTime: globalTime.maxTime,
minTime: globalTime.minTime,
}
: getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: { maxTime, minTime, selectedTime: globalTime.selectedTime },

View File

@@ -1,62 +0,0 @@
/* eslint-disable */
//@ts-nocheck
import { memo } from 'react';
import ForceGraph2D from 'react-force-graph-2d';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { getGraphData, getTooltip, transformLabel } from './utils';
function ServiceMap({ fgRef, serviceMap }: any): JSX.Element {
const isDarkMode = useIsDarkMode();
const { nodes, links } = getGraphData(serviceMap, isDarkMode);
const graphData = { nodes, links };
let zoomLevel = 1;
return (
<ForceGraph2D
ref={fgRef}
cooldownTicks={100}
graphData={graphData}
linkLabel={getTooltip}
linkAutoColorBy={(d) => d.target}
linkDirectionalParticles="value"
linkDirectionalParticleSpeed={(d) => d.value}
nodeCanvasObject={(node, ctx) => {
const label = transformLabel(node.id, zoomLevel);
let { fontSize } = node;
fontSize = (fontSize * 3) / zoomLevel;
ctx.font = `${fontSize}px Roboto`;
const { width } = node;
ctx.fillStyle = node.color;
ctx.beginPath();
ctx.arc(node.x, node.y, width, 0, 2 * Math.PI, false);
ctx.fill();
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = isDarkMode ? '#ffffff' : '#000000';
ctx.fillText(label, node.x, node.y);
}}
onLinkHover={(node) => {
const tooltip = document.querySelector('.graph-tooltip');
if (tooltip && node) {
tooltip.innerHTML = getTooltip(node);
}
}}
onZoom={(zoom) => {
zoomLevel = zoom.k;
}}
nodePointerAreaPaint={(node, color, ctx) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x, node.y, 5, 0, 2 * Math.PI, false);
ctx.fill();
}}
/>
);
}
export default memo(ServiceMap);

View File

@@ -1,6 +1,6 @@
//@ts-nocheck
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
// eslint-disable-next-line no-restricted-imports
import { connect } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
@@ -16,27 +16,9 @@ import { AppState } from 'store/reducers';
import styled from 'styled-components';
import { GlobalTime } from 'types/actions/globalTime';
import Map from './Map';
import Map from './components/Map/Map';
const Container = styled.div`
.force-graph-container {
overflow: scroll;
}
.force-graph-container .graph-tooltip {
background: black;
padding: 1px;
.keyval {
display: flex;
.key {
margin-right: 4px;
}
.val {
margin-left: auto;
}
}
}
`;
const Container = styled.div``;
interface ServiceMapProps extends RouteComponentProps<any> {
serviceMap: ServiceMapStore;
@@ -48,7 +30,8 @@ interface ServiceMapProps extends RouteComponentProps<any> {
}
interface graphNode {
id: string;
group: number;
status: 'healthy' | 'error';
name: string;
}
interface graphLink {
source: string;
@@ -64,8 +47,6 @@ export interface graphDataType {
}
function ServiceMap(props: ServiceMapProps): JSX.Element {
const fgRef = useRef();
const { getDetailedServiceMapItems, globalTime, serviceMap } = props;
const { queries } = useResourceAttribute();
@@ -78,10 +59,6 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
getDetailedServiceMapItems(globalTime, queries);
}, [globalTime, getDetailedServiceMapItems, queries]);
useEffect(() => {
fgRef.current && fgRef.current.d3Force('charge').strength(-400);
});
if (serviceMap.loading) {
return <Spinner size="large" tip="Loading..." />;
}
@@ -108,7 +85,7 @@ function ServiceMap(props: ServiceMapProps): JSX.Element {
}
/>
<Map fgRef={fgRef} serviceMap={serviceMap} />
<Map serviceMap={serviceMap} />
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { Edge, EdgeProps, getBezierPath } from '@xyflow/react';
import { EDGE_DASH_PERIOD, getDashAnimation } from '../Map/Map.constants';
export interface FlowEdgeData extends Record<string, unknown> {
p99: number;
callRate: number;
errorRate: number;
maxCallRate: number;
}
// Matches @xyflow/react's BaseEdge default — the wider transparent path that
// catches hover for the tooltip even though the visible dashes are thin.
const INTERACTION_WIDTH = 20;
function FlowEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style,
markerEnd,
data,
}: EdgeProps<Edge<FlowEdgeData>>): JSX.Element {
const [edgePath] = getBezierPath({
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
});
const callRate = data?.callRate ?? 0;
const maxCallRate = data?.maxCallRate ?? 0;
const { duration } = getDashAnimation(callRate, maxCallRate);
return (
<>
<path
id={id}
className="react-flow__edge-path"
d={edgePath}
fill="none"
style={style}
markerEnd={markerEnd}
>
{duration > 0 && (
// Positive `stroke-dashoffset` shifts the dash pattern toward the
// path's start, so visually the dashes flow target -> source
// (callee -> caller), matching the original particle direction.
<animate
attributeName="stroke-dashoffset"
from="0"
to={EDGE_DASH_PERIOD}
dur={`${duration}s`}
repeatCount="indefinite"
/>
)}
</path>
<path
d={edgePath}
fill="none"
strokeOpacity={0}
strokeWidth={INTERACTION_WIDTH}
className="react-flow__edge-interaction"
/>
</>
);
}
export default FlowEdge;

View File

@@ -0,0 +1,27 @@
.tooltip {
position: fixed;
pointer-events: none;
z-index: 1000;
padding: 12px;
min-width: 160px;
font-size: 12px;
font-family: Inter, sans-serif;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
color: var(--popover-foreground);
background: var(--popover);
border: 1px solid var(--secondary-border);
}
.row {
display: flex;
justify-content: space-between;
}
.label {
margin-right: 8px;
}
.value {
margin-left: auto;
}

View File

@@ -0,0 +1,39 @@
import styles from './LinkTooltip.module.scss';
export interface LinkTooltipData {
p99: string | number;
callRate: string | number;
errorRate: string | number;
}
export interface LinkTooltipProps {
tooltip: LinkTooltipData;
x: number;
y: number;
}
const POINTER_OFFSET = 12;
function LinkTooltip({ tooltip, x, y }: LinkTooltipProps): JSX.Element {
return (
<div
className={styles.tooltip}
style={{ top: y + POINTER_OFFSET, left: x + POINTER_OFFSET }}
>
<div className={styles.row}>
<span className={styles.label}>P99 latency:</span>
<span className={styles.value}>{tooltip.p99}ms</span>
</div>
<div className={styles.row}>
<span className={styles.label}>Request:</span>
<span className={styles.value}>{tooltip.callRate}/sec</span>
</div>
<div className={styles.row}>
<span className={styles.label}>Error Rate:</span>
<span className={styles.value}>{tooltip.errorRate}%</span>
</div>
</div>
);
}
export default LinkTooltip;

View File

@@ -0,0 +1,45 @@
// Geometry of a service node as drawn on the map. Pills are split into a
// colored icon box and a tinted body; the small monospace service-id label
// rendered above the pill is what `NODE_OUTER_HEIGHT` accounts for so dagre
// reserves enough vertical room and `Map.tsx` can centre the visual mass on
// the simulated coordinate.
export const NODE_WIDTH = 184;
export const NODE_HEIGHT = 58;
export const LABEL_HEIGHT = 16;
export const NODE_LABEL_GAP = 6;
export const NODE_OUTER_HEIGHT = NODE_HEIGHT + LABEL_HEIGHT + NODE_LABEL_GAP;
// Edge dash pattern. `EDGE_DASH_PERIOD` (dash + gap) is the loop distance the
// marching-dash animation must travel for a seamless wrap; deriving it from
// the parts keeps it locked to the dasharray.
const EDGE_DASH_LENGTH = 5;
const EDGE_DASH_GAP = 4;
export const EDGE_DASH_ARRAY = `${EDGE_DASH_LENGTH} ${EDGE_DASH_GAP}`;
export const EDGE_DASH_PERIOD = EDGE_DASH_LENGTH + EDGE_DASH_GAP;
// Per-edge marching-dash speed scales with the edge's call rate *relative to
// the busiest edge in the current graph*, on a log10 ladder. The busiest edge
// always pegs the fastest march; the slowest gets a slow drift. This keeps
// the visualisation legible whether the busiest service handles 5 req/sec or 5k.
export const DASH_FAST_SECS = 0.2;
export const DASH_SLOW_SECS = 1.1;
// Compute per-period duration for an edge's call rate, scaled against the max
// call rate observed across the graph. A duration of 0 means "no call rate,
// don't animate". Pure so it can be unit-tested without rendering the edge.
export function getDashAnimation(
callRate: number,
maxCallRate: number,
): { duration: number } {
if (callRate <= 0) {
return { duration: 0 };
}
// Defensive: if a stale/zero max sneaks in, treat this edge as the max so
// `factor` stays in [0, 1] rather than going to Infinity or NaN.
const effectiveMax = Math.max(maxCallRate, callRate);
const logRate = Math.log10(callRate + 1);
const logMax = Math.log10(effectiveMax + 1);
const factor = logMax > 0 ? logRate / logMax : 1;
const duration = DASH_SLOW_SECS - factor * (DASH_SLOW_SECS - DASH_FAST_SECS);
return { duration };
}

View File

@@ -0,0 +1,21 @@
.container {
width: 100%;
height: calc(100vh - 124px);
position: relative;
border: 1px solid var(--l3-border);
border-radius: 8px;
overflow: hidden;
// ReactFlow defaults edge pointer-events to `visibleStroke`, which means
// our thin dashed line only captures hover on the painted dash segments.
// Force `stroke` on the wide invisible interaction path so the entire edge
// length is hoverable for the tooltip.
:global(.react-flow__edge-interaction) {
pointer-events: stroke;
cursor: default;
}
:global(.react-flow__edge) {
pointer-events: stroke;
}
}

View File

@@ -0,0 +1,286 @@
import '@xyflow/react/dist/style.css';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
import {
Background,
BackgroundVariant,
Controls,
Edge,
Node,
ReactFlow,
useEdgesState,
useNodesState,
} from '@xyflow/react';
import { useIsDarkMode } from 'hooks/useDarkMode';
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
import {
EDGE_DASH_ARRAY,
NODE_OUTER_HEIGHT,
NODE_WIDTH,
} from './Map.constants';
import styles from './Map.module.scss';
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
import LinkTooltip from '../LinkTooltip/LinkTooltip';
import {
computeNodePositions,
getEdgeColor,
getGraphData,
getLinkTooltip,
LinkTooltip as LinkTooltipData,
} from '../../utils';
const nodeTypes = { service: ServiceNode };
const edgeTypes = { flow: FlowEdge };
const BG_COLOR = 'var(--l2-background)';
const BASE_EDGE_STYLE = {
strokeWidth: 1.25,
strokeDasharray: EDGE_DASH_ARRAY,
};
interface HoverState {
tooltip: LinkTooltipData;
x: number;
y: number;
}
// Opacity applied to dimmed nodes/edges while a node is being hovered. Picked
// to push background elements far enough out of focus that the highlighted
// neighborhood reads as a single cluster, without making them unreadable.
const DIM_NODE_OPACITY = 0.18;
const DIM_EDGE_OPACITY = 0.12;
const DIM_TRANSITION = 'opacity 0.15s ease-out';
function ServiceMap({ serviceMap }: any): JSX.Element {
const isDarkMode = useIsDarkMode();
const [hovered, setHovered] = useState<HoverState | null>(null);
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
const { nodes: rawNodes, links } = useMemo(
() => getGraphData(serviceMap, isDarkMode),
[serviceMap, isDarkMode],
);
const positions = useMemo(
() => computeNodePositions(rawNodes, links),
[rawNodes, links],
);
const initialNodes: Node<ServiceNodeData>[] = useMemo(
() =>
rawNodes.map((node) => {
const center = positions[node.id] ?? { x: 0, y: 0 };
return {
id: node.id,
type: 'service',
// `position` is the top-left of the node bounding box; centre the
// pill (plus its above-label) on the simulated coordinate.
position: {
x: center.x - NODE_WIDTH / 2,
y: center.y - NODE_OUTER_HEIGHT / 2,
},
data: { label: node.id, status: node.status },
draggable: true,
selectable: false,
};
}),
[rawNodes, positions],
);
// Dash march speed is scaled relative to the busiest edge in the current
// graph, so each render of the edge layer needs the per-graph max.
const maxCallRate = useMemo(
() => links.reduce((max, link) => Math.max(max, link.callRate ?? 0), 0),
[links],
);
// Edge stroke is driven by the target node's health, so build a quick
// lookup once per graph to avoid an O(n) scan per edge.
const nodeStatusById = useMemo(() => {
const map: Record<string, 'healthy' | 'error'> = {};
rawNodes.forEach((node) => {
map[node.id] = node.status;
});
return map;
}, [rawNodes]);
const initialEdges: Edge<FlowEdgeData>[] = useMemo(
() =>
links.map((link, i) => ({
id: `${link.source}->${link.target}-${i}`,
source: link.source,
target: link.target,
type: 'flow',
data: {
p99: link.p99,
callRate: link.callRate,
errorRate: link.errorRate,
maxCallRate,
},
style: {
...BASE_EDGE_STYLE,
stroke: getEdgeColor(nodeStatusById[link.target] ?? 'healthy'),
},
})),
[links, maxCallRate, nodeStatusById],
);
const [flowNodes, setFlowNodes, onNodesChange] =
useNodesState<Node<ServiceNodeData>>(initialNodes);
const [flowEdges, setFlowEdges, onEdgesChange] =
useEdgesState<Edge<FlowEdgeData>>(initialEdges);
// Reset internal node/edge state when the source graph changes (filters,
// time range, theme). User drag positions during a stable graph are kept.
useEffect(() => {
setFlowNodes(initialNodes);
}, [initialNodes, setFlowNodes]);
useEffect(() => {
setFlowEdges(initialEdges);
}, [initialEdges, setFlowEdges]);
// Undirected adjacency: when hovering a node we want the cluster of its
// callers AND callees lit, regardless of the edge's source/target order.
const adjacency = useMemo(() => {
const map: Record<string, Set<string>> = {};
links.forEach((link) => {
(map[link.source] ??= new Set()).add(link.target);
(map[link.target] ??= new Set()).add(link.source);
});
return map;
}, [links]);
const isNodeHighlighted = useCallback(
(id: string): boolean => {
if (!hoveredNodeId) {
return true;
}
if (id === hoveredNodeId) {
return true;
}
return adjacency[hoveredNodeId]?.has(id) ?? false;
},
[hoveredNodeId, adjacency],
);
const isEdgeHighlighted = useCallback(
(source: string, target: string): boolean => {
if (!hoveredNodeId) {
return true;
}
// Only edges directly touching the hovered node stay lit; edges
// between two of its neighbours are dimmed too.
return source === hoveredNodeId || target === hoveredNodeId;
},
[hoveredNodeId],
);
// Display lists wrap the live `flowNodes`/`flowEdges` with a per-element
// opacity derived from the hover state. Keeping this separate from the
// state setters means a hover doesn't perturb drag positions or the
// re-init useEffect above.
const displayNodes = useMemo(
() =>
flowNodes.map((node) => ({
...node,
style: {
...node.style,
opacity: isNodeHighlighted(node.id) ? 1 : DIM_NODE_OPACITY,
transition: DIM_TRANSITION,
},
})),
[flowNodes, isNodeHighlighted],
);
const displayEdges = useMemo(
() =>
flowEdges.map((edge) => ({
...edge,
style: {
...edge.style,
opacity: isEdgeHighlighted(edge.source, edge.target)
? 1
: DIM_EDGE_OPACITY,
transition: DIM_TRANSITION,
},
})),
[flowEdges, isEdgeHighlighted],
);
const handleNodeMouseEnter = useCallback(
(_event: React.MouseEvent, node: Node): void => {
setHoveredNodeId(node.id);
},
[],
);
const handleNodeMouseLeave = useCallback((): void => {
setHoveredNodeId(null);
}, []);
const handleEdgeMouseEnter = (event: React.MouseEvent, edge: Edge): void => {
setHovered({
tooltip: getLinkTooltip(edge.data as any),
x: event.clientX,
y: event.clientY,
});
};
const handleEdgeMouseMove = (event: React.MouseEvent, edge: Edge): void => {
setHovered((prev) =>
prev
? { ...prev, x: event.clientX, y: event.clientY }
: {
tooltip: getLinkTooltip(edge.data as any),
x: event.clientX,
y: event.clientY,
},
);
};
const handleEdgeMouseLeave = (): void => {
setHovered(null);
};
return (
<div className={styles.container}>
<ReactFlow
nodes={displayNodes}
edges={displayEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
fitView
minZoom={0.2}
maxZoom={4}
nodesDraggable
nodesConnectable={false}
elementsSelectable={false}
proOptions={{ hideAttribution: true }}
colorMode={isDarkMode ? 'dark' : 'light'}
onNodeMouseEnter={handleNodeMouseEnter}
onNodeMouseLeave={handleNodeMouseLeave}
onEdgeMouseEnter={handleEdgeMouseEnter}
onEdgeMouseMove={handleEdgeMouseMove}
onEdgeMouseLeave={handleEdgeMouseLeave}
>
<Background
bgColor={BG_COLOR}
variant={BackgroundVariant.Dots}
gap={24}
size={1}
/>
<Controls showInteractive={false} />
</ReactFlow>
{hovered && (
<LinkTooltip tooltip={hovered.tooltip} x={hovered.x} y={hovered.y} />
)}
</div>
);
}
export default memo(ServiceMap);

View File

@@ -0,0 +1,110 @@
.wrap {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.id {
// `max-width` is set inline from `NODE_WIDTH` so truncation stays in
// lockstep with the pill below.
margin-bottom: 6px;
padding-left: 4px;
font-family: 'Geist Mono', 'SF Mono', 'Roboto Mono', monospace;
font-size: 12px;
line-height: 16px;
color: var(--l1-foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
.pill {
display: flex;
align-items: center;
gap: 10px;
padding: 4px;
box-sizing: border-box;
border-radius: 12px;
border-width: 1px;
border-style: solid;
position: relative;
color: var(--l1-foreground);
// Frosted glass: the .healthy/.error backgrounds are intentionally
// translucent (color-mix with transparent), so this blurs whatever sits
// behind the pill — the dotted background grid and any edges that pass
// through the node bounds — for a softer fill.
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.healthy {
border-color: var(--success-background);
background: color-mix(in srgb, var(--success-background) 32%, transparent);
.iconBox {
background: var(--success-background);
color: var(--l1-background);
}
}
.error {
border-color: var(--danger-background);
background: color-mix(in srgb, var(--danger-background) 32%, transparent);
.iconBox {
background: var(--danger-background);
color: var(--l1-foreground);
}
}
.iconBox {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 48px;
height: 48px;
border-radius: 8px;
}
.body {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1;
min-width: 0;
gap: 2px;
padding-right: 4px;
}
.name {
font-family: Inter, sans-serif;
font-size: 14px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
}
.statusText {
font-family: Inter, sans-serif;
font-size: 12px;
font-weight: 400;
line-height: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
user-select: none;
opacity: 0.85;
}
.handle {
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
border: none;
}

View File

@@ -0,0 +1,59 @@
import { Handle, Node, NodeProps, Position } from '@xyflow/react';
import cx from 'classnames';
import { HardDrive } from '@signozhq/icons';
import { NODE_HEIGHT, NODE_WIDTH } from '../Map/Map.constants';
import styles from './ServiceNode.module.scss';
export type ServiceNodeStatus = 'healthy' | 'error';
export interface ServiceNodeData extends Record<string, unknown> {
label: string;
status: ServiceNodeStatus;
}
const ICON_SIZE = 24;
// Render a friendlier name inside the pill: split on hyphens / underscores /
// whitespace and title-case each word. The lowercase service id is preserved
// above the pill for unambiguous lookup.
function formatDisplayName(label: string): string {
const parts = label.split(/[-_\s]+/).filter(Boolean);
if (parts.length === 0) {
return label;
}
return parts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
.join(' ');
}
function ServiceNode({ data }: NodeProps<Node<ServiceNodeData>>): JSX.Element {
const { status, label } = data;
const displayName = formatDisplayName(label);
const statusLabel = status === 'error' ? 'Errors' : 'Healthy';
return (
<div className={styles.wrap}>
<div className={styles.id} title={label} style={{ maxWidth: NODE_WIDTH }}>
{label}
</div>
<div
data-testid="service-node-pill"
className={cx(styles.pill, styles[status])}
style={{ width: NODE_WIDTH, height: NODE_HEIGHT }}
>
<div className={styles.iconBox}>
<HardDrive size={ICON_SIZE} strokeWidth={1.75} aria-hidden />
</div>
<div className={styles.body}>
<div className={styles.name}>{displayName}</div>
<div className={styles.statusText}>{statusLabel}</div>
</div>
<Handle type="target" position={Position.Left} className={styles.handle} />
<Handle type="source" position={Position.Right} className={styles.handle} />
</div>
</div>
);
}
export default ServiceNode;

View File

@@ -0,0 +1,139 @@
import { Position } from '@xyflow/react';
import { render } from '@testing-library/react';
import FlowEdge, { FlowEdgeData } from '../FlowEdge/FlowEdge';
import { EDGE_DASH_PERIOD } from '../Map/Map.constants';
// Stub getBezierPath so assertions don't depend on the internal path geometry
// — we only care that FlowEdge wires its inputs through and animates the
// stroke-dashoffset at a relative speed.
jest.mock('@xyflow/react', () => {
const actual = jest.requireActual('@xyflow/react');
return {
...actual,
getBezierPath: ({
sourceX,
sourceY,
targetX,
targetY,
}: {
sourceX: number;
sourceY: number;
targetX: number;
targetY: number;
}): [string, number, number, number, number] => [
`M${sourceX},${sourceY} L${targetX},${targetY}`,
(sourceX + targetX) / 2,
(sourceY + targetY) / 2,
0,
0,
],
};
});
const baseEdgeProps = {
id: 'edge-1',
source: 'a',
target: 'b',
sourceX: 0,
sourceY: 0,
targetX: 100,
targetY: 0,
sourcePosition: Position.Right,
targetPosition: Position.Left,
style: { stroke: '#000' },
markerEnd: 'url(#arrow)',
} as const;
function renderEdge(data: FlowEdgeData | undefined): ReturnType<typeof render> {
return render(<FlowEdge {...(baseEdgeProps as any)} data={data} />);
}
const SAMPLE_DATA: FlowEdgeData = {
p99: 1000000,
callRate: 25,
errorRate: 0,
maxCallRate: 1000,
};
function getVisiblePath(container: HTMLElement): SVGPathElement {
const path = container.querySelector<SVGPathElement>(
'path.react-flow__edge-path',
);
if (!path) {
throw new Error('expected to find react-flow__edge-path path');
}
return path;
}
describe('FlowEdge', () => {
it('forwards id, path, style, and markerEnd to the visible edge path', () => {
const { container } = renderEdge(SAMPLE_DATA);
const visible = getVisiblePath(container);
expect(visible).toHaveAttribute('id', 'edge-1');
// Visible path uses the forward bezier (source -> target).
expect(visible).toHaveAttribute('d', 'M0,0 L100,0');
expect(visible).toHaveAttribute('marker-end', 'url(#arrow)');
expect(visible).toHaveStyle({ stroke: '#000' });
});
it('renders a transparent wider interaction path so hover is robust', () => {
// Without this, react-flow's default hover wouldn't be triggered and
// the link tooltip would only appear when the cursor lands on a 1.25px
// painted dash segment.
const { container } = renderEdge(SAMPLE_DATA);
const interaction = container.querySelector<SVGPathElement>(
'path.react-flow__edge-interaction',
);
expect(interaction).not.toBeNull();
expect(interaction).toHaveAttribute('d', 'M0,0 L100,0');
expect(interaction).toHaveAttribute('stroke-opacity', '0');
});
it('omits the dash animation when callRate is zero', () => {
const { container } = renderEdge({ ...SAMPLE_DATA, callRate: 0 });
expect(container.querySelectorAll('animate')).toHaveLength(0);
});
it('omits the dash animation when data is missing', () => {
const { container } = renderEdge(undefined);
expect(container.querySelectorAll('animate')).toHaveLength(0);
});
it('animates stroke-dashoffset by exactly one dash period so the loop is seamless', () => {
const { container } = renderEdge(SAMPLE_DATA);
const animate = container.querySelector('animate');
expect(animate).not.toBeNull();
expect(animate).toHaveAttribute('attributeName', 'stroke-dashoffset');
expect(animate).toHaveAttribute('from', '0');
expect(animate).toHaveAttribute('to', String(EDGE_DASH_PERIOD));
expect(animate).toHaveAttribute('repeatCount', 'indefinite');
});
it('sets a faster dash duration for the busiest edge than for a quieter one', () => {
// Relative scaling: same maxCallRate, higher callRate -> shorter period.
const { container: busy } = renderEdge({
...SAMPLE_DATA,
callRate: 1000,
maxCallRate: 1000,
});
const { container: quiet } = renderEdge({
...SAMPLE_DATA,
callRate: 1,
maxCallRate: 1000,
});
const parseDur = (root: HTMLElement): number => {
const animate = root.querySelector('animate');
const dur = animate?.getAttribute('dur') ?? '';
return parseFloat(dur);
};
expect(parseDur(busy)).toBeLessThan(parseDur(quiet));
});
});

View File

@@ -0,0 +1,58 @@
import { render, screen } from '@testing-library/react';
import LinkTooltip, { LinkTooltipData } from '../LinkTooltip/LinkTooltip';
const baseTooltip: LinkTooltipData = {
p99: 12.34,
callRate: 5.6,
errorRate: 0.1,
};
describe('LinkTooltip', () => {
it('renders p99, request, and error rate rows with their suffixes', () => {
render(<LinkTooltip tooltip={baseTooltip} x={0} y={0} />);
expect(screen.getByText('P99 latency:')).toBeInTheDocument();
expect(screen.getByText('12.34ms')).toBeInTheDocument();
expect(screen.getByText('Request:')).toBeInTheDocument();
expect(screen.getByText('5.6/sec')).toBeInTheDocument();
expect(screen.getByText('Error Rate:')).toBeInTheDocument();
expect(screen.getByText('0.1%')).toBeInTheDocument();
});
it('renders string-typed metric values verbatim', () => {
render(
<LinkTooltip
tooltip={{ p99: '0', callRate: '0', errorRate: '0' }}
x={0}
y={0}
/>,
);
expect(screen.getByText('0ms')).toBeInTheDocument();
expect(screen.getByText('0/sec')).toBeInTheDocument();
expect(screen.getByText('0%')).toBeInTheDocument();
});
it('positions itself offset from the cursor coordinates', () => {
const { container } = render(
<LinkTooltip tooltip={baseTooltip} x={100} y={200} />,
);
// POINTER_OFFSET is 12 in the component; the tooltip should sit at
// (x + 12, y + 12) so it does not occlude the hovered edge segment.
const tooltip = container.firstChild as HTMLElement;
expect(tooltip).toHaveStyle({ top: '212px', left: '112px' });
});
it('handles negative coordinates without breaking the offset math', () => {
const { container } = render(
<LinkTooltip tooltip={baseTooltip} x={-50} y={-30} />,
);
const tooltip = container.firstChild as HTMLElement;
expect(tooltip).toHaveStyle({ top: '-18px', left: '-38px' });
});
});

View File

@@ -0,0 +1,70 @@
import {
DASH_FAST_SECS,
DASH_SLOW_SECS,
getDashAnimation,
} from '../Map/Map.constants';
describe('getDashAnimation', () => {
it('returns duration: 0 for non-positive call rates so the dash march is skipped', () => {
expect(getDashAnimation(0, 1000)).toStrictEqual({ duration: 0 });
expect(getDashAnimation(-5, 1000)).toStrictEqual({ duration: 0 });
});
it('saturates at DASH_FAST_SECS when callRate equals max', () => {
// Whatever the absolute scale, the busiest edge should peg the
// visualisation — that's the point of the relative scaling.
const EPS = 1e-9;
[5, 50, 500, 5_000, 1_000_000].forEach((rate) => {
const { duration } = getDashAnimation(rate, rate);
expect(duration).toBeGreaterThanOrEqual(DASH_FAST_SECS - EPS);
expect(duration).toBeLessThanOrEqual(DASH_FAST_SECS + EPS);
});
});
it('clamps to DASH_FAST_SECS even if max is stale or zero', () => {
// Defensive: if max somehow lags behind callRate, factor still clamps to 1.
const EPS = 1e-9;
expect(getDashAnimation(1000, 0).duration).toBeLessThanOrEqual(
DASH_FAST_SECS + EPS,
);
expect(getDashAnimation(1000, 100).duration).toBeLessThanOrEqual(
DASH_FAST_SECS + EPS,
);
});
it('produces different durations for the same callRate at different scales', () => {
// 50 req/sec is "busy" in a 50-max graph but "trickle" in a 5k-max graph.
const busy = getDashAnimation(50, 50);
const trickle = getDashAnimation(50, 5000);
expect(busy.duration).toBeLessThan(trickle.duration);
});
it('monotonically decreases per-period duration as rate climbs toward max', () => {
const max = 5000;
const rates = [0.5, 5, 50, 500, max];
const durations = rates.map((r) => getDashAnimation(r, max).duration);
for (let i = 1; i < durations.length; i += 1) {
expect(durations[i]).toBeLessThanOrEqual(durations[i - 1]);
}
});
it('keeps positive-rate duration bounded between DASH_FAST_SECS and DASH_SLOW_SECS', () => {
// At saturation the formula computes to DASH_FAST_SECS up to floating
// point error (~1e-16), so allow a small epsilon.
const EPS = 1e-9;
const cases: Array<[number, number]> = [
[0.01, 1000],
[1, 1000],
[10, 1000],
[100, 1000],
[1000, 1000],
[1000, 0], // defensive max
[1_000_000, 1_000_000],
];
cases.forEach(([rate, max]) => {
const { duration } = getDashAnimation(rate, max);
expect(duration).toBeGreaterThanOrEqual(DASH_FAST_SECS - EPS);
expect(duration).toBeLessThanOrEqual(DASH_SLOW_SECS + EPS);
});
});
});

View File

@@ -0,0 +1,103 @@
import { render, screen } from '@testing-library/react';
import ServiceNode, { ServiceNodeData } from '../ServiceNode/ServiceNode';
import { NODE_HEIGHT, NODE_WIDTH } from '../Map/Map.constants';
// `Handle` requires a ReactFlowProvider to mount. We don't exercise its
// connection logic from this component, so a stub keeps the test isolated to
// ServiceNode's own rendering responsibilities.
jest.mock('@xyflow/react', () => {
const actual = jest.requireActual('@xyflow/react');
return {
...actual,
Handle: ({
type,
position,
}: {
type: string;
position: string;
}): JSX.Element => (
<div data-testid={`handle-${type}`} data-position={position} />
),
};
});
const baseNodeProps = {
id: 'frontend',
type: 'service',
dragging: false,
isConnectable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
zIndex: 0,
selectable: false,
deletable: false,
draggable: true,
selected: false,
} as const;
function renderNode(data: ServiceNodeData): ReturnType<typeof render> {
return render(<ServiceNode {...(baseNodeProps as any)} data={data} />);
}
describe('ServiceNode', () => {
it('renders the raw service id above the pill so users can map it back to backend names', () => {
// The above-pill text stays verbatim (no case change, no separator
// rewrite) because that is the canonical service identifier.
renderNode({ label: 'checkout-service', status: 'healthy' });
const id = screen.getByText('checkout-service');
expect(id).toBeInTheDocument();
expect(id).toHaveAttribute('title', 'checkout-service');
});
it('renders a title-cased display name inside the pill, splitting on hyphens and underscores', () => {
renderNode({ label: 'checkout-service', status: 'healthy' });
expect(screen.getByText('Checkout Service')).toBeInTheDocument();
});
it('renders the raw label inside the pill when there are no separators to split on', () => {
// Single token gets a leading capital but isn't otherwise transformed —
// we don't try to split unknown camel/compound boundaries.
renderNode({ label: 'redis', status: 'healthy' });
expect(screen.getByText('Redis')).toBeInTheDocument();
});
it('shows "Healthy" inside the pill when status is healthy', () => {
renderNode({ label: 'frontend', status: 'healthy' });
expect(screen.getByText('Healthy')).toBeInTheDocument();
});
it('shows "Errors" inside the pill when status is error', () => {
// The non-healthy state visually flips the icon-box and body tints to
// the danger color via the .error class — assert via the status text
// since CSS module classnames are hashed and brittle to match on.
renderNode({ label: 'frontend', status: 'error' });
expect(screen.getByText('Errors')).toBeInTheDocument();
});
it('sizes the pill to NODE_WIDTH x NODE_HEIGHT', () => {
// All pills render at the same configured dimensions — there is no
// per-node sizing, so layout in dagre stays predictable.
renderNode({ label: 'frontend', status: 'healthy' });
expect(screen.getByTestId('service-node-pill')).toHaveStyle({
width: `${NODE_WIDTH}px`,
height: `${NODE_HEIGHT}px`,
});
});
it('renders a target handle on the left and a source handle on the right', () => {
renderNode({ label: 'frontend', status: 'healthy' });
const target = screen.getByTestId('handle-target');
const source = screen.getByTestId('handle-source');
expect(target).toHaveAttribute('data-position', 'left');
expect(source).toHaveAttribute('data-position', 'right');
});
});

View File

@@ -1,5 +1,6 @@
//@ts-nocheck
import dagre from '@dagrejs/dagre';
import {
cloneDeep,
find,
@@ -12,27 +13,7 @@ import {
import { graphDataType } from './ServiceMap';
const MIN_WIDTH = 10;
const MAX_WIDTH = 20;
const DEFAULT_FONT_SIZE = 6;
export const getDimensions = (
num: number,
highest: number,
): {
fontSize: number;
width: number;
} => {
const percentage = (num / highest) * 100;
const width = (percentage * (MAX_WIDTH - MIN_WIDTH)) / 100 + MIN_WIDTH;
const fontSize = DEFAULT_FONT_SIZE;
return {
fontSize,
width,
};
};
export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
export const getGraphData = (serviceMap, _isDarkMode): graphDataType => {
const { items } = serviceMap;
const services = Object.values(groupBy(items, 'child')).map((e) => {
return {
@@ -42,7 +23,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
};
});
const highestCallCount = maxBy(items, (e) => e?.callCount)?.callCount;
const highestCallRate = maxBy(services, (e) => e?.callRate)?.callRate;
const divNum = Number(
String(1).padEnd(highestCallCount.toString().length, '0'),
@@ -62,31 +42,13 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
const uniqParent = uniqBy(cloneDeep(items), 'parent').map((e) => e.parent);
const uniqChild = uniqBy(cloneDeep(items), 'child').map((e) => e.child);
const uniqNodes = uniq([...uniqParent, ...uniqChild]);
const nodes = uniqNodes.map((node, i) => {
const nodes = uniqNodes.map((node) => {
const service = find(services, (service) => service.serviceName === node);
let color = isDarkMode ? '#7CA568' : '#D5F2BB';
if (!service) {
return {
id: node,
group: i + 1,
fontSize: DEFAULT_FONT_SIZE,
width: MIN_WIDTH,
color,
nodeVal: MIN_WIDTH,
name: node,
};
}
if (service.errorRate > 0) {
color = isDarkMode ? '#DB836E' : '#F98989';
}
const { fontSize, width } = getDimensions(service.callRate, highestCallRate);
const status: 'healthy' | 'error' =
service && service.errorRate > 0 ? 'error' : 'healthy';
return {
id: node,
group: i + 1,
fontSize,
width,
color,
nodeVal: width,
status,
name: node,
};
});
@@ -96,20 +58,6 @@ export const getGraphData = (serviceMap, isDarkMode): graphDataType => {
};
};
export const getZoomPx = (): number => {
const { width } = window.screen;
if (width < 1400) {
return 190;
}
if (width > 1400 && width < 1700) {
return 380;
}
if (width > 1700) {
return 470;
}
return 190;
};
const getRound2DigitsAfterDecimal = (num: number): number => {
if (num === 0) {
return 0;
@@ -117,27 +65,30 @@ const getRound2DigitsAfterDecimal = (num: number): number => {
return num.toFixed(20).match(/^-?\d*\.?0*\d{0,2}/)[0];
};
export const getTooltip = (link: {
export interface LinkTooltip {
p99: string | number;
callRate: string | number;
errorRate: string | number;
}
export const getLinkTooltip = (link: {
p99: number;
errorRate: number;
callRate: number;
id: string;
}): string => {
return `<div style="color:#333333;padding:12px;background: white;border-radius: 2px;">
<div class="keyval">
<div class="key">P99 latency:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.p99 / 1000000)}ms</div>
</div>
<div class="keyval">
<div class="key">Request:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.callRate)}/sec</div>
</div>
<div class="keyval">
<div class="key">Error Rate:</div>
<div class="val">${getRound2DigitsAfterDecimal(link.errorRate)}%</div>
</div>
</div>`;
};
}): LinkTooltip => ({
p99: getRound2DigitsAfterDecimal(link.p99 / 1000000),
callRate: getRound2DigitsAfterDecimal(link.callRate),
errorRate: getRound2DigitsAfterDecimal(link.errorRate),
});
// Edges inherit the target node's health: red when the callee has errors,
// green otherwise. Mixed with transparent so the dashed strokes read as a
// softer accent against the busy node fills, while still letting a glance
// at the map surface which downstream services are unhappy.
export const getEdgeColor = (targetStatus: 'healthy' | 'error'): string =>
targetStatus === 'error'
? 'color-mix(in srgb, var(--danger-background) 65%, transparent)'
: 'color-mix(in srgb, var(--success-background) 65%, transparent)';
export const transformLabel = (label: string, zoomLevel: number): string => {
//? 13 is the minimum label length. Scaling factor of 0.9 which is slightly less than 1
@@ -150,3 +101,51 @@ export const transformLabel = (label: string, zoomLevel: number): string => {
}
return label;
};
// Layered DAG layout via dagre. For service maps the data flows
// caller -> callee, so a left-to-right rank direction reads naturally and
// minimises edge crossings vs. a force-directed simulation.
//
// `nodeBoxWidth`/`nodeBoxHeight` reserve the pill's bounding box plus the
// monospace service-id rendered above it, with a little breathing room so
// adjacent ranks don't overlap.
export const computeNodePositions = (
nodes: { id: string }[],
links: { source: string; target: string }[],
nodeBoxWidth = 220,
nodeBoxHeight = 110,
): Record<string, { x: number; y: number }> => {
const result: Record<string, { x: number; y: number }> = {};
if (nodes.length === 0) {
return result;
}
const g = new dagre.graphlib.Graph({ multigraph: true, compound: false });
g.setGraph({
rankdir: 'LR',
nodesep: 40,
ranksep: 90,
marginx: 40,
marginy: 40,
});
g.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node) => {
g.setNode(node.id, { width: nodeBoxWidth, height: nodeBoxHeight });
});
links.forEach((link, i) => {
// `name` makes parallel edges (same source+target, different metrics)
// safe under multigraph mode.
g.setEdge(link.source, link.target, {}, `${link.source}-${link.target}-${i}`);
});
dagre.layout(g);
nodes.forEach((node) => {
const laidOut = g.node(node.id);
if (laidOut) {
result[node.id] = { x: laidOut.x, y: laidOut.y };
}
});
return result;
};

View File

@@ -2953,6 +2953,18 @@
resolved "https://registry.yarnpkg.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz#b6c75a56a1947cc916ea058772d666a2c8932f31"
integrity sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==
"@dagrejs/dagre@3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@dagrejs/dagre/-/dagre-3.0.0.tgz#543f20188f7494db0f45d634f7b3760747f87f23"
integrity sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==
dependencies:
"@dagrejs/graphlib" "4.0.1"
"@dagrejs/graphlib@4.0.1":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@dagrejs/graphlib/-/graphlib-4.0.1.tgz#a9cf907cc5ddf9140a64360ad487766f17d1ee36"
integrity sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==
"@date-fns/tz@^1.4.1":
version "1.4.1"
resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4"
@@ -6002,11 +6014,6 @@
resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz"
integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==
"@tweenjs/tween.js@18 - 25":
version "25.0.0"
resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9"
integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==
"@tybys/wasm-util@^0.10.0", "@tybys/wasm-util@^0.10.1":
version "0.10.1"
resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414"
@@ -6124,6 +6131,13 @@
resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.1.tgz#006b7bd838baec1511270cb900bf4fc377bbbf41"
integrity sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==
"@types/d3-drag@^3.0.7":
version "3.0.7"
resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02"
integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==
dependencies:
"@types/d3-selection" "*"
"@types/d3-format@3.0.1":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.1.tgz#194f1317a499edd7e58766f96735bdc0216bb89d"
@@ -6141,6 +6155,13 @@
resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-1.1.11.tgz#c3bd70d025621f73cb3319e97e08ae4c9051c791"
integrity sha512-lnQiU7jV+Gyk9oQYk0GGYccuexmQPTp08E0+4BidgFdiJivjEvf+esPSdZqCZ2C7UwTWejWpqetVaU8A+eX3FA==
"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.4":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c"
integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==
dependencies:
"@types/d3-color" "*"
"@types/d3-interpolate@3.0.1", "@types/d3-interpolate@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.1.tgz#e7d17fa4a5830ad56fe22ce3b4fac8541a9572dc"
@@ -6160,6 +6181,11 @@
dependencies:
"@types/d3-time" "*"
"@types/d3-selection@*", "@types/d3-selection@^3.0.10":
version "3.0.11"
resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3"
integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==
"@types/d3-shape@^1.3.1":
version "1.3.12"
resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-1.3.12.tgz#8f2f9f7a12e631ce6700d6d55b84795ce2c8b259"
@@ -6182,6 +6208,21 @@
resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.0.tgz#e1ac0f3e9e195135361fa1a1d62f795d87e6e819"
integrity sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==
"@types/d3-transition@^3.0.8":
version "3.0.9"
resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706"
integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==
dependencies:
"@types/d3-selection" "*"
"@types/d3-zoom@^3.0.8":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b"
integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==
dependencies:
"@types/d3-interpolate" "*"
"@types/d3-selection" "*"
"@types/debug@^4.0.0":
version "4.1.8"
resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.8.tgz#cef723a5d0a90990313faec2d1e22aee5eecb317"
@@ -7072,6 +7113,30 @@
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz"
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
"@xyflow/react@12.10.2":
version "12.10.2"
resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.10.2.tgz#40f6d71944f674f0ffbb83c660f9473018adbe61"
integrity sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==
dependencies:
"@xyflow/system" "0.0.76"
classcat "^5.0.3"
zustand "^4.4.0"
"@xyflow/system@0.0.76":
version "0.0.76"
resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.76.tgz#57da5e4d230cdbec56548a6d5eec115f22858259"
integrity sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==
dependencies:
"@types/d3-drag" "^3.0.7"
"@types/d3-interpolate" "^3.0.4"
"@types/d3-selection" "^3.0.10"
"@types/d3-transition" "^3.0.8"
"@types/d3-zoom" "^3.0.8"
d3-drag "^3.0.0"
d3-interpolate "^3.0.1"
d3-selection "^3.0.0"
d3-zoom "^3.0.0"
"@zxing/text-encoding@0.9.0":
version "0.9.0"
resolved "https://registry.yarnpkg.com/@zxing/text-encoding/-/text-encoding-0.9.0.tgz#fb50ffabc6c7c66a0c96b4c03e3d9be74864b70b"
@@ -7097,11 +7162,6 @@ abort-controller@^3.0.0:
dependencies:
event-target-shim "^5.0.0"
accessor-fn@1:
version "1.4.1"
resolved "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.4.1.tgz"
integrity sha512-P7yNKfmpuWLUwiRVk9RkRIPGjngemjZ7yANc0DL7otgDqEIWkEByMhShzfgQ5ZwCPEUmba4v1kOqCdGhpzY3ew==
acorn-globals@^7.0.0:
version "7.0.1"
resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-7.0.1.tgz#0dbf05c44fa7c94332914c02066d5beff62c40c3"
@@ -7999,11 +8059,6 @@ better-xlsx@^0.7.5:
jszip "^3.2.2"
kind-of "^6.0.3"
"bezier-js@3 - 6":
version "6.1.3"
resolved "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.3.tgz"
integrity sha512-VPFvkyO98oCJ1Tsi+bFBrKEWLdefAj4DJVaWp3xTEsdCbunC7Pt/nTeIgu/UdskBNcmHv8TOfsgdMZb1GsICmg==
big-integer@^1.6.16:
version "1.6.51"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
@@ -8273,13 +8328,6 @@ caniuse-lite@^1.0.30001759:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz#0279c498e862efb067938bba0a0aabafe8d0b730"
integrity sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==
canvas-color-tracker@^1.3:
version "1.3.2"
resolved "https://registry.yarnpkg.com/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz#b924cf94b33441b82692938fca5b936be971a46d"
integrity sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==
dependencies:
tinycolor2 "^1.6.0"
ccount@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5"
@@ -8421,6 +8469,11 @@ class-variance-authority@^0.7.0:
dependencies:
clsx "^2.1.1"
classcat@^5.0.3:
version "5.0.5"
resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77"
integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==
classnames@2.3.2, classnames@2.x, classnames@^2.2.1, classnames@^2.2.3, classnames@^2.2.5, classnames@^2.2.6, classnames@^2.3.1, classnames@^2.3.2:
version "2.3.2"
resolved "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz"
@@ -9055,7 +9108,7 @@ csstype@^3.1.2:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
"d3-array@1 - 3", "d3-array@2 - 3", "d3-array@2.10.0 - 3":
"d3-array@2 - 3", "d3-array@2.10.0 - 3":
version "3.2.3"
resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.3.tgz"
integrity sha512-JRHwbQQ84XuAESWhvIPaUV4/1UYTBOLiOPGWqgFDHZS1D5QN9c57FbH3QpEnQMYiOXNzKUQyGTZf+EVO7RT5TQ==
@@ -9081,11 +9134,6 @@ d3-array@^1.2.0:
resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f"
integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw==
d3-binarytree@1:
version "1.0.2"
resolved "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz"
integrity sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==
"d3-color@1 - 3", d3-color@3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz"
@@ -9103,7 +9151,7 @@ d3-delaunay@6.0.2:
resolved "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz"
integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==
"d3-drag@2 - 3":
"d3-drag@2 - 3", d3-drag@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz"
integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==
@@ -9116,17 +9164,6 @@ d3-delaunay@6.0.2:
resolved "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz"
integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==
"d3-force-3d@2 - 3":
version "3.0.5"
resolved "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.5.tgz"
integrity sha512-tdwhAhoTYZY/a6eo9nR7HP3xSW/C6XvJTbeRpR92nlPzH6OiE+4MliN9feuSFd0tPtEUo+191qOhCTWx3NYifg==
dependencies:
d3-binarytree "1"
d3-dispatch "1 - 3"
d3-octree "1"
d3-quadtree "1 - 3"
d3-timer "1 - 3"
"d3-format@1 - 3", d3-format@3.1.0:
version "3.1.0"
resolved "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz"
@@ -9149,18 +9186,13 @@ d3-hierarchy@^1.1.4:
resolved "https://registry.yarnpkg.com/d3-hierarchy/-/d3-hierarchy-1.1.9.tgz#2f6bee24caaea43f8dc37545fa01628559647a83"
integrity sha512-j8tPxlqh1srJHAtxfvOUwKNYJkQuBFdM1+JAUfq6xqH5eAqf93L7oG1NVqDa4CpFZNvnNKtCYEUC8KY9yEn9lQ==
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1:
"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@3.0.1, d3-interpolate@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d"
integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==
dependencies:
d3-color "1 - 3"
d3-octree@1:
version "1.0.2"
resolved "https://registry.npmjs.org/d3-octree/-/d3-octree-1.0.2.tgz"
integrity sha512-Qxg4oirJrNXauiuC94uKMbgxwnhdda9xRLl9ihq45srlJ4Ga3CSgqGcAL8iW7N5CIv4Oz8x3E734ulxyvHPvwA==
d3-path@1, d3-path@^1.0.5:
version "1.0.9"
resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-1.0.9.tgz#48c050bb1fe8c262493a8caf5524e3e9591701cf"
@@ -9171,20 +9203,7 @@ d3-polygon@^1.0.3:
resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-1.0.6.tgz#0bf8cb8180a6dc107f518ddf7975e12abbfbd38e"
integrity sha512-k+RF7WvI08PC8reEoXa/w2nSg5AUMTi+peBD9cmFc+0ixHfbs4QmxxkarVal1IkVkgxVuk9JSHhJURHiyHKAuQ==
"d3-quadtree@1 - 3":
version "3.0.1"
resolved "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz"
integrity sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==
"d3-scale-chromatic@1 - 3":
version "3.0.0"
resolved "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.0.0.tgz"
integrity sha512-Lx9thtxAKrO2Pq6OO2Ua474opeziKr279P/TKZsMAhYyNDD3EnCffdbgeSYN5O7m2ByQsxtuP2CSDczNUIZ22g==
dependencies:
d3-color "1 - 3"
d3-interpolate "1 - 3"
"d3-scale@1 - 4", d3-scale@4.0.2:
d3-scale@4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz"
integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==
@@ -9195,9 +9214,9 @@ d3-polygon@^1.0.3:
d3-time "2.1.1 - 3"
d3-time-format "2 - 4"
"d3-selection@2 - 3", d3-selection@3:
"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz"
resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31"
integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==
d3-shape@^1.0.6, d3-shape@^1.2.0:
@@ -9237,9 +9256,9 @@ d3-shape@^1.0.6, d3-shape@^1.2.0:
d3-interpolate "1 - 3"
d3-timer "1 - 3"
"d3-zoom@2 - 3":
d3-zoom@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz"
resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3"
integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==
dependencies:
d3-dispatch "1 - 3"
@@ -10460,15 +10479,6 @@ flatted@^3.4.2:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
float-tooltip@^1.7:
version "1.7.5"
resolved "https://registry.yarnpkg.com/float-tooltip/-/float-tooltip-1.7.5.tgz#7083bf78f0de5a97f9c2d6aa8e90d2139f34047f"
integrity sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==
dependencies:
d3-selection "2 - 3"
kapsule "^1.16"
preact "10"
flubber@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/flubber/-/flubber-0.4.2.tgz#14452d4a838cc3b9f2fb6175da94e35acd55fbaa"
@@ -10505,27 +10515,6 @@ for-each@^0.3.5:
dependencies:
is-callable "^1.2.7"
force-graph@^1.51:
version "1.51.1"
resolved "https://registry.yarnpkg.com/force-graph/-/force-graph-1.51.1.tgz#c967249bf6ad2cb4a3ba89ed4c6d79895bd70fe1"
integrity sha512-uEEX8iRzgq1IKRISOw6RrB2RLMhcI25xznQYrCTVvxZHZZ+A2jH6qIolYuwavVxAMi64pFp2yZm4KFVdD993cg==
dependencies:
"@tweenjs/tween.js" "18 - 25"
accessor-fn "1"
bezier-js "3 - 6"
canvas-color-tracker "^1.3"
d3-array "1 - 3"
d3-drag "2 - 3"
d3-force-3d "2 - 3"
d3-scale "1 - 4"
d3-scale-chromatic "1 - 3"
d3-selection "2 - 3"
d3-zoom "2 - 3"
float-tooltip "^1.7"
index-array-by "1"
kapsule "^1.16"
lodash-es "4"
foreground-child@^3.1.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
@@ -11673,11 +11662,6 @@ indent-string@^4.0.0:
resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz"
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
index-array-by@1:
version "1.4.1"
resolved "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.1.tgz"
integrity sha512-Zu6THdrxQdyTuT2uA5FjUoBEsFHPzHcPIj18FszN6yXKHxSfGcR4TPLabfuT//E25q1Igyx9xta2WMvD/x9P/g==
inflected@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/inflected/-/inflected-2.1.0.tgz#2816ac17a570bbbc8303ca05bca8bf9b3f959687"
@@ -12383,11 +12367,6 @@ jake@^10.8.5:
filelist "^1.0.4"
picocolors "^1.1.1"
jerrypick@^1.1.1:
version "1.1.1"
resolved "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.1.tgz"
integrity sha512-XTtedPYEyVp4t6hJrXuRKr/jHj8SC4z+4K0b396PMkov6muL+i8IIamJIvZWe3jUspgIJak0P+BaWKawMYNBLg==
jest-changed-files@30.2.0:
version "30.2.0"
resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-30.2.0.tgz#602266e478ed554e1e1469944faa7efd37cee61c"
@@ -13103,13 +13082,6 @@ junk@^3.1.0:
resolved "https://registry.yarnpkg.com/junk/-/junk-3.1.0.tgz#31499098d902b7e98c5d9b9c80f43457a88abfa1"
integrity sha512-pBxcB3LFc8QVgdggvZWyeys+hnrNWg4OcZIU/1X59k5jQdLBlCsYGRQaz234SqoRLTCgMH00fY0xRJH+F9METQ==
kapsule@^1.16:
version "1.16.3"
resolved "https://registry.yarnpkg.com/kapsule/-/kapsule-1.16.3.tgz#5684ed89838b6658b30d0f2cc056dffc3ba68c30"
integrity sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==
dependencies:
lodash-es "4"
keyv@^4.0.0:
version "4.5.4"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"
@@ -13334,7 +13306,7 @@ locate-path@^7.1.0:
dependencies:
p-locate "^6.0.0"
lodash-es@4, lodash-es@^4.17.21:
lodash-es@^4.17.21:
version "4.17.21"
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
@@ -15645,11 +15617,6 @@ powershell-utils@^0.1.0:
resolved "https://registry.yarnpkg.com/powershell-utils/-/powershell-utils-0.1.0.tgz#5a42c9a824fb4f2f251ccb41aaae73314f5d6ac2"
integrity sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==
preact@10:
version "10.28.4"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.28.4.tgz#8ffab01c5c0590535bdaecdd548801f44c6e483a"
integrity sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==
preact@^10.19.3:
version "10.22.0"
resolved "https://registry.yarnpkg.com/preact/-/preact-10.22.0.tgz#a50f38006ae438d255e2631cbdaf7488e6dd4e16"
@@ -15711,7 +15678,7 @@ progress@^2.0.3:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
prop-types@15, prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
prop-types@15.8.1, prop-types@15.x, prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.8, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
@@ -16335,15 +16302,6 @@ react-fast-compare@^3.2.0:
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49"
integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==
react-force-graph-2d@^1.29.1:
version "1.29.1"
resolved "https://registry.yarnpkg.com/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz#a0784d4387b12b28e2b552058ec09d092b4e8cda"
integrity sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==
dependencies:
force-graph "^1.51"
prop-types "15"
react-kapsule "^2.5"
react-full-screen@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/react-full-screen/-/react-full-screen-1.1.1.tgz#b707d56891015a71c503a65dbab3086d75be97d7"
@@ -16413,13 +16371,6 @@ react-is@^18.3.1:
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
react-kapsule@^2.5:
version "2.5.7"
resolved "https://registry.yarnpkg.com/react-kapsule/-/react-kapsule-2.5.7.tgz#dcd957ae8e897ff48055fc8ff48ed04ebe3c5bd2"
integrity sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==
dependencies:
jerrypick "^1.1.1"
react-lottie@1.2.10:
version "1.2.10"
resolved "https://registry.yarnpkg.com/react-lottie/-/react-lottie-1.2.10.tgz#399f78a448a7833b2380d74fc489ecf15f8d18c7"
@@ -18489,7 +18440,7 @@ tiny-warning@^1.0.0:
resolved "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"
integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==
tinycolor2@1.6.0, tinycolor2@^1.6.0:
tinycolor2@1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e"
integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==
@@ -19194,7 +19145,7 @@ use-sidecar@^1.1.3:
detect-node-es "^1.1.0"
tslib "^2.0.0"
use-sync-external-store@1.6.0:
use-sync-external-store@1.6.0, use-sync-external-store@^1.2.2:
version "1.6.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d"
integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==
@@ -19837,6 +19788,13 @@ zustand@5.0.11:
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"
integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==
zustand@^4.4.0:
version "4.5.7"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55"
integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==
dependencies:
use-sync-external-store "^1.2.2"
zwitch@^2.0.0, zwitch@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7"