Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
aos
GitHub Repository: aos/grafana-agent
Path: blob/main/web/ui/src/features/graph/ComponentGraph.tsx
5285 views
1
import { FC, useEffect, useRef } from 'react';
2
import { useHref } from 'react-router-dom';
3
import * as d3 from 'd3';
4
import { coordSimplex, dagStratify, decrossTwoLayer, layeringCoffmanGraham, NodeSizeAccessor, sugiyama } from 'd3-dag';
5
import { Point } from 'd3-dag/dist/dag';
6
import { IdOperator, ParentIdsOperator } from 'd3-dag/dist/dag/create';
7
import * as d3Zoom from 'd3-zoom';
8
9
import { ComponentHealthState, ComponentInfo } from '../component/types';
10
11
let canvas: HTMLCanvasElement | undefined;
12
13
/**
14
* calcTextWidth calculates the width of text if it were to be rendered on
15
* screen.
16
*
17
* font should be a font specifier like "bold 16pt arial"
18
*/
19
function calcTextWidth(text: string, font: string): number | null {
20
// Adapted from https://stackoverflow.com/a/21015393
21
22
// Lazy-load the canvas if it hasn't been created yet.
23
if (canvas === undefined) {
24
canvas = document.createElement('canvas');
25
}
26
27
const context = canvas.getContext('2d');
28
if (context == null) {
29
return null;
30
}
31
context.font = font;
32
return context.measureText(text).width;
33
}
34
35
/**
36
* intersectsBox reports whether a point intersects a box.
37
*/
38
function intersectsBox(point: Point, box: Box): boolean {
39
return (
40
point.x >= box.x && // after starting X
41
point.y >= box.y && // after starting Y
42
point.x <= box.x + box.w && // before ending X
43
point.y <= box.y + box.h // before ending Y
44
);
45
}
46
47
interface Line {
48
start: Point;
49
end: Point;
50
}
51
52
/*
53
* boxIntersectionPoint returns the point where line intersects box.
54
*/
55
function boxIntersectionPoint(line: Line, box: Box): Point {
56
const boxTop: Line = { start: { x: box.x, y: box.y }, end: { x: box.x + box.w, y: box.y } };
57
const topIntersectionPoint = lineIntersectionPoint(line, boxTop);
58
if (topIntersectionPoint !== undefined) {
59
return topIntersectionPoint;
60
}
61
62
const boxRight: Line = { start: { x: box.x + box.w, y: box.y }, end: { x: box.x + box.w, y: box.y + box.h } };
63
const rightIntersectionPoint = lineIntersectionPoint(line, boxRight);
64
if (rightIntersectionPoint !== undefined) {
65
return rightIntersectionPoint;
66
}
67
68
const boxBottom: Line = { start: { x: box.x, y: box.y + box.h }, end: { x: box.x + box.w, y: box.y + box.h } };
69
const bottomIntersectionPoint = lineIntersectionPoint(line, boxBottom);
70
if (bottomIntersectionPoint !== undefined) {
71
return bottomIntersectionPoint;
72
}
73
74
const boxLeft: Line = { start: { x: box.x, y: box.y }, end: { x: box.x, y: box.y + box.h } };
75
const leftInsersectionPoint = lineIntersectionPoint(line, boxLeft);
76
if (leftInsersectionPoint !== undefined) {
77
return leftInsersectionPoint;
78
}
79
80
// No intersection; just return the last point of the line.
81
return line.end;
82
}
83
84
/*
85
* lineIntersectionPoint returns the point where l1 and l2 intersect.
86
*
87
* Returns undefined if the lines do not intersect.
88
*/
89
function lineIntersectionPoint(l1: Line, l2: Line): Point | undefined {
90
// https://en.wikipedia.org/wiki/Line%E2%80%93line_intersection#Given_two_points_on_each_line_segment
91
92
// l1 = (x1, y1) -> (x2, y2)
93
// l2 = (x3, y3) -> (x4, y4)
94
const [x1, y1] = [l1.start.x, l1.start.y];
95
const [x2, y2] = [l1.end.x, l1.end.y];
96
const [x3, y3] = [l2.start.x, l2.start.y];
97
const [x4, y4] = [l2.end.x, l2.end.y];
98
99
const denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
100
if (denominator === 0) {
101
return undefined;
102
}
103
104
const t_numerator = (x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4);
105
const u_numerator = (x1 - x3) * (y1 - y2) - (y1 - y3) * (x1 - x2);
106
107
// Only t is used for calculating the point, but both t and u must be defined
108
// to ensure the intersection exists.
109
const [t, u] = [t_numerator / denominator, u_numerator / denominator];
110
111
// There is an intersection if and only if 0 <= t <= 1 and 0 <= u <= 1
112
if (0 <= t && t <= 1 && 0 <= u && u <= 1) {
113
return {
114
x: x1 + t * (x2 - x1),
115
y: y1 + t * (y2 - y1),
116
};
117
}
118
119
return undefined;
120
}
121
122
interface Box {
123
x: number;
124
y: number;
125
w: number;
126
h: number;
127
}
128
129
export interface ComponentGraphProps {
130
components: ComponentInfo[];
131
}
132
133
/**
134
* ComponentGraph renders an SVG with relationships between defined components.
135
* The components prop must be a non-empty array.
136
*/
137
export const ComponentGraph: FC<ComponentGraphProps> = (props) => {
138
const baseComponentPath = useHref('/component');
139
const svgRef = useRef<SVGSVGElement>(null);
140
141
useEffect(() => {
142
// NOTE(rfratto): The default units of svg are in pixels.
143
144
const [nodeWidth, nodeHeight] = [150, 75];
145
const nodeMargin = 25;
146
const nodePadding = 5;
147
148
const contentHeight = nodeHeight - nodePadding * 2;
149
150
const widthCache: Record<string, number> = {
151
foo: 5,
152
};
153
154
const builder = dagStratify()
155
.id<IdOperator<ComponentInfo>>((n) => n.id)
156
.parentIds<ParentIdsOperator<ComponentInfo>>((n) => n.referencedBy);
157
const dag = builder(props.components);
158
159
// Our graph layout is optimized for graphs of 50 components or more. The
160
// decross method is where most of the layout time is spent; decrossOpt is
161
// far too slow.
162
//
163
// We also use Coffman Graham for layering, which constrains the final
164
// width of the graph as much as possible.
165
const layout = sugiyama()
166
.layering(layeringCoffmanGraham())
167
.decross(decrossTwoLayer())
168
.coord(coordSimplex())
169
.nodeSize<NodeSizeAccessor<ComponentInfo, undefined>>((n) => {
170
// nodeSize is the full amount of space you want the node to take up.
171
//
172
// It can be considered similar to the box model: margin and padding should
173
// be added to the size.
174
175
// n will be undefined for synthetic nodes in a layer. These synthetic
176
// nodes can be given sizes, but we keep them at [0, 0] to minimize the
177
// total width of the graph.
178
if (n === undefined) {
179
return [0, 0];
180
}
181
182
// Calculate how much width the text needs to be displayed.
183
let width = nodeWidth;
184
185
const displayFont = "bold 13px 'Roboto', sans-serif";
186
187
const nameWidth = calcTextWidth(n.data.name, displayFont);
188
if (nameWidth != null && nameWidth > width) {
189
width = nameWidth;
190
}
191
192
const labelWidth = calcTextWidth(n.data.label || '', displayFont);
193
if (labelWidth != null && labelWidth > width) {
194
width = labelWidth;
195
}
196
197
// Cache the width so it can be used while plotting the SVG.
198
widthCache[n.data.id] = width;
199
200
return [width + nodeMargin + nodePadding * 2, nodeHeight + nodeMargin + nodePadding * 2];
201
});
202
const { width, height } = layout(dag);
203
204
// svgRef.current needs to be cast to an Element for type checks to work.
205
// SVGSVGElement doesn't extend element and prevents zoom from
206
// typechecking.
207
//
208
// Everything still seems to work even with the type cast.
209
const svgSelection = d3.select(svgRef.current as Element);
210
svgSelection.selectAll('*').remove(); // Clear svg content before adding new elements
211
svgSelection.attr('viewBox', [0, 0, width, height].join(' '));
212
213
const svgWrapper = svgSelection.append('g');
214
215
// TODO(rfratto): determine a reasonable zoom scale extent based on size of
216
// layout rather than hard coding 0.1x to 10x.
217
//
218
// As it is now, you can zoom in way too close on really small graphs.
219
const zoom = d3Zoom
220
.zoom()
221
.scaleExtent([0.1, 10])
222
.on('zoom', (e) => {
223
svgWrapper.attr('transform', e.transform);
224
});
225
226
svgSelection.call(zoom).call(zoom.transform, d3Zoom.zoomIdentity);
227
228
// Add a marker element so we can draw an arrow pointing between nodes.
229
svgWrapper
230
.append('defs')
231
.append('marker')
232
.attr('id', 'arrow')
233
.attr('viewBox', [0, 0, 20, 20])
234
.attr('refX', 17)
235
.attr('refY', 10)
236
.attr('markerWidth', 5)
237
.attr('markerHeight', 5)
238
.attr('orient', 'auto-start-reverse')
239
.append('path')
240
.attr(
241
'd',
242
// Draw an arrow shape
243
d3.line()([
244
[0, 0], // Bottom left of arrow
245
[0, 20], // Top left of arrow
246
[20, 10], // Middle point of arrow
247
])
248
)
249
.attr('fill', '#c8c9ca');
250
251
const line = d3
252
.line<Point>()
253
.curve(d3.curveCatmullRom)
254
.x((d) => d.x)
255
.y((d) => d.y);
256
257
// Plot edges
258
svgWrapper
259
.append('g')
260
.selectAll('path')
261
.data(dag.links())
262
.enter()
263
.append('path')
264
.attr('marker-end', 'url(#arrow)')
265
.attr('d', (node) => {
266
// We want to draw arrows between boxes, but by default the arrows are
267
// obscured; d3-dag points lines to the middle of a box which is hidden
268
// by the rectangle.
269
//
270
// To fix this, we do the following:
271
//
272
// 1. Retrieve the set of generated points for d3-dag
273
// 2. Remove all points after the first point which intersects the box
274
// 3. Move the final point to the coordinates where it intersects the
275
// box
276
// 4. The line will now stop at the box edge as expected.
277
278
const nodeBox: Box = {
279
x: (node.target.x || 0) - widthCache[node.target.data.id] / 2 - nodePadding,
280
y: (node.target.y || 0) - nodeHeight / 2 - nodePadding,
281
w: widthCache[node.target.data.id] + nodePadding * 2,
282
h: nodeHeight + nodePadding * 2,
283
};
284
285
const idx = node.points.findIndex((p) => {
286
return intersectsBox(p, nodeBox);
287
});
288
if (idx === -1) {
289
// It shouldn't be possible for this to happen; we know that the
290
// final point always goes to the center of the target box so there
291
// should always be an intersection.
292
throw new Error('could not find point of intersection with target node');
293
}
294
const trimmedPoints = node.points.slice(0, idx + 1);
295
296
const intersectingLine = {
297
start: trimmedPoints[trimmedPoints.length - 2],
298
end: trimmedPoints[trimmedPoints.length - 1],
299
};
300
const fixedPoint = boxIntersectionPoint(intersectingLine, nodeBox);
301
trimmedPoints[trimmedPoints.length - 1] = fixedPoint;
302
303
return line(trimmedPoints);
304
})
305
.attr('fill', 'none')
306
.attr('stroke-width', '2px')
307
.attr('stroke', '#c8c9ca')
308
.append('title') // Append tooltip to edge
309
.text((n) => {
310
return `${n.source.data.id} to ${n.target.data.id}`;
311
});
312
313
// Select nodes
314
const nodes = svgWrapper
315
.append('g')
316
.selectAll('g')
317
.data(dag.descendants())
318
.enter()
319
.append('g')
320
.attr('transform', (node) => {
321
// node.x, node.y refer to the absolute center of the box.
322
//
323
// We translate the group to the top-left corner to make it easier to
324
// position all the elements. Top left corner should account for
325
// padding space.
326
const x = (node.x || 0) - widthCache[node.data.id] / 2 - nodePadding;
327
const y = (node.y || 0) - nodeHeight / 2 - nodePadding;
328
return `translate(${x}, ${y})`;
329
});
330
331
const linkedNodes = nodes.append('a').attr('href', (n) => `${baseComponentPath}/${n.data.id}`);
332
333
// Plot nodes
334
linkedNodes
335
.append('rect')
336
.attr('fill', '#f2f2f3')
337
.attr('rx', 3)
338
.attr('height', nodeHeight + nodePadding * 2)
339
.attr('width', (node) => {
340
return widthCache[node.data.id] + nodePadding * 2;
341
})
342
.attr('stroke-width', '1')
343
.attr('stroke', '#e4e5e6');
344
345
// Create a group for node content which is anchored inside of the padding
346
// area.
347
const nodeContent = linkedNodes.append('g').attr('transform', `translate(${nodePadding}, ${nodePadding})`);
348
349
// Add component name text
350
nodeContent
351
.append('text')
352
.text((d) => d.data.name)
353
.attr('font-size', '13')
354
.attr('font-weight', 'bold')
355
.attr('font-family', '"Roboto", sans-serif')
356
.attr('text-anchor', 'start')
357
.attr('alignment-baseline', 'hanging')
358
.attr('fill', 'rgb(36, 41, 46, 0.75)');
359
360
// Add component label text
361
nodeContent
362
.append('text')
363
.text((d) => d.data.label || '')
364
.attr('y', 13 /* font size */ + 2 /* margin from previous text line */)
365
.attr('font-size', '13')
366
.attr('font-weight', 'normal')
367
.attr('font-family', '"Roboto", sans-serif')
368
.attr('text-anchor', 'start')
369
.attr('alignment-baseline', 'hanging')
370
.attr('fill', 'rgb(36, 41, 46, 0.75)');
371
372
// Draw health status
373
const healthBox = nodeContent
374
.append('g')
375
.attr('transform', `translate(0, ${contentHeight - 3})`); /* 1/4 height (why?) */
376
377
healthBox
378
.append('rect')
379
.attr('fill', (node) => {
380
switch (node.data.health.state || ComponentHealthState.UNKNOWN) {
381
case ComponentHealthState.HEALTHY:
382
return '#3b8160';
383
case ComponentHealthState.UNHEALTHY:
384
return '#d2476d';
385
case ComponentHealthState.EXITED:
386
return '#d2476d';
387
case ComponentHealthState.UNKNOWN:
388
return '#f5d65b';
389
}
390
})
391
.attr('rx', 1)
392
.attr('height', 14)
393
.attr('width', 45);
394
395
healthBox
396
.append('text')
397
.text((d) => {
398
const text = d.data.health.state || 'unknown';
399
return text.charAt(0).toUpperCase() + text.substring(1);
400
})
401
.attr('x', 45 / 2) // Anchor to middle of box
402
.attr('y', 14 / 2) // Middle of box
403
.attr('font-size', '7')
404
.attr('font-weight', 'bold')
405
.attr('font-family', '"Roboto", sans-serif')
406
.attr('text-anchor', 'middle')
407
.attr('alignment-baseline', 'middle')
408
.attr('fill', (node) => {
409
if (node.data.health.state === ComponentHealthState.UNKNOWN) {
410
return '#000000';
411
}
412
return '#ffffff';
413
});
414
});
415
416
return <svg ref={svgRef} style={{ width: '100%', height: '100%', display: 'block' }} />;
417
};
418
419