diff --git a/bazel/ui.bzl b/bazel/ui.bzl index 00399155200..435e21aedef 100644 --- a/bazel/ui.bzl +++ b/bazel/ui.bzl @@ -40,7 +40,7 @@ def _pl_webpack_deps_impl(ctx): cmd = ui_shared_cmds_start + cp_cmds + [ 'pushd "$TMPPATH/src/ui" &> /dev/null', - "yarn install --immutable &> build.log", + "/opt/px_dev/tools/node/bin/yarn install --immutable &> build.log", # Pick a deterministic mtime so that the output is not volatile. # This helps ensure that bazel can cache the ui builds as expected. 'tar --mtime="2018-01-01 00:00:00 UTC" -czf "$BASE_PATH/{}" .'.format(out.path), @@ -87,9 +87,7 @@ def _pl_webpack_library_impl(ctx): 'pushd "$TMPPATH/src/ui" &> /dev/null', 'tar -xzf "$BASE_PATH/{}"'.format(ctx.file.deps.path), 'mv -f "$BASE_PATH/{}" src/pages/credits/licenses.json'.format(ctx.file.licenses.path), - "retval=0", - "output=`yarn build_prod 2>&1` || retval=$?", - '[ "$retval" -eq 0 ] || (echo $output; echo "Build Failed with Code: $retval"; exit $retval)', + "/opt/px_dev/tools/node/bin/yarn build_prod", 'cp dist/bundle.tar.gz "$BASE_PATH/{}"'.format(out.path), ] + ui_shared_cmds_finish @@ -165,8 +163,8 @@ def _pl_deps_licenses_impl(ctx): 'pushd "$TMPPATH/src/ui" &> /dev/null', 'export LIC_TMPPATH="$(mktemp -d)"', 'tar -xzf "$BASE_PATH/{}"'.format(ctx.file.deps.path), - "yarn license_check --excludePrivatePackages --production --json --out $LIC_TMPPATH/checker.json", - 'yarn pnpify node ./tools/licenses/yarn_license_extractor.js --input=$LIC_TMPPATH/checker.json --output="$BASE_PATH/{}"'.format(out.path), + "/opt/px_dev/tools/node/bin/yarn license_check --excludePrivatePackages --production --json --out $LIC_TMPPATH/checker.json", + '/opt/px_dev/tools/node/bin/yarn pnpify node ./tools/licenses/yarn_license_extractor.js --input=$LIC_TMPPATH/checker.json --output="$BASE_PATH/{}"'.format(out.path), ] + ui_shared_cmds_finish ctx.actions.run_shell( diff --git a/private/cockpit/script_bundles_config.yaml b/private/cockpit/script_bundles_config.yaml index 963c4ee6724..5930e5693bd 100644 --- a/private/cockpit/script_bundles_config.yaml +++ b/private/cockpit/script_bundles_config.yaml @@ -6,7 +6,7 @@ metadata: data: SCRIPT_BUNDLE_URLS: >- [ - "https://k8sstormcenter.github.io/pixie/pxl_scripts/bundle.json" + "/bundle-oss.json" ] SCRIPT_BUNDLE_DEV: "false" PL_SCRIPT_MODIFICATION_DISABLED: "false" diff --git a/src/api/proto/vispb/vis.proto b/src/api/proto/vispb/vis.proto index b5edb2058bd..a208db71462 100644 --- a/src/api/proto/vispb/vis.proto +++ b/src/api/proto/vispb/vis.proto @@ -379,6 +379,12 @@ message Graph { // The value at which the edge should be considered 'HIGH'. int64 high_threshold = 2; } + message NodeThresholds { + // The value at which the node should be considered 'MEDIUM'. + int64 medium_threshold = 1; + // The value at which the node should be considered 'HIGH'. + int64 high_threshold = 2; + } oneof input { // The column which contains the dot-formatted graph file to render. string dot_column = 1; @@ -399,6 +405,21 @@ message Graph { int64 edge_length = 8; // Whether the graph should start in hierarchy mode as a default. bool enable_default_hierarchy = 9; + // The column whose value is rendered as a persistent label on the edge. Optional. + // When unset, edges have no label (current behaviour). + string edge_label_column = 10; + // The column whose value overrides the default node id label. Optional. + // When unset, the node id is used as the label (current behaviour). + string node_label_column = 11; + // The column to use to determine what the color (fill) of the node should be. Optional. + // Interpreted via NodeThresholds when set; falls back to the SemanticType-derived shape + // color when unset. + string node_color_column = 12; + // The threshold at which node values are classified as a 'LOW'/'MEDIUM'/'HIGH' color. + // Optional, but must have a NodeColorColumn specified. + NodeThresholds node_thresholds = 13; + // The columns to display when hovering over a node. Optional. + repeated string node_hover_info = 14; } // Display traffic between pods or services as a graph. diff --git a/src/pxl_scripts/BUILD.bazel b/src/pxl_scripts/BUILD.bazel index d833444c519..40f6d2098fc 100644 --- a/src/pxl_scripts/BUILD.bazel +++ b/src/pxl_scripts/BUILD.bazel @@ -39,9 +39,10 @@ genrule( ], outs = ["bundle-oss.json"], cmd = """ - export PATH_PREFIX=$$(dirname $(location //src/pxl_scripts:Makefile))/; - EXECUTABLES=../../$(location //src/pixie_cli:px) make -C $$PATH_PREFIX bundle-oss.json; - cp bundle-oss.json $(@D)/bundle-oss.json + MAKEDIR=$$(dirname $(location //src/pxl_scripts:Makefile)); + PX_ABS=$$(pwd)/$(location //src/pixie_cli:px); + ( cd $$MAKEDIR && EXECUTABLES=$$PX_ABS make PATH_PREFIX= bundle-oss.json ); + cp $$MAKEDIR/bundle-oss.json $(@D)/bundle-oss.json """, tools = [ "//src/pixie_cli:px", diff --git a/src/pxl_scripts/px/dx_evidence_graph/README.md b/src/pxl_scripts/px/dx_evidence_graph/README.md new file mode 100644 index 00000000000..d7e09d709c4 --- /dev/null +++ b/src/pxl_scripts/px/dx_evidence_graph/README.md @@ -0,0 +1,89 @@ +# dx_evidence_graph + +A Pixie UI dashboard that renders one dx-agent investigation as a +**severity-weighted, all-protocol pod-to-pod attack graph**. Replaces +the latency-weighted HTTP service map in `cluster_overview` for +security work. + +* Nodes = pods. Falls back to service → IP, mirroring `net_flow_graph`. +* Edges = the attack path emitted by dx (delivery → egress → + execution → collection → exfil → pivot). +* Display spec: `vispb.Graph`. **`edgeWeightColumn = weight`** + (open-ended UInt16 sum of CRS severity → edge thickness), + **`edgeColorColumn = max_severity`** (discrete 2-5 heat → edge + colour). +* Read source: `forensic_db.dx_attack_graph` via `px.DataFrame`'s + `clickhouse_dsn` kwarg (`src/carnot/planner/objects/dataframe.cc:43`). + +## Schema — `forensic_db.dx_attack_graph` + +Locked with dx-agent in PR #62 / `entlein/dx#68`. The +`attackgraph.Edge` Go struct is the single source of truth for the +JSON wire format, the ClickHouse row, and the test fixture. + +| Column | Type | Role | +|---|---|---| +| `investigation_id` | String | one graph per dx verdict / pivot incident (UI filter key) | +| `ts` | UInt64 | unix nanos | +| `requestor_pod` / `responder_pod` | String | the hop (`ns/pod`); `""` if only an IP is known | +| `requestor_service` / `responder_service` | String | | +| `requestor_ip` / `responder_ip` | String | peer IP when pod unresolved | +| `weight` | UInt16 | Σ CRS severity on the hop — `edgeWeightColumn` | +| `max_severity` | UInt8 | top single-criterion severity (2-5) — `edgeColorColumn` | +| `confidence` | Float32 | verdict confidence | +| `edge_kind` | String | `delivery`/`egress`/`execution`/`collection`/`exfil`/`pivot` | +| `condition` / `criteria` | String | ruled-in condition + criterion label(s) | +| `num_findings` | UInt32 | | + +Table DDL (mirrors `kubescape_logs` partition/TTL convention): + +```sql +CREATE TABLE forensic_db.dx_attack_graph ( ...columns above... ) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Nano(ts)) +ORDER BY (investigation_id, requestor_pod, responder_pod) +TTL toDateTime(fromUnixTimestamp64Nano(ts)) + INTERVAL 30 DAY DELETE; +``` + +## Per-rig ClickHouse DSN + +The bundled `vis.json` ships with `clickhouse_dsn` **empty** — the +default is intentionally non-credentialed so the bundle stays +portable across clusters. Operators fill the DSN in via the Pixie +UI script-args panel at run time. + +For the in-cluster soc deployment the DSN is: + +``` +forensic_analyst:changeme-analyst@clickhouse-forensic-soc-db.clickhouse.svc.cluster.local:9000/forensic_db +``` + +`forensic_analyst` has read-only SELECT on `forensic_db`; same +credential the existing `soc/analysis/px_clickhouse/kubescape/observe.pxl` +script uses for `kubescape_logs`. Override in the UI for other rigs. + +## Deploy + +Bundle build path: + +1. `//src/pxl_scripts:script_bundle` walks every `*.pxl` + `vis.json` + under `src/pxl_scripts/` and emits `bundle-oss.json` + (`src/pxl_scripts/BUILD.bazel:34`). +2. `//src/cloud/proxy:proxy_server_image` bakes the bundle in as a + container layer at `/bundle` + (`src/cloud/proxy/BUILD.bazel:36`). +3. `skaffold run -f skaffold/skaffold_cloud.yaml` rebuilds the + cloud-proxy image and applies the Deployment. + +Vizier / PEM / standalone-pem images are unaffected — this is a +UI-bundle-only change. + +## Out of scope for v1 + +* `conn_stats` overlay (the "render the benign neighbourhood + light + up the attack path" view). Ship the attack-path-only graph first; + add the join in v2 once the visual has been used on a real + incident. +* Time anchoring relative to `ts` rather than free-form `start_time`. + Operators today use `-15m` defaults; a future widget could centre + the window on the investigation's first `ts`. diff --git a/src/pxl_scripts/px/dx_evidence_graph/dx_evidence_graph.pxl b/src/pxl_scripts/px/dx_evidence_graph/dx_evidence_graph.pxl new file mode 100644 index 00000000000..e45322a4ac6 --- /dev/null +++ b/src/pxl_scripts/px/dx_evidence_graph/dx_evidence_graph.pxl @@ -0,0 +1,39 @@ +# Copyright 2018- The Pixie 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. +# +# SPDX-License-Identifier: Apache-2.0 + +import px + + +def dx_attack_graph(start_time: str, clickhouse_dsn: str, table: str): + df = px.DataFrame(table, clickhouse_dsn=clickhouse_dsn, start_time=start_time) + # Node identity: pod, else service, else IP. Edges whose peer is an IP or a + # non-pod entity (the k8s API server, an external endpoint, a consulted + # socket) have an empty *_pod; keying the graph on *_pod alone collapses ALL + # of them into one bogus "" node. Coalesce so each distinct peer is its own + # node — same idiom Pixie's net_flow_graph uses (px.select(src=='', src_ip, src)). + df.requestor = px.select(df.requestor_pod == '', + px.select(df.requestor_service == '', df.requestor_ip, df.requestor_service), + df.requestor_pod) + df.responder = px.select(df.responder_pod == '', + px.select(df.responder_service == '', df.responder_ip, df.responder_service), + df.responder_pod) + return df[['requestor', 'responder', + 'requestor_pod', 'responder_pod', + 'requestor_service', 'responder_service', + 'requestor_ip', 'responder_ip', + 'weight', 'max_severity', 'confidence', + 'edge_kind', 'condition', 'criteria', 'num_findings', + 'investigation_id']] diff --git a/src/pxl_scripts/px/dx_evidence_graph/manifest.yaml b/src/pxl_scripts/px/dx_evidence_graph/manifest.yaml new file mode 100644 index 00000000000..d98c6882a58 --- /dev/null +++ b/src/pxl_scripts/px/dx_evidence_graph/manifest.yaml @@ -0,0 +1,9 @@ +--- +short: DX Attack Graph +long: > + Severity-weighted, all-protocol pod-to-pod attack graph for one + dx-agent investigation. Renders attackgraph.Edge records emitted by + dx with weight (sum of CRS evidence severity) on the edges and + max_severity colouring the heat. v0 manual-load only — wires up to + the dx_attack_graph ClickHouse / Pixie ingest in v1. See README.md + in this directory. diff --git a/src/pxl_scripts/px/dx_evidence_graph/vis.json b/src/pxl_scripts/px/dx_evidence_graph/vis.json new file mode 100644 index 00000000000..facd8eacd19 --- /dev/null +++ b/src/pxl_scripts/px/dx_evidence_graph/vis.json @@ -0,0 +1,75 @@ +{ + "variables": [ + { + "name": "start_time", + "type": "PX_STRING", + "description": "Start time of the window.", + "defaultValue": "-15m" + }, + { + "name": "clickhouse_dsn", + "type": "PX_STRING", + "description": "ClickHouse DSN: user:pass@host:port/db.", + "defaultValue": "forensic_analyst:changeme-analyst@clickhouse-forensic-soc-db.clickhouse.svc.cluster.local:9000/forensic_db" + }, + { + "name": "table", + "type": "PX_STRING", + "description": "dx_attack_graph_malicious (default; rule-ins only — benign is NOT pulled from ClickHouse) or dx_attack_graph (full table, includes benign).", + "defaultValue": "dx_attack_graph_malicious" + } + ], + "globalFuncs": [ + { + "outputName": "dx_graph", + "func": { + "name": "dx_attack_graph", + "args": [ + {"name": "start_time", "variable": "start_time"}, + {"name": "clickhouse_dsn", "variable": "clickhouse_dsn"}, + {"name": "table", "variable": "table"} + ] + } + } + ], + "widgets": [ + { + "name": "DX Attack Graph", + "position": {"x": 0, "y": 0, "w": 12, "h": 5}, + "globalFuncOutputName": "dx_graph", + "displaySpec": { + "@type": "types.px.dev/px.vispb.Graph", + "adjacencyList": { + "fromColumn": "requestor", + "toColumn": "responder" + }, + "edgeWeightColumn": "weight", + "edgeColorColumn": "max_severity", + "edgeLabelColumn": "edge_kind", + "edgeThresholds": { + "mediumThreshold": 3, + "highThreshold": 4 + }, + "edgeHoverInfo": [ + "edge_kind", + "condition", + "criteria", + "weight", + "max_severity", + "confidence", + "num_findings", + "investigation_id" + ], + "edgeLength": 500 + } + }, + { + "name": "Edges", + "position": {"x": 0, "y": 5, "w": 12, "h": 4}, + "globalFuncOutputName": "dx_graph", + "displaySpec": { + "@type": "types.px.dev/px.vispb.Table" + } + } + ] +} diff --git a/src/ui/src/containers/live-widgets/graph/graph-utils.ts b/src/ui/src/containers/live-widgets/graph/graph-utils.ts index d7a219a5e6d..467a25061bb 100644 --- a/src/ui/src/containers/live-widgets/graph/graph-utils.ts +++ b/src/ui/src/containers/live-widgets/graph/graph-utils.ts @@ -63,6 +63,12 @@ export function getGraphOptions(theme: Theme, edgeLength: number): Options { smooth: false, scaling: { max: 5, + label: false, + }, + font: { + strokeWidth: 0, + color: theme.palette.text.primary, + face: 'Roboto', }, arrows: { to: { diff --git a/src/ui/src/containers/live-widgets/graph/graph.tsx b/src/ui/src/containers/live-widgets/graph/graph.tsx index 4ebae4519ba..4ea9c7788f7 100644 --- a/src/ui/src/containers/live-widgets/graph/graph.tsx +++ b/src/ui/src/containers/live-widgets/graph/graph.tsx @@ -20,6 +20,7 @@ import * as React from 'react'; import { Button } from '@mui/material'; import { useTheme } from '@mui/material/styles'; +import { createPortal } from 'react-dom'; import { useHistory } from 'react-router-dom'; import { data as visData, @@ -56,6 +57,11 @@ interface EdgeThresholds { highThreshold: number; } +interface NodeThresholds { + mediumThreshold: number; + highThreshold: number; +} + export interface GraphDisplay extends WidgetDisplay { readonly dotColumn?: string; readonly adjacencyList?: AdjacencyList; @@ -67,6 +73,11 @@ export interface GraphDisplay extends WidgetDisplay { readonly edgeHoverInfo?: string[]; readonly edgeLength?: number; readonly enableDefaultHierarchy?: boolean; + readonly edgeLabelColumn?: string; + readonly nodeLabelColumn?: string; + readonly nodeColorColumn?: string; + readonly nodeThresholds?: NodeThresholds; + readonly nodeHoverInfo?: string[]; } interface GraphProps { @@ -82,6 +93,11 @@ interface GraphProps { edgeHoverInfo?: ColInfo[]; edgeLength?: number; enableDefaultHierarchy?: boolean; + edgeLabelColumn?: ColInfo; + nodeLabelColumn?: ColInfo; + nodeColorColumn?: ColInfo; + nodeThresholds?: NodeThresholds; + nodeHoverInfo?: ColInfo[]; setExternalControls?: React.RefCallback; } @@ -125,9 +141,20 @@ function getColorForEdge(col: ColInfo, val: number, thresholds: EdgeThresholds): return val > highThreshold ? 'high' : 'med'; } +function getColorForNode(val: number, thresholds: NodeThresholds): GaugeLevel { + const medThreshold = thresholds ? thresholds.mediumThreshold : 100; + const highThreshold = thresholds ? thresholds.highThreshold : 200; + + if (val < medThreshold) { + return 'low'; + } + return val > highThreshold ? 'high' : 'med'; +} + export const Graph = React.memo(({ dot, toCol, fromCol, data, propagatedArgs, edgeWeightColumn, nodeWeightColumn, edgeColorColumn, edgeThresholds, edgeHoverInfo, edgeLength, enableDefaultHierarchy, + edgeLabelColumn, nodeLabelColumn, nodeColorColumn, nodeThresholds, nodeHoverInfo, setExternalControls, }) => { const theme = useTheme(); @@ -138,6 +165,16 @@ export const Graph = React.memo(({ const [hierarchyEnabled, setHierarchyEnabled] = React.useState(enableDefaultHierarchy); const [network, setNetwork] = React.useState(null); const [graph, setGraph] = React.useState(null); + const [pinned, setPinned] = React.useState>([]); + const pinSeq = React.useRef(0); + + const [edgeLabels, setEdgeLabels] = React.useState>(() => new Map()); + const [labelOffsets, setLabelOffsets] = React.useState>(() => new Map()); + const [labelLayout, setLabelLayout] = React.useState>([]); const { embedState } = React.useContext(LiveRouteContext); @@ -170,6 +207,9 @@ export const Graph = React.memo(({ const edges = new visData.DataSet(); const nodes = new visData.DataSet(); const idToSemType = {}; + const labelMap = new Map(); + const selfLoopCounts = new Map(); + const selfLoopRank = new Map(); const upsertNode = (label: string, st: SemanticType, weight: number) => { if (!idToSemType[label]) { @@ -187,7 +227,7 @@ export const Graph = React.memo(({ idToSemType[label] = st; } }; - data.forEach((d) => { + data.forEach((d, idx) => { const nt = d[toCol.name]; const nf = d[fromCol.name]; @@ -199,7 +239,9 @@ export const Graph = React.memo(({ upsertNode(nt, toCol?.semType, nodeWeight); upsertNode(nf, fromCol?.semType, nodeWeight); + const edgeId = `e${idx}`; const edge = { + id: edgeId, from: nf, to: nt, } as Edge; @@ -213,6 +255,16 @@ export const Graph = React.memo(({ edge.color = getColor(level, theme); } + if (edgeLabelColumn) { + labelMap.set(edgeId, String(d[edgeLabelColumn.name])); + edge.label = ''; + if (nf === nt) { + const rank = selfLoopCounts.get(nf) || 0; + selfLoopCounts.set(nf, rank + 1); + selfLoopRank.set(edgeId, rank); + } + } + if (edgeHoverInfo && edgeHoverInfo.length > 0) { let edgeInfo = ''; edgeHoverInfo.forEach((info, i) => { @@ -236,6 +288,17 @@ export const Graph = React.memo(({ setGraph({ nodes, edges, idToSemType, }); + setEdgeLabels(labelMap); + setLabelOffsets((prev) => { + const next = new Map(); + selfLoopRank.forEach((rank, edgeId) => { + next.set(edgeId, prev.get(edgeId) || { dx: 0, dy: -28 - rank * 22 }); + }); + labelMap.forEach((_, edgeId) => { + if (!next.has(edgeId)) next.set(edgeId, prev.get(edgeId) || { dx: 0, dy: 0 }); + }); + return next; + }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dot, data, toCol, fromCol]); @@ -257,9 +320,31 @@ export const Graph = React.memo(({ }; } + if (ref.current) { + ref.current.style.position = 'relative'; + } + const n = new Network(ref.current, graph, opts); n.on('doubleClick', doubleClickCallback); + n.on('click', (params: any) => { + if (params.edges.length > 0 && params.nodes.length === 0) { + const edgeId = params.edges[0]; + const edgeData: any = graph.edges.get(edgeId); + const rect = ref.current.getBoundingClientRect(); + const text = edgeLabels.get(edgeId) || String(edgeData?.label ?? ''); + pinSeq.current += 1; + const key = pinSeq.current; + setPinned((prev) => [...prev, { + key, + label: text, + title: String(edgeData?.title ?? ''), + x: rect.left + params.pointer.DOM.x, + y: rect.top + params.pointer.DOM.y, + }]); + } + }); + n.on('stabilizationIterationsDone', () => { n.setOptions({ physics: false }); }); @@ -268,6 +353,81 @@ export const Graph = React.memo(({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [graph, doubleClickCallback, hierarchyEnabled]); + React.useEffect(() => { + if (!network || edgeLabels.size === 0) { + setLabelLayout([]); + return undefined; + } + let raf = 0; + const recompute = () => { + if (raf) cancelAnimationFrame(raf); + raf = requestAnimationFrame(() => { + const next: Array<{ id: string, text: string, x: number, y: number }> = []; + edgeLabels.forEach((text, edgeId) => { + const ends = network.getConnectedNodes(edgeId) as Array; + if (!ends || ends.length === 0) return; + const fromId = String(ends[0]); + const toId = String(ends.length > 1 ? ends[1] : ends[0]); + const fromPos = network.getPositions([fromId])[fromId]; + const toPos = network.getPositions([toId])[toId]; + if (!fromPos || !toPos) return; + const cx = (fromPos.x + toPos.x) / 2; + const cy = (fromPos.y + toPos.y) / 2; + const dom = network.canvasToDOM({ x: cx, y: cy }); + const off = labelOffsets.get(edgeId) || { dx: 0, dy: 0 }; + next.push({ id: edgeId, text, x: dom.x + off.dx, y: dom.y + off.dy }); + }); + setLabelLayout(next); + }); + }; + network.on('afterDrawing', recompute); + recompute(); + return () => { + network.off('afterDrawing', recompute); + if (raf) cancelAnimationFrame(raf); + }; + }, [network, edgeLabels, labelOffsets]); + + const onLabelPointerDown = React.useCallback((edgeId: string) => (e: React.PointerEvent) => { + e.stopPropagation(); + e.preventDefault(); + const startX = e.clientX; + const startY = e.clientY; + const initial = labelOffsets.get(edgeId) || { dx: 0, dy: 0 }; + const move = (ev: PointerEvent) => { + setLabelOffsets((prev) => { + const m = new Map(prev); + m.set(edgeId, { dx: initial.dx + ev.clientX - startX, dy: initial.dy + ev.clientY - startY }); + return m; + }); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }, [labelOffsets]); + + const onPinPointerDown = React.useCallback((key: number, initialX: number, initialY: number) => + (e: React.PointerEvent) => { + if ((e.target as HTMLElement).closest('[data-pin-close]')) return; + e.stopPropagation(); + const startX = e.clientX; + const startY = e.clientY; + const move = (ev: PointerEvent) => { + setPinned((prev) => prev.map((p) => (p.key === key + ? { ...p, x: initialX + ev.clientX - startX, y: initialY + ev.clientY - startY } + : p))); + }; + const up = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }, []); + const controls = React.useMemo(() => (