Browse Source

HPCC-25165 Add React WUQuery + Filters

Signed-off-by: Gordon Smith <GordonJSmith@gmail.com>
Gordon Smith 4 năm trước cách đây
mục cha
commit
ab821c66b6

+ 1 - 1
esp/src/eclwatch/WUQueryWidget.js

@@ -475,7 +475,7 @@ define([
                         label: this.i18n.WUID, width: 180,
                         formatter: function (Wuid, idx) {
                             var wu = ESPWorkunit.Get(Wuid);
-                            return wu.getStateImageHTML() + "&nbsp;<a href='#' class='dgrid-row-url' onClick='return false;'>" + Wuid + "</a>";
+                            return wu.getStateImageHTML() + "&nbsp;<a href='#/workunits/" + Wuid + "' class='dgrid-row-url' onClick='return false;'>" + Wuid + "</a>";
                         }
                     },
                     Owner: { label: this.i18n.Owner, width: 90 },

+ 1 - 1
esp/src/lws.config.js

@@ -42,4 +42,4 @@ let rewrite = [
 module.exports = {
     port: 8080,
     rewrite: rewrite
-}
+}

+ 4 - 0
esp/src/src-react/components/Common.tsx

@@ -0,0 +1,4 @@
+import * as React from "react";
+import { VerticalDivider } from "@fluentui/react";
+
+export const ShortVerticalDivider = () => <VerticalDivider styles={{ divider: { paddingTop: "20%", height: "60%" } }} />;

+ 66 - 0
esp/src/src-react/components/DojoGrid.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;
+}

+ 58 - 0
esp/src/src-react/components/DojoGrid.tsx

@@ -0,0 +1,58 @@
+import * as React from "react";
+import { useConst } from "@fluentui/react-hooks";
+import * as declare from "dojo/_base/declare";
+// @ts-ignore
+import * as selector from "dgrid/selector";
+// @ts-ignore
+import * as tree from "dgrid/tree";
+import * as ESPUtil from "src/ESPUtil";
+import { DojoComponent } from "../layouts/DojoAdapter";
+
+import "srcReact/components/DojoGrid.css";
+
+export { selector, tree };
+
+const PageSelGrid = declare([ESPUtil.Grid(true, true, undefined, false, "PageSelGrid")]);
+const SelGrid = declare([ESPUtil.Grid(false, true, undefined, false, "SelGrid")]);
+
+type GridType = "PageSel" | "Sel";
+
+interface DojoGridProps {
+    type?: GridType;
+    enablePagination?: boolean;
+    enableSelection?: boolean;
+    overrides?: object;
+    enableCompoundColumns?: boolean;
+    store: any;
+    query?: any;
+    sort?: any;
+    columns: any;
+    setGrid: (_: any) => void;
+    setSelection: (_: any[]) => void;
+}
+
+export const DojoGrid: React.FunctionComponent<DojoGridProps> = ({
+    type = "PageSel",
+    store,
+    query = {},
+    sort,
+    columns,
+    setGrid,
+    setSelection
+}) => {
+
+    const Grid = useConst(() => {
+        switch (type) {
+            case "Sel":
+                return SelGrid;
+            case "PageSel":
+            default:
+                return PageSelGrid;
+        }
+    });
+
+    return <DojoComponent Widget={Grid} WidgetParams={{ deselectOnRefresh: true, store, query, sort, columns: { ...columns } }} postCreate={grid => {
+        grid.onSelectionChanged(() => setSelection(grid.getSelected()));
+        setGrid(grid);
+    }} />;
+};

+ 346 - 0
esp/src/src-react/components/Filter.tsx

@@ -0,0 +1,346 @@
+import * as React from "react";
+import { FormGroup, TextField, FormControlLabel, Checkbox, Dialog, DialogTitle, DialogContent, DialogActions, Button, MenuItem, TextFieldProps } from "@material-ui/core";
+import { Topology, TpLogicalClusterQuery } from "@hpcc-js/comms";
+import { TpGroupQuery } from "src/WsTopology";
+import { States } from "src/WsWorkunits";
+import { States as DFUStates } from "src/FileSpray";
+import nlsHPCC from "src/nlsHPCC";
+
+type FieldType = "string" | "checkbox" | "datetime" |
+    "workunit-state" |
+    "file-type" | "file-sortby" |
+    "queries-suspend-state" | "queries-active-state" |
+    "target-cluster" | "target-group" |
+    "logicalfile-type" |
+    "dfuworkunit-state"
+    ;
+
+const states = Object.keys(States).map(s => States[s]);
+const dfustates = Object.keys(DFUStates).map(s => DFUStates[s]);
+
+interface BaseField {
+    type: FieldType;
+    label: string;
+    disabled?: (params) => boolean;
+    placeholder?: string;
+}
+
+interface StringField extends BaseField {
+    type: "string";
+    value?: string;
+}
+
+interface DateTimeField extends BaseField {
+    type: "datetime";
+    value?: string;
+}
+
+interface CheckboxField extends BaseField {
+    type: "checkbox";
+    value?: boolean;
+}
+
+interface WorkunitStateField extends BaseField {
+    type: "workunit-state";
+    value?: string;
+}
+
+interface FileTypeField extends BaseField {
+    type: "file-type";
+    value?: string;
+}
+
+interface FileSortByField extends BaseField {
+    type: "file-sortby";
+    value?: string;
+}
+
+interface QueriesSuspendStateField extends BaseField {
+    type: "queries-suspend-state";
+    value?: string;
+}
+
+interface QueriesActiveStateField extends BaseField {
+    type: "queries-active-state";
+    value?: string;
+}
+
+interface TargetClusterField extends BaseField {
+    type: "target-cluster";
+    value?: string;
+}
+
+interface TargetGroupField extends BaseField {
+    type: "target-group";
+    value?: string;
+}
+
+interface LogicalFileType extends BaseField {
+    type: "logicalfile-type";
+    value?: string;
+}
+
+interface DFUWorkunitStateField extends BaseField {
+    type: "dfuworkunit-state";
+    value?: string;
+}
+
+type Field = StringField | CheckboxField | DateTimeField |
+    WorkunitStateField |
+    FileTypeField | FileSortByField |
+    QueriesSuspendStateField | QueriesActiveStateField |
+    TargetClusterField | TargetGroupField |
+    LogicalFileType |
+    DFUWorkunitStateField;
+export type Fields = { [name: string]: Field };
+export type Values = { [name: string]: string | number | boolean | (string | number | boolean)[] };
+
+const fieldsToRequest = (fields: Fields) => {
+    const retVal: Values = {};
+    for (const name in fields) {
+        if (!fields[name].disabled(fields)) {
+            retVal[name] = fields[name].value;
+        }
+    }
+    return retVal;
+};
+
+const TargetClusterTextField: React.FunctionComponent<TextFieldProps> = (props) => {
+
+    const [targetClusters, setTargetClusters] = React.useState<TpLogicalClusterQuery.TpLogicalCluster[]>([]);
+
+    React.useEffect(() => {
+        const topology = new Topology({ baseUrl: "" });
+        topology.fetchLogicalClusters().then(response => {
+            setTargetClusters([{ Name: "", Type: "", LanguageVersion: "", Process: "", Queue: "" }, ...response]);
+        });
+    }, []);
+
+    return <TextField {...props} >
+        {targetClusters.map(tc => <MenuItem key={tc.Name} value={tc.Name}>{tc.Name}{tc.Name !== tc.Type ? ` (${tc.Type})` : ""}</MenuItem>)}
+    </TextField>;
+};
+
+export const TargetGroupTextField: React.FunctionComponent<TextFieldProps> = (props) => {
+
+    const [targetGroups, setTargetGroups] = React.useState([]);
+
+    React.useEffect(() => {
+        TpGroupQuery({}).then(({ TpGroupQueryResponse }) => {
+            setTargetGroups(TpGroupQueryResponse.TpGroups.TpGroup);
+        });
+    }, []);
+
+    return <TextField {...props} >
+        {targetGroups.map(tc => <MenuItem key={tc.Name} value={tc.Name}>{tc.Name}{tc.Name !== tc.Kind ? ` (${tc.Kind})` : ""}</MenuItem>)}
+    </TextField>;
+};
+
+interface FormContentProps {
+    fields: Fields;
+    doSubmit: boolean;
+    doReset: boolean;
+    onSubmit: (fields: Values) => void;
+    onReset: (fields: Values) => void;
+}
+
+export const FormContent: React.FunctionComponent<FormContentProps> = ({
+    fields,
+    doSubmit,
+    doReset,
+    onSubmit,
+    onReset
+}) => {
+
+    const [localFields, setLocalFields] = React.useState<Fields>({ ...fields });
+
+    React.useEffect(() => {
+        if (doSubmit === false) return;
+        onSubmit(fieldsToRequest(localFields));
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [doSubmit]);
+
+    React.useEffect(() => {
+        if (doReset === false) return;
+        for (const key in localFields) {
+            delete localFields[key].value;
+        }
+        setLocalFields(localFields);
+        onReset(fieldsToRequest(localFields));
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [doReset]);
+
+    const handleChange = ev => {
+        const field = localFields[ev.target.name];
+        switch (field.type) {
+            case "checkbox":
+                localFields[ev.target.name].value = ev.target.checked;
+                setLocalFields({ ...localFields });
+                break;
+            default:
+                localFields[ev.target.name].value = ev.target.value;
+                setLocalFields({ ...localFields });
+                break;
+        }
+    };
+
+    const formFields = [];
+    for (const fieldID in localFields) {
+        const field: Field = localFields[fieldID];
+        if (!field.disabled) {
+            field.disabled = () => false;
+        }
+        switch (field.type) {
+            case "string":
+                field.value = field.value || "";
+                formFields.push(<TextField key={fieldID} label={field.label} type="string" name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} />);
+                break;
+            case "checkbox":
+                field.value = field.value || false;
+                formFields.push(<FormControlLabel key={fieldID} label={field.label} name={fieldID} control={
+                    <Checkbox checked={field.value === true ? true : false} onChange={handleChange} />
+                } />);
+                break;
+            case "datetime":
+                field.value = field.value || "";
+                formFields.push(<TextField key={fieldID} label={field.label} type="datetime-local" name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} InputLabelProps={{ shrink: true }} />);
+                break;
+            case "workunit-state":
+                field.value = field.value || "";
+                formFields.push(
+                    <TextField key={fieldID} label={field.label} select name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} >
+                        {states.map(state => <MenuItem key={state} value={state}>{state}</MenuItem>)}
+                    </TextField>
+                );
+                break;
+            case "file-type":
+                field.value = field.value || "";
+                formFields.push(
+                    <TextField key={fieldID} label={field.label} select name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} >
+                        <MenuItem key={""} value="">{nlsHPCC.LogicalFilesAndSuperfiles}</MenuItem>
+                        <MenuItem key={"Logical Files Only"} value="Logical Files Only">{nlsHPCC.LogicalFilesOnly}</MenuItem>
+                        <MenuItem key={"Superfiles Only"} value="Superfiles Only">{nlsHPCC.SuperfilesOnly}</MenuItem>
+                        <MenuItem key={"Not in Superfiles"} value="Not in Superfiles">{nlsHPCC.NotInSuperfiles}</MenuItem>
+                    </TextField>
+                );
+                break;
+            case "file-sortby":
+                field.value = field.value || "";
+                formFields.push(
+                    <TextField key={fieldID} label={field.label} select name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} >
+                        <MenuItem key={""} value="">&nbsp;</MenuItem>
+                        <MenuItem key={"Newest"} value="Newest">{nlsHPCC.Newest}</MenuItem>
+                        <MenuItem key={"Oldest"} value="Oldest">{nlsHPCC.Oldest}</MenuItem>
+                        <MenuItem key={"Smallest"} value="Smallest">{nlsHPCC.Smallest}</MenuItem>
+                        <MenuItem key={"Largest"} value="Largest">{nlsHPCC.Largest}</MenuItem>
+                    </TextField>
+                );
+                break;
+            case "queries-suspend-state":
+                field.value = field.value || "";
+                formFields.push(
+                    <TextField key={fieldID} label={field.label} select name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} >
+                        <MenuItem key={""} value="">&nbsp;</MenuItem>
+                        <MenuItem key={"Not suspended"} value="Not suspended">{nlsHPCC.NotSuspended}</MenuItem>
+                        <MenuItem key={"Suspended"} value="Suspended">{nlsHPCC.Suspended}</MenuItem>
+                        <MenuItem key={"Suspended by user"} value="Suspended by user">{nlsHPCC.SuspendedByUser}</MenuItem>
+                        <MenuItem key={"Suspended by first node"} value="Suspended by first node">{nlsHPCC.SuspendedByFirstNode}</MenuItem>
+                        <MenuItem key={"Suspended by any node"} value="Suspended by any node">{nlsHPCC.SuspendedByAnyNode}</MenuItem>
+                    </TextField>
+                );
+                break;
+            case "queries-active-state":
+                field.value = field.value || "";
+                formFields.push(
+                    <TextField key={fieldID} label={field.label} select name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} >
+                        <MenuItem key={""} value="">&nbsp;</MenuItem>
+                        <MenuItem key={"1"} value="1">{nlsHPCC.Active}</MenuItem>
+                        <MenuItem key={"0"} value="0">{nlsHPCC.NotActive}</MenuItem>
+                    </TextField>
+                );
+                break;
+            case "target-cluster":
+                field.value = field.value || "";
+                formFields.push(<TargetClusterTextField key={fieldID} label={field.label} select name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} />);
+                break;
+            case "target-group":
+                field.value = field.value || "";
+                formFields.push(<TargetGroupTextField key={fieldID} label={field.label} select name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} />);
+                break;
+            case "logicalfile-type":
+                field.value = field.value || "Created";
+                formFields.push(
+                    <TextField key={fieldID} label={field.label} select name={fieldID} value={field.value} disabled={field.disabled(localFields)} placeholder={field.placeholder} onChange={handleChange} >
+                        <MenuItem key={"Created"} value="Created">{nlsHPCC.CreatedByWorkunit}</MenuItem>
+                        <MenuItem key={"Used"} value="Used">{nlsHPCC.UsedByWorkunit}</MenuItem>
+                    </TextField>
+                );
+                break;
+            case "dfuworkunit-state":
+                field.value = field.value || "";
+                formFields.push(
+                    <TextField key={fieldID} label={field.label} select name={fieldID} value={field.value} placeholder={field.placeholder} onChange={handleChange} >
+                        {dfustates.map(state => <MenuItem key={state} value={state}>{state}</MenuItem>)}
+                    </TextField>
+                );
+                break;
+
+        }
+    }
+
+    return <FormGroup style={{ minWidth: "320px" }}>
+        {...formFields}
+    </FormGroup >;
+};
+
+interface FilterProps {
+    filterFields: Fields;
+    onApply: (values: Values) => void;
+
+    showFilter: boolean;
+    setShowFilter: (_: boolean) => void;
+}
+
+export const Filter: React.FunctionComponent<FilterProps> = ({
+    filterFields,
+    onApply,
+    showFilter,
+    setShowFilter
+}) => {
+
+    const [doSubmit, setDoSubmit] = React.useState(false);
+    const [doReset, setDoReset] = React.useState(false);
+
+    const closeFilter = () => setShowFilter(false);
+
+    return <Dialog onClose={closeFilter} aria-labelledby="simple-dialog-title" open={showFilter} >
+        <DialogTitle id="form-dialog-title">{nlsHPCC.Filter}</DialogTitle>
+        <DialogContent>
+            <FormContent
+                fields={filterFields}
+                doSubmit={doSubmit}
+                doReset={doReset}
+                onSubmit={fields => {
+                    setDoSubmit(false);
+                    onApply(fields);
+                }}
+                onReset={() => {
+                    setDoReset(false);
+                }}
+            />
+        </DialogContent>
+        <DialogActions>
+            <Button variant="contained" color="primary" onClick={() => {
+                setDoSubmit(true);
+                closeFilter();
+            }} >
+                {nlsHPCC.Apply}
+            </Button>
+            <Button variant="contained" color="secondary" onClick={() => {
+                setDoReset(true);
+            }} >
+                {nlsHPCC.Clear}
+            </Button>
+        </DialogActions>
+    </Dialog>;
+};

+ 3 - 2
esp/src/src-react/components/Menu.tsx

@@ -17,6 +17,7 @@ const navLinkGroups: INavLinkGroup[] = [
         name: "ECL",
         links: [
             { url: "#/workunits", name: nlsHPCC.Workunits },
+            { url: "#/workunits/legacy", name: `${nlsHPCC.Workunits} (L)` },
             { url: "#/play", name: nlsHPCC.Playground },
         ]
     },
@@ -65,12 +66,12 @@ const navStyles = (width: number, height: number): Partial<INavStyles> => {
             boxSizing: "border-box",
             border: "1px solid #eee",
             overflow: "auto",
-        },
+        }
     };
 };
 
 interface DevMenuProps {
-    location: string,
+    location: string
 }
 
 export const DevMenu: React.FunctionComponent<DevMenuProps> = ({

+ 244 - 0
esp/src/src-react/components/Workunits.tsx

@@ -0,0 +1,244 @@
+import * as React from "react";
+import { CommandBar, ContextualMenuItemType, ICommandBarItemProps } from "@fluentui/react";
+import { useConst } from "@fluentui/react-hooks";
+import * as domClass from "dojo/dom-class";
+import * as WsWorkunits from "src/WsWorkunits";
+import * as ESPWorkunit from "src/ESPWorkunit";
+import * as Utility from "src/Utility";
+import nlsHPCC from "src/nlsHPCC";
+import { HolyGrail } from "../layouts/HolyGrail";
+import { pushParams } from "../util/history";
+import { Fields, Filter } from "./Filter";
+import { ShortVerticalDivider } from "./Common";
+import { DojoGrid, selector } from "./DojoGrid";
+
+const FilterFields: Fields = {
+    "Type": { type: "checkbox", label: nlsHPCC.ArchivedOnly },
+    "Wuid": { type: "string", label: nlsHPCC.WUID, placeholder: "W20200824-060035" },
+    "Owner": { type: "string", label: nlsHPCC.Owner, placeholder: nlsHPCC.jsmi },
+    "JobName": { type: "string", label: nlsHPCC.JobName, placeholder: nlsHPCC.log_analysis_1 },
+    "Cluster": { type: "target-cluster", label: nlsHPCC.Cluster, placeholder: nlsHPCC.Owner },
+    "State": { type: "workunit-state", label: nlsHPCC.State, placeholder: nlsHPCC.Created },
+    "ECL": { type: "string", label: nlsHPCC.ECL, placeholder: nlsHPCC.dataset },
+    "LogicalFile": { type: "string", label: nlsHPCC.LogicalFile, placeholder: nlsHPCC.somefile },
+    "LogicalFileSearchType": { type: "logicalfile-type", label: nlsHPCC.LogicalFileType, placeholder: "", disabled: (params: Fields) => !params.LogicalFile.value },
+    "StartDate": { type: "datetime", label: nlsHPCC.FromDate, placeholder: "" },
+    "EndDate": { type: "datetime", label: nlsHPCC.ToDate, placeholder: "" },
+    "LastNDays": { type: "string", label: nlsHPCC.LastNDays, placeholder: "2" }
+};
+
+function formatQuery(filter) {
+    if (filter.StartDate) {
+        filter.StartDate = new Date(filter.StartDate).toISOString();
+    }
+    if (filter.EndDate) {
+        filter.EndDate = new Date(filter.StartDate).toISOString();
+    }
+    return filter;
+}
+
+const defaultUIState = {
+    hasSelection: false,
+    hasProtected: false,
+    hasNotProtected: false,
+    hasFailed: false,
+    hasNotFailed: false,
+    hasCompleted: false,
+    hasNotCompleted: false
+};
+
+interface WorkunitsProps {
+    filter?: object;
+    store?: any;
+}
+
+const emptyFilter = {};
+
+export const Workunits: React.FunctionComponent<WorkunitsProps> = ({
+    filter = emptyFilter,
+    store
+}) => {
+
+    const [grid, setGrid] = React.useState<any>(undefined);
+    const [showFilter, setShowFilter] = React.useState(false);
+    const [mine, setMine] = React.useState(false);
+    const [selection, setSelection] = React.useState([]);
+    const [uiState, setUIState] = React.useState({ ...defaultUIState });
+
+    //  Command Bar  ---
+    const buttons: ICommandBarItemProps[] = [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => refreshTable()
+        },
+        { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "open", text: nlsHPCC.Open, disabled: !uiState.hasSelection, iconProps: { iconName: "WindowEdit" },
+            onClick: () => {
+                if (selection.length === 1) {
+                    window.location.href = `#/workunits/${selection[0].Wuid}`;
+                } else {
+                    for (let i = selection.length - 1; i >= 0; --i) {
+                        window.open(`#/workunits/${selection[i].Wuid}`, "_blank");
+                    }
+                }
+            }
+        },
+        {
+            key: "delete", text: nlsHPCC.Delete, disabled: !uiState.hasNotProtected, iconProps: { iconName: "Delete" },
+            onClick: () => {
+                const list = selection.map(s => s.Wuid);
+                if (confirm(nlsHPCC.DeleteSelectedWorkunits + "\n" + list)) {
+                    WsWorkunits.WUAction(selection, "Delete").then(() => refreshTable(true));
+                }
+            }
+        },
+        { key: "divider_2", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "setFailed", text: nlsHPCC.SetToFailed, disabled: !uiState.hasNotProtected,
+            onClick: () => { WsWorkunits.WUAction(selection, "SetToFailed"); }
+        },
+        {
+            key: "abort", text: nlsHPCC.Abort, disabled: !uiState.hasNotCompleted,
+            onClick: () => { WsWorkunits.WUAction(selection, "Abort"); }
+        },
+        { key: "divider_3", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "protect", text: nlsHPCC.Protect, disabled: !uiState.hasNotProtected,
+            onClick: () => { WsWorkunits.WUAction(selection, "Protect"); }
+        },
+        {
+            key: "unprotect", text: nlsHPCC.Unprotect, disabled: !uiState.hasProtected,
+            onClick: () => { WsWorkunits.WUAction(selection, "Unprotect"); }
+        },
+        { key: "divider_4", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "filter", text: nlsHPCC.Filter, disabled: !!store, iconProps: { iconName: "Filter" },
+            onClick: () => {
+                setShowFilter(true);
+            }
+        },
+        {
+            key: "mine", text: nlsHPCC.Mine, disabled: true, iconProps: { iconName: "Contact" }, canCheck: true, checked: mine,
+            onClick: () => {
+                setMine(!mine);
+            }
+        },
+    ];
+
+    const rightButtons: ICommandBarItemProps[] = [
+        {
+            key: "copy", text: nlsHPCC.CopyWUIDs, disabled: !uiState.hasSelection || !navigator?.clipboard?.writeText, iconOnly: true, iconProps: { iconName: "Copy" },
+            onClick: () => {
+                const wuids = selection.map(s => s.Wuid);
+                navigator?.clipboard?.writeText(wuids.join("\n"));
+            }
+        },
+        {
+            key: "download", text: nlsHPCC.DownloadToCSV, disabled: !uiState.hasSelection, iconOnly: true, iconProps: { iconName: "Download" },
+            onClick: () => {
+                Utility.downloadToCSV(grid, selection.map(row => ([row.Protected, row.Wuid, row.Owner, row.Jobname, row.Cluster, row.RoxieCluster, row.State, row.TotalClusterTime])), "workunits.csv");
+            }
+        }
+    ];
+
+    //  Grid ---
+    const gridStore = useConst(store || ESPWorkunit.CreateWUQueryStore({}));
+    const gridQuery = useConst(formatQuery(filter));
+    const gridSort = useConst([{ attribute: "Wuid", "descending": true }]);
+    const gridColumns = useConst({
+        col1: selector({
+            width: 27,
+            selectorType: "checkbox"
+        }),
+        Protected: {
+            renderHeaderCell: function (node) {
+                node.innerHTML = Utility.getImageHTML("locked.png", nlsHPCC.Protected);
+            },
+            width: 25,
+            sortable: false,
+            formatter: function (_protected) {
+                if (_protected === true) {
+                    return Utility.getImageHTML("locked.png");
+                }
+                return "";
+            }
+        },
+        Wuid: {
+            label: nlsHPCC.WUID, width: 180,
+            formatter: function (Wuid) {
+                const wu = ESPWorkunit.Get(Wuid);
+                return `${wu.getStateImageHTML()}&nbsp;<a href='#/workunits/${Wuid}' class='dgrid-row-url''>${Wuid}</a>`;
+            }
+        },
+        Owner: { label: nlsHPCC.Owner, width: 90 },
+        Jobname: { label: nlsHPCC.JobName, width: 500 },
+        Cluster: { label: nlsHPCC.Cluster, width: 90 },
+        RoxieCluster: { label: nlsHPCC.RoxieCluster, width: 99 },
+        State: { label: nlsHPCC.State, width: 90 },
+        TotalClusterTime: {
+            label: nlsHPCC.TotalClusterTime, width: 117,
+            renderCell: function (object, value, node) {
+                domClass.add(node, "justify-right");
+                node.innerText = value;
+            }
+        }
+    });
+
+    const refreshTable = (clearSelection = false) => {
+        grid?.set("query", formatQuery(filter));
+        if (clearSelection) {
+            grid?.clearSelection();
+        }
+    };
+
+    //  Filter  ---
+    const filterFields: Fields = {};
+    for (const field in FilterFields) {
+        filterFields[field] = { ...FilterFields[field], value: filter[field] };
+    }
+
+    React.useEffect(() => {
+        refreshTable();
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [filter]);
+
+    //  Selection  ---
+    React.useEffect(() => {
+        const state = { ...defaultUIState };
+
+        for (let i = 0; i < selection.length; ++i) {
+            state.hasSelection = true;
+            if (selection[i] && selection[i].Protected !== null) {
+                if (selection[i].Protected !== false) {
+                    state.hasProtected = true;
+                } else {
+                    state.hasNotProtected = true;
+                }
+            }
+            if (selection[i] && selection[i].StateID !== null) {
+                if (selection[i].StateID === 4) {
+                    state.hasFailed = true;
+                } else {
+                    state.hasNotFailed = true;
+                }
+                if (WsWorkunits.isComplete(selection[i].StateID, selection[i].ActionEx)) {
+                    state.hasCompleted = true;
+                } else {
+                    state.hasNotCompleted = true;
+                }
+            }
+        }
+        setUIState(state);
+    }, [selection]);
+
+    return <HolyGrail
+        header={<CommandBar items={buttons} overflowButtonProps={{}} farItems={rightButtons} />}
+        main={
+            <>
+                <DojoGrid store={gridStore} query={gridQuery} sort={gridSort} columns={gridColumns} setGrid={setGrid} setSelection={setSelection} />
+                <Filter showFilter={showFilter} setShowFilter={setShowFilter} filterFields={filterFields} onApply={pushParams} />
+            </>
+        }
+    />;
+};

+ 11 - 0
esp/src/src-react/hooks/Grid.ts

@@ -0,0 +1,11 @@
+
+export function useGrid(store, filter, sort, columns) {
+
+    return {
+        store,
+        filter,
+        sort,
+        columns
+    };
+
+} 

+ 46 - 3
esp/src/src-react/layouts/DojoAdapter.tsx

@@ -1,11 +1,11 @@
 import * as React from "react";
 import * as ReactDOM from "react-dom";
-import { useId } from "@fluentui/react-hooks";
+import { useConst, useId } from "@fluentui/react-hooks";
 import * as registry from "dijit/registry";
 import nlsHPCC from "src/nlsHPCC";
 import { resolve } from "src/Utility";
 
-export interface DojoProps {
+export interface DojoAdapterProps {
     widgetClassID?: string;
     widgetClass?: any;
     params?: object;
@@ -18,7 +18,7 @@ export interface DojoState {
     widget: any;
 }
 
-export const DojoAdapter: React.FunctionComponent<DojoProps> = ({
+export const DojoAdapter: React.FunctionComponent<DojoAdapterProps> = ({
     widgetClassID,
     widgetClass,
     params,
@@ -88,3 +88,46 @@ export const DojoAdapter: React.FunctionComponent<DojoProps> = ({
 
     return <div ref={myRef} style={{ width: "100%", height: "100%" }}>{nlsHPCC.Loading} {widgetClassID}...</div>;
 };
+
+export interface DojoComponentProps {
+    Widget: any;
+    WidgetParams: any;
+    postCreate?: (widget: any) => void;
+}
+
+export const DojoComponent: React.FunctionComponent<DojoComponentProps> = ({
+    Widget,
+    WidgetParams,
+    postCreate
+}) => {
+
+    const id = useId();
+    const divID = useConst(`dojo-component-${id}`);
+
+    React.useEffect(() => {
+        const w = new Widget({
+            ...WidgetParams,
+            id: `dojo-component-widget-${id}`,
+            style: {
+                margin: "0px",
+                padding: "0px",
+                width: "100%",
+                height: "100%"
+            }
+        }, divID);
+
+        if (postCreate) {
+            postCreate(w);
+        }
+
+        return () => {
+            w.destroyRecursive();
+        };
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, []);
+
+    return <div style={{ width: "100%", height: "100%", position: "relative" }}>
+        <div id={divID} className="dojo-component">
+        </div>
+    </div>;
+};

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

@@ -1,6 +1,6 @@
 import * as React from "react";
 import { Routes } from "universal-router";
-import { initialize } from "./util/history";
+import { initialize, parseSearch } from "./util/history";
 
 export interface ToDoProps {
 }
@@ -55,7 +55,7 @@ const routes: Routes = [
     {
         path: "/workunits",
         children: [
-            { path: "", action: (context) => import("./layouts/DojoAdapter").then(_ => <_.DojoAdapter widgetClassID="WUQueryWidget" />) },
+            { path: "", action: (context) => import("./components/Workunits").then(_ => <_.Workunits filter={parseSearch(context.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} />) }
         ]

+ 1 - 1
esp/src/src/ESPRequest.ts

@@ -131,7 +131,7 @@ class RequestHelper {
         });
     }
 
-    send(service, action, params?) {
+    send(service, action, params?): Promise<any> {
         if (!this.isSessionCall(service, action) && (!this.hasServerSetCookie() || (this.hasAuthentication() && !this.isAuthenticated()))) {
             // tslint:disable-next-line: deprecation
             window.location.reload(true);

+ 2 - 2
esp/src/src/ESPUtil.ts

@@ -445,9 +445,9 @@ export class UndefinedMemory extends UndefinedMemoryBase {
     }
 }
 
-export function Grid(pagination?, selection?, overrides?, compoundColumns?, gridName?) {
+export function Grid(pagination?, selection?, overrides?: object, compoundColumns?, gridName?) {
     let baseClass = [];
-    const params = {};
+    const params = overrides || {};
     const rows = Number(localStorage.getItem(gridName));
 
     if (pagination) {