Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions canvas/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# Plugin Module: canvas

The canvas panel renders weathermap-style network diagrams. Nodes and edges can be colored by
query-bound thresholds, labeled with metric values, and (for edges) scaled in thickness by value.

The panel has two modes, selected in the panel's **Settings** editor under **Items**:

- **Manual** — draw the graph by hand: place nodes, connect them with edges, and add background
shapes or images. Each node/edge can bind to a query to drive its color and label.
- **Generated** — generate the graph live from query results. Use this for dynamic topologies
(for example, frequently changing service or host relationships) where drawing by hand is not
feasible.

### Data-driven mode

In **Generated** mode the panel derives nodes and edges from your queries on every refresh and
lays them out automatically — no manual positioning. Graph structure is inferred from the labels on
the returned time series, and each series' **last value** drives color (via the panel thresholds),
label interpolation, and edge thickness.

Configuration:

- **Node query** — the query whose series define the nodes.
- **Node id label** — the series label that identifies a node (e.g. `instance`). One node is created
per distinct label value.
- **Node label template** — optional caption, using `{{label_name}}` / `{{value}}` placeholders.
Defaults to the node id label.
- **Edge query** — the query whose series define the edges (leave as *None* for a nodes-only graph).
- **Edge source label** / **Edge target label** — series labels whose values name the edge's source
and target nodes. Edges whose endpoints are not among the generated nodes are dropped.
- **Edge label template** — optional edge caption (e.g. `{{value}}`).
- **Bidirectional edges** — render edges with arrows in both directions.

Node colors come from the **Node color** thresholds configured for the panel, and edge colors use
those same thresholds. Edge thickness is set per breakpoint under **Edge thickness**. Layout is
deterministic: a given topology always settles to the same positions, so nodes stay put as their
metric values change — they only move when nodes or edges are added or removed.

### How to install

This plugin requires react and react-dom 18
Expand Down
15 changes: 15 additions & 0 deletions canvas/schemas/panels/canvas/canvas.cue
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,25 @@ spec: close({
querySettings?: #querySettings
edgeDefaultStrokeWidth?: number & >0
edgeThresholdWidths?: [...#edgeThresholdStep]
nodeDefaultSize?: number & >0
backgrounds?: [...#background]
nodes?: [...#node]
edges?: [...#edge]
graphGeneration?: #graphGeneration
})

#graphGeneration: {
enabled?: bool
nodeQueryIndex: int & >=0
nodeIdLabel: string
nodeLabelTemplate?: string
edgeQueryIndex?: int & >=0
edgeSourceLabel?: string
edgeTargetLabel?: string
edgeLabelTemplate?: string
bidirectional?: bool
}

#legend: {
position: "bottom" | "right"
}
Expand Down Expand Up @@ -75,6 +89,7 @@ spec: close({
queryIndex?: int & >=0
colorMode?: "threshold" | "fixed"
color?: =~"^#(?:[0-9a-fA-F]{3}){1,2}$"
sizeMode?: "fixed" | "threshold"
}

#edge: {
Expand Down
69 changes: 60 additions & 9 deletions canvas/sdk/go/canvas.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ const (
ThresholdThicknessMode ThicknessMode = "threshold"
)

type SizeMode string

const (
FixedSizeMode SizeMode = "fixed"
ThresholdSizeMode SizeMode = "threshold"
)

type ImageFit string

const (
Expand Down Expand Up @@ -94,7 +101,8 @@ type NodeSpec struct {
QueryIndex *uint `json:"queryIndex,omitempty" yaml:"queryIndex,omitempty"`
ColorMode ColorMode `json:"colorMode,omitempty" yaml:"colorMode,omitempty"`
// Color is a hex color string (e.g. "#ff0000").
Color string `json:"color,omitempty" yaml:"color,omitempty"`
Color string `json:"color,omitempty" yaml:"color,omitempty"`
SizeMode SizeMode `json:"sizeMode,omitempty" yaml:"sizeMode,omitempty"`
}

type EdgeSpec struct {
Expand Down Expand Up @@ -138,15 +146,32 @@ type QueryColorSettings struct {
ColorValue string `json:"colorValue" yaml:"colorValue"`
}

// GraphGenerationSpec configures the data-driven graph mode. When Enabled, the panel derives nodes
// and edges live from query results and lays them out automatically; graph structure is inferred
// from series labels.
type GraphGenerationSpec struct {
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
NodeQueryIndex uint `json:"nodeQueryIndex" yaml:"nodeQueryIndex"`
NodeIDLabel string `json:"nodeIdLabel" yaml:"nodeIdLabel"`
NodeLabelTemplate string `json:"nodeLabelTemplate,omitempty" yaml:"nodeLabelTemplate,omitempty"`
EdgeQueryIndex *uint `json:"edgeQueryIndex,omitempty" yaml:"edgeQueryIndex,omitempty"`
EdgeSourceLabel string `json:"edgeSourceLabel,omitempty" yaml:"edgeSourceLabel,omitempty"`
EdgeTargetLabel string `json:"edgeTargetLabel,omitempty" yaml:"edgeTargetLabel,omitempty"`
EdgeLabelTemplate string `json:"edgeLabelTemplate,omitempty" yaml:"edgeLabelTemplate,omitempty"`
Bidirectional bool `json:"bidirectional,omitempty" yaml:"bidirectional,omitempty"`
}

type PluginSpec struct {
Thresholds *common.Thresholds `json:"thresholds,omitempty" yaml:"thresholds,omitempty"`
Format *common.Format `json:"format,omitempty" yaml:"format,omitempty"`
EdgeThresholdWidths []EdgeThresholdStep `json:"edgeThresholdWidths,omitempty" yaml:"edgeThresholdWidths,omitempty"`
EdgeDefaultStrokeWidth *float64 `json:"edgeDefaultStrokeWidth,omitempty" yaml:"edgeDefaultStrokeWidth,omitempty"`
QuerySettings []QueryColorSettings `json:"querySettings,omitempty" yaml:"querySettings,omitempty"`
Backgrounds []BackgroundSpec `json:"backgrounds,omitempty" yaml:"backgrounds,omitempty"`
Nodes []NodeSpec `json:"nodes,omitempty" yaml:"nodes,omitempty"`
Edges []EdgeSpec `json:"edges,omitempty" yaml:"edges,omitempty"`
Thresholds *common.Thresholds `json:"thresholds,omitempty" yaml:"thresholds,omitempty"`
Format *common.Format `json:"format,omitempty" yaml:"format,omitempty"`
EdgeThresholdWidths []EdgeThresholdStep `json:"edgeThresholdWidths,omitempty" yaml:"edgeThresholdWidths,omitempty"`
EdgeDefaultStrokeWidth *float64 `json:"edgeDefaultStrokeWidth,omitempty" yaml:"edgeDefaultStrokeWidth,omitempty"`
NodeDefaultSize *float64 `json:"nodeDefaultSize,omitempty" yaml:"nodeDefaultSize,omitempty"`
QuerySettings []QueryColorSettings `json:"querySettings,omitempty" yaml:"querySettings,omitempty"`
Backgrounds []BackgroundSpec `json:"backgrounds,omitempty" yaml:"backgrounds,omitempty"`
Nodes []NodeSpec `json:"nodes,omitempty" yaml:"nodes,omitempty"`
Edges []EdgeSpec `json:"edges,omitempty" yaml:"edges,omitempty"`
GraphGeneration *GraphGenerationSpec `json:"graphGeneration,omitempty" yaml:"graphGeneration,omitempty"`
}

type Option func(plugin *Builder) error
Expand Down Expand Up @@ -180,3 +205,29 @@ func Chart(options ...Option) panel.Option {
return nil
}
}

// WithGraphGeneration enables the data-driven graph mode, deriving nodes and edges from query
// results instead of a manually authored graph.
func WithGraphGeneration(spec GraphGenerationSpec) Option {
return func(builder *Builder) error {
builder.GraphGeneration = &spec
return nil
}
}

// WithThresholds sets the color scheme applied to nodes.
func WithThresholds(thresholds common.Thresholds) Option {
return func(builder *Builder) error {
builder.Thresholds = &thresholds
return nil
}
}

// WithNodeDefaultSize sets the base width/height (px) for generated nodes. Defaults to 48 when
// unset.
func WithNodeDefaultSize(size float64) Option {
return func(builder *Builder) error {
builder.NodeDefaultSize = &size
return nil
}
}
80 changes: 73 additions & 7 deletions canvas/src/components/panel/CanvasPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import { useChartsTheme } from '@perses-dev/components';
import type { ThresholdOptions } from '@perses-dev/components';
import type { TimeSeries } from '@perses-dev/spec';
import type { MouseEvent, ReactElement } from 'react';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';

import { useZoomContext, ZoomProvider } from '../../contexts/ZoomContext';
import { useZoom } from '../../hooks/useZoom';
import type { CanvasProps } from '../../model';
import type { CanvasProps, CanvasSpec, Point } from '../../model';
import { buildGraphFromQueries } from '../../utils/graphGeneration';
import { layoutGraph } from '../../utils/graphLayout';
import { nodeBoundingBox } from '../../utils/resizeUtils';
import { BackgroundLayer, GlobalBackgroundLayer } from '../shared/BackgroundLayer';
import { PanelEdgeLayer } from './PanelEdgeLayer';
Expand All @@ -31,13 +33,23 @@ const EMPTY_THRESHOLDS: ThresholdOptions = {};

interface PanelSvgProps {
svgRef: (node: SVGSVGElement | null) => void;
props: CanvasProps;
spec: CanvasSpec;
contentDimensions: CanvasProps['contentDimensions'];
seriesByQueryIndex: Map<number, TimeSeries>;
paletteColors: string[];
seriesByItemId?: Map<string, TimeSeries>;
reverseSeriesByItemId?: Map<string, TimeSeries>;
}

function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvgProps): ReactElement {
const { contentDimensions, spec } = props;
function PanelSvg({
svgRef,
spec,
contentDimensions,
seriesByQueryIndex,
paletteColors,
seriesByItemId,
reverseSeriesByItemId,
}: PanelSvgProps): ReactElement {
const { transform, isPanning, fitView, resetPan } = useZoomContext();

const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]);
Expand Down Expand Up @@ -80,12 +92,15 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg
seriesByQueryIndex={seriesByQueryIndex}
k={transform.k}
paletteColors={paletteColors}
seriesByItemId={seriesByItemId}
/>
<PanelEdgeLayer
spec={spec}
seriesByQueryIndex={seriesByQueryIndex}
k={transform.k}
paletteColors={paletteColors}
seriesByItemId={seriesByItemId}
reverseSeriesByItemId={reverseSeriesByItemId}
/>
</g>

Expand All @@ -102,7 +117,7 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg
}

export function CanvasPanel(props: CanvasProps): ReactElement | null {
const { queryResults } = props;
const { queryResults, spec } = props;
const chartsTheme = useChartsTheme();
const paletteColors = chartsTheme.thresholds.palette;

Expand All @@ -117,6 +132,49 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null {
return map;
}, [queryResults]);

// Data-driven mode: derive nodes/edges from query results on every refresh, then lay them out
// with a deterministic force simulation. Because layout is a pure function of the topology, nodes
// settle to the same positions regardless of how metric values change between refreshes.
const generation = spec.graphGeneration;
const generated = useMemo(
() => (generation?.enabled ? buildGraphFromQueries(generation, queryResults, spec.nodeDefaultSize) : undefined),
[generation, queryResults, spec.nodeDefaultSize],
);

// The force simulation is expensive (O(n²) per iteration) and must not rerun when only metric
// values change. Cache just the origin-centered positions (keyed by node id) for a given topology,
// using React's sanctioned "adjust state during render" pattern: a value-only refresh keeps the
// same topologyKey and reuses the cached positions, so the simulation reruns only when the set of
// nodes/edges actually changes. Caching positions rather than whole node specs means appearance
// edits (default size, labels) still flow through from the freshly generated nodes below.
const [layoutCache, setLayoutCache] = useState<{ key: string; positions: Map<string, Point> } | undefined>(undefined);
let positions = layoutCache?.positions;
if (generated && layoutCache?.key !== generated.topologyKey) {
positions = new Map(layoutGraph(generated.nodes, generated.edges).map((node) => [node.id, node.position]));
setLayoutCache({ key: generated.topologyKey, positions });
} else if (!generated && layoutCache) {
positions = undefined;
setLayoutCache(undefined);
}

const effectiveSpec: CanvasSpec = useMemo(() => {
if (!generated || !positions) {
return spec;
}
// layoutGraph centers the generated graph on the origin (0, 0). Under the identity zoom
// transform the origin is the SVG's top-left corner, so the graph would render clipped into that
// corner. Offset every node by the viewport center so the graph sits in the middle of the panel.
// Apply the cached position onto the freshly generated node (keeping its current label/size), so
// appearance edits take effect without rerunning the simulation.
const offsetX = (props.contentDimensions?.width ?? 600) / 2;
const offsetY = (props.contentDimensions?.height ?? 400) / 2;
Comment thread
Gladorme marked this conversation as resolved.
Resolved
const nodes = generated.nodes.map((node) => {
const pos = positions.get(node.id) ?? node.position;
return { ...node, position: { x: pos.x + offsetX, y: pos.y + offsetY } };
});
return { ...spec, nodes, edges: generated.edges };
}, [spec, generated, positions, props.contentDimensions?.width, props.contentDimensions?.height]);

const { svgRef, toCanvasPoint, transform, isPanning, fitView, resetPan } = useZoom();

const zoomValue = useMemo(
Expand All @@ -126,7 +184,15 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null {

return (
<ZoomProvider value={zoomValue}>
<PanelSvg svgRef={svgRef} props={props} seriesByQueryIndex={seriesByQueryIndex} paletteColors={paletteColors} />
<PanelSvg
svgRef={svgRef}
spec={effectiveSpec}
contentDimensions={props.contentDimensions}
seriesByQueryIndex={seriesByQueryIndex}
paletteColors={paletteColors}
seriesByItemId={generated?.seriesByItemId}
reverseSeriesByItemId={generated?.reverseSeriesByItemId}
/>
</ZoomProvider>
);
}
Loading
Loading