Skip to content

Commit

Permalink
HPCC-30921 Improve Graph Rendering Root
Browse files Browse the repository at this point in the history
* Add breadcrumbs for graph lineage
* Don't render parent for selected subgraphs
* Add status spinner with node count

Signed-off-by: Gordon Smith <[email protected]>
  • Loading branch information
GordonSmith committed Nov 30, 2023
1 parent 2e1c3bb commit 9ae2953
Show file tree
Hide file tree
Showing 7 changed files with 1,813 additions and 932 deletions.
2,277 changes: 1,465 additions & 812 deletions esp/src/package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion esp/src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"main": "src/stub.js",
"dependencies": {
"@fluentui/react": "8.110.7",
"@fluentui/react-components": "9.23.1",
"@fluentui/react-components": "9.41.0",
"@fluentui/react-experiments": "8.14.95",
"@fluentui/react-hooks": "8.6.29",
"@fluentui/react-icons-mdl2": "1.3.47",
Expand Down
208 changes: 152 additions & 56 deletions esp/src/src-react/components/Metrics.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import * as React from "react";
import { CommandBar, ContextualMenuItemType, ICommandBarItemProps, IIconProps, SearchBox } from "@fluentui/react";
import { Breadcrumb, BreadcrumbButton, BreadcrumbDivider, BreadcrumbItem, Spinner } from "@fluentui/react-components";
import { useConst } from "@fluentui/react-hooks";
import { bundleIcon, Folder20Filled, Folder20Regular, FolderOpen20Filled, FolderOpen20Regular, } from "@fluentui/react-icons";
import { WorkunitsServiceEx } from "@hpcc-js/comms";
import { Table } from "@hpcc-js/dgrid";
import { compare, scopedLogger } from "@hpcc-js/util";
import nlsHPCC from "src/nlsHPCC";
import { WUTimelinePatched } from "src/Timings";
import * as Utility from "src/Utility";
import { useDeepEffect } from "../hooks/deepHooks";
import { useMetricsOptions, useWorkunitMetrics } from "../hooks/metrics";
import { FetchStatus, useMetricsOptions, useWorkunitMetrics } from "../hooks/metrics";
import { HolyGrail } from "../layouts/HolyGrail";
import { AutosizeHpccJSComponent } from "../layouts/HpccJSAdapter";
import { AutosizeComponent, AutosizeHpccJSComponent } from "../layouts/HpccJSAdapter";
import { DockPanel, DockPanelItems, ReactWidget, ResetableDockPanel } from "../layouts/DockPanel";
import { IScope, MetricGraph, MetricGraphWidget } from "../util/metricGraph";
import { IScope, MetricGraph, MetricGraphWidget, isGraphvizWorkerResponse, layoutCache } from "../util/metricGraph";
import { pushUrl } from "../util/history";
import { debounce } from "../util/throttle";
import { ErrorBoundary } from "../util/errorBoundary";
Expand All @@ -23,6 +24,9 @@ const logger = scopedLogger("src-react/components/Metrics.tsx");

const filterIcon: IIconProps = { iconName: "Filter" };

const LineageIcon = bundleIcon(Folder20Filled, Folder20Regular);
const SelectedLineageIcon = bundleIcon(FolderOpen20Filled, FolderOpen20Regular);

const defaultUIState = {
hasSelection: false
};
Expand Down Expand Up @@ -112,20 +116,26 @@ interface MetricsProps {
export const Metrics: React.FunctionComponent<MetricsProps> = ({
wuid,
parentUrl = `/workunits/${wuid}/metrics`,
selection = ""
selection
}) => {
const [_uiState, _setUIState] = React.useState({ ...defaultUIState });
const [timelineFilter, setTimelineFilter] = React.useState("");
const [selectedMetrics, setSelectedMetrics] = React.useState([]);
const [selectedMetricsSource, setSelectedMetricsSource] = React.useState<"" | "scopesTable" | "metricGraphWidget" | "hotspot" | "reset">("");
const [selectedMetrics, setSelectedMetrics] = React.useState<IScope[]>([]);
const [selectedMetricsPtr, setSelectedMetricsPtr] = React.useState<number>(-1);
const [metrics, columns, _activities, _properties, _measures, _scopeTypes, refresh] = useWorkunitMetrics(wuid);
const [metrics, columns, _activities, _properties, _measures, _scopeTypes, fetchStatus, refresh] = useWorkunitMetrics(wuid);
const [showMetricOptions, setShowMetricOptions] = React.useState(false);
const [options, setOptions, saveOptions] = useMetricsOptions();
const [dockpanel, setDockpanel] = React.useState<ResetableDockPanel>();
const [showTimeline, setShowTimeline] = React.useState<boolean>(true);
const [trackSelection, setTrackSelection] = React.useState<boolean>(true);
const [fullscreen, setFullscreen] = React.useState<boolean>(false);
const [hotspots, setHotspots] = React.useState<string>("");
const [lineage, setLineage] = React.useState<IScope[]>([]);
const [selectedLineage, setSelectedLineage] = React.useState<IScope>();
const [isLayoutComplete, setIsLayoutComplete] = React.useState<boolean>(false);
const [isRenderComplete, setIsRenderComplete] = React.useState<boolean>(false);
const [dot, setDot] = React.useState<string>("");

React.useEffect(() => {
const service = new WorkunitsServiceEx({ baseUrl: "" });
Expand All @@ -151,6 +161,7 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
}, [wuid]);

const onHotspot = React.useCallback(() => {
setSelectedMetricsSource("hotspot");
pushUrl(`/workunits/${wuid}/metrics/${selection}`);
}, [wuid, selection]);

Expand Down Expand Up @@ -228,14 +239,15 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
.columns(["##", nlsHPCC.Type, nlsHPCC.Scope, ...options.properties])
.sortable(true)
.on("click", debounce((row, col, sel) => {
const selection = scopesTable.selection();
pushUrl(`${parentUrl}/${selection.map(row => row.__lparam.id).join(",")}`);
if (sel) {
const selection = scopesTable.selection();
setSelectedMetricsSource("scopesTable");
pushUrl(`${parentUrl}/${selection.map(row => row.__lparam.id).join(",")}`);
}
}, 100))
);

const [tableLoaded, setTableLoaded] = React.useState(false);
React.useEffect(() => {
setTableLoaded(false);
scopesTable
.columns(["##", nlsHPCC.Type, nlsHPCC.Scope, ...options.properties])
.data(metrics.filter(scopeFilterFunc).filter(row => {
Expand All @@ -247,11 +259,10 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
}))
.render()
;
setTableLoaded(true);
}, [metrics, options.properties, options.scopeTypes, scopeFilterFunc, scopesTable, timelineFilter]);

const updateScopesTable = React.useCallback((selection: IScope[]) => {
if (tableLoaded) {
if (scopesTable?.renderCount() > 0) {
const prevSelection = scopesTable.selection().map(row => row.__lparam.id);
const newSelection = selection.map(row => row.id);
const diffs = compare(prevSelection, newSelection);
Expand All @@ -261,14 +272,15 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
}));
}
}
}, [scopesTable, tableLoaded]);
}, [scopesTable]);

// Graph ---
const metricGraph = useConst(() => new MetricGraph());
const metricGraphWidget = useConst(() => new MetricGraphWidget()
.zoomToFitLimit(1)
.on("selectionChanged", () => {
const selection = metricGraphWidget.selection().filter(id => metricGraph.item(id)).map(id => metricGraph.item(id).id);
setSelectedMetricsSource("metricGraphWidget");
pushUrl(`${parentUrl}/${selection.join(",")}`);
})
);
Expand All @@ -277,30 +289,66 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
metricGraph.load(metrics);
}, [metrics, metricGraph]);

const updateMetricGraph = React.useCallback((selection: IScope[]) => {
if (metricGraphWidget.renderCount() > 0) {
// Check if selection is already visible ---
const newSel = selection.map(s => s.name);
if (!selection.length || selection.every(row => metricGraphWidget.exists(row.name))) {
metricGraphWidget
.selection(newSel)
.render(() => {
if (trackSelection) {
metricGraphWidget.zoomToSelection();
}
})
;
} else {
metricGraphWidget
.dot(metricGraph.graphTpl(selection, options))
.resize()
.render(() => {
metricGraphWidget.selection(newSel);
})
;
const updateLineage = React.useCallback((selection: IScope[]) => {
const newLineage: IScope[] = [];

let minLen = Number.MAX_SAFE_INTEGER;
const lineages = selection.map(item => {
const retVal = metricGraph.lineage(item);
minLen = Math.min(minLen, retVal.length);
return retVal;
});

if (lineages.length) {
for (let i = 0; i < minLen; ++i) {
const item = lineages[0][i];
if (lineages.every(lineage => lineage[i] === item)) {
if (metricGraph.isSubgraph(item) && item.name) {
newLineage.push(item);
}
} else {
break;
}
}
}
}, [metricGraph, metricGraphWidget, options, trackSelection]);

setLineage(newLineage);
if (!layoutCache.isComplete(dot) || newLineage.find(item => item === selectedLineage) === undefined) {
setSelectedLineage(newLineage[newLineage.length - 1]);
}
}, [dot, metricGraph, selectedLineage]);

const updateMetricGraph = React.useCallback((svg: string, selection: IScope[]) => {
let cancelled = false;
if (metricGraphWidget?.renderCount() > 0) {
setIsRenderComplete(false);
metricGraphWidget
.svg(svg)
.visible(false)
.resize()
.render(() => {
if (!cancelled) {
const newSel = selection.map(s => s.name).filter(sel => !!sel);
metricGraphWidget
.visible(true)
.selection(newSel)
;
if (trackSelection && selectedMetricsSource !== "metricGraphWidget") {
if (newSel.length) {
metricGraphWidget.zoomToSelection(0);
} else {
metricGraphWidget.zoomToFit(0);
}
}
}
setIsRenderComplete(true);
})
;
}
return () => {
cancelled = true;
};
}, [metricGraphWidget, selectedMetricsSource, trackSelection]);

const graphButtons = React.useMemo((): ICommandBarItemProps[] => [
{
Expand All @@ -318,17 +366,8 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
metricGraphWidget.centerOnItem(selectedMetrics[selectedMetricsPtr + 1].name);
setSelectedMetricsPtr(selectedMetricsPtr + 1);
}
},
{
key: "reset", text: nlsHPCC.Reset, iconProps: { iconName: "Undo" },
onClick: () => {
metricGraphWidget.reset();
setSelectedMetrics([]);
setSelectedMetricsPtr(0);
pushUrl(parentUrl);
}
}
], [metricGraphWidget, parentUrl, selectedMetrics, selectedMetricsPtr]);
], [metricGraphWidget, selectedMetrics, selectedMetricsPtr]);

const graphRightButtons = React.useMemo((): ICommandBarItemProps[] => [
{
Expand Down Expand Up @@ -365,12 +404,44 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
},
], [metricGraphWidget, selectedMetrics.length, trackSelection]);

const spinnerLabel: string = React.useMemo((): string => {
if (fetchStatus === FetchStatus.STARTED) {
return nlsHPCC.FetchingData;
} else if (!isLayoutComplete) {
return `${nlsHPCC.PerformingLayout} (${dot.split("\n").length})`;
} else if (!isRenderComplete) {
return `${nlsHPCC.RenderSVG}`;
}
return "";
}, [fetchStatus, isLayoutComplete, isRenderComplete, dot]);

const graphComponent = React.useMemo(() => {
return <HolyGrail
header={<CommandBar items={graphButtons} farItems={graphRightButtons} />}
main={<AutosizeHpccJSComponent widget={metricGraphWidget} ></AutosizeHpccJSComponent>}
header={<>
<CommandBar items={graphButtons} farItems={graphRightButtons} />
<Breadcrumb>{
lineage.map((item, idx) => {
return <>
<BreadcrumbItem key={idx} >
<BreadcrumbButton current={selectedLineage === item} icon={selectedLineage === item ? <SelectedLineageIcon /> : <LineageIcon />} onClick={() => setSelectedLineage(item)}>
{item.id}
</BreadcrumbButton>
</BreadcrumbItem>
{idx < lineage.length - 1 && <BreadcrumbDivider />}
</>;
})
}</Breadcrumb>
</>}
main={<>
<AutosizeComponent hidden={!spinnerLabel}>
<Spinner size="extra-large" label={spinnerLabel} labelPosition="below"></Spinner>
</AutosizeComponent>
<AutosizeHpccJSComponent widget={metricGraphWidget}>
</AutosizeHpccJSComponent>
</>
}
/>;
}, [graphButtons, graphRightButtons, metricGraphWidget]);
}, [graphButtons, graphRightButtons, lineage, spinnerLabel, metricGraphWidget, selectedLineage]);

// Props Table ---
const propsTable = useConst(() => new Table()
Expand Down Expand Up @@ -439,17 +510,41 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
portal.children(<h1>{timelineFilter}</h1>).lazyRender();
}, [portal, timelineFilter]);

useDeepEffect(() => {
React.useEffect(() => {
const dot = metricGraph.graphTpl(selectedLineage ? [selectedLineage] : [], options);
setDot(dot);
}, [metricGraph, options, selectedLineage]);

React.useEffect(() => {
let cancelled = false;
if (metricGraphWidget?.renderCount() > 0) {
setIsLayoutComplete(false);
layoutCache.calcSVG(dot).then(response => {
if (!cancelled) {
if (isGraphvizWorkerResponse(response)) {
updateMetricGraph(response.svg, selectedMetrics?.length ? selectedMetrics : []);
}
}
setIsLayoutComplete(true);
});
}
return () => {
cancelled = true;
};
}, [dot, metricGraphWidget, selectedMetrics, updateMetricGraph]);

React.useEffect(() => {
if (selectedMetrics) {
updateScopesTable(selectedMetrics);
updateMetricGraph(selectedMetrics);
updatePropsTable(selectedMetrics);
updatePropsTable2(selectedMetrics);
updateLineage(selectedMetrics);
}
}, [], [selectedMetrics]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMetrics]);

React.useEffect(() => {
const selectedIDs = selection.split(",");
const selectedIDs = selection?.split(",") ?? [];
setSelectedMetrics(metrics.filter(m => selectedIDs.indexOf(m.id) >= 0));
setSelectedMetricsPtr(0);
}, [metrics, selection]);
Expand Down Expand Up @@ -495,7 +590,9 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
const buttons = React.useMemo((): ICommandBarItemProps[] => [
{
key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
onClick: () => refresh()
onClick: () => {
refresh();
}
},
{
key: "hotspot", text: nlsHPCC.Hotspots, iconProps: { iconName: "SpeedHigh" },
Expand Down Expand Up @@ -553,7 +650,6 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
text: nlsHPCC.DownloadToDOT,
iconProps: { iconName: "Relationship" },
onClick: () => {
const dot = metricGraph.graphTpl(selectedMetrics, options);
Utility.downloadText(dot, `metrics-${wuid}.dot`);
}
}]
Expand All @@ -562,7 +658,7 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
key: "fullscreen", title: nlsHPCC.MaximizeRestore, iconProps: { iconName: fullscreen ? "ChromeRestore" : "FullScreen" },
onClick: () => setFullscreen(!fullscreen)
}
], [formatColumns, fullscreen, metricGraph, metrics, options, selectedMetrics, wuid]);
], [dot, formatColumns, fullscreen, metrics, wuid]);

return <HolyGrail fullscreen={fullscreen}
header={<>
Expand Down
Loading

0 comments on commit 9ae2953

Please sign in to comment.