浏览代码

HPCC-25178 Add N-Way Workunits Dashboard

POC Dashboard

Signed-off-by: Gordon Smith <GordonJSmith@gmail.com>
Gordon Smith 4 年之前
父节点
当前提交
303d4bf00e

文件差异内容过多而无法显示
+ 271 - 478
esp/src/package-lock.json


+ 4 - 2
esp/src/package.json

@@ -33,9 +33,11 @@
   },
   "main": "src/stub.js",
   "dependencies": {
-    "@fluentui/react": "^8.0.0-beta.34",
+    "@fluentui/react": "^8.0.0-beta.35",
+    "@fluentui/react-button": "^1.0.0-beta.18",
+    "@fluentui/react-cards": "^1.0.0-beta.34",
     "@fluentui/react-hooks": "^8.0.0-beta.0",
-    "@fluentui/react-theme-provider": "^1.0.0-beta.13",
+    "@fluentui/react-theme-provider": "^1.0.0-beta.14",
     "@hpcc-js/chart": "2.52.0",
     "@hpcc-js/codemirror": "2.33.0",
     "@hpcc-js/common": "2.42.0",

+ 1 - 0
esp/src/src-react/components/Menu.tsx

@@ -17,6 +17,7 @@ const navLinkGroups: INavLinkGroup[] = [
         name: "ECL",
         links: [
             { url: "#/workunits", name: nlsHPCC.Workunits },
+            { url: "#/workunits/dashboard", name: `${nlsHPCC.Workunits} (D)` },
             { url: "#/workunits/legacy", name: `${nlsHPCC.Workunits} (L)` },
             { url: "#/play", name: nlsHPCC.Playground },
         ]

+ 1 - 1
esp/src/src-react/components/Workunits.tsx

@@ -201,7 +201,7 @@ export const Workunits: React.FunctionComponent<WorkunitsProps> = ({
     React.useEffect(() => {
         refreshTable();
         // eslint-disable-next-line react-hooks/exhaustive-deps
-    }, [filter]);
+    }, [filter, store?.data]);
 
     //  Selection  ---
     React.useEffect(() => {

+ 299 - 0
esp/src/src-react/components/WorkunitsDashboard.tsx

@@ -0,0 +1,299 @@
+import * as React from "react";
+import { Dropdown, IStackItemStyles, IStackStyles, IStackTokens, Overlay, Spinner, SpinnerSize, Stack, Text } from "@fluentui/react";
+import { Card } from "@fluentui/react-cards";
+import * as Memory from "dojo/store/Memory";
+import * as Observable from "dojo/store/Observable";
+import * as ESPWorkunit from "src/ESPWorkunit";
+import { WorkunitsService, WUQuery } from "@hpcc-js/comms";
+import { Area, Column, Pie, Bar } from "@hpcc-js/chart";
+import { chain, filter, group, map, sort } from "@hpcc-js/dataflow";
+import Chip from "@material-ui/core/Chip";
+import nlsHPCC from "src/nlsHPCC";
+import { pushParamExact } from "../util/history";
+import { AutosizeHpccJSComponent } from "../layouts/HpccJSAdapter";
+import { Workunits } from "./Workunits";
+import { useConst } from "@fluentui/react-hooks";
+
+const service = new WorkunitsService({ baseUrl: "" });
+
+const wuidToDate = (wuid: string) => `${wuid.substr(1, 4)}-${wuid.substr(5, 2)}-${wuid.substr(7, 2)}`;
+
+interface WorkunitEx extends WUQuery.ECLWorkunit {
+    Day: string;
+}
+
+export interface WorkunitsDashboardFilter {
+    lastNDays?: number;
+    cluster?: string;
+    owner?: string;
+    state?: string;
+    protected?: string;
+    day?: string;
+}
+
+export interface WorkunitsDashboardProps {
+    filterProps?: WorkunitsDashboardFilter;
+}
+
+export const WorkunitsDashboard: React.FunctionComponent<WorkunitsDashboardProps> = ({
+    filterProps
+}) => {
+    filterProps = {
+        lastNDays: 7,
+        ...filterProps
+    };
+
+    const [loading, setLoading] = React.useState(false);
+    const [workunits, setWorkunits] = React.useState<WorkunitEx[]>([]);
+
+    React.useEffect(() => {
+        setLoading(true);
+        setWorkunits([]);
+        const end = new Date();
+        const start = new Date();
+        start.setDate(start.getDate() - filterProps.lastNDays);
+        service.WUQuery({
+            StartDate: start.toISOString(),
+            EndDate: end.toISOString(),
+            PageSize: 999999
+        }).then(response => {
+            setWorkunits([...map(response.Workunits.ECLWorkunit, (row: WUQuery.ECLWorkunit) => ({ ...row, Day: wuidToDate(row.Wuid) }))]);
+            setLoading(false);
+        });
+    }, [filterProps.lastNDays]);
+
+    //  Cluster Chart ---
+    const clusterChart = React.useRef(
+        new Bar()
+            .columns(["Cluster", "Count"])
+            .on("click", (row, col, sel) => pushParamExact("cluster", sel ? row.Cluster : undefined))
+    ).current;
+
+    const clusterPipeline = chain(
+        filter(row => filterProps.state === undefined || row.State === filterProps.state),
+        filter(row => filterProps.owner === undefined || row.Owner === filterProps.owner),
+        filter(row => filterProps.day === undefined || row.Day === filterProps.day),
+        filter(row => filterProps.protected === undefined || row.Protected === filterProps.protected),
+        group((row: WUQuery.ECLWorkunit) => row.Cluster),
+        map(row => [row.key, row.value.length] as [string, number]),
+        sort((l, r) => l[0].localeCompare(r[0])),
+    );
+
+    clusterChart
+        .data([...clusterPipeline(workunits)])
+        ;
+
+    //  Owner Chart ---
+    const ownerChart = React.useRef(
+        new Column()
+            .columns(["Owner", "Count"])
+            .on("click", (row, col, sel) => pushParamExact("owner", sel ? row.Owner : undefined))
+    ).current;
+
+    const ownerPipeline = chain(
+        filter(row => filterProps.cluster === undefined || row.Cluster === filterProps.cluster),
+        filter(row => filterProps.state === undefined || row.State === filterProps.state),
+        filter(row => filterProps.day === undefined || row.Day === filterProps.day),
+        filter(row => filterProps.protected === undefined || row.Protected === filterProps.protected),
+        group((row: WUQuery.ECLWorkunit) => row.Owner),
+        map(row => [row.key, row.value.length] as [string, number]),
+        sort((l, r) => l[0].localeCompare(r[0])),
+    );
+
+    ownerChart
+        .data([...ownerPipeline(workunits)])
+        ;
+
+    //  State Chart ---
+    const stateChart = React.useRef(
+        new Pie()
+            .columns(["State", "Count"])
+            .on("click", (row, col, sel) => pushParamExact("state", sel ? row.State : undefined))
+    ).current;
+
+    const statePipeline = chain(
+        filter(row => filterProps.cluster === undefined || row.Cluster === filterProps.cluster),
+        filter(row => filterProps.owner === undefined || row.Owner === filterProps.owner),
+        filter(row => filterProps.day === undefined || row.Day === filterProps.day),
+        filter(row => filterProps.protected === undefined || row.Protected === filterProps.protected),
+        group((row: WUQuery.ECLWorkunit) => row.State),
+        map(row => [row.key, row.value.length])
+    );
+
+    stateChart
+        .data([...statePipeline(workunits)])
+        ;
+
+    //  Protected Chart ---
+    const protectedChart = React.useRef(
+        new Pie()
+            .columns(["Protected", "Count"])
+            .on("click", (row, col, sel) => pushParamExact("protected", sel ? row.Protected === "true" : undefined))
+    ).current;
+
+    const protectedPipeline = chain(
+        filter(row => filterProps.cluster === undefined || row.Cluster === filterProps.cluster),
+        filter(row => filterProps.owner === undefined || row.Owner === filterProps.owner),
+        filter(row => filterProps.day === undefined || row.Day === filterProps.day),
+        group((row: WorkunitEx) => "" + row.Protected),
+        map(row => [row.key, row.value.length])
+    );
+
+    protectedChart
+        .data([...protectedPipeline(workunits)])
+        ;
+
+    //  Day Chart ---
+    const dayChart = React.useRef(
+        new Area()
+            .columns(["Day", "Count"])
+            .xAxisType("time")
+            .interpolate("cardinal")
+            // .xAxisTypeTimePattern("")
+            .on("click", (row, col, sel) => pushParamExact("day", sel ? row.Day : undefined))
+    ).current;
+
+    const dayPipeline = chain(
+        filter(row => filterProps.cluster === undefined || row.Cluster === filterProps.cluster),
+        filter(row => filterProps.owner === undefined || row.Owner === filterProps.owner),
+        filter(row => filterProps.state === undefined || row.State === filterProps.state),
+        filter(row => filterProps.protected === undefined || row.Protected === filterProps.protected),
+        group(row => row.Day),
+        map(row => [row.key, row.value.length] as [string, number]),
+        sort((l, r) => l[0].localeCompare(r[0])),
+    );
+
+    dayChart
+        .data([...dayPipeline(workunits)])
+        ;
+
+    //  Table ---
+    const workunitsStore = useConst(Observable(new Memory({ idProperty: "Wuid", data: [] })));
+    const tablePipeline = chain(
+        filter(row => filterProps.cluster === undefined || row.Cluster === filterProps.cluster),
+        filter(row => filterProps.owner === undefined || row.Owner === filterProps.owner),
+        filter(row => filterProps.state === undefined || row.State === filterProps.state),
+        filter(row => filterProps.protected === undefined || row.Protected === filterProps.protected),
+        filter(row => filterProps.day === undefined || row.Day === filterProps.day),
+        map(row => ESPWorkunit.Get(row.Wuid, row))
+    );
+    workunitsStore.setData([...tablePipeline(workunits)]);
+
+    //  --- --- ---
+    const stackStyles: IStackStyles = {
+        root: {
+            height: "100%",
+        },
+    };
+    const stackItemStyles: IStackItemStyles = {
+        root: {
+            minHeight: 240
+        },
+    };
+    const outerStackTokens: IStackTokens = { childrenGap: 5 };
+    const innerStackTokens: IStackTokens = {
+        childrenGap: 5,
+        padding: 10,
+    };
+
+    return <>
+        <Stack tokens={outerStackTokens} styles={{ root: { height: "100%" } }}>
+            <Stack styles={stackStyles} tokens={innerStackTokens}>
+                <Stack.Item styles={stackItemStyles}>
+                    <Stack horizontal tokens={{ childrenGap: 16 }}  >
+                        <Stack.Item align="start" styles={{ root: { width: "25%", height: "100%" } }}>
+                            <Card tokens={{ childrenMargin: 12, minWidth: "100%", minHeight: "100%" }}>
+                                <Card.Item>
+                                    <Stack horizontal horizontalAlign="space-between">
+                                        <Text variant="large" nowrap block styles={{ root: { fontWeight: "bold" } }}>{nlsHPCC.State}</Text>
+                                        {filterProps.state !== undefined && <Chip label={filterProps.state} clickable color="primary" onDelete={() => pushParamExact("state", undefined)} />}
+                                    </Stack>
+                                </Card.Item>
+                                <Card.Item>
+                                    <AutosizeHpccJSComponent widget={stateChart} fixedHeight="240px" />
+                                </Card.Item>
+                            </Card>
+                        </Stack.Item>
+                        <Stack.Item align="center" styles={{ root: { width: "50%" } }}>
+                            <Card tokens={{ childrenMargin: 12, minWidth: "100%" }} >
+                                <Card.Item>
+                                    <Stack horizontal horizontalAlign="space-between">
+                                        <Text variant="large" nowrap block styles={{ root: { fontWeight: "bold" } }}>{nlsHPCC.Day}</Text>
+                                        {filterProps.day !== undefined && <Chip label={filterProps.day} clickable color="primary" onDelete={() => pushParamExact("day", undefined)} />}
+                                        <Dropdown onChange={(evt, opt, idx) => { pushParamExact("lastNDays", opt.key); }}
+                                            options={[
+                                                { key: 1, text: "1 Day", selected: filterProps.lastNDays === 1 },
+                                                { key: 2, text: "2 Days", selected: filterProps.lastNDays === 2 },
+                                                { key: 3, text: "3 Days", selected: filterProps.lastNDays === 3 },
+                                                { key: 7, text: "1 Week", selected: filterProps.lastNDays === 7 },
+                                                { key: 14, text: "2 Weeks", selected: filterProps.lastNDays === 14 },
+                                                { key: 21, text: "3 Weeks", selected: filterProps.lastNDays === 21 },
+                                                { key: 31, text: "1 Month", selected: filterProps.lastNDays === 31 }
+                                            ]}
+                                        />
+                                    </Stack>
+                                </Card.Item>
+                                <Card.Item>
+                                    <AutosizeHpccJSComponent widget={dayChart} fixedHeight="240px" />
+                                </Card.Item>
+                            </Card>
+                        </Stack.Item>
+                        <Stack.Item align="end" styles={{ root: { width: "25%" } }}>
+                            <Card tokens={{ childrenMargin: 12, minWidth: "100%" }}>
+                                <Card.Item>
+                                    <Stack horizontal horizontalAlign="space-between">
+                                        <Text variant="large" nowrap block styles={{ root: { fontWeight: "bold" } }}>{nlsHPCC.Protected}</Text>
+                                        {filterProps.protected !== undefined && <Chip label={"" + filterProps.protected} clickable color="primary" onDelete={() => pushParamExact("protected", undefined)} />}
+                                    </Stack>
+                                </Card.Item>
+                                <Card.Item>
+                                    <AutosizeHpccJSComponent widget={protectedChart} fixedHeight="240px" />
+                                </Card.Item>
+                            </Card>
+                        </Stack.Item>
+                    </Stack>
+                </Stack.Item>
+                <Stack.Item styles={stackItemStyles}>
+                    <Stack horizontal tokens={{ childrenGap: 16 }} >
+                        <Stack.Item align="start" styles={{ root: { width: "66%" } }}>
+                            <Card tokens={{ childrenMargin: 12, minWidth: "100%" }}>
+                                <Card.Item>
+                                    <Stack horizontal horizontalAlign="space-between">
+                                        <Text variant="large" nowrap block styles={{ root: { fontWeight: "bold" } }}>{nlsHPCC.Owner}</Text>
+                                        {filterProps.owner !== undefined && <Chip label={filterProps.owner} clickable color="primary" onDelete={() => pushParamExact("owner", undefined)} />}
+                                    </Stack>
+                                </Card.Item>
+                                <Card.Item>
+                                    <AutosizeHpccJSComponent widget={ownerChart} fixedHeight="240px" />
+                                </Card.Item>
+                            </Card>
+                        </Stack.Item>
+                        <Stack.Item align="center" styles={{ root: { width: "34%" } }}>
+                            <Card tokens={{ childrenMargin: 12, minWidth: "100%" }} >
+                                <Card.Item>
+                                    <Stack horizontal horizontalAlign="space-between">
+                                        <Text variant="large" nowrap block styles={{ root: { fontWeight: "bold" } }}>{nlsHPCC.Cluster}</Text>
+                                        {filterProps.cluster !== undefined && <Chip label={filterProps.cluster} clickable color="primary" onDelete={() => pushParamExact("cluster", undefined)} />}
+                                    </Stack>
+                                </Card.Item>
+                                <Card.Item>
+                                    <AutosizeHpccJSComponent widget={clusterChart} fixedHeight="240px" />
+                                </Card.Item>
+                            </Card>
+                        </Stack.Item>
+                    </Stack>
+                </Stack.Item>
+                <Stack.Item grow={5} styles={stackItemStyles}>
+                    <Card tokens={{ childrenMargin: 4, minWidth: "100%", height: "100%" }}>
+                        <Card.Section tokens={{}} styles={{ root: { height: "100%" } }}>
+                            <Workunits store={workunitsStore} />
+                        </Card.Section>
+                    </Card>
+                </Stack.Item>
+            </Stack>
+        </Stack>
+        {loading && <Overlay styles={{ root: { display: "flex", justifyContent: "center" } }}>
+            <Spinner label={nlsHPCC.Loading} size={SpinnerSize.large} />
+        </Overlay>}
+    </>;
+};

+ 35 - 0
esp/src/src-react/hooks/Workunit.ts

@@ -0,0 +1,35 @@
+import * as React from "react";
+import { Workunit, Result, WUStateID } from "@hpcc-js/comms";
+
+export function useWorkunit(wuid: string): [Workunit, WUStateID] {
+
+    const [workunit, setWorkunit] = React.useState<Workunit>();
+    const [state, setState] = React.useState<WUStateID>();
+
+    React.useEffect(() => {
+        const wu = Workunit.attach({ baseUrl: "" }, wuid);
+        const handle = wu.watch(() => {
+            setState(wu.StateID);
+        });
+        setWorkunit(wu);
+        return () => {
+            handle.release();
+        };
+    }, [wuid]);
+
+    return [workunit, state];
+}
+
+export function useWorkunitResults(wuid: string): [Result[], Workunit, WUStateID] {
+
+    const [workunit, state] = useWorkunit(wuid);
+    const [results, setResults] = React.useState<Result[]>([]);
+
+    React.useEffect(() => {
+        workunit?.fetchResults().then(results => {
+            setResults(results);
+        });
+    }, [workunit, state]);
+
+    return [results, workunit, state];
+}

+ 66 - 0
esp/src/src-react/layouts/HpccJSAdapter.css

@@ -0,0 +1,66 @@
+:root{
+    --grid-font-family: "Segoe WPC", "Segoe UI", sans-serif;
+    --header-font-size: 14px;
+    --row-font-size: 13px;
+    --grid-background:transparent;
+    --grid-selectionForeground:rgb(50, 49, 48);
+    --grid-selectionBackground: rgb(235, 235, 235);
+    --grid-hoverBackground: rgb(247, 245, 243);
+    --grid-selectionHoverBackground:  rgb(227, 225, 223);
+}
+
+.flat .dojo-component.dgrid.dgrid-grid {
+    font-family: var(--grid-font-family);
+    border-style: hidden;
+}
+
+.flat .dojo-component.dgrid .dgrid-header-row {
+    background-color: var(--grid-background);
+    font-size: var(--header-font-size);
+    line-height: 32px;
+}
+
+.flat .dojo-component.dgrid .dgrid-header-row .dgrid-cell {
+    border-bottom-color:  var(--grid-selectionBackground);
+}
+
+.flat .dojo-component.dgrid .dgrid-header-row:focus {
+    outline-width: 0px;
+}
+
+.flat .dojo-component.dgrid .dgrid-header.dgrid-header-scroll {
+    background-color: var(--grid-background);
+}
+
+.flat .dojo-component.dgrid .dgrid-row {
+    font-family: var(--grid-font-family);
+    font-size: var(--row-font-size);
+    line-height: 22px;
+}
+
+.flat .dojo-component.dgrid .dgrid-row:hover {
+    background-color: var(--grid-hoverBackground);
+}
+
+.flat .dojo-component.dgrid .dgrid-row.dgrid-selected:hover {
+    background-color: var(--grid-selectionHoverBackground);
+}
+
+.flat .dojo-component.dgrid .dgrid-row .dgrid-selected {
+    background-color: var(--grid-selectionBackground);
+    color: var(--grid-selectionForeground);
+    border-color: var(--grid-background);
+}
+
+.flat .dojo-component.dgrid .dgrid-cell {
+    border-color: var(--grid-background);
+}
+
+.flat .dojo-component.dgrid .dgrid-focus:focus {
+    outline-width: 0px;
+}
+
+.flat .dojo-component.dgrid .dgrid-fakeline {
+    border:0px;
+    margin:2px;
+}

+ 67 - 0
esp/src/src-react/layouts/HpccJSAdapter.tsx

@@ -0,0 +1,67 @@
+import * as React from "react";
+import { useId } from "@fluentui/react-hooks";
+import { SizeMe } from "react-sizeme";
+import { Widget } from "@hpcc-js/common";
+
+import "srcReact/layouts/HpccJSAdapter.css";
+
+export interface HpccJSComponentProps {
+    widget: Widget;
+    width: number;
+    height: number;
+    debounce?: boolean;
+}
+
+export const HpccJSComponent: React.FunctionComponent<HpccJSComponentProps> = ({
+    widget,
+    width,
+    height,
+    debounce = true
+}) => {
+
+    const divID = useId("viz-component-");
+
+    React.useEffect(() => {
+        widget
+            .target(divID)
+            .render()
+            ;
+        return () => {
+            widget.target(null);
+        };
+    }, [divID, widget]);
+
+    if (widget.target()) {
+        widget.resize({ width, height });
+        if (debounce) {
+            widget.lazyRender();
+        } else {
+            widget.render();
+        }
+    }
+
+    return <div id={divID} className="hpcc-js-component" style={{ width, height }}>
+    </div>;
+};
+
+export interface AutosizeHpccJSComponentProps {
+    widget: Widget;
+    fixedHeight?: string;
+    debounce?: boolean;
+}
+
+export const AutosizeHpccJSComponent: React.FunctionComponent<AutosizeHpccJSComponentProps> = ({
+    widget,
+    fixedHeight = "100%",
+    debounce = true
+}) => {
+
+    return <SizeMe monitorHeight>{({ size }) =>
+        <div style={{ width: "100%", height: fixedHeight, position: "relative" }}>
+            <div style={{ position: "absolute" }}>
+                <HpccJSComponent widget={widget} debounce={debounce} width={size.width} height={size.height} />
+            </div>
+        </div>
+    }
+    </SizeMe>;
+};

+ 2 - 1
esp/src/src-react/routes.tsx

@@ -55,7 +55,8 @@ const routes: Routes = [
     {
         path: "/workunits",
         children: [
-            { path: "", action: (context) => import("./components/Workunits").then(_ => <_.Workunits filter={parseSearch(context.search) as any} />) },
+            { path: "", action: (ctx) => import("./components/Workunits").then(_ => <_.Workunits filter={parseSearch(ctx.search) as any} />) },
+            { path: "/dashboard", action: (ctx) => import("./components/WorkunitsDashboard").then(_ => <_.WorkunitsDashboard filterProps={parseSearch(ctx.search) as any} />) },
             { path: "/legacy", action: () => import("./layouts/DojoAdapter").then(_ => <_.DojoAdapter widgetClassID="WUQueryWidget" />) },
             { path: "/:Wuid", action: (ctx, params) => import("./layouts/DojoAdapter").then(_ => <_.DojoAdapter widgetClassID="WUDetailsWidget" params={params} />) }
         ]

+ 6 - 2
esp/src/src-react/util/history.ts

@@ -123,12 +123,16 @@ export function pushParam(key: string, val?: string | string[] | number | boolea
     pushParams({ [key]: val }, state);
 }
 
-export function pushParams(search: { [key: string]: string | string[] | number | boolean }, state?: any) {
+export function pushParamExact(key: string, val?: string | string[] | number | boolean, state?: any) {
+    pushParams({ [key]: val }, state, true);
+}
+
+export function pushParams(search: { [key: string]: string | string[] | number | boolean }, state?: any, keepEmpty: boolean = false) {
     const params = parseSearch(hashHistory.location.search);
     for (const key in search) {
         const val = search[key];
         //  No empty strings OR "false" booleans...
-        if (val === "" || val === false) {
+        if (!keepEmpty && (val === "" || val === false)) {
             delete params[key];
         } else {
             params[key] = val;