Skip to content

Commit

Permalink
Merge pull request #18821 from GordonSmith/METRICS_SQL
Browse files Browse the repository at this point in the history
HPCC-32158 Add SQL Driven OLAP engine for WU Metrics
  • Loading branch information
GordonSmith authored Jun 27, 2024
2 parents 43f13e6 + 9d2ef45 commit 95c82e6
Show file tree
Hide file tree
Showing 13 changed files with 683 additions and 218 deletions.
381 changes: 251 additions & 130 deletions esp/src/package-lock.json

Large diffs are not rendered by default.

7 changes: 4 additions & 3 deletions esp/src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,11 @@
"@fluentui/react-icons-mdl2": "1.3.59",
"@fluentui/react-migration-v8-v9": "9.6.3",
"@hpcc-js/chart": "2.83.3",
"@hpcc-js/codemirror": "2.61.4",
"@hpcc-js/codemirror": "2.62.0",
"@hpcc-js/common": "2.71.17",
"@hpcc-js/comms": "2.92.2",
"@hpcc-js/dataflow": "8.1.6",
"@hpcc-js/eclwatch": "2.74.3",
"@hpcc-js/eclwatch": "2.74.5",
"@hpcc-js/graph": "2.85.15",
"@hpcc-js/html": "2.42.20",
"@hpcc-js/layout": "2.49.22",
Expand All @@ -56,6 +56,7 @@
"@hpcc-js/react": "2.53.16",
"@hpcc-js/tree": "2.40.17",
"@hpcc-js/util": "2.51.0",
"@hpcc-js/wasm": "2.17.1",
"@kubernetes/client-node": "0.20.0",
"clipboard": "2.0.11",
"d3-dsv": "3.0.1",
Expand All @@ -67,9 +68,9 @@
"es6-promise": "4.2.8",
"font-awesome": "4.7.0",
"formik": "2.4.5",
"octokit": "3.1.2",
"put-selector": "0.3.6",
"query-string": "7.1.3",
"octokit": "3.1.2",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-hook-form": "7.51.2",
Expand Down
68 changes: 41 additions & 27 deletions esp/src/src-react/components/Metrics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { ShortVerticalDivider } from "./Common";
import { MetricsOptions } from "./MetricsOptions";
import { BreadcrumbInfo, OverflowBreadcrumb } from "./controls/OverflowBreadcrumb";
import { MetricsPropertiesTables } from "./MetricsPropertiesTables";
import { MetricsSQL } from "./MetricsSQL";

const logger = scopedLogger("src-react/components/Metrics.tsx");

Expand Down Expand Up @@ -77,29 +78,34 @@ class TableEx extends Table {

_rawDataMap: { [id: number]: string } = {};
metrics(metrics: any[], options: MetricsOptionsT, timelineFilter: string, scopeFilter: string): this {
this.columns(["##", nlsHPCC.Type, nlsHPCC.Scope, ...options.properties]);
this.data(metrics.filter(m => this.scopeFilterFunc(m, scopeFilter)).filter(row => {
return (timelineFilter === "" || row.name?.indexOf(timelineFilter) === 0) &&
(options.scopeTypes.indexOf(row.type) >= 0);
}).map((row, idx) => {
if (idx === 0) {
this._rawDataMap = {
0: "##", 1: "type", 2: "name"
};
options.properties.forEach((p, idx2) => {
this._rawDataMap[3 + idx2] = p;
});
}
row.__hpcc_id = row.name;
return [idx, row.type, row.name, ...options.properties.map(p => {
return row.__groupedProps[p]?.Value ??
row.__groupedProps[p]?.Max ??
row.__groupedProps[p]?.Avg ??
row.__formattedProps[p] ??
row[p] ??
"";
}), row];
}));
this
.columns(["##"]) // Reset hash to force recalculation of default widths
.columns(["##", nlsHPCC.Type, nlsHPCC.Scope, ...options.properties])
.data(metrics
.filter(m => this.scopeFilterFunc(m, scopeFilter))
.filter(row => {
return (timelineFilter === "" || row.name?.indexOf(timelineFilter) === 0) &&
(options.scopeTypes.indexOf(row.type) >= 0);
}).map((row, idx) => {
if (idx === 0) {
this._rawDataMap = {
0: "##", 1: "type", 2: "name"
};
options.properties.forEach((p, idx2) => {
this._rawDataMap[3 + idx2] = p;
});
}
row.__hpcc_id = row.name;
return [idx, row.type, row.name, ...options.properties.map(p => {
return row.__groupedProps[p]?.Value ??
row.__groupedProps[p]?.Max ??
row.__groupedProps[p]?.Avg ??
row.__formattedProps[p] ??
row[p] ??
"";
}), row];
}))
;
return this;
}

Expand Down Expand Up @@ -129,6 +135,8 @@ class TableEx extends Table {
}
}

type SelectedMetricsSource = "" | "scopesTable" | "scopesSqlTable" | "metricGraphWidget" | "hotspot" | "reset";

interface MetricsProps {
wuid: string;
querySet?: string;
Expand All @@ -146,7 +154,7 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
}) => {
const [_uiState, _setUIState] = React.useState({ ...defaultUIState });
const [timelineFilter, setTimelineFilter] = React.useState("");
const [selectedMetricsSource, setSelectedMetricsSource] = React.useState<"" | "scopesTable" | "metricGraphWidget" | "hotspot" | "reset">("");
const [selectedMetricsSource, setSelectedMetricsSource] = React.useState<SelectedMetricsSource>("");
const [selectedMetrics, setSelectedMetrics] = React.useState<IScope[]>([]);
const [selectedMetricsPtr, setSelectedMetricsPtr] = React.useState<number>(-1);
const [metrics, columns, _activities, _properties, _measures, _scopeTypes, fetchStatus, refresh] = useWUQueryMetrics(wuid, querySet, queryId);
Expand Down Expand Up @@ -243,15 +251,18 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
setScopeFilter(newValue || "");
}, []);

const scopesSelectionChanged = React.useCallback((source: SelectedMetricsSource, selection: IScope[]) => {
setSelectedMetricsSource(source);
pushUrl(`${parentUrl}/${selection.map(row => row.__lparam?.id ?? row.id).join(",")}`);
}, [parentUrl]);

const scopesTable = useConst(() => new TableEx()
.multiSelect(true)
.metrics([], options, timelineFilter, scopeFilter)
.sortable(true)
.on("click", debounce((row, col, sel) => {
if (sel) {
const selection = scopesTable.selection();
setSelectedMetricsSource("scopesTable");
pushUrl(`${parentUrl}/${selection.map(row => row.__lparam.id).join(",")}`);
scopesSelectionChanged("scopesTable", scopesTable.selection());
}
}, 100))
);
Expand Down Expand Up @@ -617,6 +628,9 @@ export const Metrics: React.FunctionComponent<MetricsProps> = ({
main={<AutosizeHpccJSComponent widget={scopesTable} ></AutosizeHpccJSComponent>}
/>
</DockPanelItem>
<DockPanelItem key="metricsSql" title={nlsHPCC.MetricsSQL} location="tab-after" relativeTo="scopesTable">
<MetricsSQL defaultSql={options.sql} scopes={metrics} onSelectionChanged={selection => scopesSelectionChanged("scopesSqlTable", selection)}></MetricsSQL>
</DockPanelItem>
<DockPanelItem key="metricGraph" title={nlsHPCC.Graph} location="split-right" relativeTo="scopesTable" >
<HolyGrail
header={<>
Expand Down
19 changes: 13 additions & 6 deletions esp/src/src-react/components/MetricsOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { DefaultButton, PrimaryButton, Checkbox, Pivot, PivotItem, TextField } f
import nlsHPCC from "src/nlsHPCC";
import { useMetricMeta, useMetricsOptions } from "../hooks/metrics";
import { MessageBox } from "../layouts/MessageBox";
import { JSONSourceEditor } from "./SourceEditor";
import { JSONSourceEditor, SourceEditor } from "./SourceEditor";

const width = 640;
const innerHeight = 400;
Expand Down Expand Up @@ -51,7 +51,7 @@ export const MetricsOptions: React.FunctionComponent<MetricsOptionsProps> = ({
/>
</>} >
<Pivot>
<PivotItem headerText={nlsHPCC.Metrics}>
<PivotItem key="metrics" headerText={nlsHPCC.Metrics}>
<div style={{ height: innerHeight, overflow: "auto" }}>
<Checkbox key="all" label={nlsHPCC.All} checked={allChecked} onChange={(ev, checked) => {
if (checked) {
Expand All @@ -71,7 +71,7 @@ export const MetricsOptions: React.FunctionComponent<MetricsOptionsProps> = ({
})}
</div>
</PivotItem>
<PivotItem headerText={nlsHPCC.Columns}>
<PivotItem key="columns" headerText={nlsHPCC.Columns}>
<div style={{ height: innerHeight, overflow: "auto" }}>
{properties.map(p => {
return <Checkbox key={p} label={p} checked={options.properties.indexOf(p) >= 0} onChange={(ev, checked) => {
Expand All @@ -84,7 +84,14 @@ export const MetricsOptions: React.FunctionComponent<MetricsOptionsProps> = ({
})}
</div>
</PivotItem>
<PivotItem headerText={nlsHPCC.Graph}>
<PivotItem key="sql" headerText={nlsHPCC.SQL} >
<div style={{ height: innerHeight }}>
<SourceEditor mode="sql" text={options.sql} onTextChange={sql => {
setOptions({ ...options, sql });
}} />
</div>
</PivotItem>
<PivotItem key="graph" headerText={nlsHPCC.Graph}>
<div style={{ height: innerHeight, overflow: "auto" }}>
<Checkbox label={nlsHPCC.IgnoreGlobalStoreOutEdges} checked={options.ignoreGlobalStoreOutEdges} onChange={(ev, checked) => {
setOptions({ ...options, ignoreGlobalStoreOutEdges: !!checked });
Expand All @@ -100,7 +107,7 @@ export const MetricsOptions: React.FunctionComponent<MetricsOptionsProps> = ({
}} />
</div>
</PivotItem>
<PivotItem headerText={nlsHPCC.Layout} >
<PivotItem key="layout" headerText={nlsHPCC.Layout} >
<div style={{ height: innerHeight }}>
<JSONSourceEditor json={options.layout} onChange={obj => {
if (obj) {
Expand All @@ -110,5 +117,5 @@ export const MetricsOptions: React.FunctionComponent<MetricsOptionsProps> = ({
</div>
</PivotItem>
</Pivot>
</MessageBox>;
</MessageBox >;
};
174 changes: 174 additions & 0 deletions esp/src/src-react/components/MetricsSQL.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import * as React from "react";
import { CommandBarButton, Stack } from "@fluentui/react";
import { useConst } from "@fluentui/react-hooks";
import { IScope } from "@hpcc-js/comms";
import { ICompletion } from "@hpcc-js/codemirror";
import { Table } from "@hpcc-js/dgrid";
import * as Utility from "src/Utility";
import { useDuckDBConnection } from "../hooks/duckdb";
import { HolyGrail } from "../layouts/HolyGrail";
import { AutosizeHpccJSComponent } from "../layouts/HpccJSAdapter";
import { debounce } from "../util/throttle";
import { SQLSourceEditor } from "./SourceEditor";
import nlsHPCC from "src/nlsHPCC";

const spaceRegex = new RegExp("\\s", "g");

interface MetricsDataProps {
defaultSql: string;
scopes: IScope[];
onSelectionChanged: (selection: IScope[]) => void;
}

export const MetricsSQL: React.FunctionComponent<MetricsDataProps> = ({
defaultSql,
scopes,
onSelectionChanged
}) => {

const cleanScopes = React.useMemo(() => {
return scopes.map(scope => {
const retVal = { ...scope };
delete retVal.__children;
return retVal;
});
}, [scopes]);

const connection = useDuckDBConnection(cleanScopes, "metrics");
const [schema, setSchema] = React.useState<any[]>([]);
const [sql, setSql] = React.useState<string>(defaultSql);
const [sqlError, setSqlError] = React.useState<Error | undefined>();
const [dirtySql, setDirtySql] = React.useState<string>(sql);
const [data, setData] = React.useState<any[]>([]);

// Grid ---
const columns = React.useMemo((): string[] => {
const retVal: string[] = [];
schema.forEach(col => {
retVal.push(col.column_name);
});
return retVal;
}, [schema]);

const scopesTable = useConst(() => new Table()
.multiSelect(true)
.sortable(true)
.noDataMessage(nlsHPCC.loadingMessage)
.on("click", debounce((row, col, sel) => {
if (sel) {
onSelectionChanged(scopesTable.selection());
}
}, 100))
);

React.useEffect(() => {
if (columns.length === 0 && data.length === 0 && sqlError) {
scopesTable
.columns(["Error"])
.data(sqlError.message.split("\n").map(line => {
if (line.indexOf("LINE") === 0) {
} else if (line.includes("^")) {
line = line.replace(spaceRegex, "&nbsp;");
}
return [line];
}))
.lazyRender()
;
} else {
scopesTable
.columns(["##"]) // Reset hash to force recalculation of default widths
.columns(["##", ...columns])
.data(data.map((row, idx) => [idx + 1, ...row]))
.lazyRender()
;
}
}, [columns, data, sqlError, scopesTable]);

// Query ---
React.useEffect(() => {
if (cleanScopes.length === 0) {
setSchema([]);
setData([]);
} else if (connection) {
connection.query(`DESCRIBE ${sql}`).then(result => {
if (connection) {
setSchema(result.toArray().map((row) => row.toJSON()));
}
}).catch(e => {
setSchema([]);
});

setSqlError(undefined);
connection.query(sql).then(result => {
if (connection) {
setData(result.toArray().map((row) => {
return row.toArray();
}));
}
}).catch(e => {
setSqlError(e);
setData([]);
}).finally(() => {
scopesTable.noDataMessage(nlsHPCC.noDataMessage);
});
}
}, [cleanScopes.length, connection, scopesTable, sql]);

// Selection ---
const onChange = React.useCallback((newSql: string) => {
setDirtySql(newSql);
}, []);

const onFetchHints = React.useCallback((cm, option): Promise<ICompletion | null> => {
const cursor = cm.getCursor();
const lineStr = cm.getLine(cursor.line);
let lineEnd = cursor.ch;
let end = cm.indexFromPos({ line: cursor.line, ch: lineEnd });
if (connection) {
return connection.query(`SELECT * FROM sql_auto_complete("${dirtySql.substring(0, end)}")`).then(result => {
if (connection) {
const hints = result.toArray().map((row) => row.toJSON());
while (lineEnd < lineStr.length && /\w/.test(lineStr.charAt(lineEnd))) ++lineEnd;
end = cm.indexFromPos({ line: cursor.line, ch: lineEnd });
const suggestion_start = hints.length ? hints[0].suggestion_start : end;
return {
list: hints.map(row => row.suggestion),
from: cm.posFromIndex(suggestion_start),
to: cm.posFromIndex(end)
};
}
}).catch(e => {
return Promise.resolve(null);
});
}
return Promise.resolve(null);
}, [connection, dirtySql]);

const onSubmit = React.useCallback(() => {
setSql(dirtySql);
}, [dirtySql]);

const onCopy = React.useCallback(() => {
const tsv = scopesTable.export("TSV");
navigator?.clipboard?.writeText(tsv);
}, [scopesTable]);

const onDownload = React.useCallback(() => {
const csv = scopesTable.export("CSV");
Utility.downloadCSV(csv, "metrics.csv");
}, [scopesTable]);

return <HolyGrail
header={
<Stack horizontal style={{ width: "100%", height: "80px" }}>
<div style={{ width: "100%", height: "80px" }}>
<SQLSourceEditor sql={sql} toolbar={false} onSqlChange={onChange} onFetchHints={onFetchHints} onSubmit={onSubmit} ></SQLSourceEditor>
</div>
<CommandBarButton iconProps={{ iconName: "Play" }} onClick={() => setSql(dirtySql)} />
<CommandBarButton disabled={data.length === 0} iconProps={{ iconName: "Copy" }} onClick={onCopy} />
<CommandBarButton disabled={data.length === 0} iconProps={{ iconName: "Download" }} onClick={onDownload} />
</Stack>
}
main={<AutosizeHpccJSComponent widget={scopesTable} ></AutosizeHpccJSComponent>}
/>;
};
Loading

0 comments on commit 95c82e6

Please sign in to comment.