diff --git a/canvas/README.md b/canvas/README.md index 98ac5e3a6..5831ef12d 100644 --- a/canvas/README.md +++ b/canvas/README.md @@ -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 diff --git a/canvas/schemas/panels/canvas/canvas.cue b/canvas/schemas/panels/canvas/canvas.cue index 89b9b2b95..93f442279 100644 --- a/canvas/schemas/panels/canvas/canvas.cue +++ b/canvas/schemas/panels/canvas/canvas.cue @@ -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" } @@ -75,6 +89,7 @@ spec: close({ queryIndex?: int & >=0 colorMode?: "threshold" | "fixed" color?: =~"^#(?:[0-9a-fA-F]{3}){1,2}$" + sizeMode?: "fixed" | "threshold" } #edge: { diff --git a/canvas/sdk/go/canvas.go b/canvas/sdk/go/canvas.go index d0e568873..5e7504703 100644 --- a/canvas/sdk/go/canvas.go +++ b/canvas/sdk/go/canvas.go @@ -65,6 +65,13 @@ const ( ThresholdThicknessMode ThicknessMode = "threshold" ) +type SizeMode string + +const ( + FixedSizeMode SizeMode = "fixed" + ThresholdSizeMode SizeMode = "threshold" +) + type ImageFit string const ( @@ -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 { @@ -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 @@ -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 + } +} diff --git a/canvas/src/components/panel/CanvasPanel.tsx b/canvas/src/components/panel/CanvasPanel.tsx index 7ae7317ca..5bc20d64e 100644 --- a/canvas/src/components/panel/CanvasPanel.tsx +++ b/canvas/src/components/panel/CanvasPanel.tsx @@ -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'; @@ -31,13 +33,23 @@ const EMPTY_THRESHOLDS: ThresholdOptions = {}; interface PanelSvgProps { svgRef: (node: SVGSVGElement | null) => void; - props: CanvasProps; + spec: CanvasSpec; + contentDimensions: CanvasProps['contentDimensions']; seriesByQueryIndex: Map; paletteColors: string[]; + seriesByItemId?: Map; + reverseSeriesByItemId?: Map; } -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]); @@ -80,12 +92,15 @@ function PanelSvg({ svgRef, props, seriesByQueryIndex, paletteColors }: PanelSvg seriesByQueryIndex={seriesByQueryIndex} k={transform.k} paletteColors={paletteColors} + seriesByItemId={seriesByItemId} /> @@ -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; @@ -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 } | 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; + 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( @@ -126,7 +184,15 @@ export function CanvasPanel(props: CanvasProps): ReactElement | null { return ( - + ); } diff --git a/canvas/src/components/panel/PanelEdgeLayer.tsx b/canvas/src/components/panel/PanelEdgeLayer.tsx index dbc185e7c..732a34639 100644 --- a/canvas/src/components/panel/PanelEdgeLayer.tsx +++ b/canvas/src/components/panel/PanelEdgeLayer.tsx @@ -17,7 +17,7 @@ import { useMemo } from 'react'; import { useCanvasTheme } from '../../hooks/useCanvasTheme'; import type { CanvasSpec, ThicknessMode } from '../../model'; -import { edgeEndpoints, strokeWidthFromThresholds } from '../../utils/edgeUtils'; +import { clipLineToNodes, edgeEndpoints, strokeWidthFromThresholds } from '../../utils/edgeUtils'; import { colorFromThresholds, interpolateLabel } from '../../utils/panelUtils'; import { EdgeLabel } from '../shared/EdgeLabel'; import type { LineStyle } from '../shared/EdgeLines'; @@ -26,19 +26,14 @@ import { EdgeLines, edgeLabelPoints } from '../shared/EdgeLines'; const NS_PREFIX = 'wm-panel'; function resolveEdgeStyle( - queryIndex: number | undefined, + series: TimeSeries | undefined, thicknessMode: ThicknessMode | undefined, edgeStrokeWidth: number | undefined, - seriesByQueryIndex: Map, spec: CanvasSpec, paletteColors: string[], fallbackColor: string, ): { stroke: string; strokeWidth: number } { const defaultWidth = edgeStrokeWidth ?? spec.edgeDefaultStrokeWidth ?? 2; - if (queryIndex === undefined) { - return { stroke: 'currentColor', strokeWidth: defaultWidth }; - } - const series = seriesByQueryIndex.get(queryIndex); if (!series) { return { stroke: 'currentColor', strokeWidth: defaultWidth }; } @@ -63,9 +58,20 @@ interface PanelEdgeLayerProps { seriesByQueryIndex: Map; k: number; paletteColors: string[]; + /** Series backing generated edges (data-driven mode), keyed by edge id. Takes precedence over queryIndex. */ + seriesByItemId?: Map; + /** Series backing the reverse half of a reciprocal generated edge, keyed by edge id. */ + reverseSeriesByItemId?: Map; } -export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelEdgeLayerProps): ReactElement { +export function PanelEdgeLayer({ + spec, + seriesByQueryIndex, + k, + paletteColors, + seriesByItemId, + reverseSeriesByItemId, +}: PanelEdgeLayerProps): ReactElement { const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); const edges = useMemo(() => spec.edges ?? [], [spec.edges]); const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); @@ -74,36 +80,60 @@ export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P return ( <> {edges.map((edge, i) => { - const pts = edgeEndpoints(edge, nodeById); - if (!pts) { + const rawPts = edgeEndpoints(edge, nodeById); + if (!rawPts) { return null; } - function resolveLabel(queryIndex: number | undefined, template: string | undefined): string | null { - if (queryIndex === undefined) { - return null; + // Trim each end that attaches to a node center (no explicit anchor) back to the node + // boundary, so the line and arrowhead touch the shape instead of running into its center. + const clipSource = edge.sourceAnchor ? undefined : nodeById.get(edge.source); + const clipTarget = edge.target && !edge.targetAnchor ? nodeById.get(edge.target) : undefined; + const pts = clipLineToNodes(rawPts, clipSource, clipTarget); + + // Overlapping nodes collapse the clipped segment to a point; skip it rather than draw a + // reversed arrow or a stray label at the overlap. + if (pts.start.x === pts.end.x && pts.start.y === pts.end.y) { + return null; + } + + const generatedSeries = seriesByItemId?.get(edge.id); + const generatedReverseSeries = reverseSeriesByItemId?.get(edge.id); + + function resolveSeries(queryIndex: number | undefined, reverse = false): TimeSeries | undefined { + // For a reciprocal generated edge the reverse half has its own series; fall back to the + // forward series otherwise so a plain bidirectional edge still shows a value on both halves. + if (reverse && generatedReverseSeries) { + return generatedReverseSeries; + } + if (generatedSeries) { + return generatedSeries; } - const series = seriesByQueryIndex.get(queryIndex); + return queryIndex !== undefined ? seriesByQueryIndex.get(queryIndex) : undefined; + } + + function resolveLabel(series: TimeSeries | undefined, template: string | undefined): string | null { if (!series) { return null; } return interpolateLabel(template ?? '{{value}}', series, spec.format); } + const fwdSeries = resolveSeries(edge.sourceQueryIndex); + const bwdSeries = resolveSeries(edge.targetQueryIndex, true); + const fwdStyle = resolveEdgeStyle( - edge.sourceQueryIndex, + fwdSeries, edge.thicknessMode, edge.strokeWidth, - seriesByQueryIndex, spec, paletteColors, fallbackColor, ); const bwdStyle = resolveEdgeStyle( - edge.targetQueryIndex, + bwdSeries, edge.thicknessMode, edge.strokeWidth, - seriesByQueryIndex, spec, paletteColors, fallbackColor, @@ -125,8 +155,8 @@ export function PanelEdgeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P scaledFwdStyle.strokeWidth, scaledBwdStyle.strokeWidth, ); - const fwdLabel = resolveLabel(edge.sourceQueryIndex, edge.sourceLabelTemplate); - const bwdLabel = edge.bidirectional ? resolveLabel(edge.targetQueryIndex, edge.targetLabelTemplate) : null; + const fwdLabel = resolveLabel(fwdSeries, edge.sourceLabelTemplate); + const bwdLabel = edge.bidirectional ? resolveLabel(bwdSeries, edge.targetLabelTemplate) : null; return ( diff --git a/canvas/src/components/panel/PanelNodeLayer.tsx b/canvas/src/components/panel/PanelNodeLayer.tsx index ba5b49393..497d7ad8d 100644 --- a/canvas/src/components/panel/PanelNodeLayer.tsx +++ b/canvas/src/components/panel/PanelNodeLayer.tsx @@ -67,9 +67,17 @@ interface PanelNodeLayerProps { seriesByQueryIndex: Map; k: number; paletteColors: string[]; + /** Series backing generated nodes (data-driven mode), keyed by node id. Takes precedence over queryIndex. */ + seriesByItemId?: Map; } -export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: PanelNodeLayerProps): ReactElement { +export function PanelNodeLayer({ + spec, + seriesByQueryIndex, + k, + paletteColors, + seriesByItemId, +}: PanelNodeLayerProps): ReactElement { const nodes = useMemo(() => spec.nodes ?? [], [spec.nodes]); const variableValues = useAllVariableValues(); const { connection: fallbackColor, nodeDefaultFill } = useCanvasTheme(); @@ -89,7 +97,9 @@ export function PanelNodeLayer({ spec, seriesByQueryIndex, k, paletteColors }: P let labelOverride: string | undefined; let fillOverride: string | undefined; - const series = node.queryIndex !== undefined ? seriesByQueryIndex.get(node.queryIndex) : undefined; + const series = + seriesByItemId?.get(node.id) ?? + (node.queryIndex !== undefined ? seriesByQueryIndex.get(node.queryIndex) : undefined); if (series && node.label) { labelOverride = interpolateLabel(node.label, series, spec.format); } diff --git a/canvas/src/components/settings/GlobalSettingsEditor.tsx b/canvas/src/components/settings/GlobalSettingsEditor.tsx index b550a7dd0..a051a293b 100644 --- a/canvas/src/components/settings/GlobalSettingsEditor.tsx +++ b/canvas/src/components/settings/GlobalSettingsEditor.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Box } from '@mui/material'; +import { Box, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'; import type { FormatOptions, ThresholdOptions } from '@perses-dev/components'; import { FormatControls, @@ -21,21 +21,56 @@ import { ThresholdsEditor, } from '@perses-dev/components'; import type { OptionsEditorProps } from '@perses-dev/plugin-system'; -import type { ReactElement } from 'react'; +import type { MouseEvent, ReactElement } from 'react'; import { useCallback } from 'react'; import { EditorStateProvider } from '../../contexts/EditorContext'; import { SpecProvider } from '../../contexts/SpecContext'; -import type { CanvasSpec } from '../../model'; +import type { CanvasSpec, GraphGenerationSpec } from '../../model'; import { EditorItemsPanel } from '../editor/EditorItemsPanel'; import { EdgeThicknessSettings } from './EdgeThicknessSettings'; +import { GraphGenerationSettings } from './GraphGenerationSettings'; import { LegendSettings } from './LegendSettings'; const DEFAULT_FORMAT: FormatOptions = { unit: 'decimal' }; const EDITOR_COLUMN_SX = { display: 'flex', flexDirection: 'column' as const, gap: 3 }; +const DEFAULT_GENERATION: GraphGenerationSpec = { enabled: true, nodeQueryIndex: 0, nodeIdLabel: '' }; type GlobalSettingsEditorProps = OptionsEditorProps; +interface StylingSettingsProps { + value: CanvasSpec; + onChange: (value: CanvasSpec) => void; + onFormatChange: (format: FormatOptions) => void; + onThresholdsChange: (thresholds: ThresholdOptions | undefined) => void; +} + +// The appearance controls shared by both the manual and data-driven tabs. Rendering the same block +// in each tab keeps every adjustment option close together instead of split between a top section +// and the tab contents. +function StylingSettings({ value, onChange, onFormatChange, onThresholdsChange }: StylingSettingsProps): ReactElement { + return ( + + + + + + + + + + + + + + + + + + + ); +} + export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorProps): ReactElement { const onFormatChange = useCallback( (format: FormatOptions): void => { @@ -51,31 +86,57 @@ export function GlobalSettingsEditor({ value, onChange }: GlobalSettingsEditorPr [value, onChange], ); + const mode = value.graphGeneration?.enabled ? 'auto' : 'manual'; + + const onModeChange = useCallback( + (_: MouseEvent, next: 'manual' | 'auto' | null): void => { + if (next === null) { + return; + } + if (next === 'auto') { + onChange({ ...value, graphGeneration: { ...DEFAULT_GENERATION, ...value.graphGeneration, enabled: true } }); + } else { + onChange( + value.graphGeneration ? { ...value, graphGeneration: { ...value.graphGeneration, enabled: false } } : value, + ); + } + }, + [value, onChange], + ); + return ( - - - - - - - - - - - - - - - - + + Manual + Generated + + + {mode === 'auto' ? ( + + Nodes and edges are generated from query results and laid out automatically. Graph structure is inferred from + series labels; node and edge colors use the thresholds below. + + ) : null} + + {/* Each tab is self-contained: the same styling controls plus the tab's own item editor, so + every adjustment option stays together within the active mode. */} + - - - - - + {mode === 'auto' ? ( + + ) : ( + + + + + + )} ); diff --git a/canvas/src/components/settings/GraphGenerationSettings.tsx b/canvas/src/components/settings/GraphGenerationSettings.tsx new file mode 100644 index 000000000..6ee0ab7da --- /dev/null +++ b/canvas/src/components/settings/GraphGenerationSettings.tsx @@ -0,0 +1,147 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from '@mui/material'; +import { generateQueryNames, useDataQueriesContext } from '@perses-dev/plugin-system'; +import type { ReactElement } from 'react'; +import { useCallback, useMemo } from 'react'; + +import type { CanvasSpec, GraphGenerationSpec } from '../../model'; +import { SelectField } from '../shared/SelectField'; + +type GraphGenerationSettingsProps = { + value: CanvasSpec; + onChange: (next: CanvasSpec) => void; +}; + +const DEFAULT_GENERATION: GraphGenerationSpec = { enabled: true, nodeQueryIndex: 0, nodeIdLabel: '' }; + +export function GraphGenerationSettings({ value, onChange }: GraphGenerationSettingsProps): ReactElement { + const { queryDefinitions } = useDataQueriesContext(); + const queryCount = queryDefinitions.length; + const queryNames = useMemo(() => generateQueryNames(queryDefinitions), [queryDefinitions]); + const queryIndexes = useMemo(() => Array.from({ length: queryCount }, (_, i) => i), [queryCount]); + + const generation = value.graphGeneration ?? DEFAULT_GENERATION; + + const update = useCallback( + (patch: Partial): void => { + onChange({ ...value, graphGeneration: { ...generation, ...patch } }); + }, + [value, onChange, generation], + ); + + const onNodeQueryChange = useCallback( + (e: React.ChangeEvent): void => update({ nodeQueryIndex: Number(e.target.value) }), + [update], + ); + const onEdgeQueryChange = useCallback( + (e: React.ChangeEvent): void => + update({ edgeQueryIndex: e.target.value === '' ? undefined : Number(e.target.value) }), + [update], + ); + const onNodeIdLabelChange = useCallback( + (e: React.ChangeEvent): void => update({ nodeIdLabel: e.target.value }), + [update], + ); + const onNodeLabelTemplateChange = useCallback( + (e: React.ChangeEvent): void => update({ nodeLabelTemplate: e.target.value || undefined }), + [update], + ); + const onEdgeSourceLabelChange = useCallback( + (e: React.ChangeEvent): void => update({ edgeSourceLabel: e.target.value || undefined }), + [update], + ); + const onEdgeTargetLabelChange = useCallback( + (e: React.ChangeEvent): void => update({ edgeTargetLabel: e.target.value || undefined }), + [update], + ); + const onEdgeLabelTemplateChange = useCallback( + (e: React.ChangeEvent): void => update({ edgeLabelTemplate: e.target.value || undefined }), + [update], + ); + const onBidirectionalChange = useCallback( + (e: React.ChangeEvent): void => update({ bidirectional: e.target.checked }), + [update], + ); + + const bidirectionalSwitch = useMemo( + () => , + [generation.bidirectional, onBidirectionalChange], + ); + + return ( + + Nodes + + {queryIndexes.map((qi) => ( + + {queryNames[qi] ?? `#${qi + 1}`} + + ))} + + + + + Edges + + + None (nodes only) + + {queryIndexes.map((qi) => ( + + {queryNames[qi] ?? `#${qi + 1}`} + + ))} + + + + + + + ); +} diff --git a/canvas/src/components/shared/EdgeLines.test.ts b/canvas/src/components/shared/EdgeLines.test.ts new file mode 100644 index 000000000..58699f9ac --- /dev/null +++ b/canvas/src/components/shared/EdgeLines.test.ts @@ -0,0 +1,40 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { arrowScaleForSegment } from './EdgeLines'; + +// The arrowhead renders ARROW_SW_W (2.5) * strokeWidth user-units long. +describe('arrowScaleForSegment', () => { + it('keeps full size when the segment is longer than the arrowhead', () => { + // strokeWidth 2 -> arrowhead 5; a 100px segment easily fits it. + expect(arrowScaleForSegment(100, 2)).toBe(1); + }); + + it('keeps full size at exactly the arrowhead length', () => { + expect(arrowScaleForSegment(5, 2)).toBe(1); + }); + + it('shrinks the arrowhead to fit a short clipped segment', () => { + // Two 48px nodes 60px apart leave a 12px gap; an 8px stroke wants a 20px arrowhead. + // Scale to 12/20 = 0.6 so the marker tip lands on the node boundary instead of overshooting. + expect(arrowScaleForSegment(12, 8)).toBeCloseTo(0.6); + }); + + it('never returns a negative scale for a zero-length segment', () => { + expect(arrowScaleForSegment(0, 8)).toBe(0); + }); + + it('is full size when stroke width is zero (no arrowhead to overshoot)', () => { + expect(arrowScaleForSegment(10, 0)).toBe(1); + }); +}); diff --git a/canvas/src/components/shared/EdgeLines.tsx b/canvas/src/components/shared/EdgeLines.tsx index 09d632746..87072f2d6 100644 --- a/canvas/src/components/shared/EdgeLines.tsx +++ b/canvas/src/components/shared/EdgeLines.tsx @@ -20,6 +20,9 @@ import { midpoint } from '../../utils/edgeUtils'; interface EdgeGeometry { fwd: Line; bwd: Line | null; + /** Arrowhead length scale in [0, 1] per direction, shrunk so a short segment's marker still fits. */ + fwdArrowScale: number; + bwdArrowScale: number; } function shortenEnd(line: Line, amount: number): Line { @@ -31,6 +34,24 @@ function shortenEnd(line: Line, amount: number): Line { return { start: line.start, end: { x: line.start.x + dx * t, y: line.start.y + dy * t } }; } +function lineLength(line: Line): number { + return Math.hypot(line.end.x - line.start.x, line.end.y - line.start.y); +} + +/** + * The arrowhead renders `ARROW_SW_W * strokeWidth` user-units long (markerUnits="strokeWidth"). + * When the (already node-clipped) directional segment is shorter than that, the full-size marker + * would overshoot past the segment into the node. Return a scale in (0, 1] that shrinks the marker + * to fit the available length so its tip still lands on the node boundary. + */ +export function arrowScaleForSegment(segmentLength: number, strokeWidth: number): number { + const arrowLength = ARROW_SW_W * strokeWidth; + if (arrowLength <= 0 || segmentLength >= arrowLength) { + return 1; + } + return Math.max(0, segmentLength / arrowLength); +} + function computeEdgeGeometry( pts: Line, bidirectional: boolean, @@ -41,12 +62,17 @@ function computeEdgeGeometry( const bwdShorten = ARROW_SW_W * bwdStrokeWidth; if (!bidirectional) { - return { fwd: shortenEnd(pts, fwdShorten), bwd: null }; + const fwd = shortenEnd(pts, fwdShorten); + return { fwd, bwd: null, fwdArrowScale: arrowScaleForSegment(lineLength(pts), fwdStrokeWidth), bwdArrowScale: 1 }; } const mid = midpoint(pts); + const fwdFull = { start: pts.start, end: mid }; + const bwdFull = { start: pts.end, end: mid }; return { - fwd: shortenEnd({ start: pts.start, end: mid }, fwdShorten), - bwd: shortenEnd({ start: pts.end, end: mid }, bwdShorten), + fwd: shortenEnd(fwdFull, fwdShorten), + bwd: shortenEnd(bwdFull, bwdShorten), + fwdArrowScale: arrowScaleForSegment(lineLength(fwdFull), fwdStrokeWidth), + bwdArrowScale: arrowScaleForSegment(lineLength(bwdFull), bwdStrokeWidth), }; } @@ -67,19 +93,23 @@ interface EdgeArrowMarkerProps { nsPrefix: string; direction: 'fwd' | 'bwd'; fill: string; + /** Shrinks the marker so a short, node-clipped segment's arrowhead does not overshoot the node. */ + scale?: number; } -function EdgeArrowMarker({ nsPrefix, direction, fill }: EdgeArrowMarkerProps): ReactElement { +function EdgeArrowMarker({ nsPrefix, direction, fill, scale = 1 }: EdgeArrowMarkerProps): ReactElement { + const w = ARROW_SW_W * scale; + const h = ARROW_SW_H * scale; return ( - + ); } @@ -102,13 +132,20 @@ export function EdgeLines({ lineProps, }: EdgeLinesProps): ReactElement { const resolvedBwdStyle = bwdStyle ?? fwdStyle; - const { fwd, bwd } = computeEdgeGeometry(pts, bidirectional, fwdStyle.strokeWidth, resolvedBwdStyle.strokeWidth); + const { fwd, bwd, fwdArrowScale, bwdArrowScale } = computeEdgeGeometry( + pts, + bidirectional, + fwdStyle.strokeWidth, + resolvedBwdStyle.strokeWidth, + ); return ( <> - - {bwd ? : null} + + {bwd ? ( + + ) : null} @@ -53,7 +66,14 @@ export function IconNode({ node, displayLabel, defaultFill, fillOverride, rectPr ) : ( - + )} {displayLabel ? ( ; diff --git a/canvas/src/utils/edgeUtils.test.ts b/canvas/src/utils/edgeUtils.test.ts index 8107af622..89648de70 100644 --- a/canvas/src/utils/edgeUtils.test.ts +++ b/canvas/src/utils/edgeUtils.test.ts @@ -14,6 +14,7 @@ import type { NodeSpec } from '../model'; import { anchorPosition, + clipLineToNodes, closestAnchor, edgeEndpoints, midpoint, @@ -129,6 +130,49 @@ describe('midpoint', () => { }); }); +describe('clipLineToNodes', () => { + it('trims a horizontal line to a rectangle boundary on both ends', () => { + const a = makeNode('a', 0, 0, 100, 60); // half-width 50 + const b = makeNode('b', 200, 0, 100, 60); + const clipped = clipLineToNodes({ start: { x: 0, y: 0 }, end: { x: 200, y: 0 } }, a, b); + expect(clipped.start).toEqual({ x: 50, y: 0 }); + expect(clipped.end).toEqual({ x: 150, y: 0 }); + }); + + it('trims to the ellipse boundary for icon nodes', () => { + const a: NodeSpec = { id: 'a', position: { x: 0, y: 0 }, width: 48, height: 48, kind: 'icon' }; + const b: NodeSpec = { id: 'b', position: { x: 100, y: 0 }, width: 48, height: 48, kind: 'icon' }; + const clipped = clipLineToNodes({ start: { x: 0, y: 0 }, end: { x: 100, y: 0 } }, a, b); + expect(clipped.start.x).toBeCloseTo(24); // radius of the circle + expect(clipped.end.x).toBeCloseTo(76); + }); + + it('leaves an end untouched when its node is undefined (explicit anchor or free endpoint)', () => { + const a = makeNode('a', 0, 0, 100, 60); + const clipped = clipLineToNodes({ start: { x: 0, y: 0 }, end: { x: 200, y: 0 } }, a, undefined); + expect(clipped.start).toEqual({ x: 50, y: 0 }); + expect(clipped.end).toEqual({ x: 200, y: 0 }); + }); + + it('returns the line unchanged for a zero-length segment', () => { + const a = makeNode('a', 0, 0); + const line = { start: { x: 5, y: 5 }, end: { x: 5, y: 5 } }; + expect(clipLineToNodes(line, a, a)).toEqual(line); + }); + + it('collapses to a point instead of reversing when nodes overlap', () => { + // Two 48px circular nodes 40px apart overlap: each radius (24) exceeds the remaining gap, so a + // naive clip would produce start.x=24, end.x=16 and point the edge backwards. + const a: NodeSpec = { id: 'a', position: { x: 0, y: 0 }, width: 48, height: 48, kind: 'icon' }; + const b: NodeSpec = { id: 'b', position: { x: 40, y: 0 }, width: 48, height: 48, kind: 'icon' }; + const clipped = clipLineToNodes({ start: { x: 0, y: 0 }, end: { x: 40, y: 0 } }, a, b); + expect(clipped.start).toEqual(clipped.end); + // The collapsed point sits forward of the source, never past the target center. + expect(clipped.start.x).toBeGreaterThanOrEqual(0); + expect(clipped.start.x).toBeLessThanOrEqual(40); + }); +}); + describe('strokeWidthFromThresholds', () => { const steps = [ { value: 10, strokeWidth: 2 }, diff --git a/canvas/src/utils/edgeUtils.ts b/canvas/src/utils/edgeUtils.ts index 3ee656d2c..bd1a6889e 100644 --- a/canvas/src/utils/edgeUtils.ts +++ b/canvas/src/utils/edgeUtils.ts @@ -81,6 +81,63 @@ export function midpoint(line: Line): Point { return { x: (line.start.x + line.end.x) / 2, y: (line.start.y + line.end.y) / 2 }; } +/** + * Returns the distance from a node's center to its perimeter along the direction (dx, dy). Icon + * nodes render as an ellipse, so they use the ellipse boundary; rectangle and text nodes use their + * rectangular boundary. The input direction need not be normalized. + */ +function perimeterDistance(node: NodeSpec, dx: number, dy: number): number { + const len = Math.hypot(dx, dy); + if (len === 0) { + return 0; + } + const ux = dx / len; + const uy = dy / len; + const halfW = node.width / 2; + const halfH = node.height / 2; + if (node.kind === 'icon') { + // Ellipse: solve for t where ((t*ux)/halfW)^2 + ((t*uy)/halfH)^2 = 1. + const denom = Math.hypot(ux / halfW, uy / halfH); + return denom === 0 ? 0 : 1 / denom; + } + // Rectangle: distance to the nearest vertical/horizontal edge along the ray. + const tx = ux === 0 ? Infinity : halfW / Math.abs(ux); + const ty = uy === 0 ? Infinity : halfH / Math.abs(uy); + return Math.min(tx, ty); +} + +/** + * Trims an edge's endpoints back to the perimeter of the nodes they connect, so the line and its + * arrowhead touch the node boundary instead of overlapping into the node center. Endpoints that are + * not a node center (explicit anchors or a free endpoint) are left untouched. + * + * When the nodes overlap so much that their combined perimeter distances consume the whole segment, + * trimming would push the endpoints past each other and reverse the line's direction. In that case + * the edge is collapsed to a zero-length segment at the trim point so it neither renders a reversed + * arrow nor points the wrong way; callers can treat a zero-length result as "nothing to draw". + */ +export function clipLineToNodes(line: Line, source: NodeSpec | undefined, target: NodeSpec | undefined): Line { + const dx = line.end.x - line.start.x; + const dy = line.end.y - line.start.y; + const len = Math.hypot(dx, dy); + if (len === 0) { + return line; + } + const ux = dx / len; + const uy = dy / len; + const startTrim = source ? perimeterDistance(source, dx, dy) : 0; + const endTrim = target ? perimeterDistance(target, dx, dy) : 0; + if (startTrim + endTrim >= len) { + // The nodes overlap along this edge: the two perimeters meet or cross. Collapse the edge to a + // single point at the start-side trim so it does not reverse direction. + const point = { x: line.start.x + ux * startTrim, y: line.start.y + uy * startTrim }; + return { start: point, end: point }; + } + const start = source ? { x: line.start.x + ux * startTrim, y: line.start.y + uy * startTrim } : line.start; + const end = target ? { x: line.end.x - ux * endTrim, y: line.end.y - uy * endTrim } : line.end; + return { start, end }; +} + export function strokeWidthFromThresholds(value: number, steps: EdgeThresholdStep[], defaultWidth: number): number { if (!steps.length) return defaultWidth; let result = defaultWidth; diff --git a/canvas/src/utils/graphGeneration.test.ts b/canvas/src/utils/graphGeneration.test.ts new file mode 100644 index 000000000..8efaddc6d --- /dev/null +++ b/canvas/src/utils/graphGeneration.test.ts @@ -0,0 +1,232 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { PanelData } from '@perses-dev/plugin-system'; +import type { TimeSeries, TimeSeriesData } from '@perses-dev/spec'; + +import type { GraphGenerationSpec } from '../model'; +import { buildGraphFromQueries } from './graphGeneration'; + +function series(labels: Record, lastValue: number): TimeSeries { + return { labels, values: [[0, lastValue]] } as unknown as TimeSeries; +} + +function result(...s: TimeSeries[]): PanelData { + return { data: { series: s } } as unknown as PanelData; +} + +const baseConfig: GraphGenerationSpec = { + enabled: true, + nodeQueryIndex: 0, + nodeIdLabel: 'instance', + edgeQueryIndex: 1, + edgeSourceLabel: 'source', + edgeTargetLabel: 'target', +}; + +describe('buildGraphFromQueries', () => { + it('creates one node per distinct node-id label value', () => { + const results = [result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), result()]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.nodes.map((n) => n.id)).toEqual(['gen-node:a', 'gen-node:b']); + expect(graph.nodes[0]?.colorMode).toBe('threshold'); + expect(graph.nodes[0]?.label).toBe('{{instance}}'); + }); + + it('deduplicates repeated node-id values, keeping the first series', () => { + const results = [result(series({ instance: 'a' }, 1), series({ instance: 'a' }, 99)), result()]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.nodes).toHaveLength(1); + expect(graph.seriesByItemId.get('gen-node:a')?.values[0]?.[1]).toBe(1); + }); + + it('skips node series missing the id label', () => { + const results = [result(series({ other: 'x' }, 1), series({ instance: 'a' }, 2)), result()]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.nodes.map((n) => n.id)).toEqual(['gen-node:a']); + }); + + it('builds edges referencing generated node ids', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({ source: 'gen-node:a', target: 'gen-node:b' }); + expect(graph.seriesByItemId.get(graph.edges[0]!.id)?.values[0]?.[1]).toBe(5); + }); + + it('drops edges whose endpoints are not known nodes', () => { + const results = [ + result(series({ instance: 'a' }, 1)), + result(series({ source: 'a', target: 'ghost' }, 5), series({ source: 'nope', target: 'a' }, 6)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(0); + }); + + it('carries the edge label template and bidirectional flag', () => { + const config: GraphGenerationSpec = { ...baseConfig, edgeLabelTemplate: '{{value}}', bidirectional: true }; + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(config, results); + expect(graph.edges[0]?.sourceLabelTemplate).toBe('{{value}}'); + // Both directional templates come from the single generation setting so a bidirectional edge + // shows the same caption on each half instead of the reverse defaulting to `{{value}}`. + expect(graph.edges[0]?.targetLabelTemplate).toBe('{{value}}'); + expect(graph.edges[0]?.bidirectional).toBe(true); + }); + + it('gives distinct ids to endpoint pairs that share a separator-joined form', () => { + // (`a->b`, `c`) and (`a`, `b->c`) both flatten to `a->b->c` under a naive join; distinct ids + // keep the second edge from being deduplicated away. + const results = [ + result( + series({ instance: 'a->b' }, 1), + series({ instance: 'c' }, 2), + series({ instance: 'a' }, 3), + series({ instance: 'b->c' }, 4), + ), + result(series({ source: 'a->b', target: 'c' }, 5), series({ source: 'a', target: 'b->c' }, 6)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(2); + expect(new Set(graph.edges.map((e) => e.id)).size).toBe(2); + }); + + it('merges reciprocal A→B and B→A rows into one bidirectional edge with per-direction series', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5), series({ source: 'b', target: 'a' }, 9)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(1); + const edge = graph.edges[0]!; + expect(edge).toMatchObject({ source: 'gen-node:a', target: 'gen-node:b', bidirectional: true }); + // Forward half keeps the A→B series; the reverse half gets the B→A series. + expect(graph.seriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(5); + expect(graph.reverseSeriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(9); + }); + + it('keeps a single-direction edge non-bidirectional by default and leaves no reverse series', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]?.bidirectional).toBe(false); + expect(graph.reverseSeriesByItemId.get(graph.edges[0]!.id)).toBeUndefined(); + }); + + it('merges reciprocal rows regardless of which direction appears first', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'b', target: 'a' }, 9), series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges).toHaveLength(1); + const edge = graph.edges[0]!; + // Orientation follows the first-seen row (B→A here). + expect(edge).toMatchObject({ source: 'gen-node:b', target: 'gen-node:a', bidirectional: true }); + expect(graph.seriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(9); + expect(graph.reverseSeriesByItemId.get(edge.id)?.values[0]?.[1]).toBe(5); + }); + + it('keeps a stable edge id and topology key when reciprocal rows are reordered', () => { + const nodes = result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)); + const forwardFirst = [ + nodes, + result(series({ source: 'a', target: 'b' }, 5), series({ source: 'b', target: 'a' }, 9)), + ]; + const reverseFirst = [ + nodes, + result(series({ source: 'b', target: 'a' }, 9), series({ source: 'a', target: 'b' }, 5)), + ]; + const a = buildGraphFromQueries(baseConfig, forwardFirst); + const b = buildGraphFromQueries(baseConfig, reverseFirst); + // A reordering of the same reciprocal pair must not change the edge id or the topology key, + // otherwise CanvasPanel would rerun the whole force simulation on an order-only refresh. + expect(a.edges[0]?.id).toBe(b.edges[0]?.id); + expect(a.topologyKey).toBe(b.topologyKey); + }); + + it('drives generated edge thickness from the edge-thickness thresholds', () => { + const results = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const graph = buildGraphFromQueries(baseConfig, results); + expect(graph.edges[0]?.thicknessMode).toBe('threshold'); + expect(graph.nodes[0]?.sizeMode).toBe('threshold'); + }); + + it('uses the configured default node size, falling back to 48', () => { + const results = [result(series({ instance: 'a' }, 1)), result()]; + expect(buildGraphFromQueries(baseConfig, results).nodes[0]).toMatchObject({ width: 48, height: 48 }); + const sized = buildGraphFromQueries(baseConfig, results, 60); + expect(sized.nodes[0]).toMatchObject({ width: 60, height: 60 }); + }); + + it('respects a custom node label template', () => { + const config: GraphGenerationSpec = { ...baseConfig, nodeLabelTemplate: '{{instance}}: {{value}}' }; + const results = [result(series({ instance: 'a' }, 1)), result()]; + const graph = buildGraphFromQueries(config, results); + expect(graph.nodes[0]?.label).toBe('{{instance}}: {{value}}'); + }); + + it('produces a nodes-only graph when no edge query is configured', () => { + const config: GraphGenerationSpec = { enabled: true, nodeQueryIndex: 0, nodeIdLabel: 'instance' }; + const results = [result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2))]; + const graph = buildGraphFromQueries(config, results); + expect(graph.nodes).toHaveLength(2); + expect(graph.edges).toHaveLength(0); + }); + + it('builds a topology key that changes with topology but not with values', () => { + const r1 = [ + result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), + result(series({ source: 'a', target: 'b' }, 5)), + ]; + const r2 = [ + result(series({ instance: 'a' }, 100), series({ instance: 'b' }, 200)), + result(series({ source: 'a', target: 'b' }, 500)), + ]; + const r3 = [result(series({ instance: 'a' }, 1)), result()]; + const key1 = buildGraphFromQueries(baseConfig, r1).topologyKey; + const key2 = buildGraphFromQueries(baseConfig, r2).topologyKey; + const key3 = buildGraphFromQueries(baseConfig, r3).topologyKey; + expect(key1).toBe(key2); // only values changed + expect(key1).not.toBe(key3); // topology changed + }); + + it('topology key is independent of series order', () => { + const forward = [result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), result()]; + const reversed = [result(series({ instance: 'b' }, 2), series({ instance: 'a' }, 1)), result()]; + expect(buildGraphFromQueries(baseConfig, forward).topologyKey).toBe( + buildGraphFromQueries(baseConfig, reversed).topologyKey, + ); + }); + + it('topology key does not collide between distinct node sets that share a joined form', () => { + // Two nodes `a` and `b` must not key the same as a single node literally named `a,gen-node:b`. + const twoNodes = [result(series({ instance: 'a' }, 1), series({ instance: 'b' }, 2)), result()]; + const oneNode = [result(series({ instance: 'a,gen-node:b' }, 1)), result()]; + expect(buildGraphFromQueries(baseConfig, twoNodes).topologyKey).not.toBe( + buildGraphFromQueries(baseConfig, oneNode).topologyKey, + ); + }); +}); diff --git a/canvas/src/utils/graphGeneration.ts b/canvas/src/utils/graphGeneration.ts new file mode 100644 index 000000000..43e786912 --- /dev/null +++ b/canvas/src/utils/graphGeneration.ts @@ -0,0 +1,183 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { PanelData } from '@perses-dev/plugin-system'; +import type { TimeSeries, TimeSeriesData } from '@perses-dev/spec'; + +import { DEFAULT_NODE_WIDTH } from '../components/shared/NodeRenderer'; +import type { EdgeSpec, GraphGenerationSpec, NodeSpec } from '../model'; + +export interface GeneratedGraph { + nodes: NodeSpec[]; + edges: EdgeSpec[]; + /** Series backing each generated node/edge id, used to resolve color/label/thickness at render time. */ + seriesByItemId: Map; + /** + * Series backing the reverse half of a reciprocal generated edge, keyed by edge id. Present only + * for edges where the query returned both A→B and B→A; the forward half uses `seriesByItemId`. + */ + reverseSeriesByItemId: Map; + /** Stable key describing the graph topology (node + edge ids), for memoizing layout. */ + topologyKey: string; +} + +const NODE_ID_PREFIX = 'gen-node:'; +const EDGE_ID_PREFIX = 'gen-edge:'; + +function nodeId(labelValue: string): string { + return `${NODE_ID_PREFIX}${labelValue}`; +} + +/** + * Builds nodes and edges from query results by grouping series on their labels. Node series are + * grouped by `nodeIdLabel`; edge series are grouped by `edgeSourceLabel`/`edgeTargetLabel` and are + * dropped when either endpoint is not a known node. Each generated item keeps the series it was + * derived from so the existing threshold/label/thickness pipeline can resolve its visuals. + */ +export function buildGraphFromQueries( + config: GraphGenerationSpec, + queryResults: Array>, + nodeDefaultSize?: number, +): GeneratedGraph { + const seriesByItemId = new Map(); + const reverseSeriesByItemId = new Map(); + const nodes: NodeSpec[] = []; + const nodeIds = new Set(); + const nodeSize = nodeDefaultSize && nodeDefaultSize > 0 ? nodeDefaultSize : DEFAULT_NODE_WIDTH; + + const nodeResult = queryResults[config.nodeQueryIndex]; + const nodeSeries = nodeResult?.data.series ?? []; + for (const series of nodeSeries) { + const labelValue = series.labels?.[config.nodeIdLabel]; + if (labelValue === undefined || labelValue === '') { + continue; + } + const id = nodeId(labelValue); + if (nodeIds.has(id)) { + // Keep the first series for a given node id; later duplicates are ignored for determinism. + continue; + } + nodeIds.add(id); + seriesByItemId.set(id, series); + nodes.push({ + id, + position: { x: 0, y: 0 }, // filled in by layoutGraph + width: nodeSize, + height: nodeSize, + kind: 'icon', + label: config.nodeLabelTemplate ?? `{{${config.nodeIdLabel}}}`, + labelPosition: 'below', + colorMode: 'threshold', + // Opt generated nodes into data-driven size scaling. This is a no-op today (nodes keep their + // base size) and becomes active once node-size thresholds are available. + sizeMode: 'threshold', + }); + } + + const edges: EdgeSpec[] = []; + const canBuildEdges = + config.edgeQueryIndex !== undefined && config.edgeSourceLabel !== undefined && config.edgeTargetLabel !== undefined; + + if (canBuildEdges) { + const edgeResult = queryResults[config.edgeQueryIndex!]; + const edgeSeries = edgeResult?.data.series ?? []; + + // Group directed series by their unordered node pair so reciprocal rows (A→B and B→A) become a + // single bidirectional edge. Each entry keeps the orientation of the first-seen series (its + // `source`/`target`) plus the series for each observed direction. Building edges once per pair + // avoids two generated edges overpainting the same path and overlapping their labels. + interface PairEntry { + source: string; + target: string; + sourceValue: string; + targetValue: string; + forward?: TimeSeries; + reverse?: TimeSeries; + } + const pairs = new Map(); + const pairOrder: string[] = []; + + for (const series of edgeSeries) { + const sourceValue = series.labels?.[config.edgeSourceLabel!]; + const targetValue = series.labels?.[config.edgeTargetLabel!]; + if (sourceValue === undefined || targetValue === undefined) { + continue; + } + const source = nodeId(sourceValue); + const target = nodeId(targetValue); + if (!nodeIds.has(source) || !nodeIds.has(target)) { + // Drop edges whose endpoints were not produced by the node query. + continue; + } + // Unordered pair key so A→B and B→A map to the same entry. JSON.stringify of the sorted tuple + // escapes the values so distinct pairs never collide (e.g. (`a->b`, `c`) vs (`a`, `b->c`)). + const pairKey = JSON.stringify([sourceValue, targetValue].toSorted()); + let entry = pairs.get(pairKey); + if (!entry) { + entry = { source, target, sourceValue, targetValue }; + pairs.set(pairKey, entry); + pairOrder.push(pairKey); + } + // Record the series for this direction relative to the entry's orientation. A repeated series + // in the same direction keeps the first one for determinism. + if (source === entry.source && target === entry.target) { + entry.forward ??= series; + } else { + entry.reverse ??= series; + } + } + + for (const pairKey of pairOrder) { + const entry = pairs.get(pairKey)!; + const isReciprocal = entry.forward !== undefined && entry.reverse !== undefined; + // Derive the id from the canonical (order-independent) pair key, not the entry's first-seen + // orientation. Otherwise reordering reciprocal A→B / B→A rows would flip the id and thus the + // topologyKey, forcing CanvasPanel to rerun the whole force simulation on an order-only + // refresh. Orientation is still carried by entry.source/target for the directional series. + const id = `${EDGE_ID_PREFIX}${pairKey}`; + const forwardSeries = entry.forward ?? entry.reverse; + if (forwardSeries) { + seriesByItemId.set(id, forwardSeries); + } + if (isReciprocal) { + reverseSeriesByItemId.set(id, entry.reverse!); + } + edges.push({ + id, + source: entry.source, + target: entry.target, + // A reciprocal pair is always rendered bidirectionally so each direction shows on its own + // half; a single-direction edge honors the configured flag. + bidirectional: isReciprocal || (config.bidirectional ?? false), + sourceLabelTemplate: config.edgeLabelTemplate, + // Apply the single generation label template to both directions so a bidirectional edge + // shows the same caption on its forward and reverse halves instead of the reverse half + // falling back to the default `{{value}}`. + targetLabelTemplate: config.edgeLabelTemplate, + // Drive thickness from the edge-thickness thresholds so generated edges vary in width with + // their value; without this they would all fall back to the fixed default width. + thicknessMode: 'threshold', + }); + } + } + + // Serialize sorted id arrays via JSON so the key is unambiguous (no separator collision between, + // e.g., ids `a`,`b` and a single id `a,gen-node:b`) and independent of series order, so merely + // reordering the same series does not change the topology and rerun the layout. + const topologyKey = JSON.stringify({ + nodes: nodes.map((n) => n.id).toSorted(), + edges: edges.map((e) => e.id).toSorted(), + }); + + return { nodes, edges, seriesByItemId, reverseSeriesByItemId, topologyKey }; +} diff --git a/canvas/src/utils/graphLayout.test.ts b/canvas/src/utils/graphLayout.test.ts new file mode 100644 index 000000000..3459ad721 --- /dev/null +++ b/canvas/src/utils/graphLayout.test.ts @@ -0,0 +1,135 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { EdgeSpec, NodeSpec } from '../model'; +import { layoutGraph } from './graphLayout'; + +function node(id: string): NodeSpec { + return { id, position: { x: 0, y: 0 }, width: 48, height: 48, kind: 'icon' }; +} + +function edge(id: string, source: string, target: string): EdgeSpec { + return { id, source, target }; +} + +// Deterministic shuffle so order-independence tests are stable. +function shuffled(xs: T[], seed: number): T[] { + const out = [...xs]; + for (let i = out.length - 1; i > 0; i--) { + const j = (i * 1103515245 + seed) % (i + 1); + [out[i], out[j]] = [out[j]!, out[i]!]; + } + return out; +} + +describe('layoutGraph', () => { + it('returns an empty array for no nodes', () => { + expect(layoutGraph([], [])).toEqual([]); + }); + + it('centers a single node at the origin', () => { + const [n] = layoutGraph([node('a')], []); + expect(n?.position).toEqual({ x: 0, y: 0 }); + }); + + it('is deterministic: identical input yields identical positions', () => { + const nodes = [node('a'), node('b'), node('c')]; + const edges = [edge('e1', 'a', 'b'), edge('e2', 'b', 'c')]; + const first = layoutGraph(nodes, edges); + const second = layoutGraph(nodes, edges); + expect(first.map((n) => n.position)).toEqual(second.map((n) => n.position)); + }); + + it('does not depend on input array order (positions follow node id)', () => { + const a = layoutGraph([node('a'), node('b'), node('c')], []); + const b = layoutGraph([node('c'), node('a'), node('b')], []); + const posA = new Map(a.map((n) => [n.id, n.position])); + const posB = new Map(b.map((n) => [n.id, n.position])); + for (const id of ['a', 'b', 'c']) { + expect(posB.get(id)).toEqual(posA.get(id)); + } + }); + + it('is order-independent for a larger connected graph with shuffled nodes and edges', () => { + const ids = Array.from({ length: 20 }, (_, i) => `n${i}`); + const nodes = ids.map(node); + // A connected graph: a ring plus a few chords, so the spring pass is exercised. + const edges: EdgeSpec[] = ids.map((id, i) => edge(`e${i}`, id, ids[(i + 1) % ids.length]!)); + edges.push(edge('c1', 'n0', 'n10'), edge('c2', 'n3', 'n15'), edge('c3', 'n5', 'n12')); + + const base = layoutGraph(nodes, edges); + const reordered = layoutGraph(shuffled(nodes, 7), shuffled(edges, 13)); + const basePos = new Map(base.map((n) => [n.id, n.position])); + for (const n of reordered) { + expect(n.position).toEqual(basePos.get(n.id)); + } + }); + + it('produces finite coordinates and separates nodes', () => { + const nodes = [node('a'), node('b'), node('c'), node('d')]; + const edges = [edge('e1', 'a', 'b'), edge('e2', 'c', 'd')]; + const laid = layoutGraph(nodes, edges); + for (const n of laid) { + expect(Number.isFinite(n.position.x)).toBe(true); + expect(Number.isFinite(n.position.y)).toBe(true); + } + // No two nodes end up on top of each other. + for (let i = 0; i < laid.length; i++) { + for (let j = i + 1; j < laid.length; j++) { + const dist = Math.hypot(laid[i]!.position.x - laid[j]!.position.x, laid[i]!.position.y - laid[j]!.position.y); + expect(dist).toBeGreaterThan(1); + } + } + }); + + it('centers the layout on its own centroid', () => { + const nodes = [node('a'), node('b'), node('c'), node('d'), node('e')]; + const edges = [edge('e1', 'a', 'b'), edge('e2', 'b', 'c')]; + const laid = layoutGraph(nodes, edges); + const cx = laid.reduce((sum, n) => sum + n.position.x, 0) / laid.length; + const cy = laid.reduce((sum, n) => sum + n.position.y, 0) / laid.length; + expect(cx).toBeCloseTo(0, 6); + expect(cy).toBeCloseTo(0, 6); + }); + + it('preserves node identity and other fields', () => { + const laid = layoutGraph([node('a'), node('b')], []); + expect(laid.map((n) => n.id).toSorted()).toEqual(['a', 'b']); + expect(laid[0]?.width).toBe(48); + expect(laid[0]?.kind).toBe('icon'); + }); + + it('ignores edges that reference unknown nodes without crashing', () => { + const laid = layoutGraph([node('a'), node('b')], [edge('e1', 'a', 'ghost')]); + expect(laid).toHaveLength(2); + for (const n of laid) { + expect(Number.isFinite(n.position.x)).toBe(true); + } + }); + + it('stays bounded on a high-degree hub-and-spoke graph', () => { + // A star topology clusters every spoke's repulsion onto the hub; without a bounded integration + // step the inverse-square forces compound into runaway coordinates (observed order 10^61). + const hub = node('hub'); + const spokes = Array.from({ length: 128 }, (_, i) => node(`spoke-${i}`)); + const edges = spokes.map((s, i) => edge(`e${i}`, 'hub', s.id)); + const laid = layoutGraph([hub, ...spokes], edges); + // Every coordinate stays within a sane viewport-scale bound rather than diverging. + for (const n of laid) { + expect(Number.isFinite(n.position.x)).toBe(true); + expect(Number.isFinite(n.position.y)).toBe(true); + expect(Math.abs(n.position.x)).toBeLessThan(100_000); + expect(Math.abs(n.position.y)).toBeLessThan(100_000); + } + }); +}); diff --git a/canvas/src/utils/graphLayout.ts b/canvas/src/utils/graphLayout.ts new file mode 100644 index 000000000..6b9f4b779 --- /dev/null +++ b/canvas/src/utils/graphLayout.ts @@ -0,0 +1,188 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { EdgeSpec, NodeSpec, Point } from '../model'; + +export interface LayoutOptions { + iterations?: number; + /** Target edge length / natural spring length. */ + springLength?: number; + /** Repulsion strength between all node pairs. */ + repulsion?: number; + /** Spring (attraction) strength along edges. */ + attraction?: number; + /** Pull toward the origin, keeping disconnected components from drifting away. */ + centering?: number; + /** Radius of the deterministic seed circle. */ + seedRadius?: number; + /** Maximum distance a node may move on the first iteration; decays linearly to zero. */ + maxStep?: number; +} + +const DEFAULTS: Required = { + iterations: 300, + springLength: 120, + repulsion: 60000, + attraction: 0.02, + centering: 0.01, + seedRadius: 200, + maxStep: 100, +}; + +// Deterministic 32-bit string hash (FNV-1a) so a given node id always seeds to the same angle. +function hashString(str: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +/** + * Computes positions for generated nodes using a small, dependency-free force simulation + * (Coulomb-style repulsion + Hookean edge springs + mild centering). Seeding is deterministic from + * node ids, so the same topology always lays out identically across refreshes — nodes only move + * when the set of nodes/edges changes, not when their metric values change. + */ +export function layoutGraph(nodes: NodeSpec[], edges: EdgeSpec[], options: LayoutOptions = {}): NodeSpec[] { + const opts = { ...DEFAULTS, ...options }; + const n = nodes.length; + if (n === 0) { + return nodes; + } + if (n === 1) { + return [{ ...nodes[0]!, position: { x: 0, y: 0 } }]; + } + + // Run the simulation over a canonical (id-sorted) ordering of the nodes, not the incoming array + // order. Floating-point force sums are not associative, so iterating nodes in query order would + // let a mere reordering of the same topology settle to slightly different positions and visibly + // move nodes. `inputToSim[inputIdx]` maps an input-array index to its simulation slot so the + // returned array can preserve the caller's node order. + const simOrder = nodes.map((_, i) => i).toSorted((a, b) => (nodes[a]!.id < nodes[b]!.id ? -1 : 1)); + const inputToSim = Array.from({ length: n }, () => 0); + simOrder.forEach((inputIdx, simIdx) => { + inputToSim[inputIdx] = simIdx; + }); + const simNodes = simOrder.map((i) => nodes[i]!); + + // Deterministic seed: place each node on a circle at an angle derived from its id hash. + const pos: Point[] = simNodes.map((node) => { + const angle = (hashString(node.id) / 0xffffffff) * Math.PI * 2; + return { x: Math.cos(angle) * opts.seedRadius, y: Math.sin(angle) * opts.seedRadius }; + }); + + const indexById = new Map(); + simNodes.forEach((node, i) => indexById.set(node.id, i)); + + // Build the edge list in a canonical order too, so the spring pass is order-independent as well. + const edgePairs: Array<[number, number]> = []; + for (const edge of edges) { + const a = indexById.get(edge.source); + const b = edge.target !== undefined ? indexById.get(edge.target) : undefined; + if (a !== undefined && b !== undefined && a !== b) { + edgePairs.push(a < b ? [a, b] : [b, a]); + } + } + edgePairs.sort((p, q) => p[0] - q[0] || p[1] - q[1]); + + const disp: Point[] = pos.map(() => ({ x: 0, y: 0 })); + + for (let iter = 0; iter < opts.iterations; iter++) { + // Cooling schedule: the per-node step shrinks linearly to zero over the run. Capping the step + // keeps the simulation stable — without it the inverse-square repulsion between a hub's many + // tightly-seeded spokes produces enormous displacements that compound into runaway coordinates. + const maxStep = opts.maxStep * (1 - iter / opts.iterations); + + for (let i = 0; i < n; i++) { + disp[i]!.x = 0; + disp[i]!.y = 0; + } + + // Pairwise repulsion. + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + let dx = pos[i]!.x - pos[j]!.x; + let dy = pos[i]!.y - pos[j]!.y; + let distSq = dx * dx + dy * dy; + if (distSq < 0.01) { + // Deterministically nudge coincident nodes apart. + dx = ((i - j) % 2 === 0 ? 1 : -1) * 0.1; + dy = 0.1; + distSq = dx * dx + dy * dy; + } + const force = opts.repulsion / distSq; + const dist = Math.sqrt(distSq); + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + disp[i]!.x += fx; + disp[i]!.y += fy; + disp[j]!.x -= fx; + disp[j]!.y -= fy; + } + } + + // Edge springs. + for (const [a, b] of edgePairs) { + const dx = pos[b]!.x - pos[a]!.x; + const dy = pos[b]!.y - pos[a]!.y; + const dist = Math.sqrt(dx * dx + dy * dy) || 0.01; + const force = (dist - opts.springLength) * opts.attraction; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + disp[a]!.x += fx; + disp[a]!.y += fy; + disp[b]!.x -= fx; + disp[b]!.y -= fy; + } + + // Centering + integrate, clamping each node's movement to the current cooling step so the + // layout always stays bounded no matter how large the accumulated forces are. + for (let i = 0; i < n; i++) { + disp[i]!.x -= pos[i]!.x * opts.centering; + disp[i]!.y -= pos[i]!.y * opts.centering; + const mag = Math.hypot(disp[i]!.x, disp[i]!.y); + if (mag > maxStep) { + const s = maxStep / mag; + disp[i]!.x *= s; + disp[i]!.y *= s; + } + pos[i]!.x += disp[i]!.x; + pos[i]!.y += disp[i]!.y; + } + } + + // Recenter on the layout's own centroid. The centering force only approximately pulls toward the + // origin and reaches equilibrium wherever repulsion/springs balance it — for small or unevenly + // seeded graphs that equilibrium is noticeably off-origin. Shifting by the centroid guarantees the + // result is centered on (0, 0) regardless of how the simulation settled. + // + // `pos` is already in the canonical (id-sorted) simulation order, so the sum is independent of the + // caller's input order. + let cx = 0; + let cy = 0; + for (let i = 0; i < n; i++) { + cx += pos[i]!.x; + cy += pos[i]!.y; + } + cx /= n; + cy /= n; + + // Map each input node to its simulation slot so the returned array preserves the caller's order + // while carrying the canonically computed positions. + return nodes.map((node, inputIdx) => { + const p = pos[inputToSim[inputIdx]!]!; + return { ...node, position: { x: p.x - cx, y: p.y - cy } }; + }); +} diff --git a/docs/canvas/examples/README.md b/docs/canvas/examples/README.md new file mode 100644 index 000000000..0a5e0cc6b --- /dev/null +++ b/docs/canvas/examples/README.md @@ -0,0 +1,96 @@ +# Canvas data-driven graph demo + +This directory provides a self-contained demo for the canvas panel's **data-driven graph mode**: a +small microservice topology served as Prometheus metrics, plus a ready-to-apply dashboard. Use it to +see nodes and edges generated live from query data. + +The stock Perses dev metrics do not carry paired `source`/`target` labels, so they cannot exercise +edge generation. The exporter here is shaped for the feature: + +- `node_load{instance="..."}` — one series per node. The `instance` label is the node id, and each + series also carries `name`, `role`, `tier`, and `region` labels for richer captions. +- `edge_traffic{source=...,target=...}` — one series per directed edge. + +Each series' value (0..1) drives node/edge color via the panel thresholds and edge thickness via the +edge-thickness thresholds. Nodes use a traffic-light scheme (green → amber → red) while edges use a +separate blue → indigo → purple scheme, so the demo shows the independent **Node color** and **Edge +color** thresholds. Nodes also grow with their value via the **Node size** thresholds. The demo +dashboard builds node captions from the extra labels with a template (`{{name}} · {{value}}`); try +other labels such as `{{role}}` or `{{region}}` to see how the label templates work. + +## Prerequisites + +- Docker. +- A local checkout of [`perses/perses`](https://github.com/perses/perses) with its dev stack, plus + `percli`. See the [plugin development guide](../../../README.md#development). + +## Steps + +1. **Start the demo exporter** (from the plugins repo root). It serves the metrics at + `http://localhost:9101/metrics`: + + ```bash + docker compose --file docs/canvas/examples/docker-compose.yml up --detach + ``` + +2. **Scrape it from the Perses dev Prometheus.** In your `perses/perses` checkout, add this job + under `scrape_configs:` in `dev/extra/prometheus.yaml`, then start/restart Prometheus: + + ```yaml + - job_name: 'canvas-demo' + honor_labels: true + scrape_interval: 5s + static_configs: + - targets: ['host.docker.internal:9101'] + ``` + + ```bash + docker compose --file dev/docker-compose.yaml --profile prometheus up --detach + ``` + + The dev `PrometheusLocal` datasource (`http://localhost:9090`) is the default, so no datasource + setup is needed. Confirm `node_load` and `edge_traffic` return data at `http://localhost:9090`. + +3. **Run the backend and the plugin dev server.** In `perses/perses`, set `plugin.enable_dev: true` + in `dev/config.yaml`, then: + + ```bash + ./scripts/api_backend_dev.sh + percli login http://localhost:8080 + ``` + + From the plugins repo, point the plugin dev server at the canvas workspace: + + ```bash + percli plugin start ./canvas + ``` + +4. **Load the demo dashboard** (or build a panel by hand with the two queries above): + + ```bash + percli project perses + percli apply -f docs/canvas/examples/dashboard.json + ``` + + Open the `CanvasGraphDemo` dashboard in the `perses` project. You should see the microservice + nodes and the links between them, laid out automatically and colored by the thresholds. Pan with + a left or middle mouse drag and zoom with the wheel; double-click to reset the pan, or + ctrl/cmd-double-click to fit the whole graph to the panel. + +5. **Experiment.** Edit `metrics/topology.prom` to change values, or add/remove `node_load` / + `edge_traffic` series, then reload the exporter: + + ```bash + docker compose --file docs/canvas/examples/docker-compose.yml restart + ``` + + Changing values recolors nodes/edges in place on the next refresh; adding or removing a series + changes the topology and triggers a fresh layout. + +## Teardown + +```bash +docker compose --file docs/canvas/examples/docker-compose.yml down +``` + +Remove the `canvas-demo` scrape job from `dev/extra/prometheus.yaml` when you are done. diff --git a/docs/canvas/examples/dashboard.json b/docs/canvas/examples/dashboard.json new file mode 100644 index 000000000..38d0969c7 --- /dev/null +++ b/docs/canvas/examples/dashboard.json @@ -0,0 +1,96 @@ +{ + "kind": "Dashboard", + "metadata": { + "name": "CanvasGraphDemo", + "project": "perses" + }, + "spec": { + "display": { + "name": "Canvas – Data-driven graph demo" + }, + "duration": "5m", + "panels": { + "topology": { + "kind": "Panel", + "spec": { + "display": { + "name": "Host topology (generated from data)", + "description": "Nodes come from node_load (id label: instance); edges come from edge_traffic (labels: source, target). Node captions use the name/role/region labels; colors and edge thickness follow the thresholds." + }, + "plugin": { + "kind": "Canvas", + "spec": { + "thresholds": { + "steps": [ + { "value": 0.5, "color": "#f9a825" }, + { "value": 0.8, "color": "#d32f2f" } + ] + }, + "edgeThresholdWidths": [ + { "value": 0.5, "strokeWidth": 4 }, + { "value": 0.8, "strokeWidth": 8 } + ], + "nodeDefaultSize": 44, + "format": { "unit": "decimal" }, + "legend": { "position": "bottom" }, + "graphGeneration": { + "enabled": true, + "nodeQueryIndex": 0, + "nodeIdLabel": "instance", + "nodeLabelTemplate": "{{name}} · {{value}}", + "edgeQueryIndex": 1, + "edgeSourceLabel": "source", + "edgeTargetLabel": "target", + "edgeLabelTemplate": "{{value}}" + } + } + }, + "queries": [ + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "instant": true, + "query": "node_load" + } + } + } + }, + { + "kind": "TimeSeriesQuery", + "spec": { + "plugin": { + "kind": "PrometheusTimeSeriesQuery", + "spec": { + "instant": true, + "query": "edge_traffic" + } + } + } + } + ] + } + } + }, + "layouts": [ + { + "kind": "Grid", + "spec": { + "items": [ + { + "x": 0, + "y": 0, + "width": 24, + "height": 16, + "content": { + "$ref": "#/spec/panels/topology" + } + } + ] + } + } + ] + } +} diff --git a/docs/canvas/examples/docker-compose.yml b/docs/canvas/examples/docker-compose.yml new file mode 100644 index 000000000..057d684c6 --- /dev/null +++ b/docs/canvas/examples/docker-compose.yml @@ -0,0 +1,17 @@ +# Self-contained demo exporter for the canvas data-driven graph mode. +# +# Serves metrics/topology.prom at http://localhost:9101/metrics so the Perses dev Prometheus can +# scrape a controlled node/edge topology. See README.md for the full walkthrough. +# +# docker compose --file docs/canvas/examples/docker-compose.yml up --detach +# +# nginx serves the static exposition file as-is; no image build required. +services: + canvas-demo-metrics: + image: nginx:latest + container_name: canvas-demo-metrics + ports: + - '9101:80' + volumes: + - ./metrics/topology.prom:/usr/share/nginx/html/metrics:ro + - ./metrics/nginx.conf:/etc/nginx/conf.d/default.conf:ro diff --git a/docs/canvas/examples/metrics/nginx.conf b/docs/canvas/examples/metrics/nginx.conf new file mode 100644 index 000000000..589bcb80f --- /dev/null +++ b/docs/canvas/examples/metrics/nginx.conf @@ -0,0 +1,9 @@ +# nginx config for the canvas demo exporter: serve the static exposition file as text/plain so +# Prometheus v3 accepts it (it rejects the default application/octet-stream content type). +server { + listen 80; + location = /metrics { + default_type text/plain; + alias /usr/share/nginx/html/metrics; + } +} diff --git a/docs/canvas/examples/metrics/topology.prom b/docs/canvas/examples/metrics/topology.prom new file mode 100644 index 000000000..8994cbb65 --- /dev/null +++ b/docs/canvas/examples/metrics/topology.prom @@ -0,0 +1,73 @@ +# Demo metrics for the canvas panel's data-driven graph mode. +# +# A small microservice topology shaped exactly the way the canvas "From data" mode expects: +# - node_load{instance="..."} one series per node; label `instance` is the node id +# - edge_traffic{source=...,target=...} one series per directed edge +# +# The last value of each series (0..1) drives node/edge color via the panel thresholds, and edge +# thickness via the edge-thickness thresholds. The extra labels (`name`, `role`, `tier`, `region`) +# carry no behavior on their own — they exist so the demo dashboard can show off rich label +# templates like `{{name}}` / `{{role}} · {{region}}` and the `{{value}}` placeholder. +# +# Topology (an e-commerce-style request path across two regions): +# +# ┌───────────────┐ +# │ edge gateway │ +# └──────┬────────┘ +# ┌─────────┼──────────┐ +# ▼ ▼ ▼ +# web-eu web-us auth (shared) +# │ │ │ +# ▼ ▼ ▼ +# api-eu ◄──► api-us (both call auth) +# │ │ │ │ +# ▼ ▼ ▼ ▼ +# orders cart orders search +# │ │ │ │ +# ▼ ▼ ▼ ▼ +# db-eu cache db-us db-us (+ cross-region db replication) +# +# Served at /metrics by the nginx service in docker-compose.yml. See README.md. + +# HELP node_load Synthetic per-service load in [0,1]. `instance` is the node id. +# TYPE node_load gauge +node_load{instance="gateway",name="Edge Gateway",role="ingress",tier="edge",region="global"} 0.41 +node_load{instance="auth",name="Auth Service",role="authn",tier="platform",region="global"} 0.62 +node_load{instance="web-eu",name="Web (EU)",role="frontend",tier="web",region="eu-west"} 0.28 +node_load{instance="web-us",name="Web (US)",role="frontend",tier="web",region="us-east"} 0.36 +node_load{instance="api-eu",name="API (EU)",role="backend",tier="app",region="eu-west"} 0.54 +node_load{instance="api-us",name="API (US)",role="backend",tier="app",region="us-east"} 0.73 +node_load{instance="orders-eu",name="Orders (EU)",role="service",tier="app",region="eu-west"} 0.48 +node_load{instance="orders-us",name="Orders (US)",role="service",tier="app",region="us-east"} 0.81 +node_load{instance="cart",name="Cart Service",role="service",tier="app",region="eu-west"} 0.33 +node_load{instance="search",name="Search Service",role="service",tier="app",region="us-east"} 0.67 +node_load{instance="cache",name="Redis Cache",role="cache",tier="data",region="eu-west"} 0.22 +node_load{instance="db-eu",name="Postgres (EU)",role="database",tier="data",region="eu-west"} 0.59 +node_load{instance="db-us",name="Postgres (US)",role="database",tier="data",region="us-east"} 0.92 + +# HELP edge_traffic Synthetic per-link traffic in [0,1]. `source`/`target` are node ids. +# TYPE edge_traffic gauge +# Gateway fans out to the regional frontends and the shared auth service. +edge_traffic{source="gateway",target="web-eu"} 0.44 +edge_traffic{source="gateway",target="web-us"} 0.52 +edge_traffic{source="gateway",target="auth"} 0.61 +# Frontends call their regional API. +edge_traffic{source="web-eu",target="api-eu"} 0.47 +edge_traffic{source="web-us",target="api-us"} 0.58 +# Both APIs authenticate against the shared auth service. +edge_traffic{source="api-eu",target="auth"} 0.39 +edge_traffic{source="api-us",target="auth"} 0.55 +# APIs call their domain services. +edge_traffic{source="api-eu",target="orders-eu"} 0.63 +edge_traffic{source="api-eu",target="cart"} 0.31 +edge_traffic{source="api-us",target="orders-us"} 0.84 +edge_traffic{source="api-us",target="search"} 0.70 +# Cross-region API chatter (failover / shared traffic). +edge_traffic{source="api-eu",target="api-us"} 0.26 +# Services reach their data stores. +edge_traffic{source="orders-eu",target="db-eu"} 0.57 +edge_traffic{source="orders-us",target="db-us"} 0.88 +edge_traffic{source="cart",target="cache"} 0.24 +edge_traffic{source="search",target="db-us"} 0.66 +# Database replication EU -> US. +edge_traffic{source="db-eu",target="db-us"} 0.49