Prechádzať zdrojové kódy

HPCC-25457 React WU Details Page

Signed-off-by: Gordon Smith <GordonJSmith@gmail.com>
Gordon Smith 4 rokov pred
rodič
commit
46b0388a75

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 352 - 1015
esp/src/package-lock.json


+ 30 - 29
esp/src/package.json

@@ -33,28 +33,27 @@
   },
   "main": "src/stub.js",
   "dependencies": {
-    "@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",
-    "@hpcc-js/chart": "2.57.0",
-    "@hpcc-js/codemirror": "2.38.0",
-    "@hpcc-js/common": "2.47.0",
-    "@hpcc-js/comms": "2.39.0",
-    "@hpcc-js/dataflow": "3.0.1",
-    "@hpcc-js/eclwatch": "2.32.0",
-    "@hpcc-js/graph": "2.51.0",
-    "@hpcc-js/html": "2.22.0",
-    "@hpcc-js/layout": "2.24.0",
-    "@hpcc-js/map": "2.43.0",
-    "@hpcc-js/other": "2.13.57",
-    "@hpcc-js/phosphor": "2.14.37",
-    "@hpcc-js/react": "2.29.0",
-    "@hpcc-js/tree": "2.20.0",
-    "@hpcc-js/util": "2.29.0",
-    "@material-ui/core": "4.8.3",
-    "@material-ui/icons": "4.9.1",
-    "@material-ui/lab": "4.0.0-alpha.47",
+    "@fluentui/react": "^8.1.4",
+    "@fluentui/react-cards": "^1.0.0-beta.0",
+    "@fluentui/react-hooks": "^8.0.2",
+    "@hpcc-js/chart": "^2.57.0",
+    "@hpcc-js/codemirror": "^2.38.0",
+    "@hpcc-js/common": "^2.47.0",
+    "@hpcc-js/comms": "^2.39.0",
+    "@hpcc-js/dataflow": "^3.0.1",
+    "@hpcc-js/eclwatch": "^2.32.0",
+    "@hpcc-js/graph": "^2.51.0",
+    "@hpcc-js/html": "^2.22.0",
+    "@hpcc-js/layout": "^2.24.0",
+    "@hpcc-js/map": "^2.43.0",
+    "@hpcc-js/other": "^2.13.57",
+    "@hpcc-js/phosphor": "^2.14.37",
+    "@hpcc-js/react": "^2.29.0",
+    "@hpcc-js/tree": "^2.20.0",
+    "@hpcc-js/util": "^2.29.0",
+    "@material-ui/core": "^4.11.3",
+    "@material-ui/icons": "^4.11.2",
+    "@material-ui/lab": "^4.0.0-alpha.57",
     "clipboard": "2.0.4",
     "codemirror": "5.50.2",
     "detect-browser": "5.0.0",
@@ -65,23 +64,25 @@
     "dojox": "1.16.3",
     "es6-promise": "4.2.8",
     "font-awesome": "4.7.0",
+    "formik": "^2.2.6",
     "query-string": "6.13.2",
     "react": "^16.12.0",
     "react-dom": "^16.13.1",
+    "react-reflex": "^3.1.1",
     "react-sizeme": "^2.6.12",
     "universal-router": "^9.0.1"
   },
   "devDependencies": {
     "@types/dojo": "^1.9.43",
-    "@types/react": "^16.14.2",
-    "@types/react-dom": "^16.9.10",
+    "@types/react": "^16.14.4",
+    "@types/react-dom": "^16.9.11",
     "@typescript-eslint/eslint-plugin": "^3.2.0",
     "@typescript-eslint/parser": "^3.2.0",
     "braces": ">=2.3.1",
     "cpx": "^1.5.0",
     "css-loader": "^3.4.2",
-    "dojo-webpack-plugin": "^2.8.13",
-    "eslint": "^7.17.0",
+    "dojo-webpack-plugin": "^2.8.19",
+    "eslint": "^7.21.0",
     "eslint-plugin-react-hooks": "^4.2.0",
     "file-loader": "^5.1.0",
     "local-web-server": "^4.0.0",
@@ -91,11 +92,11 @@
     "source-map-loader": "^1.1.3",
     "style-loader": "^1.1.3",
     "tslib": "^1.13.0",
-    "typescript": "^3.9.7",
+    "typescript": "^3.9.9",
     "url-loader": "^3.0.0",
     "webpack": "^4.45.0",
-    "webpack-cli": "^4.3.1",
-    "webpack-dev-server": "^3.11.1"
+    "webpack-cli": "^4.5.0",
+    "webpack-dev-server": "^3.11.2"
   },
   "author": "HPCC Systems",
   "license": "Apache-2.0",

+ 390 - 0
esp/src/src-react/components/Details.tsx

@@ -0,0 +1,390 @@
+import * as React from "react";
+import { Checkbox, Dropdown, TextField, IDropdownProps, IDropdownOption, Label } from "@fluentui/react";
+import { TextField as MaterialUITextField } 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;
+    readonly?: boolean;
+    multiline?: boolean;
+}
+
+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 = { [id: string]: Field };
+
+const TargetClusterTextField: React.FunctionComponent<IDropdownProps> = (props) => {
+
+    const [targetClusters, setTargetClusters] = React.useState<IDropdownOption[]>([]);
+
+    React.useEffect(() => {
+        const topology = new Topology({ baseUrl: "" });
+        topology.fetchLogicalClusters().then((response: TpLogicalClusterQuery.TpLogicalCluster[]) => {
+            setTargetClusters(
+                [
+                    { Name: "", Type: "", LanguageVersion: "", Process: "", Queue: "" },
+                    ...response
+                ]
+                    .map(n => {
+                        return {
+                            key: n.Name,
+                            text: n.Name + (n.Name !== n.Type ? ` (${n.Type})` : "")
+                        };
+                    })
+            );
+        });
+    }, []);
+
+    return <Dropdown
+        {...props}
+        options={targetClusters}
+    />;
+};
+
+const TargetGroupTextField: React.FunctionComponent<IDropdownProps> = (props) => {
+
+    const [targetGroups, setTargetGroups] = React.useState<IDropdownOption[]>([]);
+
+    React.useEffect(() => {
+        TpGroupQuery({}).then(({ TpGroupQueryResponse }) => {
+            setTargetGroups(
+                TpGroupQueryResponse.TpGroups.TpGroup.map(n => {
+                    return {
+                        key: n.Name,
+                        text: n.Name + (n.Name !== n.Kind ? ` (${n.Kind})` : "")
+                    };
+                })
+            );
+        });
+    }, []);
+
+    return <Dropdown
+        {...props}
+        options={targetGroups}
+    />;
+};
+
+interface DetailsProps {
+    fields: Fields;
+    onChange: (id: string, newValue: any) => void;
+}
+
+export const Details: React.FunctionComponent<DetailsProps> = ({
+    fields,
+    onChange
+}) => {
+
+    const formFields: { id: string, label: string, field: any }[] = [];
+    for (const fieldID in fields) {
+        const field = fields[fieldID];
+        if (!field.disabled) {
+            field.disabled = () => false;
+        }
+        switch (field.type) {
+            case "string":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <TextField
+                        key={fieldID}
+                        type="string"
+                        name={fieldID}
+                        value={field.value}
+                        placeholder={field.placeholder}
+                        onChange={(evt, newValue) => onChange(fieldID, newValue)}
+                        borderless={field.readonly && !field.multiline}
+                        readOnly={field.readonly}
+                        multiline={field.multiline}
+                    />
+                });
+                break;
+            case "checkbox":
+                field.value = field.value || false;
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <Checkbox
+                        key={fieldID}
+                        name={fieldID}
+                        checked={field.value === true ? true : false}
+                        onChange={(evt, newValue) => onChange(fieldID, newValue)}
+                    />
+                });
+                break;
+            case "datetime":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <MaterialUITextField
+                        key={fieldID}
+                        type="datetime-local"
+                        name={fieldID}
+                        value={field.value}
+                        placeholder={field.placeholder}
+                        onChange={ev => {
+                            field.value = ev.target.value;
+                        }}
+                        InputLabelProps={{ shrink: true }
+                        }
+                    />
+                });
+                break;
+            case "workunit-state":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <Dropdown
+                        key={fieldID}
+                        defaultSelectedKey={field.value}
+                        options={states.map(state => {
+                            return {
+                                key: state,
+                                text: state
+                            };
+                        })}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                    />
+                });
+                break;
+            case "file-type":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <Dropdown
+                        key={fieldID}
+                        defaultSelectedKey={field.value}
+                        options={[
+                            { key: "", text: nlsHPCC.LogicalFilesAndSuperfiles },
+                            { key: "Logical Files Only", text: nlsHPCC.LogicalFilesOnly },
+                            { key: "Superfiles Only", text: nlsHPCC.SuperfilesOnly },
+                            { key: "Not in Superfiles", text: nlsHPCC.NotInSuperfiles },
+                        ]}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                    />
+                });
+                break;
+            case "file-sortby":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <Dropdown
+                        key={fieldID}
+                        defaultSelectedKey={field.value}
+                        options={[
+                            { key: "", text: "" },
+                            { key: "Newest", text: nlsHPCC.Newest },
+                            { key: "Oldest", text: nlsHPCC.Oldest },
+                            { key: "Smallest", text: nlsHPCC.Smallest },
+                            { key: "Largest", text: nlsHPCC.Largest }
+                        ]}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                    />
+                });
+                break;
+            case "queries-suspend-state":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <Dropdown
+                        key={fieldID}
+                        defaultSelectedKey={field.value}
+                        options={[
+                            { key: "", text: "" },
+                            { key: "Not suspended", text: nlsHPCC.NotSuspended },
+                            { key: "Suspended", text: nlsHPCC.Suspended },
+                            { key: "Suspended by user", text: nlsHPCC.SuspendedByUser },
+                            { key: "Suspended by first node", text: nlsHPCC.SuspendedByFirstNode },
+                            { key: "Suspended by any node", text: nlsHPCC.SuspendedByAnyNode },
+                        ]}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                    />
+                });
+                break;
+            case "queries-active-state":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <Dropdown
+                        key={fieldID}
+                        defaultSelectedKey={field.value}
+                        options={[
+                            { key: "", text: "" },
+                            { key: "1", text: nlsHPCC.Active },
+                            { key: "0", text: nlsHPCC.NotActive }
+                        ]}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                    />
+                });
+                break;
+            case "target-cluster":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <TargetClusterTextField
+                        key={fieldID}
+                        defaultSelectedKey={field.value}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                        options={[]}
+                    />
+                });
+                break;
+            case "target-group":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <TargetGroupTextField
+                        key={fieldID}
+                        defaultSelectedKey=""
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                        options={[]}
+                    />
+                });
+                break;
+            case "dfuworkunit-state":
+                field.value = field.value || "";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <Dropdown
+                        key={fieldID}
+                        defaultSelectedKey=""
+                        options={dfustates.map(state => {
+                            return {
+                                key: state,
+                                text: state
+                            };
+                        })}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                    />
+                });
+                break;
+            case "logicalfile-type":
+                field.value = field.value || "Created";
+                formFields.push({
+                    id: fieldID,
+                    label: field.label,
+                    field: <Dropdown
+                        key={fieldID}
+                        defaultSelectedKey=""
+                        options={[
+                            { key: "Created", text: nlsHPCC.CreatedByWorkunit },
+                            { key: "Used", text: nlsHPCC.UsedByWorkunit }
+                        ]}
+                        onChange={(ev, row) => onChange(fieldID, row.key)}
+                        placeholder={field.placeholder}
+                    />
+                });
+                break;
+        }
+    }
+
+    return <table style={{ padding: 4 }}>
+        <tbody>
+            {formFields.map((ff) => {
+                return <tr key={ff.id}>
+                    <td style={{ whiteSpace: "nowrap" }}><Label>{ff.label}</Label></td>
+                    <td style={{ width: "80%", paddingLeft: 8 }}>{ff.field}</td>
+                </tr>;
+            })}
+        </tbody>
+    </table>;
+};

+ 8 - 275
esp/src/src-react/components/Filter.tsx

@@ -1,12 +1,9 @@
 import * as React from "react";
-import { getTheme, mergeStyleSets, FontWeights, IDragOptions, IIconProps, ContextualMenu, DefaultButton, PrimaryButton, IconButton, Checkbox, Dropdown, IStackStyles, Modal, Stack, TextField, IDropdownProps, IDropdownOption } from "@fluentui/react";
+import { getTheme, mergeStyleSets, FontWeights, IDragOptions, IIconProps, ContextualMenu, DefaultButton, PrimaryButton, IconButton, Dropdown, IStackStyles, Modal, Stack, IDropdownProps, IDropdownOption } from "@fluentui/react";
 import { useId } from "@fluentui/react-hooks";
-import { TextField as MaterialUITextField } 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";
+import { Details } from "./Details";
 
 type FieldType = "string" | "checkbox" | "datetime" |
     "workunit-state" |
@@ -15,14 +12,12 @@ type FieldType = "string" | "checkbox" | "datetime" |
     "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;
+    readonly?: boolean;
 }
 
 interface StringField extends BaseField {
@@ -105,34 +100,6 @@ const fieldsToRequest = (fields: Fields) => {
     return retVal;
 };
 
-const TargetClusterTextField: React.FunctionComponent<IDropdownProps> = (props) => {
-
-    const [targetClusters, setTargetClusters] = React.useState<IDropdownOption[]>([]);
-
-    React.useEffect(() => {
-        const topology = new Topology({ baseUrl: "" });
-        topology.fetchLogicalClusters().then((response: TpLogicalClusterQuery.TpLogicalCluster[]) => {
-            setTargetClusters(
-                [
-                    { Name: "", Type: "", LanguageVersion: "", Process: "", Queue: "" },
-                    ...response
-                ]
-                .map(n => {
-                    return {
-                        key: n.Name,
-                        text: n.Name + (n.Name !== n.Type ? ` (${n.Type})` : "")
-                    };
-                })
-            );
-        });
-    }, []);
-
-    return <Dropdown
-        {...props}
-        options={targetClusters}
-    />;
-};
-
 export const TargetGroupTextField: React.FunctionComponent<IDropdownProps> = (props) => {
 
     const [targetGroups, setTargetGroups] = React.useState<IDropdownOption[]>([]);
@@ -190,253 +157,19 @@ export const FormContent: React.FunctionComponent<FormContentProps> = ({
         // eslint-disable-next-line react-hooks/exhaustive-deps
     }, [doReset]);
 
-    const handleChange = ev => {
-        const field = localFields[ev.target.name];
+    return <Details fields={localFields} onChange={(id, value) => {
+        const field = localFields[id];
         switch (field.type) {
             case "checkbox":
-                field.value = ev.target.checked;
+                field.value = value;
                 setLocalFields({ ...localFields });
                 break;
             default:
-                field.value = ev.target.value;
+                field.value = 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(
-                    <Checkbox 
-                        key={fieldID} 
-                        label={field.label} 
-                        name={fieldID} 
-                        checked={field.value === true ? true : false} 
-                        onChange={handleChange} 
-                    />
-                );
-                break;
-            case "datetime":
-                field.value = field.value || "";
-                formFields.push(
-                    <MaterialUITextField
-                        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(
-                    <Dropdown
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey={field.value}
-                        options={states.map(state => {
-                            return {
-                                key: state,
-                                text: state
-                            };
-                        })}
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                    />
-                );
-                break;
-            case "file-type":
-                field.value = field.value || "";
-                formFields.push(
-                    <Dropdown
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey={field.value}
-                        options={[
-                            { key: "", text: nlsHPCC.LogicalFilesAndSuperfiles },
-                            { key: "Logical Files Only", text: nlsHPCC.LogicalFilesOnly },
-                            { key: "Superfiles Only", text: nlsHPCC.SuperfilesOnly },
-                            { key: "Not in Superfiles", text: nlsHPCC.NotInSuperfiles },
-                        ]}
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                    />
-                );
-                break;
-            case "file-sortby":
-                field.value = field.value || "";
-                formFields.push(
-                    <Dropdown
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey={field.value}
-                        options={[
-                            { key: "", text: "" },
-                            { key: "Newest", text: nlsHPCC.Newest },
-                            { key: "Oldest", text: nlsHPCC.Oldest },
-                            { key: "Smallest", text: nlsHPCC.Smallest },
-                            { key: "Largest", text: nlsHPCC.Largest }
-                        ]}
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                    />
-                );
-                break;
-            case "queries-suspend-state":
-                field.value = field.value || "";
-                formFields.push(
-                    <Dropdown
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey={field.value}
-                        options={[
-                            { key: "", text: "" },
-                            { key: "Not suspended", text: nlsHPCC.NotSuspended },
-                            { key: "Suspended", text: nlsHPCC.Suspended },
-                            { key: "Suspended by user", text: nlsHPCC.SuspendedByUser },
-                            { key: "Suspended by first node", text: nlsHPCC.SuspendedByFirstNode },
-                            { key: "Suspended by any node", text: nlsHPCC.SuspendedByAnyNode },
-                        ]}
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                    />
-                );
-                break;
-            case "queries-active-state":
-                field.value = field.value || "";
-                formFields.push(
-                    <Dropdown
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey={field.value}
-                        options={[
-                            { key: "", text: "" },
-                            { key: "1", text: nlsHPCC.Active },
-                            { key: "0", text: nlsHPCC.NotActive }
-                        ]}
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                    />
-                );
-                break;
-            case "target-cluster":
-                field.value = field.value || "";
-                formFields.push(
-                    <TargetClusterTextField
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey={field.value}
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                        options={[]}
-                    />
-                );
-                break;
-            case "target-group":
-                field.value = field.value || "";
-                formFields.push(
-                    <TargetGroupTextField
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey=""
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                        options={[]}
-                    />
-                );
-                break;
-            case "dfuworkunit-state":
-                field.value = field.value || "";
-                formFields.push(
-                    <Dropdown
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey=""
-                        options={dfustates.map(state => {
-                            return {
-                                key: state,
-                                text: state
-                            };
-                        })}
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                    />
-                );
-                break;
-            case "logicalfile-type":
-                field.value = field.value || "Created";
-                formFields.push(
-                    <Dropdown
-                        key={fieldID}
-                        label={field.label}
-                        defaultSelectedKey=""
-                        options={[
-                            { key: "Created", text: nlsHPCC.CreatedByWorkunit },
-                            { key: "Used", text: nlsHPCC.UsedByWorkunit }
-                        ]}
-                        onChange={(ev, row) => {
-                            localFields[fieldID].value = row.key as string;
-                            setLocalFields({ ...localFields });
-                        }}
-                        placeholder={field.placeholder}
-                    />
-                );
-                break;
-        }
-    }
-
-    return <>
-        {...formFields}
-    </>;
+    }} />;
 };
 
 interface FilterProps {

+ 28 - 4
esp/src/src-react/components/Results.tsx

@@ -34,6 +34,30 @@ export const Results: React.FunctionComponent<ResultsProps> = ({
             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/${wuid}/outputs/${selection[0].Name}`;
+                } else {
+                    for (let i = selection.length - 1; i >= 0; --i) {
+                        window.open(`#/workunits/${wuid}/outputs/${selection[i].Name}`, "_blank");
+                    }
+                }
+            }
+        },
+        {
+            key: "open legacy", text: nlsHPCC.OpenLegacyMode, disabled: !uiState.hasSelection, iconProps: { iconName: "WindowEdit" },
+            onClick: () => {
+                if (selection.length === 1) {
+                    window.location.href = `#/workunits/${wuid}/outputs/${selection[0].Name}/legacy`;
+                } else {
+                    for (let i = selection.length - 1; i >= 0; --i) {
+                        window.open(`#/workunits/${wuid}/outputs/${selection[i].Name}/legacy`, "_blank");
+                    }
+                }
+            }
+        },
     ];
 
     const rightButtons: ICommandBarItemProps[] = [
@@ -63,14 +87,14 @@ export const Results: React.FunctionComponent<ResultsProps> = ({
         }),
         Name: {
             label: nlsHPCC.Name, width: 180, sortable: true,
-            formatter: function (Name, idx) {
-                return "<a href='#' onClick='return false;' class='dgrid-row-url'>" + Name + "</a>";
+            formatter: function (Name, row) {
+                return `<a href='#/workunits/${wuid}/outputs/${Name}' class='dgrid-row-url'>${Name}</a>`;
             }
         },
         FileName: {
             label: nlsHPCC.FileName, sortable: true,
             formatter: function (FileName, idx) {
-                return "<a href='#' onClick='return false;' class='dgrid-row-url2'>" + FileName + "</a>";
+                return `<a href='#/files/${FileName}' class='dgrid-row-url2'>${FileName}</a>`;
             }
         },
         Value: {
@@ -112,7 +136,7 @@ export const Results: React.FunctionComponent<ResultsProps> = ({
         gridStore.setData(results.map(row => {
             const tmp: any = row?.ResultViews;
             return {
-                __hpcc_id: row.Sequence,
+                __hpcc_id: row.Name,
                 Name: row.Name,
                 FileName: row.FileName,
                 Value: row.Value,

+ 228 - 0
esp/src/src-react/components/WorkunitDetails.tsx

@@ -0,0 +1,228 @@
+import * as React from "react";
+import { CommandBar, ContextualMenuItemType, ICommandBarItemProps, mergeStyleSets, Pivot, PivotItem, ScrollablePane, ScrollbarVisibility, Sticky, StickyPositionType } from "@fluentui/react";
+import { SizeMe } from "react-sizeme";
+import { ReflexContainer, ReflexSplitter, ReflexElement } from "react-reflex";
+import nlsHPCC from "src/nlsHPCC";
+import { getImageURL } from "src/Utility";
+import { getStateIconClass } from "src/ESPWorkunit";
+import { WUStatus } from "src/react/index";
+import { useWorkunit } from "../hooks/Workunit";
+import { DojoAdapter } from "../layouts/DojoAdapter";
+import { pushUrl } from "../util/history";
+import { ShortVerticalDivider } from "./Common";
+import { Results } from "./Results";
+import { Variables } from "./Variables";
+import { SourceFiles } from "./SourceFiles";
+import { Details } from "./Details";
+
+import "react-reflex/styles.css";
+
+const classNames = mergeStyleSets({
+    reflexScrollPane: {
+        borderWidth: 1,
+        borderStyle: "solid",
+        borderColor: "darkgray"
+    },
+    reflexPane: {
+        borderWidth: 1,
+        borderStyle: "solid",
+        borderColor: "darkgray",
+        overflow: "hidden"
+    },
+    reflexSplitter: {
+        position: "relative",
+        height: "5px",
+        backgroundColor: "transparent",
+        borderStyle: "none"
+    },
+    reflexSplitterDiv: {
+        fontFamily: "Lucida Sans,Lucida Grande,Arial !important",
+        fontSize: "13px !important",
+        cursor: "row-resize",
+        position: "absolute",
+        left: "49%",
+        background: "#9e9e9e",
+        height: "1px",
+        top: "2px",
+        width: "19px"
+    }
+});
+
+const pivotItemStyle = (size, padding: number = 4) => {
+    if (isNaN(size.width)) {
+        return { position: "absolute", padding: `${padding}px`, overflow: "auto", zIndex: 0 } as React.CSSProperties;
+    }
+    return { position: "absolute", padding: `${padding}px`, overflow: "auto", zIndex: 0, width: size.width - padding * 2, height: size.height - 45 - padding * 2 } as React.CSSProperties;
+};
+
+interface InfoGridProps {
+    wuid: string;
+    dimensions?: any;
+}
+
+const InfoGrid: React.FunctionComponent<InfoGridProps> = ({
+    wuid,
+    dimensions
+}) => {
+    return <div className="pane-content" style={{ height: dimensions.height }}>
+        <DojoAdapter widgetClassID="InfoGridWidget" params={{ Wuid: wuid }} delayProps={{ showToolbar: true }} />
+    </div>;
+};
+
+interface WorkunitDetailsProps {
+    wuid: string;
+    tab?: string;
+}
+
+export const WorkunitDetails: React.FunctionComponent<WorkunitDetailsProps> = ({
+    wuid,
+    tab = "summary"
+}) => {
+
+    const [workunit] = useWorkunit(wuid, true);
+    const [jobname, setJobname] = React.useState("");
+    const [description, setDescription] = React.useState("");
+    const [_protected, setProtected] = React.useState(false);
+
+    React.useEffect(() => {
+        setJobname(jobname || workunit?.Jobname);
+        setDescription(description || workunit?.Description);
+        setProtected(_protected || workunit?.Protected);
+
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, [workunit?.Jobname, workunit?.Jobname, workunit?.Jobname]);
+
+    const canSave = workunit && (
+        jobname !== workunit.Jobname ||
+        description !== workunit.Description ||
+        _protected !== workunit.Protected
+    );
+
+    const buttons: ICommandBarItemProps[] = [
+        {
+            key: "refresh", text: nlsHPCC.Refresh, iconProps: { iconName: "Refresh" },
+            onClick: () => {
+                workunit.refresh();
+            }
+        },
+        { key: "divider_1", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+        {
+            key: "save", text: nlsHPCC.Save, iconProps: { iconName: "Save" }, disabled: !canSave,
+            onClick: () => {
+                workunit?.update({
+                    Jobname: jobname,
+                    Description: description,
+                    Protected: _protected
+                });
+            }
+        },
+        {
+            key: "copy", text: nlsHPCC.CopyWUID, iconProps: { iconName: "Copy" },
+            onClick: () => {
+                navigator?.clipboard?.writeText(wuid);
+            }
+        },
+        { key: "divider_2", itemType: ContextualMenuItemType.Divider, onRender: () => <ShortVerticalDivider /> },
+    ];
+
+    const protectedImage = getImageURL(workunit?.Protected ? "locked.png" : "unlocked.png");
+    const stateIconClass = getStateIconClass(workunit?.StateID, workunit?.isComplete(), workunit?.Archived);
+    const serviceNames = workunit?.ServiceNames?.Item?.join("\n") || "";
+
+    return <SizeMe monitorHeight>{({ size }) =>
+        <Pivot overflowBehavior="menu" style={{ height: "100%" }} defaultSelectedKey={tab} onLinkClick={evt => pushUrl(`/workunits/${wuid}/${evt.props.itemKey}`)}>
+            <PivotItem headerText={wuid} itemKey="summary" style={pivotItemStyle(size)}>
+                <div style={{ height: "100%", position: "relative" }}>
+                    <ReflexContainer orientation="horizontal">
+                        <ReflexElement className={classNames.reflexScrollPane}>
+                            <div className="pane-content">
+                                <ScrollablePane scrollbarVisibility={ScrollbarVisibility.auto}>
+                                    <Sticky stickyPosition={StickyPositionType.Header}>
+                                        <CommandBar items={buttons} />
+                                    </Sticky>
+                                    <Sticky stickyPosition={StickyPositionType.Header}>
+                                        <div style={{ display: "inline-block" }}>
+                                            <h2>
+                                                <img src={protectedImage} />&nbsp;<div className={stateIconClass}></div>&nbsp;<span className="bold">{wuid}</span>
+                                            </h2>
+                                        </div>
+                                        <div style={{ width: "512px", height: "64px", float: "right" }}>
+                                            <WUStatus wuid={wuid}></WUStatus>
+                                        </div>
+                                    </Sticky>
+                                    <Details fields={{
+                                        "wuid": { label: nlsHPCC.WUID, type: "string", value: wuid, readonly: true },
+                                        "action": { label: nlsHPCC.Action, type: "string", value: workunit?.ActionEx, readonly: true },
+                                        "state": { label: nlsHPCC.State, type: "string", value: workunit?.State, readonly: true },
+                                        "owner": { label: nlsHPCC.Owner, type: "string", value: workunit?.Owner, readonly: true },
+                                        "jobname": { label: nlsHPCC.JobName, type: "string", value: jobname },
+                                        "description": { label: nlsHPCC.Description, type: "string", value: description },
+                                        "protected": { label: nlsHPCC.Protected, type: "checkbox", value: _protected },
+                                        "cluster": { label: nlsHPCC.Cluster, type: "string", value: workunit?.Cluster, readonly: true },
+                                        "totalClusterTime": { label: nlsHPCC.TotalClusterTime, type: "string", value: workunit?.TotalClusterTime, readonly: true },
+                                        "abortedBy": { label: nlsHPCC.AbortedBy, type: "string", value: workunit?.AbortBy, readonly: true },
+                                        "abortedTime": { label: nlsHPCC.AbortedTime, type: "string", value: workunit?.AbortTime, readonly: true },
+                                        "ServiceNamesCustom": { label: nlsHPCC.Services, type: "string", value: serviceNames, readonly: true, multiline: true },
+                                    }} onChange={(id, value) => {
+                                        switch (id) {
+                                            case "jobname":
+                                                setJobname(value);
+                                                break;
+                                            case "description":
+                                                setDescription(value);
+                                                break;
+                                            case "protected":
+                                                setProtected(value);
+                                                break;
+                                            default:
+                                                console.log(id, value);
+                                        }
+                                    }} />
+                                </ScrollablePane>
+                            </div>
+                        </ReflexElement>
+                        <ReflexSplitter style={{ position: "relative", height: "5px", backgroundColor: "transparent", borderStyle: "none" }}>
+                            <div className={classNames.reflexSplitterDiv}></div>
+                        </ReflexSplitter>
+                        <ReflexElement propagateDimensions={true} className={classNames.reflexPane}>
+                            <InfoGrid wuid={wuid} />
+                        </ReflexElement>
+                    </ReflexContainer>
+                </div>
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Variables} itemCount={(workunit?.VariableCount || 0) + (workunit?.ApplicationValueCount || 0) + (workunit?.DebugValueCount || 0)} itemKey="variables" style={pivotItemStyle(size, 0)}>
+                <Variables wuid={wuid} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Outputs} itemKey="outputs" itemCount={workunit?.ResultCount} style={pivotItemStyle(size, 0)}>
+                <Results wuid={wuid} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Inputs} itemKey="inputs" itemCount={workunit?.SourceFileCount} style={pivotItemStyle(size, 0)}>
+                <SourceFiles wuid={wuid} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Timers} itemKey="timers" itemCount={workunit?.TimerCount} style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="TimingPageWidget" params={{ Wuid: wuid }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Graphs} itemKey="graphs" itemCount={workunit?.GraphCount} style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="GraphsWUWidget" params={{ Wuid: wuid }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Workflows} itemKey="workflows" itemCount={workunit?.WorkflowCount} style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="WorkflowsWidget" params={{ Wuid: wuid }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Queries} itemIcon="Search" itemKey="queries" style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="QuerySetQueryWidget" params={{ Wuid: wuid }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Resources} itemKey="resources" style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="ResourcesWidget" params={{ Wuid: wuid }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.Helpers} itemKey="helpers" itemCount={workunit?.HelpersCount} style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="HelpersWidget" params={{ Wuid: wuid }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.ECL} itemKey="eclsummary" style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="ECLArchiveWidget" params={{ Wuid: wuid }} />
+            </PivotItem>
+            <PivotItem headerText={nlsHPCC.XML} itemKey="xml" style={pivotItemStyle(size, 0)}>
+                <DojoAdapter widgetClassID="ECLSourceWidget" params={{ Wuid: wuid }} delayProps={{ WUXml: true }} />
+            </PivotItem>
+        </Pivot>
+    }</SizeMe>;
+};

+ 18 - 8
esp/src/src-react/components/Workunits.tsx

@@ -22,17 +22,27 @@ const FilterFields: Fields = {
     "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 },
+    "LastNDays": { type: "string", label: nlsHPCC.LastNDays, placeholder: "2" },
     "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();
+function formatQuery(_filter) {
+    const filter = { ..._filter };
+    if (filter.LastNDays) {
+        const end = new Date();
+        const start = new Date();
+        start.setDate(end.getDate() - filter.LastNDays);
+        filter.StartDate = start.toISOString();
+        filter.EndDate = end.toISOString();
+        delete filter.LastNDays;
+    } else {
+        if (filter.StartDate) {
+            filter.StartDate = new Date(filter.StartDate).toISOString();
+        }
+        if (filter.EndDate) {
+            filter.EndDate = new Date(filter.StartDate).toISOString();
+        }
     }
     return filter;
 }
@@ -200,7 +210,7 @@ export const Workunits: React.FunctionComponent<WorkunitsProps> = ({
 
     React.useEffect(() => {
         refreshTable();
-    // eslint-disable-next-line react-hooks/exhaustive-deps
+        // eslint-disable-next-line react-hooks/exhaustive-deps
     }, [filter, store?.data]);
 
     //  Selection  ---

+ 7 - 3
esp/src/src-react/hooks/Workunit.ts

@@ -2,29 +2,33 @@ import * as React from "react";
 import { Workunit, Result, WUStateID, WUInfo } from "@hpcc-js/comms";
 import nlsHPCC from "src/nlsHPCC";
 
-export function useWorkunit(wuid: string, full: boolean = false): [Workunit, WUStateID] {
+export function useWorkunit(wuid: string, full: boolean = false): [Workunit, WUStateID, number] {
 
     const [workunit, setWorkunit] = React.useState<Workunit>();
     const [state, setState] = React.useState<WUStateID>();
+    const [lastUpdate, setLastUpdate] = React.useState(Date.now());
 
     React.useEffect(() => {
         const wu = Workunit.attach({ baseUrl: "" }, wuid);
         const handle = wu.watch(() => {
             if (full) {
                 wu.refresh(true).then(() => {
-                setState(wu.StateID);
+                    setWorkunit(wu);
+                    setState(wu.StateID);
                 });
             } else {
                 setState(wu.StateID);
             }
+            setLastUpdate(Date.now());
         });
         setWorkunit(wu);
+        setLastUpdate(Date.now());
         return () => {
             handle.release();
         };
     }, [wuid, full]);
 
-    return [workunit, state];
+    return [workunit, state, lastUpdate];
 }
 
 export function useWorkunitResults(wuid: string): [Result[], Workunit, WUStateID] {

+ 5 - 1
esp/src/src-react/layouts/DojoAdapter.tsx

@@ -29,6 +29,7 @@ export const DojoAdapter: React.FunctionComponent<DojoAdapterProps> = ({
 
     const myRef = React.useRef<HTMLDivElement>();
     const uid = useId("");
+    const [widget, setWidget] = React.useState<any>();
 
     React.useEffect(() => {
 
@@ -68,6 +69,7 @@ export const DojoAdapter: React.FunctionComponent<DojoAdapterProps> = ({
                 if (onWidgetMount) {
                     onWidgetMount(widget);
                 }
+                setWidget(widget);
             }
         }
 
@@ -86,8 +88,10 @@ export const DojoAdapter: React.FunctionComponent<DojoAdapterProps> = ({
             }
             widget = null;  //  Avoid race condition  ---
         };
-    }, [onWidgetMount, params, delayProps, uid, widgetClass, widgetClassID]);
+        // eslint-disable-next-line react-hooks/exhaustive-deps
+    }, []);
 
+    widget?.resize();
     return <div ref={myRef} style={{ width: "100%", height: "100%" }}>{nlsHPCC.Loading} {widgetClassID}...</div>;
 };
 

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

@@ -59,7 +59,9 @@ const routes: Routes = [
             { 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} />) }
+            { path: "/:Wuid/:Tab", action: (ctx, params) => import("./components/WorkunitDetails").then(_ => <_.WorkunitDetails wuid={params.Wuid as string} tab={params.Tab as string} />) },
+            { path: "/:Wuid/outputs/:Name", action: (ctx, params) => import("./layouts/DojoAdapter").then(_ => <_.DojoAdapter widgetClassID="ResultWidget" params={params} />) },
+            { path: "/:Wuid", action: (ctx, params) => import("./components/WorkunitDetails").then(_ => <_.WorkunitDetails wuid={params.Wuid as string} />) }
         ]
     },
     { path: "/play", action: () => import("./layouts/DojoAdapter").then(_ => <_.DojoAdapter widgetClassID="ECLPlaygroundWidget" />) },

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

@@ -112,6 +112,12 @@ export function pushSearch(_: object, state?: any) {
     }, state);
 }
 
+export function pushUrl(_: string, state?: any) {
+    hashHistory.push({
+        pathname: _
+    }, state);
+}
+
 export function updateSearch(_: object, state?: any) {
     const search = stringify(_ as any);
     hashHistory.replace({

+ 1 - 0
esp/src/src/nls/hpcc.ts

@@ -124,6 +124,7 @@ export = {
         Copy: "Copy",
         CopyToClipboard: "Copy to clipboard",
         CopyURLToClipboard: "Copy URL to clipboard",
+        CopyWUID: "Copy WUID",
         CopyWUIDs: "Copy WUIDs to clipboard",
         CopyWUIDToClipboard: "Copy WUID to clipboard",
         CopyLogicalFiles: "Copy Logical Files to clipboard",