From a319a8bda7ce464d799a4f30a5724447baac11b4 Mon Sep 17 00:00:00 2001 From: Mike Burgess Date: Tue, 10 Dec 2024 12:19:48 +0000 Subject: [PATCH] Add download to CSV and open in new window options for benchmarks (#622) --- internal/dashboardserver/server.go | 6 + ui/dashboard/package.json | 38 +- .../dashboards/Table/TableSettings.tsx | 2 +- .../src/components/dashboards/Table/index.tsx | 27 +- .../dashboards/grouping/Benchmark/index.tsx | 42 +- .../DetectionBenchmark/ControlDimension.tsx | 13 - .../grouping/DetectionBenchmark/index.tsx | 60 ++- .../grouping/DetectionPanel/index.tsx | 188 +++++-- .../dashboards/grouping/common/Detection.ts | 56 +-- .../grouping/common/DetectionBenchmark.ts | 62 +-- .../dashboards/grouping/common/index.ts | 5 - .../grouping/common/node/DetectionNode.ts | 8 + .../dashboards/layout/Panel/PanelControls.tsx | 21 +- .../dashboards/layout/Panel/index.tsx | 15 +- .../PanelDetailDataDownloadButton.tsx | 2 +- .../src/hooks/useDetectionGrouping.tsx | 1 + .../useDownloadDetectionBenchmarkData.ts | 28 ++ .../src/hooks/useDownloadDetectionData.ts | 65 +++ .../src/hooks/useDownloadPanelData.ts | 88 ++-- ui/dashboard/src/hooks/usePanel.tsx | 41 +- ui/dashboard/src/hooks/usePanelControls.ts | 49 -- ui/dashboard/src/hooks/usePanelControls.tsx | 164 +++++++ ui/dashboard/src/utils/func.ts | 4 +- ui/dashboard/yarn.lock | 463 ++++++++++-------- 24 files changed, 913 insertions(+), 535 deletions(-) delete mode 100644 ui/dashboard/src/components/dashboards/grouping/DetectionBenchmark/ControlDimension.tsx create mode 100644 ui/dashboard/src/hooks/useDownloadDetectionBenchmarkData.ts create mode 100644 ui/dashboard/src/hooks/useDownloadDetectionData.ts delete mode 100644 ui/dashboard/src/hooks/usePanelControls.ts create mode 100644 ui/dashboard/src/hooks/usePanelControls.tsx diff --git a/internal/dashboardserver/server.go b/internal/dashboardserver/server.go index 58a70feb..917d72e1 100644 --- a/internal/dashboardserver/server.go +++ b/internal/dashboardserver/server.go @@ -162,6 +162,8 @@ func (s *Server) HandleDashboardEvent(ctx context.Context, event dashboardevents changedCards := e.ChangedCards changedCharts := e.ChangedCharts changedDashboards := e.ChangedDashboards + changedDetections := e.ChangedDetections + changedDetectionsBenchmarks := e.ChangedDetectionBenchmarks changedEdges := e.ChangedEdges changedFlows := e.ChangedFlows changedGraphs := e.ChangedGraphs @@ -182,6 +184,8 @@ func (s *Server) HandleDashboardEvent(ctx context.Context, event dashboardevents len(changedCards) == 0 && len(changedCharts) == 0 && len(changedDashboards) == 0 && + len(changedDetections) == 0 && + len(changedDetectionsBenchmarks) == 0 && len(changedEdges) == 0 && len(changedFlows) == 0 && len(changedGraphs) == 0 && @@ -241,6 +245,8 @@ func (s *Server) HandleDashboardEvent(ctx context.Context, event dashboardevents changedDashboardNames = append(changedDashboardNames, getDashboardsInterestedInResourceChanges(dashboardsBeingWatched, changedDashboardNames, changedControls)...) changedDashboardNames = append(changedDashboardNames, getDashboardsInterestedInResourceChanges(dashboardsBeingWatched, changedDashboardNames, changedCards)...) changedDashboardNames = append(changedDashboardNames, getDashboardsInterestedInResourceChanges(dashboardsBeingWatched, changedDashboardNames, changedCharts)...) + changedDashboardNames = append(changedDashboardNames, getDashboardsInterestedInResourceChanges(dashboardsBeingWatched, changedDashboardNames, changedDetections)...) + changedDashboardNames = append(changedDashboardNames, getDashboardsInterestedInResourceChanges(dashboardsBeingWatched, changedDashboardNames, changedDetectionsBenchmarks)...) changedDashboardNames = append(changedDashboardNames, getDashboardsInterestedInResourceChanges(dashboardsBeingWatched, changedDashboardNames, changedEdges)...) changedDashboardNames = append(changedDashboardNames, getDashboardsInterestedInResourceChanges(dashboardsBeingWatched, changedDashboardNames, changedFlows)...) changedDashboardNames = append(changedDashboardNames, getDashboardsInterestedInResourceChanges(dashboardsBeingWatched, changedDashboardNames, changedGraphs)...) diff --git a/ui/dashboard/package.json b/ui/dashboard/package.json index a0cebae5..7280f5f9 100644 --- a/ui/dashboard/package.json +++ b/ui/dashboard/package.json @@ -17,11 +17,11 @@ "dependencies": { "@headlessui/react": "1.7.19", "@heroicons/react": "2.2.0", - "@material-symbols/svg-300": "0.27.1", + "@material-symbols/svg-300": "0.27.2", "@popperjs/core": "2.11.8", "@supabase/sql-formatter": "4.0.3", "@tanstack/react-table": "8.20.5", - "@tanstack/react-virtual": "3.10.9", + "@tanstack/react-virtual": "3.11.1", "color-convert": "2.0.1", "copy-to-clipboard": "3.3.3", "dagre": "0.8.5", @@ -31,12 +31,12 @@ "echarts-for-react": "3.0.2", "echarts-gl": "2.0.9", "file-saver": "2.0.5", - "framer-motion": "11.12.0", + "framer-motion": "11.13.4", "jq-wasm": "0.0.9", "lodash": "4.17.21", "react": "18.3.1", "react-cool-img": "1.2.12", - "react-day-picker": "9.4.0", + "react-day-picker": "9.4.2", "react-dom": "18.3.1", "react-hotkeys": "2.0.0", "react-markdown": "9.0.1", @@ -59,28 +59,28 @@ "devDependencies": { "@chromatic-com/storybook": "3.2.2", "@craco/craco": "7.1.0", - "@storybook/addon-actions": "8.4.5", - "@storybook/addon-essentials": "8.4.5", - "@storybook/addon-links": "8.4.5", - "@storybook/node-logger": "8.4.5", - "@storybook/preset-create-react-app": "8.4.5", - "@storybook/preview-api": "8.4.5", - "@storybook/react": "8.4.5", - "@storybook/react-webpack5": "8.4.5", - "@storybook/theming": "8.4.5", + "@storybook/addon-actions": "8.4.7", + "@storybook/addon-essentials": "8.4.7", + "@storybook/addon-links": "8.4.7", + "@storybook/node-logger": "8.4.7", + "@storybook/preset-create-react-app": "8.4.7", + "@storybook/preview-api": "8.4.7", + "@storybook/react": "8.4.7", + "@storybook/react-webpack5": "8.4.7", + "@storybook/theming": "8.4.7", "@tailwindcss/forms": "0.5.9", "@tailwindcss/line-clamp": "0.4.4", "@tailwindcss/typography": "0.5.15", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "6.6.3", - "@testing-library/react": "16.0.1", + "@testing-library/react": "16.1.0", "@tsconfig/create-react-app": "2.0.5", "@types/echarts": "4.9.22", "@types/jest": "29.5.14", "@types/lodash": "4.17.13", "@types/node": "22.10.1", - "@types/react": "18.3.12", - "@types/react-dom": "18.3.1", + "@types/react": "18.3.14", + "@types/react-dom": "18.3.3", "autoprefixer": "10.4.20", "buffer": "6.0.3", "circular-dependency-plugin": "5.2.2", @@ -89,16 +89,16 @@ "if-node-version": "1.1.1", "lint-staged": "15.2.10", "npm-run-all": "4.1.5", - "prettier": "3.4.1", + "prettier": "3.4.2", "process": "0.11.10", "prop-types": "15.8.1", "react-scripts": "5.0.1", "source-map-explorer": "2.5.3", - "storybook": "8.4.5", + "storybook": "8.4.7", "storybook-addon-react-router-v6": "2.0.15", "storybook-dark-mode": "4.0.2", "stream-browserify": "3.0.0", - "tailwindcss": "3.4.15", + "tailwindcss": "3.4.16", "typescript": "4.5.5", "vm-browserify": "1.1.2" }, diff --git a/ui/dashboard/src/components/dashboards/Table/TableSettings.tsx b/ui/dashboard/src/components/dashboards/Table/TableSettings.tsx index 7570fadb..71dcc2d8 100644 --- a/ui/dashboard/src/components/dashboards/Table/TableSettings.tsx +++ b/ui/dashboard/src/components/dashboards/Table/TableSettings.tsx @@ -102,7 +102,7 @@ const TableSettings = ({ table }: { table: Table }) => { {/*@ts-ignore*/} - + {createPortal( diff --git a/ui/dashboard/src/components/dashboards/Table/index.tsx b/ui/dashboard/src/components/dashboards/Table/index.tsx index bb55cc95..0ebe88b2 100644 --- a/ui/dashboard/src/components/dashboards/Table/index.tsx +++ b/ui/dashboard/src/components/dashboards/Table/index.tsx @@ -23,9 +23,9 @@ import { LeafNodeDataColumn, LeafNodeDataRow, } from "../common"; -import { Filter } from "@powerpipe/components/dashboards/grouping/common"; import { classNames } from "@powerpipe/utils/styles"; import { createPortal } from "react-dom"; +import { Filter } from "@powerpipe/components/dashboards/grouping/common"; import { flexRender, getCoreRowModel, @@ -799,12 +799,37 @@ const TableViewVirtualizedRows = ({ doRender(); }, [columns, renderTemplates, rows, virtualizedRows, templateRenderReady]); + // const tableSettingsControl = useMemo(() => { + // if (!table) { + // return null; + // } + // return { + // key: "table-settings", + // title: "Table settings", + // icon: "data_table", + // //component: , + // action: noop, + // }; + // }, [table]); + + // const { enabled: panelControlsEnabled, setCustomControls } = + // usePanelControls(); + + // useEffect(() => { + // if (!panelControlsEnabled || !tableSettingsControl) { + // return; + // } + // setCustomControls([tableSettingsControl]); + // }, [panelControlsEnabled, tableSettingsControl, setCustomControls]); + return (
{filterEnabled && (
diff --git a/ui/dashboard/src/components/dashboards/grouping/Benchmark/index.tsx b/ui/dashboard/src/components/dashboards/grouping/Benchmark/index.tsx index d833a99a..cd0f1ab0 100644 --- a/ui/dashboard/src/components/dashboards/grouping/Benchmark/index.tsx +++ b/ui/dashboard/src/components/dashboards/grouping/Benchmark/index.tsx @@ -10,7 +10,6 @@ import Grid from "@powerpipe/components/dashboards/layout/Grid"; import Panel from "@powerpipe/components/dashboards/layout/Panel"; import PanelControls from "@powerpipe/components/dashboards/layout/Panel/PanelControls"; import useFilterConfig from "@powerpipe/hooks/useFilterConfig"; -import usePanelControls from "@powerpipe/hooks/usePanelControls"; import { BenchmarkTreeProps, CheckDisplayGroup, @@ -31,6 +30,10 @@ import { import { noop } from "@powerpipe/utils/func"; import { useDashboard } from "@powerpipe/hooks/useDashboard"; import { useEffect, useMemo, useState } from "react"; +import { + PanelControlsProvider, + usePanelControls, +} from "@powerpipe/hooks/usePanelControls"; import { Width } from "@powerpipe/components/dashboards/common"; const Table = getComponent("table"); @@ -46,7 +49,6 @@ type InnerCheckProps = { grouping: CheckNode; groupingConfig: CheckDisplayGroup[]; firstChildSummaries: CheckSummary[]; - showControls: boolean; withTitle: boolean; }; @@ -67,29 +69,35 @@ const Benchmark = (props: InnerCheckProps) => { }, [props.benchmark, props.grouping]); const [referenceElement, setReferenceElement] = useState(null); const [showBenchmarkControls, setShowBenchmarkControls] = useState(false); - const definitionWithData = useMemo(() => { - return { - ...props.definition, - data: benchmarkDataTable, - }; - }, [benchmarkDataTable, props.definition]); - const { panelControls: benchmarkControls, setCustomControls } = - usePanelControls(definitionWithData, props.showControls); + const { + panelControls: benchmarkControls, + showPanelControls, + setCustomControls, + setPanelData, + } = usePanelControls(); useEffect(() => { setCustomControls([ { + key: "filter-and-group", + title: "Filter & Group", + component: , action: async () => dispatch({ type: DashboardActions.SHOW_CUSTOMIZE_BENCHMARK_PANEL, panel_name: props.definition.name, }), - component: , - title: "Filter & Group", }, ]); }, [dispatch, props.definition.name, setCustomControls]); + useEffect(() => { + if (!benchmarkDataTable) { + return; + } + setPanelData(benchmarkDataTable); + }, [benchmarkDataTable, setPanelData]); + const summaryCards = useMemo(() => { if (!props.grouping) { return []; @@ -192,7 +200,7 @@ const Benchmark = (props: InnerCheckProps) => { name={props.definition.name} width={props.definition.width} events={{ - onMouseEnter: props.showControls + onMouseEnter: showPanelControls ? () => setShowBenchmarkControls(true) : noop, onMouseLeave: () => setShowBenchmarkControls(false), @@ -246,7 +254,6 @@ const Benchmark = (props: InnerCheckProps) => { key={summaryCard.name} definition={cardProps} parentType="benchmark" - showControls={false} > { +const Inner = ({ withTitle }) => { const { benchmark, definition, @@ -336,7 +343,6 @@ const Inner = ({ showControls, withTitle }) => { grouping={grouping} groupingConfig={groupingConfig} firstChildSummaries={firstChildSummaries} - showControls={showControls} withTitle={withTitle} /> ); @@ -371,7 +377,9 @@ type BenchmarkProps = PanelDefinition & { const BenchmarkWrapper = (props: BenchmarkProps) => { return ( - + + + ); }; diff --git a/ui/dashboard/src/components/dashboards/grouping/DetectionBenchmark/ControlDimension.tsx b/ui/dashboard/src/components/dashboards/grouping/DetectionBenchmark/ControlDimension.tsx deleted file mode 100644 index becd82f5..00000000 --- a/ui/dashboard/src/components/dashboards/grouping/DetectionBenchmark/ControlDimension.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { stringToColor } from "@powerpipe/utils/color"; - -const ControlDimension = ({ dimensionKey, dimensionValue }) => ( - - {dimensionValue} - -); - -export default ControlDimension; diff --git a/ui/dashboard/src/components/dashboards/grouping/DetectionBenchmark/index.tsx b/ui/dashboard/src/components/dashboards/grouping/DetectionBenchmark/index.tsx index abb7a9c7..5012de51 100644 --- a/ui/dashboard/src/components/dashboards/grouping/DetectionBenchmark/index.tsx +++ b/ui/dashboard/src/components/dashboards/grouping/DetectionBenchmark/index.tsx @@ -9,8 +9,8 @@ import FilterCardWrapper from "@powerpipe/components/dashboards/grouping/FilterC import Grid from "@powerpipe/components/dashboards/layout/Grid"; import Panel from "@powerpipe/components/dashboards/layout/Panel"; import PanelControls from "@powerpipe/components/dashboards/layout/Panel/PanelControls"; +import useDownloadDetectionBenchmarkData from "@powerpipe/hooks/useDownloadDetectionBenchmarkData"; import useFilterConfig from "@powerpipe/hooks/useFilterConfig"; -import usePanelControls from "@powerpipe/hooks/usePanelControls"; import { CardType } from "@powerpipe/components/dashboards/data/CardDataProcessor"; import { DashboardActions, PanelDefinition } from "@powerpipe/types"; import { DateRangePicker } from "@powerpipe/components/dashboards/inputs/DateRangePickerInput"; @@ -26,6 +26,10 @@ import { useDetectionGrouping, } from "@powerpipe/hooks/useDetectionGrouping"; import { noop } from "@powerpipe/utils/func"; +import { + PanelControlsProvider, + usePanelControls, +} from "@powerpipe/hooks/usePanelControls"; import { registerComponent } from "@powerpipe/components/dashboards"; import { TableViewWrapper as Table } from "@powerpipe/components/dashboards/Table"; import { useDashboard } from "@powerpipe/hooks/useDashboard"; @@ -52,40 +56,46 @@ const DetectionBenchmark = (props: InnerCheckProps) => { filter: { expressions }, } = useFilterConfig(props.definition?.name); const { dispatch, selectedPanel } = useDashboard(); - const benchmarkDataTable = useMemo(() => { - if ( - !props.benchmark || - !props.grouping || - props.grouping.status !== "complete" - ) { - return undefined; - } - return props.benchmark.get_data_table(); - }, [props.benchmark, props.grouping]); const [referenceElement, setReferenceElement] = useState(null); const [showBenchmarkControls, setShowBenchmarkControls] = useState(false); - const definitionWithData = useMemo(() => { - return { - ...props.definition, - data: benchmarkDataTable, - }; - }, [benchmarkDataTable, props.definition]); const { panelControls: benchmarkControls, setCustomControls } = - usePanelControls(definitionWithData, props.showControls); + usePanelControls(); + const { download, processing } = useDownloadDetectionBenchmarkData( + props.benchmark, + ); useEffect(() => { setCustomControls([ { + key: "filter-and-group", + title: "Filter & Group", + component: , action: async () => dispatch({ type: DashboardActions.SHOW_CUSTOMIZE_BENCHMARK_PANEL, panel_name: props.definition.name, }), - component: , - title: "Filter & Group", + }, + { + key: "download-data", + disabled: + processing || + !props.benchmark || + !props.grouping || + props.grouping.status !== "complete", + title: "Download data", + icon: "arrow-down-tray", + action: download, }, ]); - }, [dispatch, props.definition.name, setCustomControls]); + }, [ + dispatch, + processing, + props.benchmark, + props.grouping, + props.definition.name, + setCustomControls, + ]); const summaryCards = useMemo(() => { if (!props.grouping) { @@ -238,6 +248,10 @@ const DetectionBenchmark = (props: InnerCheckProps) => { {summaryCards .filter(({ name }) => { + // Always include the total card + if (name === `${props.definition.name}.container.summary.total`) { + return true; + } const severityFilter = expressions?.find( (expr) => expr.type === "severity", ); @@ -398,7 +412,9 @@ type DetectionBenchmarkWrapperProps = PanelDefinition & { const DetectionBenchmarkWrapper = (props: DetectionBenchmarkWrapperProps) => { return ( - + + + ); }; diff --git a/ui/dashboard/src/components/dashboards/grouping/DetectionPanel/index.tsx b/ui/dashboard/src/components/dashboards/grouping/DetectionPanel/index.tsx index bece73c2..1d7bcb5f 100644 --- a/ui/dashboard/src/components/dashboards/grouping/DetectionPanel/index.tsx +++ b/ui/dashboard/src/components/dashboards/grouping/DetectionPanel/index.tsx @@ -5,8 +5,10 @@ import DetectionNode from "@powerpipe/components/dashboards/grouping/common/node import DetectionResultNode from "../common/node/DetectionResultNode"; import DetectionSummaryChart from "@powerpipe/components/dashboards/grouping/DetetctionSummaryChart"; import DocumentationView from "@powerpipe/components/dashboards/grouping/DocumentationView"; +import PanelControls from "@powerpipe/components/dashboards/layout/Panel/PanelControls"; import sortBy from "lodash/sortBy"; import Table from "@powerpipe/components/dashboards/Table"; +import useDownloadDetectionData from "@powerpipe/hooks/useDownloadDetectionData"; import { AlarmIcon, CollapseBenchmarkIcon, @@ -24,8 +26,14 @@ import { GroupingActions, useDetectionGrouping, } from "@powerpipe/hooks/useDetectionGrouping"; +import { noop } from "@powerpipe/utils/func"; +import { + IPanelControl, + PanelControlsProvider, + usePanelControls, +} from "@powerpipe/hooks/usePanelControls"; import { useDashboard } from "@powerpipe/hooks/useDashboard"; -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; type DetectionChildrenProps = { depth: number; @@ -95,7 +103,7 @@ const DetectionChildren = ({ children, depth }: DetectionChildrenProps) => { return ( <> {children.map((child) => ( - + ))} ); @@ -285,42 +293,110 @@ const DetectionPanelSeverity = ({ ); }; +const recordChildResults = ( + node: DetectionNode, + allResults: DetectionResultNode[], +): DetectionResultNode[] => { + for (const child of node.children || []) { + if (child.type === "result") { + allResults.push(child as DetectionResultNode); + } else if (child.children?.length) { + return recordChildResults(child, allResults); + } + } + return allResults; +}; + const DetectionPanel = ({ depth, node }: DetectionPanelProps) => { const { firstChildSummaries, dispatch, groupingConfig, nodeStates } = useDetectionGrouping(); + const { + enabled: panelControlsEnabled, + panelControls, + showPanelControls, + setCustomControls, + setShowPanelControls, + } = usePanelControls(); + const [referenceElement, setReferenceElement] = useState(null); const expanded = nodeStates[node.name] ? nodeStates[node.name].expanded : false; - const [child_nodes, error_nodes, empty_nodes, result_nodes, can_be_expanded] = - useMemo(() => { - const children: DetectionNode[] = []; - const errors: DetectionErrorNode[] = []; - const empty: DetectionEmptyResultNode[] = []; - const results: DetectionResultNode[] = []; - for (const child of node.children || []) { - if (child.type === "error") { - errors.push(child as DetectionErrorNode); - } else if (child.type === "result") { - results.push(child as DetectionResultNode); - } else if (child.type === "empty_result") { - empty.push(child as DetectionEmptyResultNode); - } else if (child.type !== "running") { - children.push(child); - } + const [ + child_nodes, + error_nodes, + empty_nodes, + result_nodes, + descendant_result_nodes, + can_be_expanded, + ] = useMemo(() => { + const children: DetectionNode[] = []; + const errors: DetectionErrorNode[] = []; + const empty: DetectionEmptyResultNode[] = []; + const results: DetectionResultNode[] = []; + let descendantResults: DetectionResultNode[] = []; + for (const child of node.children || []) { + if (child.type === "error") { + errors.push(child as DetectionErrorNode); + } else if (child.type === "result") { + results.push(child as DetectionResultNode); + } else if (child.type === "empty_result") { + empty.push(child as DetectionEmptyResultNode); + } else if (child.type !== "running") { + children.push(child); + } + + if (child.children?.length) { + recordChildResults(child, descendantResults); } - return [ - sortBy(children, "sort"), - sortBy(errors, "sort"), - sortBy(empty, "sort"), - results, - children.length > 0 || - (groupingConfig && - groupingConfig.length > 0 && - groupingConfig[groupingConfig.length - 1].type === "result" && - (errors.length > 0 || empty.length > 0 || results.length > 0)), - ]; - }, [groupingConfig, node]); + } + return [ + sortBy(children, "sort"), + sortBy(errors, "sort"), + sortBy(empty, "sort"), + results, + descendantResults, + children.length > 0 || + (groupingConfig && + groupingConfig.length > 0 && + groupingConfig[groupingConfig.length - 1].type === "result" && + (errors.length > 0 || empty.length > 0 || results.length > 0)), + ]; + }, [groupingConfig, node]); + + const { download } = useDownloadDetectionData( + node, + descendant_result_nodes.length > 0 ? descendant_result_nodes : undefined, + ); + + useEffect(() => { + const controls: IPanelControl[] = [ + { + key: "download-data", + disabled: descendant_result_nodes.length === 0, + title: "Download data", + icon: "arrow-down-tray", + action: download, + }, + ]; + if (node.type === "benchmark") { + controls.push({ + key: "open-in-new-window", + title: "Open in new window", + icon: "open_in_new", + action: async () => { + console.log(window.location); + window.open(window.location.origin + "/" + node.name, "_blank"); + }, + }); + } + setCustomControls(controls); + }, [node, descendant_result_nodes, setCustomControls]); + + const hasResults = + can_be_expanded && + groupingConfig && + groupingConfig[groupingConfig.length - 1].type === "result"; return ( <> @@ -335,8 +411,13 @@ const DetectionPanel = ({ depth, node }: DetectionPanelProps) => { ? "print:break-inside-avoid-page" : null, )} + onMouseEnter={ + panelControlsEnabled ? () => setShowPanelControls(true) : noop + } + onMouseLeave={() => setShowPanelControls(false)} >
{ : null } > + {showPanelControls && ( + + )}
@@ -398,16 +486,13 @@ const DetectionPanel = ({ depth, node }: DetectionPanelProps) => { {!can_be_expanded &&
}
- {can_be_expanded && - expanded && - groupingConfig && - groupingConfig[groupingConfig.length - 1].type === "result" && ( - - )} + {hasResults && expanded && ( + + )}
{can_be_expanded && expanded && ( @@ -416,4 +501,25 @@ const DetectionPanel = ({ depth, node }: DetectionPanelProps) => { ); }; -export default DetectionPanel; +const DetectionPanelWrapper = ({ node, depth }: DetectionPanelProps) => { + const definition = useMemo( + () => ({ + data: node.data, + panel_type: node.type, + dashboard: node.name, + }), + [node], + ); + + return ( + + + + ); +}; + +export default DetectionPanelWrapper; diff --git a/ui/dashboard/src/components/dashboards/grouping/common/Detection.ts b/ui/dashboard/src/components/dashboards/grouping/common/Detection.ts index 8dc770a4..7b257267 100644 --- a/ui/dashboard/src/components/dashboards/grouping/common/Detection.ts +++ b/ui/dashboard/src/components/dashboards/grouping/common/Detection.ts @@ -1,15 +1,14 @@ +import DetectionBenchmark from "@powerpipe/components/dashboards/grouping/common/DetectionBenchmark"; import { AddDetectionResultsAction, CheckNodeStatus, CheckResultStatus, - DetectionDynamicColsMap, DetectionNode, DetectionResult, DetectionSeverity, DetectionSeveritySummary, DetectionTags, DetectionSummary, - findDimension, GroupingNodeType, DetectionResultDimension, } from "@powerpipe/components/dashboards/grouping/common"; @@ -19,7 +18,6 @@ import { LeafNodeDataColumn, LeafNodeDataRow, } from "@powerpipe/components/dashboards/common"; -import DetectionBenchmark from "@powerpipe/components/dashboards/grouping/common/DetectionBenchmark"; class Detection implements DetectionNode { private readonly _sortIndex: string; @@ -150,54 +148,18 @@ class Detection implements DetectionNode { return this._tags; } - get_dynamic_cols(): DetectionDynamicColsMap { - const dimensionKeysMap = { - dimensions: {}, - tags: {}, - }; - - Object.keys(this._tags).forEach((t) => (dimensionKeysMap.tags[t] = true)); - + get_data_columns(): LeafNodeDataColumn[] { if (this._results.length === 0) { - return dimensionKeysMap; - } - for (const result of this._results) { - for (const dimension of result.dimensions || []) { - dimensionKeysMap.dimensions[dimension.key] = true; - } + return []; } - return dimensionKeysMap; + return this._results[0].columns || []; } - get_data_rows(tags: string[], dimensions: string[]): LeafNodeDataRow[] { - let rows: LeafNodeDataRow[] = []; - this._results.forEach((result) => { - const row: LeafNodeDataRow = { - group_id: this._group_id, - title: this._group_title ? this._group_title : null, - description: this._group_description ? this._group_description : null, - detection_id: this._name, - detection_title: this._title ? this._title : null, - detection_description: this._description ? this._description : null, - severity: this._severity ? this._severity : null, - reason: result.reason, - resource: result.resource, - status: result.status, - }; - - tags.forEach((tag) => { - const val = this._tags[tag]; - row[tag] = val === undefined ? null : val; - }); - - dimensions.forEach((dimension) => { - const val = findDimension(result.dimensions, dimension); - row[dimension] = val === undefined ? null : val.value; - }); - - rows.push(row); - }); - return rows; + get_data_rows(): LeafNodeDataRow[] { + if (this._results.length === 0 || !this._results[0].rows?.length) { + return []; + } + return this._results[0].rows; } private _build_detection_loading_node = ( diff --git a/ui/dashboard/src/components/dashboards/grouping/common/DetectionBenchmark.ts b/ui/dashboard/src/components/dashboards/grouping/common/DetectionBenchmark.ts index 18cdb14b..92937ea0 100644 --- a/ui/dashboard/src/components/dashboards/grouping/common/DetectionBenchmark.ts +++ b/ui/dashboard/src/components/dashboards/grouping/common/DetectionBenchmark.ts @@ -1,9 +1,7 @@ import Detection from "@powerpipe/components/dashboards/grouping/common/Detection"; -import merge from "lodash/merge"; import padStart from "lodash/padStart"; import { AddDetectionResultsAction, - DetectionDynamicColsMap, DetectionNode, DetectionNodeStatus, GroupingNodeType, @@ -18,6 +16,7 @@ import { LeafNodeDataColumn, LeafNodeDataRow, } from "@powerpipe/components/dashboards/common"; +import { KeyValuePairs } from "@powerpipe/components/dashboards/common/types"; class DetectionBenchmark implements DetectionNode { private readonly _sortIndex: string; @@ -194,26 +193,8 @@ class DetectionBenchmark implements DetectionNode { } get_data_table(): LeafNodeData { - const columns: LeafNodeDataColumn[] = [ - { - name: "timestamp", - data_type: "TIMESTAMP", - }, - ]; - const { dimensions, tags } = this.get_dynamic_cols(); - Object.keys(tags).forEach((tag) => - columns.push({ - name: tag, - data_type: "TEXT", - }), - ); - Object.keys(dimensions).forEach((dimension) => - columns.push({ - name: dimension, - data_type: "TEXT", - }), - ); - const rows = this.get_data_rows(Object.keys(tags), Object.keys(dimensions)); + const columns = this.get_data_columns(); + const rows = this.get_data_rows(); return { columns, @@ -221,29 +202,40 @@ class DetectionBenchmark implements DetectionNode { }; } - get_dynamic_cols(): DetectionDynamicColsMap { - let keys = { - dimensions: {}, - tags: {}, - }; + get_data_columns(): LeafNodeDataColumn[] { + const columnMap: KeyValuePairs = {}; + const columns: LeafNodeDataColumn[] = []; + this._benchmarks.forEach((benchmark) => { - const subBenchmarkKeys = benchmark.get_dynamic_cols(); - keys = merge(keys, subBenchmarkKeys); + const nestedColumns = benchmark.get_data_columns(); + for (const nestedColumn of nestedColumns) { + if (columnMap[nestedColumn.name]) { + continue; + } + columnMap[nestedColumn.name] = nestedColumn; + columns.push(nestedColumn); + } }); this._detections.forEach((detection) => { - const controlKeys = detection.get_dynamic_cols(); - keys = merge(keys, controlKeys); + const nestedColumns = detection.get_data_columns(); + for (const nestedColumn of nestedColumns) { + if (columnMap[nestedColumn.name]) { + continue; + } + columnMap[nestedColumn.name] = nestedColumn; + columns.push(nestedColumn); + } }); - return keys; + return columns; } - get_data_rows(tags: string[], dimensions: string[]): LeafNodeDataRow[] { + get_data_rows(): LeafNodeDataRow[] { let rows: LeafNodeDataRow[] = []; this._benchmarks.forEach((benchmark) => { - rows = [...rows, ...benchmark.get_data_rows(tags, dimensions)]; + rows = [...rows, ...benchmark.get_data_rows()]; }); this._detections.forEach((detection) => { - rows = [...rows, ...detection.get_data_rows(tags, dimensions)]; + rows = [...rows, ...detection.get_data_rows()]; }); return rows; } diff --git a/ui/dashboard/src/components/dashboards/grouping/common/index.ts b/ui/dashboard/src/components/dashboards/grouping/common/index.ts index aac867aa..be4b414f 100644 --- a/ui/dashboard/src/components/dashboards/grouping/common/index.ts +++ b/ui/dashboard/src/components/dashboards/grouping/common/index.ts @@ -101,11 +101,6 @@ export type CheckDynamicColsMap = { tags: CheckDynamicValueMap; }; -export type DetectionDynamicColsMap = { - dimensions: CheckDynamicValueMap; - tags: CheckDynamicValueMap; -}; - export type CheckTags = { [key: string]: string; }; diff --git a/ui/dashboard/src/components/dashboards/grouping/common/node/DetectionNode.ts b/ui/dashboard/src/components/dashboards/grouping/common/node/DetectionNode.ts index c2acab30..f52b2d37 100644 --- a/ui/dashboard/src/components/dashboards/grouping/common/node/DetectionNode.ts +++ b/ui/dashboard/src/components/dashboards/grouping/common/node/DetectionNode.ts @@ -1,23 +1,31 @@ import DetectionHierarchyNode from "@powerpipe/components/dashboards/grouping/common/node/DetectionHierarchyNode"; import { DetectionNode as DetectionNodeType } from "../index"; +import { LeafNodeData } from "@powerpipe/components/dashboards/common"; class DetectionNode extends DetectionHierarchyNode { private readonly _documentation: string | undefined; + private readonly _data: LeafNodeData | undefined; constructor( sort: string, name: string, title: string | undefined, documentation: string | undefined, + data: LeafNodeData | undefined, children?: DetectionNodeType[], ) { super("detection", name, title || name, sort, children || []); this._documentation = documentation; + this._data = data; } get documentation(): string | undefined { return this._documentation; } + + get data(): LeafNodeData | undefined { + return this._data; + } } export default DetectionNode; diff --git a/ui/dashboard/src/components/dashboards/layout/Panel/PanelControls.tsx b/ui/dashboard/src/components/dashboards/layout/Panel/PanelControls.tsx index b91edb5f..e0038c10 100644 --- a/ui/dashboard/src/components/dashboards/layout/Panel/PanelControls.tsx +++ b/ui/dashboard/src/components/dashboards/layout/Panel/PanelControls.tsx @@ -1,4 +1,5 @@ import Icon from "@powerpipe/components/Icon"; +import { classNames } from "@powerpipe/utils/styles"; import { createPortal } from "react-dom"; import { ReactNode, useMemo, useState } from "react"; import { ThemeProvider, ThemeWrapper } from "@powerpipe/hooks/useTheme"; @@ -7,6 +8,7 @@ import { usePopper } from "react-popper"; export interface PanelControlProps { action: (e: any) => Promise; component?: ReactNode; + disabled?: boolean; icon: string; title: string; } @@ -14,13 +16,25 @@ export interface PanelControlProps { const PanelControl = ({ action, component, + disabled, icon, title, }: PanelControlProps) => { return (
await action(e)} + className={classNames( + "flex items-center space-x-2 px-2 py-1.5 bg-dashboard-panel first:rounded-tl-[4px] first:rounded-bl-[4px] last:rounded-tr-[4px] last:rounded-br-[4px] hover:bg-dashboard", + disabled + ? "cursor-not-allowed text-foreground-light" + : "cursor-pointer text-foreground", + )} + onClick={async (e) => { + e.stopPropagation(); + if (disabled) { + return; + } + await action(e); + }} title={title} > {component} @@ -68,6 +82,7 @@ const PanelControls = ({ controls, referenceElement, withOffset = false }) => { {controls.map((control, idx) => ( { ); }; +export { PanelControl }; + export default PanelControls; diff --git a/ui/dashboard/src/components/dashboards/layout/Panel/index.tsx b/ui/dashboard/src/components/dashboards/layout/Panel/index.tsx index 2ddab219..0b94466b 100644 --- a/ui/dashboard/src/components/dashboards/layout/Panel/index.tsx +++ b/ui/dashboard/src/components/dashboards/layout/Panel/index.tsx @@ -21,6 +21,7 @@ import { registerComponent } from "@powerpipe/components/dashboards"; import { TableProps } from "@powerpipe/components/dashboards/Table"; import { TextProps } from "@powerpipe/components/dashboards/Text"; import { useDashboard } from "@powerpipe/hooks/useDashboard"; +import { usePanelControls } from "@powerpipe/hooks/usePanelControls"; type PanelProps = { children: ReactNode; @@ -47,24 +48,20 @@ const Panel = ({ children, className, definition, - showControls = true, showPanelError = true, showPanelStatus = true, forceBackground = false, }: PanelProps) => { const { selectedPanel } = useDashboard(); - const { - inputPanelsAwaitingValue, - panelControls, - showPanelControls, - setShowPanelControls, - } = usePanel(); + const { inputPanelsAwaitingValue } = usePanel(); const [referenceElement, setReferenceElement] = useState(null); const baseStyles = classNames( "relative col-span-12", getResponsivePanelWidthClass(definition.width), "overflow-auto", ); + const { panelControls, showPanelControls, setShowPanelControls } = + usePanelControls(); if (inputPanelsAwaitingValue.length > 0) { return null; @@ -87,7 +84,9 @@ const Panel = ({ ref={setReferenceElement} id={definition.name} className={baseStyles} - onMouseEnter={showControls ? () => setShowPanelControls(true) : undefined} + onMouseEnter={ + showPanelControls ? () => setShowPanelControls(true) : undefined + } onMouseLeave={() => setShowPanelControls(false)} >
{ return ( download()} + onClick={processing ? noop : () => download(panelDefinition.data)} size={size} > <>Download diff --git a/ui/dashboard/src/hooks/useDetectionGrouping.tsx b/ui/dashboard/src/hooks/useDetectionGrouping.tsx index a1447329..c9f49807 100644 --- a/ui/dashboard/src/hooks/useDetectionGrouping.tsx +++ b/ui/dashboard/src/hooks/useDetectionGrouping.tsx @@ -245,6 +245,7 @@ const getDetectionGroupingNode = ( detectionResult.detection.name, detectionResult.detection.title, detectionResult.detection.documentation, + detectionResult.detection._results?.[0], children, ); case "detection_tag": diff --git a/ui/dashboard/src/hooks/useDownloadDetectionBenchmarkData.ts b/ui/dashboard/src/hooks/useDownloadDetectionBenchmarkData.ts new file mode 100644 index 00000000..9fbbaacd --- /dev/null +++ b/ui/dashboard/src/hooks/useDownloadDetectionBenchmarkData.ts @@ -0,0 +1,28 @@ +import DetectionBenchmark from "@powerpipe/components/dashboards/grouping/common/DetectionBenchmark"; +import useDownloadPanelData from "@powerpipe/hooks/useDownloadPanelData"; +import { PanelDefinition } from "@powerpipe/types"; +import { useMemo } from "react"; + +const useDownloadDetectionBenchmarkData = (benchmark: DetectionBenchmark) => { + const definition = useMemo( + () => + ({ + dashboard: benchmark.name, + panel_type: "benchmark", + }) as PanelDefinition, + [benchmark], + ); + const { download, processing } = useDownloadPanelData(definition); + + const downloadQueryData = async () => { + if (!benchmark) { + return; + } + const data = benchmark.get_data_table(); + return download(data); + }; + + return { download: downloadQueryData, processing }; +}; + +export default useDownloadDetectionBenchmarkData; diff --git a/ui/dashboard/src/hooks/useDownloadDetectionData.ts b/ui/dashboard/src/hooks/useDownloadDetectionData.ts new file mode 100644 index 00000000..12d75268 --- /dev/null +++ b/ui/dashboard/src/hooks/useDownloadDetectionData.ts @@ -0,0 +1,65 @@ +import DetectionResultNode from "@powerpipe/components/dashboards/grouping/common/node/DetectionResultNode"; +import { DetectionNode } from "@powerpipe/components/dashboards/grouping/common"; +import { saveAs } from "file-saver"; +import { timestampForFilename } from "@powerpipe/utils/date"; +import { useCallback, useState } from "react"; +import { useDashboard } from "./useDashboard"; +import { usePapaParse } from "react-papaparse"; + +const useDownloadDetectionData = ( + node: DetectionNode, + resultNodes?: DetectionResultNode[], +) => { + const { selectedDashboard } = useDashboard(); + const { jsonToCSV } = usePapaParse(); + const [processing, setProcessing] = useState(false); + + const download = useCallback(async () => { + if (!resultNodes || resultNodes.length === 0) { + return; + } + setProcessing(true); + const columns = Array.from( + new Set(resultNodes.flatMap((item) => item.result?.columns || [])), + ).filter((c) => !!c); + let csvRows: any[] = []; + + const jsonbColIndices = columns + .filter( + (i) => i.data_type === "VARCHAR[]" || i.data_type.startsWith("STRUCT"), + ) + .map((i) => columns.indexOf(i)); // would return e.g. [3,6,9] + + for (const resultNode of resultNodes) { + for (const row of resultNode.result?.rows || []) { + // Deep copy the row or else it will update + // the values in query output + const csvRow: any[] = []; + columns.forEach((col, index) => { + csvRow[index] = + col.name in row + ? jsonbColIndices.includes(index) + ? JSON.stringify(row[col.name]) + : row[col.name] + : null; + }); + csvRows.push(csvRow); + } + } + + const csv = jsonToCSV([columns.map((c) => c.name), ...csvRows]); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); + + saveAs( + blob, + `${node.name.replaceAll(".", "_")}_${node.type}_${timestampForFilename( + Date.now(), + )}.csv`, + ); + setProcessing(false); + }, [node.name, resultNodes, jsonToCSV, selectedDashboard]); + + return { download, processing }; +}; + +export default useDownloadDetectionData; diff --git a/ui/dashboard/src/hooks/useDownloadPanelData.ts b/ui/dashboard/src/hooks/useDownloadPanelData.ts index 38265201..ad7e522e 100644 --- a/ui/dashboard/src/hooks/useDownloadPanelData.ts +++ b/ui/dashboard/src/hooks/useDownloadPanelData.ts @@ -1,3 +1,4 @@ +import { LeafNodeData } from "@powerpipe/components/dashboards/common"; import { PanelDefinition } from "@powerpipe/types"; import { saveAs } from "file-saver"; import { timestampForFilename } from "@powerpipe/utils/date"; @@ -10,46 +11,53 @@ const useDownloadPanelData = (definition: PanelDefinition) => { const { jsonToCSV } = usePapaParse(); const [processing, setProcessing] = useState(false); - const downloadQueryData = useCallback(async () => { - if (!definition.data) { - return; - } - setProcessing(true); - const data = definition.data; - const colNames = data.columns.map((c) => c.name); - let csvRows: any[] = []; - - const jsonbColIndices = data.columns - .filter((i) => i.data_type === "JSONB") - .map((i) => data.columns.indexOf(i)); // would return e.g. [3,6,9] - - for (const row of data.rows) { - // Deep copy the row or else it will update - // the values in query output - const csvRow: any[] = []; - colNames.forEach((col, index) => { - csvRow[index] = jsonbColIndices.includes(index) - ? JSON.stringify(row[col]) - : row[col]; - }); - csvRows.push(csvRow); - } - - const csv = jsonToCSV([colNames, ...csvRows]); - const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); - - saveAs( - blob, - `${( - selectedDashboard?.full_name || - definition.dashboard || - "" - ).replaceAll(".", "_")}_${definition.panel_type}_${timestampForFilename( - Date.now(), - )}.csv`, - ); - setProcessing(false); - }, [definition, jsonToCSV, selectedDashboard]); + const downloadQueryData = useCallback( + async (data: LeafNodeData | undefined) => { + if (!data) { + return; + } + setProcessing(true); + const colNames = data.columns.map((c) => c.name); + let csvRows: any[] = []; + + const jsonbColIndices = data.columns + .filter( + (i) => + i.data_type === "JSONB" || + i.data_type === "VARCHAR[]" || + i.data_type.startsWith("STRUCT"), + ) + .map((i) => data.columns.indexOf(i)); // would return e.g. [3,6,9] + + for (const row of data.rows) { + // Deep copy the row or else it will update + // the values in query output + const csvRow: any[] = []; + colNames.forEach((col, index) => { + csvRow[index] = jsonbColIndices.includes(index) + ? JSON.stringify(row[col]) + : row[col]; + }); + csvRows.push(csvRow); + } + + const csv = jsonToCSV([colNames, ...csvRows]); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8" }); + + saveAs( + blob, + `${( + definition.dashboard || + selectedDashboard?.full_name || + "" + ).replaceAll(".", "_")}_${definition.panel_type}_${timestampForFilename( + Date.now(), + )}.csv`, + ); + setProcessing(false); + }, + [definition, jsonToCSV, selectedDashboard], + ); return { download: downloadQueryData, processing }; }; diff --git a/ui/dashboard/src/hooks/usePanel.tsx b/ui/dashboard/src/hooks/usePanel.tsx index 1baea895..34d3b3aa 100644 --- a/ui/dashboard/src/hooks/usePanel.tsx +++ b/ui/dashboard/src/hooks/usePanel.tsx @@ -1,4 +1,3 @@ -import usePanelControls from "@powerpipe/hooks/usePanelControls"; import { BaseChartProps } from "@powerpipe/components/dashboards/charts/types"; import { CardProps } from "@powerpipe/components/dashboards/Card"; import { @@ -26,8 +25,8 @@ import { InputProperties, InputProps, } from "@powerpipe/components/dashboards/inputs/types"; -import { IPanelControl } from "@powerpipe/components/dashboards/layout/Panel/PanelControls"; import { NodeAndEdgeProperties } from "@powerpipe/components/dashboards/common/types"; +import { PanelControlsProvider } from "@powerpipe/hooks/usePanelControls"; import { TableProps } from "@powerpipe/components/dashboards/Table"; import { TextProps } from "@powerpipe/components/dashboards/Text"; import { useDashboard } from "@powerpipe/hooks/useDashboard"; @@ -48,12 +47,9 @@ type IPanelContext = { dependencies: PanelDefinition[]; dependenciesByStatus: PanelDependenciesByStatus; inputPanelsAwaitingValue: PanelDefinition[]; - panelControls: IPanelControl[]; panelInformation: ReactNode | null; - showPanelControls: boolean; showPanelInformation: boolean; setPanelInformation: (information: ReactNode) => void; - setShowPanelControls: (show: boolean) => void; setShowPanelInformation: (show: boolean) => void; }; @@ -133,12 +129,10 @@ const PanelProvider = ({ }: PanelProviderProps) => { const { updateChildStatus } = useContainer(); const { selectedDashboardInputs, panelsMap } = useDashboard(); - const [showPanelControls, setShowPanelControls] = useState(false); const [showPanelInformation, setShowPanelInformation] = useState(false); const [panelInformation, setPanelInformation] = useState( null, ); - const { panelControls } = usePanelControls(definition, showControls); const { dependencies, dependenciesByStatus, inputPanelsAwaitingValue } = useMemo(() => { if (!definition) { @@ -231,23 +225,22 @@ const PanelProvider = ({ }, [definition, inputPanelsAwaitingValue, parentType, updateChildStatus]); return ( - - {children} - + + + {children} + + ); }; diff --git a/ui/dashboard/src/hooks/usePanelControls.ts b/ui/dashboard/src/hooks/usePanelControls.ts deleted file mode 100644 index 8819e074..00000000 --- a/ui/dashboard/src/hooks/usePanelControls.ts +++ /dev/null @@ -1,49 +0,0 @@ -import useDownloadPanelData from "./useDownloadPanelData"; -import useSelectPanel from "./useSelectPanel"; -import { IPanelControl } from "@powerpipe/components/dashboards/layout/Panel/PanelControls"; -import { PanelDefinition } from "@powerpipe/types"; -import { useCallback, useEffect, useState } from "react"; - -const usePanelControls = (definition: PanelDefinition, show = false) => { - const { download } = useDownloadPanelData(definition); - const { select } = useSelectPanel(definition); - - const downloadPanelData = useCallback( - async (e) => { - e.stopPropagation(); - await download(); - }, - [download], - ); - - const getBasePanelControls = useCallback(() => { - const controls: IPanelControl[] = []; - if (!show || !definition) { - return controls; - } - if (definition.data) { - controls.push({ - action: downloadPanelData, - icon: "arrow-down-tray", - title: "Download data", - }); - } - controls.push({ - action: select, - icon: "arrows-pointing-out", - title: "View detail", - }); - return controls; - }, [definition, downloadPanelData, select, show]); - - const [panelControls, setPanelControls] = useState(getBasePanelControls()); - const [customControls, setCustomControls] = useState([]); - - useEffect(() => { - setPanelControls(() => [...customControls, ...getBasePanelControls()]); - }, [customControls, getBasePanelControls]); - - return { panelControls, setCustomControls }; -}; - -export default usePanelControls; diff --git a/ui/dashboard/src/hooks/usePanelControls.tsx b/ui/dashboard/src/hooks/usePanelControls.tsx new file mode 100644 index 00000000..640bf440 --- /dev/null +++ b/ui/dashboard/src/hooks/usePanelControls.tsx @@ -0,0 +1,164 @@ +import useDownloadPanelData from "./useDownloadPanelData"; +import useSelectPanel from "./useSelectPanel"; +import { BaseChartProps } from "@powerpipe/components/dashboards/charts/types"; +import { CardProps } from "@powerpipe/components/dashboards/Card"; +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { FlowProps } from "@powerpipe/components/dashboards/flows/types"; +import { GraphProps } from "@powerpipe/components/dashboards/graphs/types"; +import { HierarchyProps } from "@powerpipe/components/dashboards/hierarchies/types"; +import { ImageProps } from "@powerpipe/components/dashboards/Image"; +import { InputProps } from "@powerpipe/components/dashboards/inputs/types"; +import { LeafNodeData } from "@powerpipe/components/dashboards/common"; +import { PanelDefinition } from "@powerpipe/types"; +import { TableProps } from "@powerpipe/components/dashboards/Table"; +import { TextProps } from "@powerpipe/components/dashboards/Text"; +import { noop } from "@powerpipe/utils/func"; + +export type IPanelControlsContext = { + enabled: boolean; + panelControls: IPanelControl[]; + showPanelControls: boolean; + setShowPanelControls: (show: boolean) => void; + setCustomControls: (controls: IPanelControl[]) => void; + setPanelData: (data: LeafNodeData) => void; +}; + +type PanelControlsProviderProps = { + children: ReactNode; + definition: + | BaseChartProps + | CardProps + | FlowProps + | GraphProps + | HierarchyProps + | ImageProps + | InputProps + | PanelDefinition + | TableProps + | TextProps; + enabled?: boolean; + panelDetailEnabled?: boolean; +}; + +export interface IPanelControl { + disabled?: boolean; + key: string; + action: (e: any) => Promise; + component?: ReactNode; + icon?: string; + title: string; +} + +const PanelControlsContext = createContext({ + enabled: false, + panelControls: [], + showPanelControls: false, + setCustomControls: noop, + setPanelData: noop, + setShowPanelControls: noop, +}); + +const PanelControlsProvider = ({ + children, + definition, + enabled, + panelDetailEnabled = true, +}: PanelControlsProviderProps) => { + const [panelData, setPanelData] = useState( + definition.data, + ); + const { download } = useDownloadPanelData(definition); + const { select } = useSelectPanel(definition); + const [showPanelControls, setShowPanelControls] = useState(false); + + useEffect(() => setPanelData(() => definition.data), [definition.data]); + + const downloadPanelData = useCallback( + async (e) => { + e.stopPropagation(); + await download(definition.data); + }, + [definition, download], + ); + + const getBasePanelControls = useCallback(() => { + const controls: IPanelControl[] = []; + if (!enabled || !definition) { + return controls; + } + if (panelData) { + controls.push({ + key: "download-data", + title: "Download data", + icon: "arrow-down-tray", + action: downloadPanelData, + }); + } + if (panelDetailEnabled) { + controls.push({ + key: "view-panel-detail", + title: "View detail", + icon: "arrows-pointing-out", + action: select, + }); + } + return controls; + }, [definition, downloadPanelData, panelDetailEnabled, select, enabled]); + + const [panelControls, setPanelControls] = useState(getBasePanelControls()); + const [customControls, setCustomControls] = useState([]); + + useEffect(() => { + const uniqueCustomControls: IPanelControl[] = []; + let baseControls = getBasePanelControls(); + for (const control of customControls) { + const existingIndex = baseControls.findIndex( + (c) => c.key === control.key, + ); + if (existingIndex === -1) { + uniqueCustomControls.push(control); + } else { + baseControls = [ + ...baseControls.slice(0, existingIndex), + control, + ...baseControls.slice(existingIndex + 1), + ]; + } + } + setPanelControls(() => [...uniqueCustomControls, ...baseControls]); + }, [customControls, getBasePanelControls]); + + return ( + + {children} + + ); +}; + +const usePanelControls = () => { + const context = useContext(PanelControlsContext); + if (context === undefined) { + throw new Error( + "usePanelControls must be used within a PanelControlsContext", + ); + } + return context as IPanelControlsContext; +}; + +export { PanelControlsProvider, usePanelControls }; diff --git a/ui/dashboard/src/utils/func.ts b/ui/dashboard/src/utils/func.ts index f40e4964..a63a1c46 100644 --- a/ui/dashboard/src/utils/func.ts +++ b/ui/dashboard/src/utils/func.ts @@ -1,3 +1,5 @@ +const asyncNoop = async () => Promise.resolve(); + const noop = () => {}; -export { noop }; +export { asyncNoop, noop }; diff --git a/ui/dashboard/yarn.lock b/ui/dashboard/yarn.lock index f86ae7f5..28895e01 100644 --- a/ui/dashboard/yarn.lock +++ b/ui/dashboard/yarn.lock @@ -2598,10 +2598,10 @@ __metadata: languageName: node linkType: hard -"@material-symbols/svg-300@npm:0.27.1": - version: 0.27.1 - resolution: "@material-symbols/svg-300@npm:0.27.1" - checksum: 10c0/39032a3eb25684ddfee195fc583e4eeaf6007990fcafc0dfa35f304b68c97648f2a6547ec5459c6880c5ca6278c4dd57a68257a8a4b0cfcadd9ab2c103e23017 +"@material-symbols/svg-300@npm:0.27.2": + version: 0.27.2 + resolution: "@material-symbols/svg-300@npm:0.27.2" + checksum: 10c0/58c5de2961bc385797d3d07e9e75186c8c484e28f0d6782661558378aac8dcc1bbcb05b1ebd080de3eec4c3c559952d514bd7418a9d87d28d253f39f24ae2a33 languageName: node linkType: hard @@ -2933,9 +2933,9 @@ __metadata: languageName: node linkType: hard -"@storybook/addon-actions@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-actions@npm:8.4.5" +"@storybook/addon-actions@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-actions@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" "@types/uuid": "npm:^9.0.1" @@ -2943,149 +2943,149 @@ __metadata: polished: "npm:^4.2.2" uuid: "npm:^9.0.0" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/b689c16a01302c4d64f24dc777b666456bddc1ab820aaf9b6b6f9d3ab7081d6f573a6641bc2dcb9ee8c3ec9425f36426737abd6735da6fcfc670ee6b9f3d8280 + storybook: ^8.4.7 + checksum: 10c0/411be60f358101291cbd4ff8e5ddbac58fa0583c95338b82b410dc030a73632b654eaf7004b421c7e309cf0bfa709c4f93728b943e1b59dcfff5a249686501c1 languageName: node linkType: hard -"@storybook/addon-backgrounds@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-backgrounds@npm:8.4.5" +"@storybook/addon-backgrounds@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-backgrounds@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" memoizerific: "npm:^1.11.3" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/863c4cb60957c1113231a5bedf833de8ba86846509b950e878acd788d1e1ad13e07ad9b2e183c96a8bee8c01442b22ee8cdf2f324e6fca297d88f43b26b3fef1 + storybook: ^8.4.7 + checksum: 10c0/d22c4acd1d99f616865dde11c70b444a0aac7fe7623904479a29a0142b504f284ddc2407eacfd1203c3b0856e5497e7902eb86e287516364c7735b90e224bbcb languageName: node linkType: hard -"@storybook/addon-controls@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-controls@npm:8.4.5" +"@storybook/addon-controls@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-controls@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" dequal: "npm:^2.0.2" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/1ca92a32d5ff018f2120d8a8787b834d1ee2bbf2423b422ccb6f2a9f1ce0f66ad5f67de6e268330434d47734dbe0cec8b130678392705db36510d13770ce6616 + storybook: ^8.4.7 + checksum: 10c0/900c71d172e9f75a1c39a87de1d411890fcea012586be02e3293c705c500a3a62a2bdecb10c11ba9c9f6117706dfbc34aaa40d2ca8e8a9d7b8a6a739d6a73e0c languageName: node linkType: hard -"@storybook/addon-docs@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-docs@npm:8.4.5" +"@storybook/addon-docs@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-docs@npm:8.4.7" dependencies: "@mdx-js/react": "npm:^3.0.0" - "@storybook/blocks": "npm:8.4.5" - "@storybook/csf-plugin": "npm:8.4.5" - "@storybook/react-dom-shim": "npm:8.4.5" + "@storybook/blocks": "npm:8.4.7" + "@storybook/csf-plugin": "npm:8.4.7" + "@storybook/react-dom-shim": "npm:8.4.7" react: "npm:^16.8.0 || ^17.0.0 || ^18.0.0" react-dom: "npm:^16.8.0 || ^17.0.0 || ^18.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/cb3731d6cc738ea01094acc83dfe92b918aed4dde3d0251b61aaa2105fd5b692686be06c092f27d37653855f2ffae86d24888a6066e121f6fc97c92b86dfd2c1 + storybook: ^8.4.7 + checksum: 10c0/0eb1854ddb6dbef1b32f89746944ee7a16db986403fe0a3712f43d39faa6335e0bce4ac21a8c20d09955ae73cccd1962f3b45037ab1144f61c1317d686e8695f languageName: node linkType: hard -"@storybook/addon-essentials@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-essentials@npm:8.4.5" +"@storybook/addon-essentials@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-essentials@npm:8.4.7" dependencies: - "@storybook/addon-actions": "npm:8.4.5" - "@storybook/addon-backgrounds": "npm:8.4.5" - "@storybook/addon-controls": "npm:8.4.5" - "@storybook/addon-docs": "npm:8.4.5" - "@storybook/addon-highlight": "npm:8.4.5" - "@storybook/addon-measure": "npm:8.4.5" - "@storybook/addon-outline": "npm:8.4.5" - "@storybook/addon-toolbars": "npm:8.4.5" - "@storybook/addon-viewport": "npm:8.4.5" + "@storybook/addon-actions": "npm:8.4.7" + "@storybook/addon-backgrounds": "npm:8.4.7" + "@storybook/addon-controls": "npm:8.4.7" + "@storybook/addon-docs": "npm:8.4.7" + "@storybook/addon-highlight": "npm:8.4.7" + "@storybook/addon-measure": "npm:8.4.7" + "@storybook/addon-outline": "npm:8.4.7" + "@storybook/addon-toolbars": "npm:8.4.7" + "@storybook/addon-viewport": "npm:8.4.7" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/fed258f3bbe6b380d61dd14f77b22049f8b2c38ac63cb08b66aa368301be7209cc7d7f2dea57caeed4f7021bedc6d35468ba42fda3e1e1cfe67a91713c0e0564 + storybook: ^8.4.7 + checksum: 10c0/82ddd8424dfd5bf0ef44cee6a320f8395c63678bc0d4566307b2c68bd83c39f6bd447fb421681e3ab581c35c9d991207b01bebf20269c083931f581bb4651d6d languageName: node linkType: hard -"@storybook/addon-highlight@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-highlight@npm:8.4.5" +"@storybook/addon-highlight@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-highlight@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/ba0b3f824e17279339ddafdf9c6b5e50158601c0f48185e24d26ff4070537d5e095452ad632402bd8d48a886a1a696c70bf996dd74637158858d3e98c18de44f + storybook: ^8.4.7 + checksum: 10c0/2256b880d1f83c86c64287988bd4f4b76a8e1990f2a2a080a322994a9a8e553013fc21b7503c218ec394a880c1b72b131975e6eeadec6accb7eb35d3cb85a6ce languageName: node linkType: hard -"@storybook/addon-links@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-links@npm:8.4.5" +"@storybook/addon-links@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-links@npm:8.4.7" dependencies: "@storybook/csf": "npm:^0.1.11" "@storybook/global": "npm:^5.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.5 + storybook: ^8.4.7 peerDependenciesMeta: react: optional: true - checksum: 10c0/842db4f5a1a9232cc27bd93e08ab777b02ceb186ccf8f7c6ac47dfc0c5cc111d94f8250185c99ca4715e84a19816593aac49bc4c597b430e75013fdce9af1f20 + checksum: 10c0/475d3231ac6c6531cfa5d01e8816b90cbf51e993c1575fa7bf541540bf76af52d7f1087e929b87d771ce41ae4fd7762df1e25c9d8543200630f8618d85b16520 languageName: node linkType: hard -"@storybook/addon-measure@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-measure@npm:8.4.5" +"@storybook/addon-measure@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-measure@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" tiny-invariant: "npm:^1.3.1" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/793670594ac4154b8456f2aba6bb113dd4afbf5079c547d54092ea91f8d0d5139d61a15180b5e24402b309874249c628f3e1ff389cba446abd98be31153bb917 + storybook: ^8.4.7 + checksum: 10c0/a9e87c91cbcade2d0059cdc471e8ba479ad6d9dee0c2558c3b706e37d58b4cb3d986924ea0ff623aa791300ee2a8d2429e8fb3ef32eeec9d49861f8677815ac2 languageName: node linkType: hard -"@storybook/addon-outline@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-outline@npm:8.4.5" +"@storybook/addon-outline@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-outline@npm:8.4.7" dependencies: "@storybook/global": "npm:^5.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/14f3993aa88a33035a048ea00713d936f0025055eb205c3033a65e5d885012c54b77a7363f994faeaefa362e6f88aad1f174bf01a1f6b0c85b3f96fbe8332772 + storybook: ^8.4.7 + checksum: 10c0/13e8579ad1e9c8e338a66935331764351d9681e177469c7be72bc8383d6ab0441a783b2089ac3a730979d9a97c347800a47769b1f1ab5b4dfd7fc31f29e1709f languageName: node linkType: hard -"@storybook/addon-toolbars@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-toolbars@npm:8.4.5" +"@storybook/addon-toolbars@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-toolbars@npm:8.4.7" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/dbb76bad06d5c7ded93881d6195f2e63a744a006011cf177cd316e51cc42de323aa47b5d9c5c9a374f4b1c8c13c1dd503b079501bf0379672bc6be83df0863f0 + storybook: ^8.4.7 + checksum: 10c0/1c315d5ad07291f35ad780ef69fbd6570a582c008ab911cf14bff84061546b9ea1373d1127213844652d73a47c3011d28c1ad08d465fc120969c133dabfe7638 languageName: node linkType: hard -"@storybook/addon-viewport@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/addon-viewport@npm:8.4.5" +"@storybook/addon-viewport@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/addon-viewport@npm:8.4.7" dependencies: memoizerific: "npm:^1.11.3" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/4d828612602605f13fcac83e8f5095713ecfe791a47f36a301e306d15892ce038c15e9fb075a1ea18974cdf5a7a8da2f81e85260fcbb9ebd9b0ac1f2e60eae35 + storybook: ^8.4.7 + checksum: 10c0/4dec3b59be1f3b99d3c9eaab695a7e346d975b772f6691f8286005d78a13a204c5680c6c8733ae83060c7639b56efed9f3580cee7413834ac6595b56345183ef languageName: node linkType: hard -"@storybook/blocks@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/blocks@npm:8.4.5" +"@storybook/blocks@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/blocks@npm:8.4.7" dependencies: "@storybook/csf": "npm:^0.1.11" "@storybook/icons": "npm:^1.2.12" @@ -3093,21 +3093,21 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.5 + storybook: ^8.4.7 peerDependenciesMeta: react: optional: true react-dom: optional: true - checksum: 10c0/6839e8439e0cec41c8562ed2b68641780ad017dd5ff45ea7414df00a85dc168cb06dc339523be46997827f630225ea77afdbbf859ed5a322d974a4aa92a14522 + checksum: 10c0/1cb87811f9c7bad087dca752fb0d6483c237cb5776abea59cb555d8fce9ca14f4d5487725f5d8679a49f7e3f38bbe84189703498a31f2a9aa306f9fb3c8e65c8 languageName: node linkType: hard -"@storybook/builder-webpack5@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/builder-webpack5@npm:8.4.5" +"@storybook/builder-webpack5@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/builder-webpack5@npm:8.4.7" dependencies: - "@storybook/core-webpack": "npm:8.4.5" + "@storybook/core-webpack": "npm:8.4.7" "@types/node": "npm:^22.0.0" "@types/semver": "npm:^7.3.4" browser-assert: "npm:^1.2.1" @@ -3133,20 +3133,20 @@ __metadata: webpack-hot-middleware: "npm:^2.25.1" webpack-virtual-modules: "npm:^0.6.0" peerDependencies: - storybook: ^8.4.5 + storybook: ^8.4.7 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/9afb98ccde44b3fc94390cd19036669a7d43649cbbb202c3a375ca3b75a34de3bef6ca28a590ac501d6bbb3e6b5660d34ea7786f3357e2c4473025e7419325b0 + checksum: 10c0/848f4b03eae1980c2133a41c458e4560cf3df873fd4a462c66c04a751f8019d86706b3309d2ec465a95f712017ce2826365011415de05a0645f94e0fba512656 languageName: node linkType: hard -"@storybook/components@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/components@npm:8.4.5" +"@storybook/components@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/components@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/b166a73e79fee2747360d4e49a5d7171c3b45869dcc5a5bc72475bb711fc3d0bdf7dd1264ec248b69bf9a9afc7d85a1077616036ccb05e2f5c219aecab077176 + checksum: 10c0/7c1eb12fe2310a306f3c2f77a499c3a0caeb4694d4af8dde418f3b2d2ac8a3549b3f56cdc4629b9c15d79177c72e8668dd781a71bf257948f799b0e9cba201fa languageName: node linkType: hard @@ -3168,21 +3168,21 @@ __metadata: languageName: node linkType: hard -"@storybook/core-webpack@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/core-webpack@npm:8.4.5" +"@storybook/core-webpack@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/core-webpack@npm:8.4.7" dependencies: "@types/node": "npm:^22.0.0" ts-dedent: "npm:^2.0.0" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/aa18fee0553db5f6f0682e6949c0fa3ab4407782b315fff52f28afeef3733b8d125936d60c0887b0c481faab272a6a2cf182fbca47b07fecfce94e8f627a30d8 + storybook: ^8.4.7 + checksum: 10c0/3bbfadc9215c9699cb4b294eb5b1b67deb2537547e734b4c23521d153151d493ed5daecc2f6d82cfd26ad2ed6a9fcf19c8c0a6b20bda3059456d8bada3434783 languageName: node linkType: hard -"@storybook/core@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/core@npm:8.4.5" +"@storybook/core@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/core@npm:8.4.7" dependencies: "@storybook/csf": "npm:^0.1.11" better-opn: "npm:^3.0.2" @@ -3200,18 +3200,18 @@ __metadata: peerDependenciesMeta: prettier: optional: true - checksum: 10c0/426327ebb7042c3f574fd076fa80c20662b26bdfab3f75c752f6facc03fa9100dfa7afda9c026c04dfe8a7f426524650423c644d1e511cbc96bdbc6c8c4c20e4 + checksum: 10c0/0943ea7cd092739834ae4347cb46c66aa1c238ee9494af60345364f11568ee60d6290875a593808cd7aeb79715ae27365c2448e6ae5c644e316cd194af184755 languageName: node linkType: hard -"@storybook/csf-plugin@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/csf-plugin@npm:8.4.5" +"@storybook/csf-plugin@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/csf-plugin@npm:8.4.7" dependencies: unplugin: "npm:^1.3.1" peerDependencies: - storybook: ^8.4.5 - checksum: 10c0/c23b423740820679a4fcef9df8b077b24a047f250d1710e87a2fd6918b71bfeb513749eb41c0c072f1ac86e5888e666486cd7f58105d44e5e5bd727653ca1401 + storybook: ^8.4.7 + checksum: 10c0/da38e2422e474e323e237e569b3dd678af77d975a4a08fa36108e66c9228858e510246628e18b013bd859a4e674c1a3d0072952a71dac0d7058e03e7c3417b3f languageName: node linkType: hard @@ -3241,12 +3241,12 @@ __metadata: languageName: node linkType: hard -"@storybook/manager-api@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/manager-api@npm:8.4.5" +"@storybook/manager-api@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/manager-api@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/bf75ad329d7bcc66e810b34930ea39bf22d8fb052c6c8e26d113f0531b7e294374cd6b1c7250a9e3f0fb668a9026627a13a80f61f2e3991facf7a288020589ad + checksum: 10c0/a3aeed441a2cca1a8fac73336a853b389a00a1e7dbbbbcd54492a90f2f12f86e976235fd1272f27a606532fb7e0f82dec3f7ecd1f2b87b03ffa74b667830152a languageName: node linkType: hard @@ -3259,18 +3259,18 @@ __metadata: languageName: node linkType: hard -"@storybook/node-logger@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/node-logger@npm:8.4.5" +"@storybook/node-logger@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/node-logger@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/76ad142462346ac4daae2dad3aa9e0f5f0db4d1750589cbb1b8f68e0ab21b416131dc9e9d03f586c5adf802d0da03d19f942a67864faff98bbea8f259e3d59d3 + checksum: 10c0/71fc1e04fb842b37d175d029875771eab9e72a4c6ccb5c4aef41bd415cacc83d82b2be8bf9f37d0786559f3550dc6b792a64d1257abab97b25d004f405f02738 languageName: node linkType: hard -"@storybook/preset-create-react-app@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/preset-create-react-app@npm:8.4.5" +"@storybook/preset-create-react-app@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/preset-create-react-app@npm:8.4.7" dependencies: "@pmmmwh/react-refresh-webpack-plugin": "npm:^0.5.1" "@storybook/react-docgen-typescript-plugin": "npm:1.0.6--canary.9.0c3f3b7.0" @@ -3279,17 +3279,17 @@ __metadata: semver: "npm:^7.5.4" peerDependencies: react-scripts: ">=5.0.0" - storybook: ^8.4.5 - checksum: 10c0/c22bb40e68c567ece1cdbf41be2e17df5051a8cc328462518ac9b8c74645b7778427e8bc6f1a9565d85f5e4cbcf087e03cf3c3fcf798c4a9ef8a23ff44b72cc2 + storybook: ^8.4.7 + checksum: 10c0/1ec5952069da45ea7925f60d2b6a2e0161f94410e386a03e30cbfc5e094e072c282051adde4ea2fb0a1dbaaf76655abd133e545c0baf7f91b5da730dfe6827ee languageName: node linkType: hard -"@storybook/preset-react-webpack@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/preset-react-webpack@npm:8.4.5" +"@storybook/preset-react-webpack@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/preset-react-webpack@npm:8.4.7" dependencies: - "@storybook/core-webpack": "npm:8.4.5" - "@storybook/react": "npm:8.4.5" + "@storybook/core-webpack": "npm:8.4.7" + "@storybook/react": "npm:8.4.7" "@storybook/react-docgen-typescript-plugin": "npm:1.0.6--canary.9.0c3f3b7.0" "@types/node": "npm:^22.0.0" "@types/semver": "npm:^7.3.4" @@ -3303,20 +3303,20 @@ __metadata: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.5 + storybook: ^8.4.7 peerDependenciesMeta: typescript: optional: true - checksum: 10c0/f65bff5620e0ead062c92e2befe966bdaa6deba4a0faeae19278ba39ceb08ceeb1930b71330b6f721a64a8996045cd792401271bc904f4299513c1e332050ad9 + checksum: 10c0/f928dbdbaf5e8222e1350a683296dfddd3a24001f31b4c6aaf5533531cf004a2fed21d586e6a260d212fa5bd4c96a23791252829f416da2a0cd920864e0efc29 languageName: node linkType: hard -"@storybook/preview-api@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/preview-api@npm:8.4.5" +"@storybook/preview-api@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/preview-api@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/e00955596f28e12ae19060d4e0c04c7b4e39f31293200afe861dfd94f5da1a3389a1a223afe3cf01dc5552c1dac46b23d88b07eee6a7d2be36ecc90aa98f8af8 + checksum: 10c0/86e8dd8e46b20a4cab99655ded093a76ae5a2b2b9ab03af57292022c8143d76e0f76a137f8768b8f6847fd1b522abf3dee8504f0ba5ff16b5779120d3875967c languageName: node linkType: hard @@ -3338,68 +3338,68 @@ __metadata: languageName: node linkType: hard -"@storybook/react-dom-shim@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/react-dom-shim@npm:8.4.5" +"@storybook/react-dom-shim@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/react-dom-shim@npm:8.4.7" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.5 - checksum: 10c0/358bdb85346517128acca483ffad9110e79c4d279d64b40929256158190f5d5b774b16631c84b121ab39b616ac893468d7172c19d542dd53368456bb649ebb52 + storybook: ^8.4.7 + checksum: 10c0/5db1306c844a36264587836860d17f3fd44e5981a2417e66ccb0699d2b05364736f29df2ebc605ae19a7f7b9b9d6a19845771c3052b167ce27702e20337cd334 languageName: node linkType: hard -"@storybook/react-webpack5@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/react-webpack5@npm:8.4.5" +"@storybook/react-webpack5@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/react-webpack5@npm:8.4.7" dependencies: - "@storybook/builder-webpack5": "npm:8.4.5" - "@storybook/preset-react-webpack": "npm:8.4.5" - "@storybook/react": "npm:8.4.5" + "@storybook/builder-webpack5": "npm:8.4.7" + "@storybook/preset-react-webpack": "npm:8.4.7" + "@storybook/react": "npm:8.4.7" "@types/node": "npm:^22.0.0" peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.5 + storybook: ^8.4.7 typescript: ">= 4.2.x" peerDependenciesMeta: typescript: optional: true - checksum: 10c0/822ec20e3b3cbaa63e73583bd7e1553dd58cbd6a287eb92f33bf6ca1cf6f64fb600e18013477828df5ddf84162821439be07013f32cbe46b24c847905a3b1c8d + checksum: 10c0/69fa652110bdc7057806bd9508661ad1757d313de9462d65b31232ee2467356beda88c24a86fcb83880beadf14bbed913ff9244916a06f56302c8eeef72364bd languageName: node linkType: hard -"@storybook/react@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/react@npm:8.4.5" +"@storybook/react@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/react@npm:8.4.7" dependencies: - "@storybook/components": "npm:8.4.5" + "@storybook/components": "npm:8.4.7" "@storybook/global": "npm:^5.0.0" - "@storybook/manager-api": "npm:8.4.5" - "@storybook/preview-api": "npm:8.4.5" - "@storybook/react-dom-shim": "npm:8.4.5" - "@storybook/theming": "npm:8.4.5" + "@storybook/manager-api": "npm:8.4.7" + "@storybook/preview-api": "npm:8.4.7" + "@storybook/react-dom-shim": "npm:8.4.7" + "@storybook/theming": "npm:8.4.7" peerDependencies: - "@storybook/test": 8.4.5 + "@storybook/test": 8.4.7 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta - storybook: ^8.4.5 + storybook: ^8.4.7 typescript: ">= 4.2.x" peerDependenciesMeta: "@storybook/test": optional: true typescript: optional: true - checksum: 10c0/207e03c3dfcabb0b11d3a2440166d8eeb4f76318e32dd274c87a9503af7f2bedee255a13d358d653654f6eca2b81fb579c88f909f3e86f6f167187ca0aaadba9 + checksum: 10c0/9ca588446171491458e9adb5f9cf69b17517feddb4edd876da495843a45fa48a9c9272d4823090546e24a78dd7a93f1dcedef96257054383eb5678bfae6ccc09 languageName: node linkType: hard -"@storybook/theming@npm:8.4.5": - version: 8.4.5 - resolution: "@storybook/theming@npm:8.4.5" +"@storybook/theming@npm:8.4.7": + version: 8.4.7 + resolution: "@storybook/theming@npm:8.4.7" peerDependencies: storybook: ^8.2.0 || ^8.3.0-0 || ^8.4.0-0 || ^8.5.0-0 || ^8.6.0-0 - checksum: 10c0/9dbb92605f88eef3a5d4ca3b01a8815939e9a08c9eb3cef55e05c8f196c6bcd1a92ab1592ff0a489256382e172587c385a7cfdac227feb64e21cba65017fa818 + checksum: 10c0/20a4975478063cea616ce6ab6b1e9ec181af1424280678ed74dc5afc15b828c043e843696a1643601331c4fd266169ec4bcc5bb43fd2f1f3c01c0e21443a658a languageName: node linkType: hard @@ -3612,7 +3612,19 @@ __metadata: languageName: node linkType: hard -"@tanstack/react-virtual@npm:3.10.9, @tanstack/react-virtual@npm:^3.0.0-beta.60": +"@tanstack/react-virtual@npm:3.11.1": + version: 3.11.1 + resolution: "@tanstack/react-virtual@npm:3.11.1" + dependencies: + "@tanstack/virtual-core": "npm:3.10.9" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/5eafab335de0c65daa8ad8fe732c506bea922861032aad35732ca65ff16fdbb111bd4e72fafc0d3f4449a793ceba4ed0fcadb27390bda6f5c8ba4a55737d556d + languageName: node + linkType: hard + +"@tanstack/react-virtual@npm:^3.0.0-beta.60": version: 3.10.9 resolution: "@tanstack/react-virtual@npm:3.10.9" dependencies: @@ -3669,23 +3681,23 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:16.0.1": - version: 16.0.1 - resolution: "@testing-library/react@npm:16.0.1" +"@testing-library/react@npm:16.1.0": + version: 16.1.0 + resolution: "@testing-library/react@npm:16.1.0" dependencies: "@babel/runtime": "npm:^7.12.5" peerDependencies: "@testing-library/dom": ^10.0.0 - "@types/react": ^18.0.0 - "@types/react-dom": ^18.0.0 - react: ^18.0.0 - react-dom: ^18.0.0 + "@types/react": ^18.0.0 || ^19.0.0 + "@types/react-dom": ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 peerDependenciesMeta: "@types/react": optional: true "@types/react-dom": optional: true - checksum: 10c0/67d05dec5ad5a2e6f92b6a3234af785435c7bb62bdbf12f3bfc89c9bca0c871a189e88c4ba023ed4cea504704c87c6ac7e86e24a3962df6c521ae89b62f48ff7 + checksum: 10c0/8451dcc76ba0d4f3504af78f2a4aacc13117691f4b7a3c279f3e047d5ea817ff686496ad53e7f65f6183112aef2be3f318af609b1f5d666eed42b1014d1c68d5 languageName: node linkType: hard @@ -4443,12 +4455,12 @@ __metadata: languageName: node linkType: hard -"@types/react-dom@npm:18.3.1": - version: 18.3.1 - resolution: "@types/react-dom@npm:18.3.1" - dependencies: - "@types/react": "npm:*" - checksum: 10c0/8b416551c60bb6bd8ec10e198c957910cfb271bc3922463040b0d57cf4739cdcd24b13224f8d68f10318926e1ec3cd69af0af79f0291b599a992f8c80d47f1eb +"@types/react-dom@npm:18.3.3": + version: 18.3.3 + resolution: "@types/react-dom@npm:18.3.3" + peerDependencies: + "@types/react": ^18.0.0 + checksum: 10c0/f0bc467e09bfd1212bc350826de0faa155e1d77c5e3d8fbed3b172187173baeb90161474d2ef40df59204974a9712827baa3c46ffa7c66ba433b696f7715cb15 languageName: node linkType: hard @@ -4461,7 +4473,7 @@ __metadata: languageName: node linkType: hard -"@types/react@npm:*, @types/react@npm:18.3.12": +"@types/react@npm:*": version: 18.3.12 resolution: "@types/react@npm:18.3.12" dependencies: @@ -4471,6 +4483,16 @@ __metadata: languageName: node linkType: hard +"@types/react@npm:18.3.14": + version: 18.3.14 + resolution: "@types/react@npm:18.3.14" + dependencies: + "@types/prop-types": "npm:*" + csstype: "npm:^3.0.2" + checksum: 10c0/d925fbfcf084238b93d1a0b5406d4cf9aeb37c4a1191559aa4ee107c2e55cc15327989140f03eddda4d471f5b935d4673fd74a86f451860edea18eae48ca44f8 + languageName: node + linkType: hard + "@types/resolve@npm:1.17.1": version: 1.17.1 resolution: "@types/resolve@npm:1.17.1" @@ -9050,10 +9072,12 @@ __metadata: languageName: node linkType: hard -"framer-motion@npm:11.12.0": - version: 11.12.0 - resolution: "framer-motion@npm:11.12.0" +"framer-motion@npm:11.13.4": + version: 11.13.4 + resolution: "framer-motion@npm:11.13.4" dependencies: + motion-dom: "npm:^11.13.0" + motion-utils: "npm:^11.13.0" tslib: "npm:^2.4.0" peerDependencies: "@emotion/is-prop-valid": "*" @@ -9066,7 +9090,7 @@ __metadata: optional: true react-dom: optional: true - checksum: 10c0/9d1cfa356f5230b9bbed2107cbef50d38a7afa4c9d03fc7820c8d7bd8be697046af0e5b45f01d926c6b784f789858ad683afb361073271f826e63f29af4b05fb + checksum: 10c0/8ee9c27eebe0f7996927fed1aeb247a66d1e67f159d4a33fbf7af9764ffcb9d56e5b3e2e501399db2b522132dc7c8722470aa6ae92b7d4ce600e7edf9cc5a0cc languageName: node linkType: hard @@ -11582,6 +11606,13 @@ __metadata: languageName: node linkType: hard +"lilconfig@npm:^3.1.3": + version: 3.1.3 + resolution: "lilconfig@npm:3.1.3" + checksum: 10c0/f5604e7240c5c275743561442fbc5abf2a84ad94da0f5adc71d25e31fa8483048de3dcedcb7a44112a942fed305fd75841cdf6c9681c7f640c63f1049e9a5dcc + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -12841,6 +12872,20 @@ __metadata: languageName: node linkType: hard +"motion-dom@npm:^11.13.0": + version: 11.13.0 + resolution: "motion-dom@npm:11.13.0" + checksum: 10c0/5ba904289ca027a8070e001ca4f84a877539d6b08eaae04acaf117b5fc4bc37a9f4ca812208b41f4dc61a2db7af841140b30517d442c32d5243827b81bb11db7 + languageName: node + linkType: hard + +"motion-utils@npm:^11.13.0": + version: 11.13.0 + resolution: "motion-utils@npm:11.13.0" + checksum: 10c0/db65509cf7abc7819ec40aae4c50a29f5c07619ee0ff4df04ea3950e85ba5ec68d00cf0f2ef3072bd41787306335eab364de9d199c145631fafd9de39df1eef9 + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -14593,33 +14638,33 @@ __metadata: "@craco/craco": "npm:7.1.0" "@headlessui/react": "npm:1.7.19" "@heroicons/react": "npm:2.2.0" - "@material-symbols/svg-300": "npm:0.27.1" + "@material-symbols/svg-300": "npm:0.27.2" "@popperjs/core": "npm:2.11.8" - "@storybook/addon-actions": "npm:8.4.5" - "@storybook/addon-essentials": "npm:8.4.5" - "@storybook/addon-links": "npm:8.4.5" - "@storybook/node-logger": "npm:8.4.5" - "@storybook/preset-create-react-app": "npm:8.4.5" - "@storybook/preview-api": "npm:8.4.5" - "@storybook/react": "npm:8.4.5" - "@storybook/react-webpack5": "npm:8.4.5" - "@storybook/theming": "npm:8.4.5" + "@storybook/addon-actions": "npm:8.4.7" + "@storybook/addon-essentials": "npm:8.4.7" + "@storybook/addon-links": "npm:8.4.7" + "@storybook/node-logger": "npm:8.4.7" + "@storybook/preset-create-react-app": "npm:8.4.7" + "@storybook/preview-api": "npm:8.4.7" + "@storybook/react": "npm:8.4.7" + "@storybook/react-webpack5": "npm:8.4.7" + "@storybook/theming": "npm:8.4.7" "@supabase/sql-formatter": "npm:4.0.3" "@tailwindcss/forms": "npm:0.5.9" "@tailwindcss/line-clamp": "npm:0.4.4" "@tailwindcss/typography": "npm:0.5.15" "@tanstack/react-table": "npm:8.20.5" - "@tanstack/react-virtual": "npm:3.10.9" + "@tanstack/react-virtual": "npm:3.11.1" "@testing-library/dom": "npm:10.4.0" "@testing-library/jest-dom": "npm:6.6.3" - "@testing-library/react": "npm:16.0.1" + "@testing-library/react": "npm:16.1.0" "@tsconfig/create-react-app": "npm:2.0.5" "@types/echarts": "npm:4.9.22" "@types/jest": "npm:29.5.14" "@types/lodash": "npm:4.17.13" "@types/node": "npm:22.10.1" - "@types/react": "npm:18.3.12" - "@types/react-dom": "npm:18.3.1" + "@types/react": "npm:18.3.14" + "@types/react-dom": "npm:18.3.3" autoprefixer: "npm:10.4.20" buffer: "npm:6.0.3" circular-dependency-plugin: "npm:5.2.2" @@ -14633,19 +14678,19 @@ __metadata: echarts-for-react: "npm:3.0.2" echarts-gl: "npm:2.0.9" file-saver: "npm:2.0.5" - framer-motion: "npm:11.12.0" + framer-motion: "npm:11.13.4" fs-extra: "npm:11.2.0" if-node-version: "npm:1.1.1" jq-wasm: "npm:0.0.9" lint-staged: "npm:15.2.10" lodash: "npm:4.17.21" npm-run-all: "npm:4.1.5" - prettier: "npm:3.4.1" + prettier: "npm:3.4.2" process: "npm:0.11.10" prop-types: "npm:15.8.1" react: "npm:18.3.1" react-cool-img: "npm:1.2.12" - react-day-picker: "npm:9.4.0" + react-day-picker: "npm:9.4.2" react-dom: "npm:18.3.1" react-hotkeys: "npm:2.0.0" react-markdown: "npm:9.0.1" @@ -14663,11 +14708,11 @@ __metadata: remark-gfm: "npm:4.0.0" semver: "npm:7.6.3" source-map-explorer: "npm:2.5.3" - storybook: "npm:8.4.5" + storybook: "npm:8.4.7" storybook-addon-react-router-v6: "npm:2.0.15" storybook-dark-mode: "npm:4.0.2" stream-browserify: "npm:3.0.0" - tailwindcss: "npm:3.4.15" + tailwindcss: "npm:3.4.16" typescript: "npm:4.5.5" use-deep-compare-effect: "npm:1.8.1" uuid: "npm:11.0.3" @@ -14690,12 +14735,12 @@ __metadata: languageName: node linkType: hard -"prettier@npm:3.4.1": - version: 3.4.1 - resolution: "prettier@npm:3.4.1" +"prettier@npm:3.4.2": + version: 3.4.2 + resolution: "prettier@npm:3.4.2" bin: prettier: bin/prettier.cjs - checksum: 10c0/2d6cc3101ad9de72b49c59339480b0983e6ff6742143da0c43f476bf3b5ef88ede42ebd9956d7a0a8fa59f7a5990e8ef03c9ad4c37f7e4c9e5db43ee0853156c + checksum: 10c0/99e076a26ed0aba4ebc043880d0f08bbb8c59a4c6641cdee6cdadf2205bdd87aa1d7823f50c3aea41e015e99878d37c58d7b5f0e663bba0ef047f94e36b96446 languageName: node linkType: hard @@ -15023,15 +15068,15 @@ __metadata: languageName: node linkType: hard -"react-day-picker@npm:9.4.0": - version: 9.4.0 - resolution: "react-day-picker@npm:9.4.0" +"react-day-picker@npm:9.4.2": + version: 9.4.2 + resolution: "react-day-picker@npm:9.4.2" dependencies: "@date-fns/tz": "npm:^1.2.0" date-fns: "npm:^4.1.0" peerDependencies: react: ">=16.8.0" - checksum: 10c0/1f454735438c2cb90ffe46d26947f9b8c5351d24b22d868ab196247c7effceb1af4092a9f81dba4b0e0cde79e59541c3760501d2bba0e7a015d354e954c2ac60 + checksum: 10c0/afd04c4056536e1f26596f3fd9903bd1031bc47491374fb35d815be225a2ac3f4c2e9f34a8c824fa0fb1634f473d9227085a4ccb9fc344fff22bd3b997184073 languageName: node linkType: hard @@ -16706,11 +16751,11 @@ __metadata: languageName: node linkType: hard -"storybook@npm:8.4.5": - version: 8.4.5 - resolution: "storybook@npm:8.4.5" +"storybook@npm:8.4.7": + version: 8.4.7 + resolution: "storybook@npm:8.4.7" dependencies: - "@storybook/core": "npm:8.4.5" + "@storybook/core": "npm:8.4.7" peerDependencies: prettier: ^2 || ^3 peerDependenciesMeta: @@ -16720,7 +16765,7 @@ __metadata: getstorybook: ./bin/index.cjs sb: ./bin/index.cjs storybook: ./bin/index.cjs - checksum: 10c0/8dd216ea47ab8e76bb9cb24776999373b6d6cde061ff89db4e469e899e6b35b7f5882123e769eb6bf48457a995d0870a08f57a257afc2099161fbb6f6f098c4e + checksum: 10c0/795b79950b88b41ee0158fe2e2583a8ce97ff843c054f91e3c55310967b9e5c4e4d72814773380b543c33bd6d57ce6b5f377ce93ce73962e803b250a751be37c languageName: node linkType: hard @@ -17158,9 +17203,9 @@ __metadata: languageName: node linkType: hard -"tailwindcss@npm:3.4.15": - version: 3.4.15 - resolution: "tailwindcss@npm:3.4.15" +"tailwindcss@npm:3.4.16": + version: 3.4.16 + resolution: "tailwindcss@npm:3.4.16" dependencies: "@alloc/quick-lru": "npm:^5.2.0" arg: "npm:^5.0.2" @@ -17171,7 +17216,7 @@ __metadata: glob-parent: "npm:^6.0.2" is-glob: "npm:^4.0.3" jiti: "npm:^1.21.6" - lilconfig: "npm:^2.1.0" + lilconfig: "npm:^3.1.3" micromatch: "npm:^4.0.8" normalize-path: "npm:^3.0.0" object-hash: "npm:^3.0.0" @@ -17187,7 +17232,7 @@ __metadata: bin: tailwind: lib/cli.js tailwindcss: lib/cli.js - checksum: 10c0/709058837c5adf0b7e1386ba353983dcf2af3d390e8822fac8d53ecaaad0f6f040fd3050b1db636e2abd46ae775317a89b350ce925477ea96cca8f6c56d901df + checksum: 10c0/f716ff38e0ea6f25c2b3d811e0aaac07f627193e8527d8ad945d5088d4a188ac1eb1e8ee9aef76d8525e756ffbb8369d717013d3ffc2f49fabaa94cb1e6784c1 languageName: node linkType: hard